Skip to content

Commit e68374d

Browse files
accorvinclaude
andauthored
feat: display team managers on team detail page header (#114)
Show configured managers as clickable links in the team detail header. When no manager is configured, display guidance text with team-admin names so users know who to contact. Adds a lightweight GET /api/roles/members/:role endpoint (any authenticated user) and getUsersByRole() to the role store. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 20b4ace commit e68374d

3 files changed

Lines changed: 179 additions & 1 deletion

File tree

modules/team-tracker/client/views/TeamRosterView.vue

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,57 @@
6363
</div>
6464
</div>
6565

66+
<!-- Team managers -->
67+
<div v-if="isInAppMode && !managersLoading" class="mt-2 flex flex-wrap items-center gap-x-1.5 text-sm text-gray-600 dark:text-gray-400">
68+
<template v-if="teamManagers.length > 0">
69+
<span class="text-gray-400 dark:text-gray-500 shrink-0">Manager{{ teamManagers.length > 1 ? 's' : '' }}:</span>
70+
<template v-for="(mgr, i) in teamManagers" :key="mgr.uid">
71+
<template v-if="i > 0">, </template>
72+
<button
73+
@click="navigateToPerson(mgr.uid)"
74+
class="text-primary-600 dark:text-primary-400 hover:underline"
75+
>{{ mgr.name || mgr.uid }}</button>
76+
</template>
77+
<button
78+
v-if="canManageMembers"
79+
@click="showManagersModal = true"
80+
class="ml-1 p-0.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
81+
title="Edit managers"
82+
>
83+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
84+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
85+
</svg>
86+
</button>
87+
</template>
88+
<template v-else>
89+
<span class="text-gray-400 dark:text-gray-500 italic">
90+
No manager configured.
91+
<template v-if="teamAdminNames.length > 0">
92+
Contact a Team Admin ({{ teamAdminNames.join(', ') }}) to assign one.
93+
</template>
94+
<template v-else>
95+
Contact an admin to assign one.
96+
</template>
97+
</span>
98+
<button
99+
v-if="canManageMembers"
100+
@click="showManagersModal = true"
101+
class="ml-1 text-primary-600 dark:text-primary-400 hover:underline text-xs"
102+
>
103+
+ Add manager
104+
</button>
105+
</template>
106+
</div>
107+
108+
<!-- Team Managers Modal -->
109+
<TeamManagersModal
110+
v-if="showManagersModal"
111+
:teamId="team.teamId"
112+
:teamName="team.displayName"
113+
@close="showManagersModal = false"
114+
@updated="handleManagersUpdated"
115+
/>
116+
66117
<!-- Team description -->
67118
<div v-if="isInAppMode && (team.description || canManageMembers)" class="mt-3">
68119
<template v-if="!editingDescription">
@@ -337,6 +388,7 @@ import TeamDeliveryTab from '../components/TeamDeliveryTab.vue'
337388
import TeamBacklogTab from '../components/TeamBacklogTab.vue'
338389
import TeamAutofixTab from '../components/autofix/TeamAutofixTab.vue'
339390
import TeamFieldEditor from '../components/TeamFieldEditor.vue'
391+
import TeamManagersModal from '../components/TeamManagersModal.vue'
340392
import ContributionBoundary from '@shared/client/components/ContributionBoundary.vue'
341393
import { getTeamDetailTabs, runGuard } from '../contributions'
342394
import RefreshModal from '@shared/client/components/RefreshModal.vue'
@@ -347,6 +399,7 @@ import { usePermissions } from '@shared/client/composables/usePermissions'
347399
import { useFieldDefinitions } from '@shared/client/composables/useFieldDefinitions'
348400
import { useOrgRoster } from '../composables/useOrgRoster'
349401
import { refreshMetrics, getTeamMetrics, apiRequest } from '@shared/client/services/api'
402+
import { useTeams } from '@shared/client/composables/useTeams'
350403
import { useManagerTutorial } from '../composables/useManagerTutorial'
351404
import { Marked } from 'marked'
352405
import DOMPurify from 'dompurify'
@@ -358,10 +411,41 @@ const { loadGitlabStats } = useGitlabStats()
358411
const { isAdmin } = useAuth()
359412
const { canEditTeam, managedUids } = usePermissions()
360413
const { definitions, fetchDefinitions } = useFieldDefinitions()
414+
const { fetchTeamManagers } = useTeams()
361415
const { resumeTourIfActive, destroyTour } = useManagerTutorial()
362416
363417
const fromSotu = computed(() => nav.params.value?.from === 'sotu')
364418
419+
// --- Team managers ---
420+
const teamManagers = ref([])
421+
const managersLoading = ref(true)
422+
const showManagersModal = ref(false)
423+
const teamAdminNames = ref([])
424+
425+
async function fetchManagersForTeam() {
426+
if (!team.value?.teamId) return
427+
managersLoading.value = true
428+
try {
429+
teamManagers.value = await fetchTeamManagers(team.value.teamId)
430+
} catch {
431+
teamManagers.value = []
432+
}
433+
if (teamManagers.value.length === 0) {
434+
try {
435+
const data = await apiRequest('/roles/members/team-admin')
436+
teamAdminNames.value = (data.members || []).map(m => m.name)
437+
} catch {
438+
teamAdminNames.value = []
439+
}
440+
}
441+
managersLoading.value = false
442+
}
443+
444+
async function handleManagersUpdated() {
445+
await fetchManagersForTeam()
446+
await reloadRoster()
447+
}
448+
365449
function handleBack() {
366450
if (fromSotu.value) {
367451
window.location.hash = '#/'
@@ -726,6 +810,7 @@ onMounted(() => {
726810
fetchRfeConfig()
727811
loadGitlabStats()
728812
fetchDefinitions()
813+
fetchManagersForTeam()
729814
resumeTourIfActive('team-detail')
730815
})
731816
@@ -737,13 +822,15 @@ watch(() => nav.params.value?.teamKey, () => {
737822
fetchTeamMetrics()
738823
fetchTeamDetail()
739824
fetchRfeConfig()
825+
fetchManagersForTeam()
740826
})
741827
742828
// Retry loading once team resolves from roster async load
743829
watch(team, (newVal, oldVal) => {
744830
if (newVal && !oldVal) {
745831
if (!teamMetrics.value) fetchTeamMetrics()
746832
if (!teamDetail.value) fetchTeamDetail()
833+
if (managersLoading.value) fetchManagersForTeam()
747834
}
748835
})
749836
</script>

server/routes/roles.js

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
function registerRoleRoutes(app, context) {
9-
const { requireAdmin, requireScope, blockDuringImpersonation, roleStore, roleRegistry } = context;
9+
const { requireAdmin, requireScope, blockDuringImpersonation, roleStore, roleRegistry, storage } = context;
1010

1111
/**
1212
* @openapi
@@ -158,6 +158,83 @@ function registerRoleRoutes(app, context) {
158158
res.json({ roles: await roleStore.getRoles(req.userEmail) });
159159
});
160160

161+
/**
162+
* @openapi
163+
* /api/roles/members/{role}:
164+
* get:
165+
* tags: [Auth]
166+
* summary: Get names of users with a specific role
167+
* description: Returns display names of users who hold the given role. Available to any authenticated user.
168+
* parameters:
169+
* - in: path
170+
* name: role
171+
* required: true
172+
* schema:
173+
* type: string
174+
* responses:
175+
* 200:
176+
* description: List of user names with the role
177+
* content:
178+
* application/json:
179+
* schema:
180+
* type: object
181+
* properties:
182+
* members:
183+
* type: array
184+
* items:
185+
* type: object
186+
* properties:
187+
* name:
188+
* type: string
189+
* 400:
190+
* description: Invalid role
191+
* content:
192+
* application/json:
193+
* schema:
194+
* $ref: '#/components/schemas/ErrorResponse'
195+
*/
196+
app.get('/api/roles/members/:role', async function(req, res) {
197+
try {
198+
const { role } = req.params;
199+
if (!role || (roleRegistry && !roleRegistry.isValid(role))) {
200+
return res.status(400).json({ error: 'Invalid role' });
201+
}
202+
203+
const emails = await roleStore.getUsersByRole(role);
204+
if (emails.length === 0) {
205+
return res.json({ members: [] });
206+
}
207+
208+
let registry;
209+
try {
210+
registry = await storage.readFromStorage('team-data/registry.json');
211+
} catch {
212+
registry = null;
213+
}
214+
215+
const emailIndex = new Map();
216+
const uidIndex = new Map();
217+
if (registry?.people) {
218+
for (const [uid, person] of Object.entries(registry.people)) {
219+
if (person.email) emailIndex.set(person.email.toLowerCase(), person.name);
220+
if (uid) uidIndex.set(uid.toLowerCase(), person.name);
221+
}
222+
}
223+
224+
const members = emails.map(email => {
225+
const lower = email.toLowerCase();
226+
const name = emailIndex.get(lower)
227+
|| uidIndex.get(lower.split('@')[0]);
228+
return { name: name || email };
229+
});
230+
231+
res.json({ members });
232+
} catch (error) {
233+
console.error('Get role members error:', error);
234+
res.status(500).json({ error: error.message });
235+
}
236+
});
237+
161238
app.get('/api/roles', requireAdmin, requireScope('admin:manage'), async function(req, res) {
162239
try {
163240
res.json({ assignments: await roleStore.listAssignments() });

shared/server/role-store.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,19 @@ function createRoleStore(readFromStorage, writeToStorage, options = {}) {
350350
.map(([email]) => email);
351351
}
352352

353+
async function getUsersByRole(role) {
354+
if (!role) return [];
355+
if (RoleModel) {
356+
const docs = await RoleModel.find({ roles: role }, { email: 1 }).lean();
357+
return docs.map(d => d.email);
358+
}
359+
360+
const data = await readRolesFile();
361+
return Object.entries(data.assignments)
362+
.filter(([, entry]) => entry.roles.includes(role))
363+
.map(([email]) => email);
364+
}
365+
353366
async function migrateFromAllowlist() {
354367
if (RoleModel) {
355368
const count = await RoleModel.countDocuments();
@@ -525,6 +538,7 @@ function createRoleStore(readFromStorage, writeToStorage, options = {}) {
525538
revokeRole,
526539
listAssignments,
527540
getAdminEmails,
541+
getUsersByRole,
528542
migrateFromAllowlist,
529543
migrateEmailDomains,
530544
invalidateCache

0 commit comments

Comments
 (0)