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
5const props = defineProps<{
6 orgName: string
7}>()
8
9const {
10 isConnected,
11 lastExecutionTime,
12 listOrgTeams,
13 listOrgUsers,
14 listTeamUsers,
15 addOperation,
16 error: connectorError,
17} = useConnector()
18
19// Teams data
20const teams = shallowRef<string[]>([])
21const teamUsers = ref<Record<string, string[]>>({})
22const isLoadingTeams = shallowRef(false)
23const isLoadingUsers = ref<Record<string, boolean>>({})
24const error = shallowRef<string | null>(null)
25
26// Org members (to check if user needs to be added to org first)
27const orgMembers = shallowRef<Record<string, 'developer' | 'admin' | 'owner'>>({})
28
29// Search/filter
30const searchQuery = shallowRef('')
31const sortBy = shallowRef<'name' | 'members'>('name')
32const sortOrder = shallowRef<'asc' | 'desc'>('asc')
33
34// Expanded teams (to show members)
35const expandedTeams = ref<Set<string>>(new Set())
36
37// Create team form
38const showCreateTeam = shallowRef(false)
39const newTeamName = shallowRef('')
40const isCreatingTeam = shallowRef(false)
41
42// Add user form (per team)
43const showAddUserFor = shallowRef<string | null>(null)
44const newUserUsername = shallowRef('')
45const isAddingUser = shallowRef(false)
46
47// Filtered and sorted teams
48const filteredTeams = computed(() => {
49 let result = teams.value
50
51 // Filter by search
52 if (searchQuery.value.trim()) {
53 const query = searchQuery.value.toLowerCase()
54 result = result.filter(team => team.toLowerCase().includes(query))
55 }
56
57 // Sort
58 result = [...result].sort((a, b) => {
59 if (sortBy.value === 'name') {
60 return sortOrder.value === 'asc' ? a.localeCompare(b) : b.localeCompare(a)
61 } else {
62 const aCount = teamUsers.value[a]?.length ?? 0
63 const bCount = teamUsers.value[b]?.length ?? 0
64 return sortOrder.value === 'asc' ? aCount - bCount : bCount - aCount
65 }
66 })
67
68 return result
69})
70
71// Load teams and org members
72async function loadTeams() {
73 if (!isConnected.value) return
74
75 isLoadingTeams.value = true
76 error.value = null
77
78 try {
79 // Load teams and org members in parallel
80 const [teamsResult, membersResult] = await Promise.all([
81 listOrgTeams(props.orgName),
82 listOrgUsers(props.orgName),
83 ])
84
85 if (teamsResult) {
86 // Teams come as "org:team" format, extract just the team name
87 teams.value = teamsResult.map((t: string) => t.replace(`${props.orgName}:`, ''))
88 } else {
89 error.value = connectorError.value || 'Failed to load teams'
90 }
91
92 if (membersResult) {
93 orgMembers.value = membersResult
94 }
95 } finally {
96 isLoadingTeams.value = false
97 }
98}
99
100// Load team members
101async function loadTeamUsers(teamName: string) {
102 if (!isConnected.value) return
103
104 isLoadingUsers.value[teamName] = true
105
106 try {
107 const scopeTeam = buildScopeTeam(props.orgName, teamName)
108 const result = await listTeamUsers(scopeTeam)
109 if (result) {
110 teamUsers.value[teamName] = result
111 }
112 } finally {
113 isLoadingUsers.value[teamName] = false
114 }
115}
116
117// Toggle team expansion
118async function toggleTeam(teamName: string) {
119 if (expandedTeams.value.has(teamName)) {
120 expandedTeams.value.delete(teamName)
121 } else {
122 expandedTeams.value.add(teamName)
123 // Load users if not already loaded
124 if (!teamUsers.value[teamName]) {
125 await loadTeamUsers(teamName)
126 }
127 }
128 // Force reactivity
129 expandedTeams.value = new Set(expandedTeams.value)
130}
131
132// Create team
133async function handleCreateTeam() {
134 if (!newTeamName.value.trim()) return
135
136 isCreatingTeam.value = true
137 try {
138 const teamName = newTeamName.value.trim()
139 const scopeTeam = buildScopeTeam(props.orgName, teamName)
140 const operation: NewOperation = {
141 type: 'team:create',
142 params: { scopeTeam },
143 description: `Create team ${scopeTeam}`,
144 command: `npm team create ${scopeTeam}`,
145 }
146
147 await addOperation(operation)
148 newTeamName.value = ''
149 showCreateTeam.value = false
150 } finally {
151 isCreatingTeam.value = false
152 }
153}
154
155// Destroy team
156async function handleDestroyTeam(teamName: string) {
157 const scopeTeam = buildScopeTeam(props.orgName, teamName)
158 const operation: NewOperation = {
159 type: 'team:destroy',
160 params: { scopeTeam },
161 description: `Destroy team ${scopeTeam}`,
162 command: `npm team destroy ${scopeTeam}`,
163 }
164
165 await addOperation(operation)
166}
167
168// Add user to team (auto-invites to org if needed)
169async function handleAddUser(teamName: string) {
170 if (!newUserUsername.value.trim()) return
171
172 isAddingUser.value = true
173 try {
174 const username = newUserUsername.value.trim().replace(/^@/, '')
175 const scopeTeam = buildScopeTeam(props.orgName, teamName)
176
177 let dependsOnId: string | undefined
178
179 // If user is not in org, add them first with developer role
180 const isInOrg = username in orgMembers.value
181 if (!isInOrg) {
182 const orgOperation: NewOperation = {
183 type: 'org:add-user',
184 params: {
185 org: props.orgName,
186 user: username,
187 role: 'developer',
188 },
189 description: `Add @${username} to @${props.orgName} as developer`,
190 command: `npm org set ${props.orgName} ${username} developer`,
191 }
192 const addedOp = await addOperation(orgOperation)
193 if (addedOp) {
194 dependsOnId = addedOp.id
195 }
196 }
197
198 // Then add user to team (depends on org op if user wasn't in org)
199 const teamOperation: NewOperation = {
200 type: 'team:add-user',
201 params: { scopeTeam, user: username },
202 description: `Add @${username} to team ${teamName}`,
203 command: `npm team add ${scopeTeam} ${username}`,
204 dependsOn: dependsOnId,
205 }
206 await addOperation(teamOperation)
207
208 newUserUsername.value = ''
209 showAddUserFor.value = null
210 } finally {
211 isAddingUser.value = false
212 }
213}
214
215// Remove user from team
216async function handleRemoveUser(teamName: string, username: string) {
217 const scopeTeam = buildScopeTeam(props.orgName, teamName)
218 const operation: NewOperation = {
219 type: 'team:rm-user',
220 params: { scopeTeam, user: username },
221 description: `Remove @${username} from ${scopeTeam}`,
222 command: `npm team rm ${scopeTeam} ${username}`,
223 }
224
225 await addOperation(operation)
226}
227
228// Toggle sort
229function toggleSort(field: 'name' | 'members') {
230 if (sortBy.value === field) {
231 sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
232 } else {
233 sortBy.value = field
234 sortOrder.value = 'asc'
235 }
236}
237
238// Load on mount when connected
239watch(
240 isConnected,
241 connected => {
242 if (connected) {
243 loadTeams()
244 }
245 },
246 { immediate: true },
247)
248
249// Refresh data when operations complete
250watch(lastExecutionTime, () => {
251 if (isConnected.value) {
252 loadTeams()
253 }
254})
255</script>
256
257<template>
258 <section v-if="isConnected" class="bg-bg-subtle border border-border rounded-lg overflow-hidden">
259 <!-- Header -->
260 <div class="flex items-center justify-start p-4 border-b border-border">
261 <h2 id="teams-heading" class="font-mono text-sm font-medium flex items-center gap-2">
262 <span class="i-lucide:users w-4 h-4 text-fg-muted" aria-hidden="true" />
263 {{ $t('org.teams.title') }}
264 <span v-if="teams.length > 0" class="text-fg-muted">({{ teams.length }})</span>
265 </h2>
266 <span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
267 <button
268 type="button"
269 class="p-1.5 text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
270 :aria-label="$t('org.teams.refresh')"
271 :disabled="isLoadingTeams"
272 @click="loadTeams"
273 >
274 <span
275 class="i-lucide:refresh-ccw w-4 h-4"
276 :class="{ 'animate-spin': isLoadingTeams }"
277 aria-hidden="true"
278 />
279 </button>
280 </div>
281
282 <!-- Search and sort -->
283 <div class="flex items-center gap-2 p-3 border-b border-border bg-bg">
284 <div class="flex-1 relative">
285 <span
286 class="absolute inset-is-2 top-1/2 -translate-y-1/2 i-lucide:search w-3.5 h-3.5 text-fg-subtle"
287 aria-hidden="true"
288 />
289 <label for="teams-search" class="sr-only">{{ $t('org.teams.filter_label') }}</label>
290 <InputBase
291 id="teams-search"
292 v-model="searchQuery"
293 type="search"
294 name="teams-search"
295 :placeholder="$t('org.teams.filter_placeholder')"
296 no-correct
297 class="w-full min-w-25 ps-7"
298 size="medium"
299 />
300 </div>
301 <div
302 class="flex items-center gap-1 text-xs"
303 role="group"
304 :aria-label="$t('org.teams.sort_by')"
305 >
306 <button
307 type="button"
308 class="px-2 py-1 font-mono rounded transition-colors duration-200 focus-visible:outline-accent/70"
309 :class="sortBy === 'name' ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg'"
310 :aria-pressed="sortBy === 'name'"
311 @click="toggleSort('name')"
312 >
313 {{ $t('common.sort.name') }}
314 <span v-if="sortBy === 'name'">{{ sortOrder === 'asc' ? '↑' : '↓' }}</span>
315 </button>
316 <button
317 type="button"
318 class="px-2 py-1 font-mono rounded transition-colors duration-200 focus-visible:outline-accent/70"
319 :class="sortBy === 'members' ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg'"
320 :aria-pressed="sortBy === 'members'"
321 @click="toggleSort('members')"
322 >
323 {{ $t('common.sort.members') }}
324 <span v-if="sortBy === 'members'">{{ sortOrder === 'asc' ? '↑' : '↓' }}</span>
325 </button>
326 </div>
327 </div>
328
329 <!-- Loading state -->
330 <div v-if="isLoadingTeams && teams.length === 0" class="p-8 text-center">
331 <span class="i-svg-spinners:ring-resize w-5 h-5 text-fg-muted mx-auto" aria-hidden="true" />
332 <p class="font-mono text-sm text-fg-muted mt-2">{{ $t('org.teams.loading') }}</p>
333 </div>
334
335 <!-- Error state -->
336 <div v-else-if="error" class="p-4 text-center" role="alert">
337 <p class="font-mono text-sm text-red-400">
338 {{ error }}
339 </p>
340 <button
341 type="button"
342 class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
343 @click="loadTeams"
344 >
345 {{ $t('common.try_again') }}
346 </button>
347 </div>
348
349 <!-- Empty state -->
350 <div v-else-if="teams.length === 0" class="p-8 text-center">
351 <p class="font-mono text-sm text-fg-muted">{{ $t('org.teams.no_teams') }}</p>
352 </div>
353
354 <!-- Teams list -->
355 <ul v-else class="divide-y divide-border" :aria-label="$t('org.teams.list_label')">
356 <li v-for="teamName in filteredTeams" :key="teamName" class="bg-bg">
357 <!-- Team header -->
358 <div
359 class="flex items-center justify-start p-3 hover:bg-bg-subtle transition-colors duration-200"
360 >
361 <button
362 type="button"
363 class="flex-1 flex items-center gap-2 text-start rounded focus-visible:outline-accent/70"
364 :aria-expanded="expandedTeams.has(teamName)"
365 :aria-controls="`team-${teamName}-members`"
366 @click="toggleTeam(teamName)"
367 >
368 <span
369 class="w-4 h-4 transition-transform duration-200 rtl-flip"
370 :class="[
371 expandedTeams.has(teamName) ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right',
372 'text-fg-muted',
373 ]"
374 aria-hidden="true"
375 />
376 <span class="font-mono text-sm text-fg">{{ teamName }}</span>
377 <span v-if="teamUsers[teamName]" class="font-mono text-xs text-fg-subtle">
378 ({{
379 $t(
380 'org.teams.member_count',
381 { count: teamUsers[teamName].length },
382 teamUsers[teamName].length,
383 )
384 }})
385 </span>
386 <span
387 v-if="isLoadingUsers[teamName]"
388 class="i-svg-spinners:ring-resize w-3 h-3 text-fg-muted"
389 aria-hidden="true"
390 />
391 </button>
392 <span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
393 <button
394 type="button"
395 class="p-1 text-fg-subtle hover:text-red-400 transition-colors duration-200 rounded focus-visible:outline-accent/70"
396 :aria-label="$t('org.teams.delete_team', { name: teamName })"
397 @click.stop="handleDestroyTeam(teamName)"
398 >
399 <span class="i-lucide:trash w-4 h-4" aria-hidden="true" />
400 </button>
401 </div>
402
403 <!-- Expanded: Team members -->
404 <div
405 v-if="expandedTeams.has(teamName)"
406 :id="`team-${teamName}-members`"
407 class="ps-9 pe-3 pb-3"
408 >
409 <!-- Members list -->
410 <ul
411 v-if="teamUsers[teamName]?.length"
412 class="space-y-1 mb-2"
413 :aria-label="$t('org.teams.members_of', { team: teamName })"
414 >
415 <li
416 v-for="user in teamUsers[teamName]"
417 :key="user"
418 class="flex items-center justify-start py-1 ps-2 pe-1 rounded hover:bg-bg-subtle transition-colors duration-200"
419 >
420 <NuxtLink
421 :to="{ name: '~username', params: { username: user } }"
422 class="font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200"
423 >
424 ~{{ user }}
425 </NuxtLink>
426 <span class="font-mono text-sm text-fg mx-2">{{ teamName }}</span>
427 <button
428 type="button"
429 class="p-1 text-fg-subtle hover:text-red-400 transition-colors duration-200 rounded focus-visible:outline-accent/70"
430 :aria-label="$t('org.teams.remove_user', { user })"
431 @click="handleRemoveUser(teamName, user)"
432 >
433 <span class="i-lucide:x w-3.5 h-3.5" aria-hidden="true" />
434 </button>
435 </li>
436 </ul>
437 <p v-else-if="!isLoadingUsers[teamName]" class="font-mono text-xs text-fg-subtle py-1">
438 {{ $t('org.teams.no_members') }}
439 </p>
440
441 <!-- Add user form -->
442 <div v-if="showAddUserFor === teamName" class="mt-2">
443 <form class="flex items-center gap-2" @submit.prevent="handleAddUser(teamName)">
444 <label :for="`add-user-${teamName}`" class="sr-only">{{
445 $t('org.teams.username_to_add', { team: teamName })
446 }}</label>
447 <InputBase
448 :id="`add-user-${teamName}`"
449 v-model="newUserUsername"
450 type="text"
451 :name="`add-user-${teamName}`"
452 :placeholder="$t('org.teams.username_placeholder')"
453 no-correct
454 class="flex-1 min-w-25"
455 size="medium"
456 />
457 <button
458 type="submit"
459 :disabled="!newUserUsername.trim() || isAddingUser"
460 class="px-2 py-1 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"
461 >
462 {{ isAddingUser ? '…' : $t('org.teams.add_button') }}
463 </button>
464 <button
465 type="button"
466 class="p-1 text-fg-subtle hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
467 :aria-label="$t('org.teams.cancel_add_user')"
468 @click="showAddUserFor = null"
469 >
470 <span class="i-lucide:x w-4 h-4" aria-hidden="true" />
471 </button>
472 </form>
473 </div>
474 <button
475 v-else
476 type="button"
477 class="mt-2 px-2 py-1 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
478 @click="showAddUserFor = teamName"
479 >
480 {{ $t('org.teams.add_member') }}
481 </button>
482 </div>
483 </li>
484 </ul>
485
486 <!-- No results -->
487 <div v-if="teams.length > 0 && filteredTeams.length === 0" class="p-4 text-center">
488 <p class="font-mono text-sm text-fg-muted">
489 {{ $t('org.teams.no_match', { query: searchQuery }) }}
490 </p>
491 </div>
492
493 <!-- Create team -->
494 <div class="p-3 border-t border-border">
495 <div v-if="showCreateTeam">
496 <form class="flex items-center gap-2" @submit.prevent="handleCreateTeam">
497 <div class="flex-1 flex items-center">
498 <span
499 class="px-2 py-3 leading-none font-mono text-sm text-fg-subtle bg-bg border border-ie-0 border-border rounded-is"
500 >
501 {{ orgName }}:
502 </span>
503 <label for="new-team-name" class="sr-only">{{ $t('org.teams.team_name_label') }}</label>
504 <InputBase
505 id="new-team-name"
506 v-model="newTeamName"
507 type="text"
508 name="new-team-name"
509 :placeholder="$t('org.teams.team_name_placeholder')"
510 no-correct
511 class="flex-1 min-w-25 rounded-is-none"
512 size="medium"
513 />
514 </div>
515 <button
516 type="submit"
517 :disabled="!newTeamName.trim() || isCreatingTeam"
518 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"
519 >
520 {{ isCreatingTeam ? '…' : $t('org.teams.create_button') }}
521 </button>
522 <button
523 type="button"
524 class="p-1.5 text-fg-subtle hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
525 :aria-label="$t('org.teams.cancel_create')"
526 @click="showCreateTeam = false"
527 >
528 <span class="i-lucide:x w-4 h-4" aria-hidden="true" />
529 </button>
530 </form>
531 </div>
532 <button
533 v-else
534 type="button"
535 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"
536 @click="showCreateTeam = true"
537 >
538 {{ $t('org.teams.create_team') }}
539 </button>
540 </div>
541 </section>
542</template>