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 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>