forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import type { NewOperation } from '~/composables/useConnector'
3import { buildScopeTeam } from '~/utils/npm/common'
4
5type MemberRole = 'developer' | 'admin' | 'owner'
6type MemberRoleFilter = MemberRole | 'all'
7
8const props = defineProps<{
9 orgName: string
10}>()
11
12const emit = defineEmits<{
13 'select-team': [teamName: string]
14}>()
15
16const {
17 isConnected,
18 lastExecutionTime,
19 listOrgUsers,
20 listOrgTeams,
21 listTeamUsers,
22 addOperation,
23 error: connectorError,
24} = useConnector()
25
26// Members data: { username: role }
27const members = shallowRef<Record<string, MemberRole>>({})
28const isLoading = shallowRef(false)
29const error = shallowRef<string | null>(null)
30
31// Team membership data: { teamName: [members] }
32const teamMembers = ref<Record<string, string[]>>({})
33const isLoadingTeams = shallowRef(false)
34
35// Search/filter
36const searchQuery = shallowRef('')
37const filterRole = shallowRef<MemberRoleFilter>('all')
38const filterTeam = shallowRef<string>('')
39const sortBy = shallowRef<'name' | 'role'>('name')
40const sortOrder = shallowRef<'asc' | 'desc'>('asc')
41
42// Add member form
43const showAddMember = shallowRef(false)
44const newUsername = shallowRef('')
45const newRole = shallowRef<MemberRole>('developer')
46const newTeam = shallowRef<string>('') // Empty string means "developers" (default)
47const isAddingMember = shallowRef(false)
48
49// Role priority for sorting
50const rolePriority = { owner: 0, admin: 1, developer: 2 }
51
52// Get teams a member belongs to
53function getMemberTeams(username: string): string[] {
54 const teams: string[] = []
55 for (const [team, membersList] of Object.entries(teamMembers.value)) {
56 if (membersList.includes(username)) {
57 teams.push(team)
58 }
59 }
60 return teams.sort()
61}
62
63// All team names (for filter dropdown)
64const teamNames = computed(() => Object.keys(teamMembers.value).sort())
65
66// Computed member list with teams
67const memberList = computed(() => {
68 return Object.entries(members.value).map(([name, role]) => ({
69 name,
70 role,
71 teams: getMemberTeams(name),
72 }))
73})
74
75// Filtered and sorted members
76const filteredMembers = computed(() => {
77 let result = memberList.value
78
79 // Filter by search
80 if (searchQuery.value.trim()) {
81 const query = searchQuery.value.toLowerCase()
82 result = result.filter(m => m.name.toLowerCase().includes(query))
83 }
84
85 // Filter by role
86 if (filterRole.value !== 'all') {
87 result = result.filter(m => m.role === filterRole.value)
88 }
89
90 // Filter by team
91 if (filterTeam.value) {
92 result = result.filter(m => m.teams.includes(filterTeam.value!))
93 }
94
95 // Sort
96 result = [...result].sort((a, b) => {
97 if (sortBy.value === 'name') {
98 return sortOrder.value === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
99 } else {
100 const diff = rolePriority[a.role] - rolePriority[b.role]
101 return sortOrder.value === 'asc' ? diff : -diff
102 }
103 })
104
105 return result
106})
107
108// Role counts
109const roleCounts = computed(() => {
110 const counts = { developer: 0, admin: 0, owner: 0 }
111 for (const role of Object.values(members.value)) {
112 counts[role]++
113 }
114 return counts
115})
116
117// Refresh all data
118function refreshData() {
119 loadMembers()
120 loadTeamMemberships()
121}
122
123// Load members
124async function loadMembers() {
125 if (!isConnected.value) return
126
127 isLoading.value = true
128 error.value = null
129
130 try {
131 const result = await listOrgUsers(props.orgName)
132 if (result) {
133 members.value = result
134 } else {
135 error.value = connectorError.value || 'Failed to load members'
136 }
137 } finally {
138 isLoading.value = false
139 }
140}
141
142// Load all teams and their members
143async function loadTeamMemberships() {
144 if (!isConnected.value) return
145
146 isLoadingTeams.value = true
147
148 try {
149 const teamsResult = await listOrgTeams(props.orgName)
150 if (teamsResult) {
151 // Teams come as "org:team" format from npm, need @scope:team for API calls
152 const teamPromises = teamsResult.map(async (fullTeamName: string) => {
153 const teamName = fullTeamName.replace(`${props.orgName}:`, '')
154 const membersResult = await listTeamUsers(buildScopeTeam(props.orgName, teamName))
155 if (membersResult) {
156 teamMembers.value[teamName] = membersResult
157 }
158 })
159 await Promise.all(teamPromises)
160 }
161 } finally {
162 isLoadingTeams.value = false
163 }
164}
165
166// Add member (with optional team assignment)
167async function handleAddMember() {
168 if (!newUsername.value.trim()) return
169
170 isAddingMember.value = true
171 try {
172 const username = newUsername.value.trim().replace(/^@/, '')
173
174 // First operation: add user to org
175 const orgOperation: NewOperation = {
176 type: 'org:add-user',
177 params: {
178 org: props.orgName,
179 user: username,
180 role: newRole.value,
181 },
182 description: `Add @${username} to @${props.orgName} as ${newRole.value}`,
183 command: `npm org set ${props.orgName} ${username} ${newRole.value}`,
184 }
185 const addedOrgOp = await addOperation(orgOperation)
186
187 // Second operation: add user to team (if a team is selected)
188 // This depends on the org operation completing first
189 if (newTeam.value && addedOrgOp) {
190 const scopeTeam = buildScopeTeam(props.orgName, newTeam.value)
191 const teamOperation: NewOperation = {
192 type: 'team:add-user',
193 params: {
194 scopeTeam,
195 user: username,
196 },
197 description: `Add @${username} to team ${newTeam.value}`,
198 command: `npm team add ${scopeTeam} ${username}`,
199 dependsOn: addedOrgOp.id,
200 }
201 await addOperation(teamOperation)
202 }
203
204 newUsername.value = ''
205 newTeam.value = ''
206 showAddMember.value = false
207 } finally {
208 isAddingMember.value = false
209 }
210}
211
212// Remove member
213async function handleRemoveMember(username: string) {
214 const operation: NewOperation = {
215 type: 'org:rm-user',
216 params: {
217 org: props.orgName,
218 user: username,
219 },
220 description: `Remove @${username} from @${props.orgName}`,
221 command: `npm org rm ${props.orgName} ${username}`,
222 }
223
224 await addOperation(operation)
225}
226
227// Change role
228async function handleChangeRole(username: string, newRoleValue: 'developer' | 'admin' | 'owner') {
229 const operation: NewOperation = {
230 type: 'org:add-user',
231 params: {
232 org: props.orgName,
233 user: username,
234 role: newRoleValue,
235 },
236 description: `Change @${username} role to ${newRoleValue} in @${props.orgName}`,
237 command: `npm org set ${props.orgName} ${username} ${newRoleValue}`,
238 }
239
240 await addOperation(operation)
241}
242
243// Toggle sort
244function toggleSort(field: 'name' | 'role') {
245 if (sortBy.value === field) {
246 sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
247 } else {
248 sortBy.value = field
249 sortOrder.value = 'asc'
250 }
251}
252
253// Role badge color
254function getRoleBadgeClass(role: string): string {
255 switch (role) {
256 case 'owner':
257 return 'bg-purple-500/20 text-purple-400 border-purple-500/30'
258 case 'admin':
259 return 'bg-blue-500/20 text-blue-400 border-blue-500/30'
260 default:
261 return 'bg-fg-subtle/20 text-fg-muted border-border'
262 }
263}
264
265const roleLabels = computed(() => ({
266 owner: $t('org.members.role.owner'),
267 admin: $t('org.members.role.admin'),
268 developer: $t('org.members.role.developer'),
269 all: $t('org.members.role.all'),
270}))
271
272function getRoleLabel(role: MemberRoleFilter): string {
273 return roleLabels.value[role]
274}
275
276// Click on team badge to switch to teams tab and highlight
277function handleTeamClick(teamName: string) {
278 emit('select-team', teamName)
279}
280
281// Load on mount when connected
282watch(
283 isConnected,
284 connected => {
285 if (connected) {
286 loadMembers()
287 loadTeamMemberships()
288 }
289 },
290 { immediate: true },
291)
292
293// Refresh data when operations complete
294watch(lastExecutionTime, () => {
295 if (isConnected.value) {
296 loadMembers()
297 loadTeamMemberships()
298 }
299})
300</script>
301
302<template>
303 <section v-if="isConnected" class="bg-bg-subtle border border-border rounded-lg overflow-hidden">
304 <!-- Header -->
305 <div class="flex items-center justify-between p-4 border-b border-border">
306 <h2 id="members-heading" class="font-mono text-sm font-medium flex items-center gap-2">
307 <span class="i-lucide:users w-4 h-4 text-fg-muted" aria-hidden="true" />
308 {{ $t('org.members.title') }}
309 <span v-if="memberList.length > 0" class="text-fg-muted">({{ memberList.length }})</span>
310 </h2>
311 <button
312 type="button"
313 class="p-1.5 text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
314 :aria-label="$t('org.members.refresh')"
315 :disabled="isLoading"
316 @click="refreshData"
317 >
318 <span
319 class="i-lucide:refresh-ccw w-4 h-4"
320 :class="{ 'motion-safe:animate-spin': isLoading || isLoadingTeams }"
321 aria-hidden="true"
322 />
323 </button>
324 </div>
325
326 <!-- Search, filter, sort -->
327 <div class="flex flex-wrap items-center gap-2 p-3 border-b border-border bg-bg">
328 <div class="flex-1 min-w-[150px] relative">
329 <span
330 class="absolute inset-is-2 top-1/2 -translate-y-1/2 i-lucide:search w-3.5 h-3.5 text-fg-subtle"
331 aria-hidden="true"
332 />
333 <label for="members-search" class="sr-only">{{ $t('org.members.filter_label') }}</label>
334 <InputBase
335 id="members-search"
336 v-model="searchQuery"
337 type="search"
338 name="members-search"
339 :placeholder="$t('org.members.filter_placeholder')"
340 no-correct
341 class="w-full min-w-25 ps-7"
342 size="small"
343 />
344 </div>
345 <div
346 class="flex items-center gap-1"
347 role="group"
348 :aria-label="$t('org.members.filter_by_role')"
349 >
350 <button
351 v-for="role in ['all', 'owner', 'admin', 'developer'] as const"
352 :key="role"
353 type="button"
354 class="px-2 py-1 font-mono text-xs rounded transition-colors duration-200 focus-visible:outline-accent/70"
355 :class="filterRole === role ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg'"
356 :aria-pressed="filterRole === role"
357 @click="filterRole = role"
358 >
359 {{ getRoleLabel(role) }}
360 <span v-if="role !== 'all'" class="text-fg-subtle">({{ roleCounts[role] }})</span>
361 </button>
362 </div>
363 <!-- Team filter -->
364 <div v-if="teamNames.length > 0">
365 <SelectField
366 :label="$t('org.members.filter_by_team')"
367 hidden-label
368 id="team-filter"
369 v-model="filterTeam"
370 name="team-filter"
371 block
372 size="sm"
373 :items="[
374 { label: $t('org.members.all_teams'), value: '' },
375 ...teamNames.map(team => ({ label: team, value: team })),
376 ]"
377 />
378 </div>
379 <div
380 class="flex items-center gap-1 text-xs"
381 role="group"
382 :aria-label="$t('org.members.sort_by')"
383 >
384 <button
385 type="button"
386 class="px-2 py-1 font-mono rounded transition-colors duration-200 focus-visible:outline-accent/70"
387 :class="sortBy === 'name' ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg'"
388 :aria-pressed="sortBy === 'name'"
389 @click="toggleSort('name')"
390 >
391 {{ $t('common.sort.name') }}
392 <span v-if="sortBy === 'name'">{{ sortOrder === 'asc' ? '↑' : '↓' }}</span>
393 </button>
394 <button
395 type="button"
396 class="px-2 py-1 font-mono rounded transition-colors duration-200 focus-visible:outline-accent/70"
397 :class="sortBy === 'role' ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg'"
398 :aria-pressed="sortBy === 'role'"
399 @click="toggleSort('role')"
400 >
401 {{ $t('common.sort.role') }}
402 <span v-if="sortBy === 'role'">{{ sortOrder === 'asc' ? '↑' : '↓' }}</span>
403 </button>
404 </div>
405 </div>
406
407 <!-- Loading state -->
408 <div v-if="isLoading && memberList.length === 0" class="p-8 text-center">
409 <span
410 class="i-svg-spinners:ring-resize w-5 h-5 text-fg-muted animate-spin mx-auto"
411 aria-hidden="true"
412 />
413 <p class="font-mono text-sm text-fg-muted mt-2">{{ $t('org.members.loading') }}</p>
414 </div>
415
416 <!-- Error state -->
417 <div v-else-if="error" class="p-4 text-center" role="alert">
418 <p class="font-mono text-sm text-red-400">
419 {{ error }}
420 </p>
421 <button
422 type="button"
423 class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
424 @click="loadMembers"
425 >
426 {{ $t('common.try_again') }}
427 </button>
428 </div>
429
430 <!-- Empty state -->
431 <div v-else-if="memberList.length === 0" class="p-8 text-center">
432 <p class="font-mono text-sm text-fg-muted">{{ $t('org.members.no_members') }}</p>
433 </div>
434
435 <!-- Members list -->
436 <ul
437 v-else
438 class="divide-y divide-border max-h-[400px] overflow-y-auto"
439 :aria-label="$t('org.members.list_label')"
440 >
441 <li
442 v-for="member in filteredMembers"
443 :key="member.name"
444 class="flex flex-col gap-2 p-3 bg-bg hover:bg-bg-subtle transition-colors duration-200"
445 >
446 <div class="flex items-center justify-between">
447 <div class="flex items-center gap-3">
448 <NuxtLink
449 :to="{ name: '~username', params: { username: member.name } }"
450 class="font-mono text-sm text-fg hover:text-fg transition-colors duration-200"
451 >
452 ~{{ member.name }}
453 </NuxtLink>
454 <span
455 class="px-1.5 py-0.5 font-mono text-xs border rounded"
456 :class="getRoleBadgeClass(member.role)"
457 >
458 {{ getRoleLabel(member.role) }}
459 </span>
460 </div>
461 <div class="flex items-center gap-1">
462 <!-- Role selector -->
463 <label :for="`role-${member.name}`" class="sr-only">{{
464 $t('org.members.change_role_for', { name: member.name })
465 }}</label>
466 <SelectField
467 :label="$t('org.members.change_role_for', { name: member.name })"
468 hidden-label
469 :id="`role-${member.name}`"
470 :model-value="member.role"
471 :name="`role-${member.name}`"
472 block
473 size="sm"
474 :items="[
475 { label: getRoleLabel('developer'), value: 'developer' },
476 { label: getRoleLabel('admin'), value: 'admin' },
477 { label: getRoleLabel('owner'), value: 'owner' },
478 ]"
479 :value="member.role"
480 @update:modelValue="value => handleChangeRole(member.name, value as MemberRole)"
481 />
482 <!-- Remove button -->
483 <button
484 type="button"
485 class="p-1 text-fg-subtle hover:text-red-400 transition-colors duration-200 rounded focus-visible:outline-accent/70"
486 :aria-label="$t('org.members.remove_from_org', { name: member.name })"
487 @click="handleRemoveMember(member.name)"
488 >
489 <span class="i-lucide:x w-4 h-4" aria-hidden="true" />
490 </button>
491 </div>
492 </div>
493 <!-- Team badges -->
494 <div v-if="member.teams.length > 0" class="flex flex-wrap gap-1 ps-0">
495 <button
496 v-for="team in member.teams"
497 :key="team"
498 type="button"
499 class="inline-flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs text-fg-muted border border-border rounded hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:outline-accent/70"
500 :aria-label="$t('org.members.view_team', { team })"
501 @click="handleTeamClick(team)"
502 >
503 {{ team }}
504 </button>
505 </div>
506 </li>
507 </ul>
508
509 <!-- No results -->
510 <div v-if="memberList.length > 0 && filteredMembers.length === 0" class="p-4 text-center">
511 <p class="font-mono text-sm text-fg-muted">{{ $t('org.members.no_match') }}</p>
512 </div>
513
514 <!-- Add member -->
515 <div class="p-3 border-t border-border">
516 <div v-if="showAddMember">
517 <form class="space-y-2" @submit.prevent="handleAddMember">
518 <label for="new-member-username" class="sr-only">{{
519 $t('org.members.username_label')
520 }}</label>
521 <InputBase
522 id="new-member-username"
523 v-model="newUsername"
524 type="text"
525 name="new-member-username"
526 :placeholder="$t('org.members.username_placeholder')"
527 no-correct
528 class="w-full min-w-25"
529 size="small"
530 />
531 <div class="flex items-center gap-2">
532 <SelectField
533 :label="$t('org.members.role_label')"
534 hidden-label
535 id="new-member-role"
536 v-model="newRole"
537 name="new-member-role"
538 block
539 class="flex-1"
540 size="sm"
541 :items="[
542 { label: $t('org.members.role.developer'), value: 'developer' },
543 { label: $t('org.members.role.admin'), value: 'admin' },
544 { label: $t('org.members.role.owner'), value: 'owner' },
545 ]"
546 />
547 <!-- Team selection -->
548 <SelectField
549 :label="$t('org.members.team_label')"
550 hidden-label
551 id="new-member-team"
552 v-model="newTeam"
553 name="new-member-team"
554 block
555 class="flex-1"
556 size="sm"
557 :items="[
558 { label: $t('org.members.no_team'), value: '' },
559 ...teamNames.map(team => ({ label: team, value: team })),
560 ]"
561 />
562 <button
563 type="submit"
564 :disabled="!newUsername.trim() || isAddingMember"
565 class="px-3 py-2 font-mono text-xs text-bg bg-fg rounded transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70"
566 >
567 {{ isAddingMember ? '…' : $t('org.members.add_button') }}
568 </button>
569 <button
570 type="button"
571 class="p-1.5 text-fg-subtle hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
572 :aria-label="$t('org.members.cancel_add')"
573 @click="showAddMember = false"
574 >
575 <span class="i-lucide:x w-4 h-4" aria-hidden="true" />
576 </button>
577 </div>
578 </form>
579 </div>
580 <button
581 v-else
582 type="button"
583 class="w-full px-3 py-2 font-mono text-sm text-fg-muted bg-bg border border-border rounded transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70"
584 @click="showAddMember = true"
585 >
586 {{ $t('org.members.add_member') }}
587 </button>
588 </div>
589 </section>
590</template>