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