[READ-ONLY] a fast, modern browser for the npm registry
at main 306 lines 9.8 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 packageName: string 7}>() 8 9const { 10 isConnected, 11 lastExecutionTime, 12 listOrgTeams, 13 listPackageCollaborators, 14 addOperation, 15 error: connectorError, 16} = useConnector() 17 18// Extract org name from scoped package (e.g., "@nuxt/kit" -> "nuxt") 19const orgName = computed(() => { 20 if (!props.packageName.startsWith('@')) return null 21 const match = props.packageName.match(/^@([^/]+)\//) 22 return match ? match[1] : null 23}) 24 25// Data 26const collaborators = shallowRef<Record<string, 'read-only' | 'read-write'>>({}) 27const teams = shallowRef<string[]>([]) 28const isLoadingCollaborators = shallowRef(false) 29const isLoadingTeams = shallowRef(false) 30const error = shallowRef<string | null>(null) 31 32// Grant access form 33const showGrantAccess = shallowRef(false) 34const selectedTeam = shallowRef('') 35const permission = shallowRef<'read-only' | 'read-write'>('read-only') 36const isGranting = shallowRef(false) 37 38// Computed collaborator list with type detection 39const collaboratorList = computed(() => { 40 return Object.entries(collaborators.value) 41 .map(([name, perm]) => { 42 // Check if this looks like a team (org:team format) or user 43 const isTeam = name.includes(':') 44 return { 45 name, 46 permission: perm, 47 isTeam, 48 displayName: isTeam ? name.split(':')[1] : name, 49 } 50 }) 51 .sort((a, b) => { 52 // Teams first, then users 53 if (a.isTeam !== b.isTeam) return a.isTeam ? -1 : 1 54 return a.name.localeCompare(b.name) 55 }) 56}) 57 58// Load collaborators 59async function loadCollaborators() { 60 if (!isConnected.value) return 61 62 isLoadingCollaborators.value = true 63 error.value = null 64 65 try { 66 const result = await listPackageCollaborators(props.packageName) 67 if (result) { 68 collaborators.value = result 69 } else { 70 error.value = connectorError.value || 'Failed to load collaborators' 71 } 72 } finally { 73 isLoadingCollaborators.value = false 74 } 75} 76 77// Load teams for dropdown 78async function loadTeams() { 79 if (!isConnected.value || !orgName.value) return 80 81 isLoadingTeams.value = true 82 83 try { 84 const result = await listOrgTeams(orgName.value) 85 if (result) { 86 // Teams come as "org:team" format, extract just the team name 87 teams.value = result.map((t: string) => t.replace(`${orgName.value}:`, '')) 88 } 89 } finally { 90 isLoadingTeams.value = false 91 } 92} 93 94// Grant access 95async function handleGrantAccess() { 96 if (!selectedTeam.value || !orgName.value) return 97 98 isGranting.value = true 99 try { 100 const scopeTeam = buildScopeTeam(orgName.value, selectedTeam.value) 101 const operation: NewOperation = { 102 type: 'access:grant', 103 params: { 104 permission: permission.value, 105 scopeTeam, 106 pkg: props.packageName, 107 }, 108 description: `Grant ${permission.value} access to ${scopeTeam} for ${props.packageName}`, 109 command: `npm access grant ${permission.value} ${scopeTeam} ${props.packageName}`, 110 } 111 112 await addOperation(operation) 113 selectedTeam.value = '' 114 showGrantAccess.value = false 115 } finally { 116 isGranting.value = false 117 } 118} 119 120// Revoke access 121async function handleRevokeAccess(collaboratorName: string) { 122 // For teams, we use the full org:team format 123 // For users... actually npm access revoke only works for teams 124 // Users get access via maintainers/owners which is managed separately 125 126 const operation: NewOperation = { 127 type: 'access:revoke', 128 params: { 129 scopeTeam: collaboratorName, 130 pkg: props.packageName, 131 }, 132 description: `Revoke ${collaboratorName} access to ${props.packageName}`, 133 command: `npm access revoke ${collaboratorName} ${props.packageName}`, 134 } 135 136 await addOperation(operation) 137} 138 139// Reload when package changes 140watch( 141 () => [isConnected.value, props.packageName, lastExecutionTime.value], 142 ([connected]) => { 143 if (connected && orgName.value) { 144 loadCollaborators() 145 loadTeams() 146 } 147 }, 148 { immediate: true }, 149) 150</script> 151 152<template> 153 <section v-if="isConnected && orgName"> 154 <div class="flex items-center justify-between mb-3"> 155 <h2 id="access-heading" class="text-xs text-fg-subtle uppercase tracking-wider"> 156 {{ $t('package.access.title') }} 157 </h2> 158 <button 159 type="button" 160 class="p-1 text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70" 161 :aria-label="$t('package.access.refresh')" 162 :disabled="isLoadingCollaborators" 163 @click="loadCollaborators" 164 > 165 <span 166 class="i-lucide:refresh-ccw w-3.5 h-3.5" 167 :class="{ 'motion-safe:animate-spin': isLoadingCollaborators }" 168 aria-hidden="true" 169 /> 170 </button> 171 </div> 172 173 <!-- Loading state --> 174 <div v-if="isLoadingCollaborators && collaboratorList.length === 0" class="py-4 text-center"> 175 <span 176 class="i-svg-spinners:ring-resize w-4 h-4 text-fg-muted animate-spin mx-auto" 177 aria-hidden="true" 178 /> 179 </div> 180 181 <!-- Error state --> 182 <div v-else-if="error" class="text-xs text-red-400 mb-2" role="alert"> 183 {{ error }} 184 </div> 185 186 <!-- Collaborators list --> 187 <ul 188 v-if="collaboratorList.length > 0" 189 class="space-y-1 mb-3" 190 :aria-label="$t('package.access.list_label')" 191 > 192 <li 193 v-for="collab in collaboratorList" 194 :key="collab.name" 195 class="flex items-center justify-between py-1" 196 > 197 <div class="flex items-center gap-2 min-w-0"> 198 <span 199 v-if="collab.isTeam" 200 class="i-lucide:users w-3.5 h-3.5 text-fg-subtle shrink-0" 201 aria-hidden="true" 202 /> 203 <span 204 v-else 205 class="i-lucide:user w-3.5 h-3.5 text-fg-subtle shrink-0" 206 aria-hidden="true" 207 /> 208 <span class="font-mono text-sm text-fg-muted truncate"> 209 {{ collab.isTeam ? collab.displayName : `@${collab.name}` }} 210 </span> 211 <span 212 class="px-1 py-0.5 font-mono text-xs rounded shrink-0" 213 :class=" 214 collab.permission === 'read-write' 215 ? 'bg-green-500/20 text-green-400' 216 : 'bg-fg-subtle/20 text-fg-muted' 217 " 218 > 219 {{ 220 collab.permission === 'read-write' ? $t('package.access.rw') : $t('package.access.ro') 221 }} 222 </span> 223 </div> 224 <!-- Only show revoke for teams (users are managed via owners) --> 225 <button 226 v-if="collab.isTeam" 227 type="button" 228 class="p-1 text-fg-subtle hover:text-red-400 transition-colors duration-200 shrink-0 rounded focus-visible:outline-accent/70" 229 :aria-label="$t('package.access.revoke_access', { name: collab.displayName })" 230 @click="handleRevokeAccess(collab.name)" 231 > 232 <span class="i-lucide:x w-3.5 h-3.5" aria-hidden="true" /> 233 </button> 234 <span v-else class="text-xs text-fg-subtle"> {{ $t('package.access.owner') }} </span> 235 </li> 236 </ul> 237 238 <p v-else-if="!isLoadingCollaborators && !error" class="text-xs text-fg-subtle mb-3"> 239 {{ $t('package.access.no_access') }} 240 </p> 241 242 <!-- Grant access form --> 243 <div v-if="showGrantAccess"> 244 <form class="space-y-2" @submit.prevent="handleGrantAccess"> 245 <div class="flex items-center gap-2"> 246 <SelectField 247 :label="$t('package.access.select_team_label')" 248 hidden-label 249 id="grant-team-select" 250 v-model="selectedTeam" 251 name="grant-team" 252 block 253 size="sm" 254 :disabled="isLoadingTeams" 255 :items="[ 256 { 257 label: isLoadingTeams 258 ? $t('package.access.loading_teams') 259 : $t('package.access.select_team'), 260 value: '', 261 disabled: true, 262 }, 263 ...teams.map(team => ({ label: `${orgName}:${team}`, value: team })), 264 ]" 265 /> 266 <SelectField 267 :label="$t('package.access.permission_label')" 268 hidden-label 269 id="grant-permission-select" 270 v-model="permission" 271 name="grant-permission" 272 block 273 size="sm" 274 :items="[ 275 { label: $t('package.access.permission.read_only'), value: 'read-only' }, 276 { label: $t('package.access.permission.read_write'), value: 'read-write' }, 277 ]" 278 /> 279 <button 280 type="submit" 281 :disabled="!selectedTeam || isGranting" 282 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" 283 > 284 {{ isGranting ? '…' : $t('package.access.grant_button') }} 285 </button> 286 <button 287 type="button" 288 class="p-1.5 text-fg-subtle hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70" 289 :aria-label="$t('package.access.cancel_grant')" 290 @click="showGrantAccess = false" 291 > 292 <span class="i-lucide:x w-4 h-4" aria-hidden="true" /> 293 </button> 294 </div> 295 </form> 296 </div> 297 <button 298 v-else 299 type="button" 300 class="w-full px-3 py-1.5 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70" 301 @click="showGrantAccess = true" 302 > 303 {{ $t('package.access.grant_access') }} 304 </button> 305 </section> 306</template>