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