[READ-ONLY] a fast, modern browser for the npm registry
at main 282 lines 8.4 kB view raw
1<script setup lang="ts"> 2import type { NewOperation } from '~/composables/useConnector' 3 4const props = defineProps<{ 5 packageName: string 6 maintainers?: Array<{ name?: string; email?: string }> 7}>() 8 9const { 10 isConnected, 11 lastExecutionTime, 12 npmUser, 13 addOperation, 14 listPackageCollaborators, 15 listTeamUsers, 16} = useConnector() 17 18const showAddOwner = shallowRef(false) 19const newOwnerUsername = shallowRef('') 20const isAdding = shallowRef(false) 21const showAllMaintainers = shallowRef(false) 22 23const DEFAULT_VISIBLE_MAINTAINERS = 5 24 25// Show admin controls when connected (let npm CLI handle permission errors) 26const canManageOwners = computed(() => isConnected.value) 27 28// Computed for visible maintainers with show more/fewer support 29const visibleMaintainers = computed(() => { 30 if (canManageOwners.value || showAllMaintainers.value) { 31 return maintainerAccess.value 32 } 33 return maintainerAccess.value.slice(0, DEFAULT_VISIBLE_MAINTAINERS) 34}) 35 36const hiddenMaintainersCount = computed(() => 37 Math.max(0, maintainerAccess.value.length - DEFAULT_VISIBLE_MAINTAINERS), 38) 39 40// Extract org name from scoped package 41const orgName = computed(() => { 42 if (!props.packageName.startsWith('@')) return null 43 const match = props.packageName.match(/^@([^/]+)\//) 44 return match ? match[1] : null 45}) 46 47// Access data: who has access and via what 48const collaborators = shallowRef<Record<string, 'read-only' | 'read-write'>>({}) 49const teamMembers = ref<Record<string, string[]>>({}) // team -> members 50const isLoadingAccess = shallowRef(false) 51 52// Compute access source for each maintainer 53const maintainerAccess = computed(() => { 54 if (!props.maintainers) return [] 55 56 return props.maintainers.map(maintainer => { 57 const name = maintainer.name 58 if (!name) return { ...maintainer, accessVia: [] as string[] } 59 60 const accessVia: string[] = [] 61 62 // Check if they're a direct owner (in collaborators as a user, not team) 63 if (collaborators.value[name]) { 64 accessVia.push('owner') 65 } 66 67 // Check which teams they're in that have access 68 for (const [collab, _perm] of Object.entries(collaborators.value)) { 69 // Teams are in format "org:team" 70 if (collab.includes(':')) { 71 const teamName = collab.split(':')[1] 72 const members = teamMembers.value[collab] 73 if (members?.includes(name)) { 74 accessVia.push(teamName || collab) 75 } 76 } 77 } 78 79 // If no specific access found, they're likely an owner 80 if (accessVia.length === 0) { 81 accessVia.push('owner') 82 } 83 84 return { ...maintainer, accessVia } 85 }) 86}) 87 88// Load access information 89async function loadAccessInfo() { 90 if (!isConnected.value) return 91 92 isLoadingAccess.value = true 93 94 try { 95 // Get collaborators (teams and users with access) 96 const collabResult = await listPackageCollaborators(props.packageName) 97 if (collabResult) { 98 collaborators.value = collabResult 99 100 // For each team collaborator, load its members 101 const teamPromises: Promise<void>[] = [] 102 for (const collab of Object.keys(collabResult)) { 103 if (collab.includes(':')) { 104 teamPromises.push( 105 listTeamUsers(collab).then((members: string[] | null) => { 106 if (members) { 107 teamMembers.value[collab] = members 108 } 109 }), 110 ) 111 } 112 } 113 await Promise.all(teamPromises) 114 } 115 } finally { 116 isLoadingAccess.value = false 117 } 118} 119 120async function handleAddOwner() { 121 if (!newOwnerUsername.value.trim()) return 122 123 isAdding.value = true 124 try { 125 const username = newOwnerUsername.value.trim().replace(/^@/, '') 126 const operation: NewOperation = { 127 type: 'owner:add', 128 params: { 129 user: username, 130 pkg: props.packageName, 131 }, 132 description: `Add @${username} as owner of ${props.packageName}`, 133 command: `npm owner add ${username} ${props.packageName}`, 134 } 135 136 await addOperation(operation) 137 newOwnerUsername.value = '' 138 showAddOwner.value = false 139 } finally { 140 isAdding.value = false 141 } 142} 143 144async function handleRemoveOwner(username: string) { 145 const operation: NewOperation = { 146 type: 'owner:rm', 147 params: { 148 user: username, 149 pkg: props.packageName, 150 }, 151 description: `Remove @${username} from ${props.packageName}`, 152 command: `npm owner rm ${username} ${props.packageName}`, 153 } 154 155 await addOperation(operation) 156} 157 158// Load access info when connected and for scoped packages 159watch( 160 [isConnected, () => props.packageName, lastExecutionTime], 161 ([connected]) => { 162 if (connected && orgName.value) { 163 loadAccessInfo() 164 } 165 }, 166 { immediate: true }, 167) 168</script> 169 170<template> 171 <CollapsibleSection 172 v-if="maintainers?.length" 173 id="maintainers" 174 :title="$t('package.maintainers.title')" 175 > 176 <ul 177 class="space-y-2 list-none m-0 p-0 my-1 px-1" 178 :aria-label="$t('package.maintainers.list_label')" 179 > 180 <li 181 v-for="maintainer in visibleMaintainers" 182 :key="maintainer.name ?? maintainer.email" 183 class="flex items-center justify-between gap-2" 184 > 185 <div class="flex items-center gap-2 min-w-0"> 186 <LinkBase 187 v-if="maintainer.name" 188 :to="{ 189 name: '~username', 190 params: { username: maintainer.name }, 191 }" 192 class="link-subtle text-sm shrink-0" 193 dir="ltr" 194 > 195 ~{{ maintainer.name }} 196 </LinkBase> 197 <span v-else class="font-mono text-sm text-fg-muted" dir="ltr">{{ 198 maintainer.email 199 }}</span> 200 201 <!-- Access source badges --> 202 <span 203 v-if="isConnected && maintainer.accessVia?.length && !isLoadingAccess" 204 class="text-xs text-fg-subtle truncate" 205 > 206 {{ 207 $t('package.maintainers.via', { 208 teams: maintainer.accessVia.join(', '), 209 }) 210 }} 211 </span> 212 <span 213 v-if="canManageOwners && maintainer.name === npmUser" 214 class="text-xs text-fg-subtle shrink-0" 215 >{{ $t('package.maintainers.you') }}</span 216 > 217 </div> 218 219 <!-- Remove button (only when can manage and not self) --> 220 <ButtonBase 221 v-if="canManageOwners && maintainer.name && maintainer.name !== npmUser" 222 type="button" 223 class="hover:text-red-400" 224 :aria-label=" 225 $t('package.maintainers.remove_owner', { 226 name: maintainer.name, 227 }) 228 " 229 @click="handleRemoveOwner(maintainer.name)" 230 > 231 <span class="i-lucide:x w-3.5 h-3.5" aria-hidden="true" /> 232 </ButtonBase> 233 </li> 234 </ul> 235 236 <!-- Show more/less toggle (only when not managing and there are hidden maintainers) --> 237 <ButtonBase 238 v-if="!canManageOwners && hiddenMaintainersCount > 0" 239 @click="showAllMaintainers = !showAllMaintainers" 240 > 241 {{ 242 showAllMaintainers 243 ? $t('package.maintainers.show_less') 244 : $t('package.maintainers.show_more', { 245 count: hiddenMaintainersCount, 246 }) 247 }} 248 </ButtonBase> 249 250 <!-- Add owner form (only when can manage) --> 251 <div v-if="canManageOwners" class="mt-3"> 252 <div v-if="showAddOwner"> 253 <form class="flex items-center gap-2" @submit.prevent="handleAddOwner"> 254 <label for="add-owner-username" class="sr-only">{{ 255 $t('package.maintainers.username_to_add') 256 }}</label> 257 <InputBase 258 id="add-owner-username" 259 v-model="newOwnerUsername" 260 type="text" 261 name="add-owner-username" 262 :placeholder="$t('package.maintainers.username_placeholder')" 263 no-correct 264 class="flex-1 min-w-25 m-1" 265 size="small" 266 /> 267 <ButtonBase type="submit" :disabled="!newOwnerUsername.trim() || isAdding"> 268 {{ isAdding ? '…' : $t('package.maintainers.add_button') }} 269 </ButtonBase> 270 <ButtonBase 271 :aria-label="$t('package.maintainers.cancel_add')" 272 @click="showAddOwner = false" 273 classicon="i-lucide:x" 274 /> 275 </form> 276 </div> 277 <ButtonBase v-else type="button" @click="showAddOwner = true"> 278 {{ $t('package.maintainers.add_owner') }} 279 </ButtonBase> 280 </div> 281 </CollapsibleSection> 282</template>