[READ-ONLY] a fast, modern browser for the npm registry
at main 542 lines 19 kB view raw
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>