+
+ |
+
+ |
+
| null> {
+ const response = await connectorFetch>>(
+ `/team/${encodeURIComponent(scopeTeam)}/packages`,
+ )
+ return response?.success ? (response.data ?? null) : null
+ }
+
async function listPackageCollaborators(
pkg: string,
): Promise | null> {
@@ -448,6 +457,7 @@ export const useConnector = createSharedComposable(function useConnector() {
listOrgUsers,
listOrgTeams,
listTeamUsers,
+ listTeamPackages,
listPackageCollaborators,
listUserPackages,
listUserOrgs,
diff --git a/app/composables/usePackageSelection.ts b/app/composables/usePackageSelection.ts
new file mode 100644
index 000000000..dcb46883e
--- /dev/null
+++ b/app/composables/usePackageSelection.ts
@@ -0,0 +1,162 @@
+/**
+ * Composable for managing package selection state.
+ * Used for bulk operations on multiple packages.
+ */
+export function usePackageSelection() {
+ // Selection state
+ const selected = ref>(new Set())
+ const isSelectionMode = shallowRef(false)
+
+ // Computed helpers
+ const selectedCount = computed(() => selected.value.size)
+ const selectedPackages = computed(() => Array.from(selected.value))
+ const hasSelection = computed(() => selected.value.size > 0)
+
+ /**
+ * Check if a package is selected
+ */
+ function isSelected(packageName: string): boolean {
+ return selected.value.has(packageName)
+ }
+
+ /**
+ * Toggle selection for a single package
+ */
+ function toggle(packageName: string): void {
+ const newSet = new Set(selected.value)
+ if (newSet.has(packageName)) {
+ newSet.delete(packageName)
+ } else {
+ newSet.add(packageName)
+ }
+ selected.value = newSet
+ }
+
+ /**
+ * Select a single package
+ */
+ function select(packageName: string): void {
+ if (!selected.value.has(packageName)) {
+ const newSet = new Set(selected.value)
+ newSet.add(packageName)
+ selected.value = newSet
+ }
+ }
+
+ /**
+ * Deselect a single package
+ */
+ function deselect(packageName: string): void {
+ if (selected.value.has(packageName)) {
+ const newSet = new Set(selected.value)
+ newSet.delete(packageName)
+ selected.value = newSet
+ }
+ }
+
+ /**
+ * Select all packages from a list
+ */
+ function selectAll(packages: string[]): void {
+ const newSet = new Set(selected.value)
+ for (const pkg of packages) {
+ newSet.add(pkg)
+ }
+ selected.value = newSet
+ }
+
+ /**
+ * Deselect all packages
+ */
+ function deselectAll(): void {
+ selected.value = new Set()
+ }
+
+ /**
+ * Enter selection mode
+ */
+ function enterSelectionMode(): void {
+ isSelectionMode.value = true
+ }
+
+ /**
+ * Exit selection mode and clear selection
+ */
+ function exitSelectionMode(): void {
+ isSelectionMode.value = false
+ deselectAll()
+ }
+
+ /**
+ * Toggle selection mode
+ */
+ function toggleSelectionMode(): void {
+ if (isSelectionMode.value) {
+ exitSelectionMode()
+ } else {
+ enterSelectionMode()
+ }
+ }
+
+ /**
+ * Check if all packages from a list are selected
+ */
+ function areAllSelected(packages: string[]): boolean {
+ if (packages.length === 0) return false
+ return packages.every(pkg => selected.value.has(pkg))
+ }
+
+ /**
+ * Check if some (but not all) packages from a list are selected
+ */
+ function areSomeSelected(packages: string[]): boolean {
+ if (packages.length === 0) return false
+ const selectedCount = packages.filter(pkg => selected.value.has(pkg)).length
+ return selectedCount > 0 && selectedCount < packages.length
+ }
+
+ /**
+ * Toggle all packages from a list (select all if not all selected, deselect all if all selected)
+ */
+ function toggleAll(packages: string[]): void {
+ if (areAllSelected(packages)) {
+ // Deselect all from the list
+ const newSet = new Set(selected.value)
+ for (const pkg of packages) {
+ newSet.delete(pkg)
+ }
+ selected.value = newSet
+ } else {
+ // Select all from the list
+ selectAll(packages)
+ }
+ }
+
+ return {
+ // State
+ selected: readonly(selected),
+ selectedPackages,
+ selectedCount,
+ hasSelection,
+ isSelectionMode: readonly(isSelectionMode),
+
+ // Selection actions
+ isSelected,
+ toggle,
+ select,
+ deselect,
+ selectAll,
+ deselectAll,
+ areAllSelected,
+ areSomeSelected,
+ toggleAll,
+
+ // Mode actions
+ enterSelectionMode,
+ exitSelectionMode,
+ toggleSelectionMode,
+ }
+}
+
+// Create a shared instance for the org page
+export const useOrgPackageSelection = createSharedComposable(usePackageSelection)
diff --git a/app/pages/org/[org].vue b/app/pages/org/[org].vue
index b513e5e9e..55c86542f 100644
--- a/app/pages/org/[org].vue
+++ b/app/pages/org/[org].vue
@@ -14,6 +14,31 @@ const orgName = computed(() => route.params.org.toLowerCase())
const { isConnected } = useConnector()
+// Package selection for bulk operations
+const {
+ selected: selectedPackagesSet,
+ selectedPackages: selectedPackagesList,
+ selectedCount,
+ isSelectionMode,
+ toggle: togglePackageSelection,
+ toggleAll,
+ deselectAll,
+ toggleSelectionMode,
+ exitSelectionMode,
+} = useOrgPackageSelection()
+
+// Convert ReadonlySet to Set for compatibility with child components
+const selectedPackages = computed(() => new Set(selectedPackagesSet.value))
+
+// Modal refs
+const bulkGrantModalRef = useTemplateRef<{ open: () => void; close: () => void }>('bulkGrantModal')
+const copyAccessModalRef = useTemplateRef<{ open: () => void; close: () => void }>(
+ 'copyAccessModal',
+)
+
+// Connector modal for viewing queued operations
+const connectorModal = useModal('connector-modal')
+
// Fetch all packages in this org using the org packages API (lazy to not block navigation)
const { data: results, status, error } = useOrgPackages(orgName)
@@ -116,14 +141,34 @@ watch(orgName, () => {
clearAllFilters()
setSort('updated-desc')
currentPage.value = 1
+ exitSelectionMode()
})
+// Handle package selection events
+function handleToggleSelect(packageName: string) {
+ togglePackageSelection(packageName)
+}
+
+function handleToggleSelectAll() {
+ const packageNames = sortedPackages.value.map(p => p.package.name)
+ toggleAll(packageNames)
+}
+
+// Handle bulk operations completion
+function handleOperationsQueued() {
+ // Open connector modal to show queued operations
+ connectorModal.open()
+}
+
+// Available package names for copy access modal
+const availablePackageNames = computed(() => packages.value.map(p => p.package.name))
+
// Handle filter chip removal
function handleClearFilter(chip: FilterChip) {
clearFilter(chip)
}
-const activeTab = shallowRef<'members' | 'teams'>('members')
+const activeTab = shallowRef<'members' | 'teams' | 'team-access'>('members')
// Canonical URL for this org page
const canonicalUrl = computed(() => `https://npmx.dev/@${orgName.value}`)
@@ -225,11 +270,24 @@ defineOgImageComponent('Default', {
>
{{ $t('org.page.teams_tab') }}
+
-
+
+
@@ -258,9 +316,47 @@ defineOgImageComponent('Default', {
-
- {{ $t('org.page.packages_title') }}
-
+
+
+ {{ $t('org.page.packages_title') }}
+
+
+
+
+
+
+
+
+
+
+
+
@@ -315,5 +415,22 @@ defineOgImageComponent('Default', {
/>
+
+
+
+
+
+
diff --git a/cli/src/mock-app.ts b/cli/src/mock-app.ts
index 24ebe6b88..4a472d3fb 100644
--- a/cli/src/mock-app.ts
+++ b/cli/src/mock-app.ts
@@ -30,6 +30,7 @@ const _endpointCheck: AssertEndpointsImplemented<
| 'GET /org/:org/users'
| 'GET /org/:org/teams'
| 'GET /team/:scopeTeam/users'
+ | 'GET /team/:scopeTeam/packages'
| 'GET /package/:pkg/collaborators'
| 'GET /user/packages'
| 'GET /user/orgs'
@@ -313,6 +314,33 @@ function createMockConnectorApp(stateManager: MockConnectorStateManager) {
>
})
+ // GET /team/:scopeTeam/packages
+ app.get('/team/:scopeTeam/packages', (event: H3Event) => {
+ requireAuth(event)
+
+ const scopeTeam = event.context.params?.scopeTeam
+ if (!scopeTeam) {
+ throw new HTTPError({ statusCode: 400, message: 'Missing scopeTeam parameter' })
+ }
+
+ if (!scopeTeam.startsWith('@') || !scopeTeam.includes(':')) {
+ throw new HTTPError({
+ statusCode: 400,
+ message: 'Invalid scope:team format (expected @scope:team)',
+ })
+ }
+
+ const [scope, team] = scopeTeam.split(':')
+ if (!scope || !team) {
+ throw new HTTPError({ statusCode: 400, message: 'Invalid scope:team format' })
+ }
+
+ const packages = stateManager.getTeamPackages(scope, team)
+ return { success: true, data: packages ?? {} } satisfies ApiResponse<
+ ConnectorEndpoints['GET /team/:scopeTeam/packages']['data']
+ >
+ })
+
// GET /package/:pkg/collaborators
app.get('/package/:pkg/collaborators', (event: H3Event) => {
requireAuth(event)
diff --git a/cli/src/mock-state.ts b/cli/src/mock-state.ts
index 829e0ff3c..b499edc66 100644
--- a/cli/src/mock-state.ts
+++ b/cli/src/mock-state.ts
@@ -153,6 +153,22 @@ export class MockConnectorStateManager {
return org.teamMembers[team] ?? null
}
+ getTeamPackages(scope: string, team: string): Record | null {
+ const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`
+ const scopeTeam = `${normalizedScope}:${team}`
+
+ // Find all packages where this team has access
+ const result: Record = {}
+ for (const [pkg, data] of Object.entries(this.state.packages)) {
+ const permission = data.collaborators[scopeTeam]
+ if (permission) {
+ result[pkg] = permission
+ }
+ }
+
+ return Object.keys(result).length > 0 ? result : {}
+ }
+
// -- Package data --
setPackageData(pkg: string, data: MockPackageData): void {
diff --git a/cli/src/npm-client.ts b/cli/src/npm-client.ts
index a3f61a5c1..4a31dfe5b 100644
--- a/cli/src/npm-client.ts
+++ b/cli/src/npm-client.ts
@@ -548,6 +548,16 @@ export async function listUserPackages(user: string): Promise {
return execNpm(['access', 'list', 'packages', `@${user}`, '--json'], { silent: true })
}
+/**
+ * Lists all packages that a team has access to.
+ * Uses `npm access list packages {scopeTeam} --json`
+ * Returns a map of package name to permission level
+ */
+export async function listTeamPackages(scopeTeam: string): Promise {
+ validateScopeTeam(scopeTeam)
+ return execNpm(['access', 'list', 'packages', scopeTeam, '--json'], { silent: true })
+}
+
/**
* Initialize and publish a new package to claim the name.
* Creates a minimal package.json in a temp directory and publishes it.
diff --git a/cli/src/server.ts b/cli/src/server.ts
index ee798c7a9..12864a83c 100644
--- a/cli/src/server.ts
+++ b/cli/src/server.ts
@@ -26,6 +26,7 @@ const _endpointCheck: AssertEndpointsImplemented<
| 'GET /org/:org/users'
| 'GET /org/:org/teams'
| 'GET /team/:scopeTeam/users'
+ | 'GET /team/:scopeTeam/packages'
| 'GET /package/:pkg/collaborators'
| 'GET /user/packages'
| 'GET /user/orgs'
@@ -47,6 +48,7 @@ import {
accessGrant,
accessRevoke,
accessListCollaborators,
+ listTeamPackages,
ownerAdd,
ownerRemove,
packageInit,
@@ -608,6 +610,52 @@ export function createConnectorApp(expectedToken: string) {
}
})
+ app.get('/team/:scopeTeam/packages', async event => {
+ const auth = event.req.headers.get('authorization')
+ if (!validateToken(auth)) {
+ throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
+ }
+
+ const scopeTeamRaw = event.context.params?.scopeTeam
+ if (!scopeTeamRaw) {
+ throw new HTTPError({ statusCode: 400, message: 'Team name required' })
+ }
+
+ // Decode the team name (handles encoded colons like nuxt%3Adevelopers)
+ const scopeTeam = decodeURIComponent(scopeTeamRaw)
+
+ const validationResult = safeParse(ScopeTeamSchema, scopeTeam)
+ if (!validationResult.success) {
+ logError('scope:team validation failed')
+ logDebug(validationResult.error, { scopeTeamRaw, scopeTeam })
+ throw new HTTPError({
+ statusCode: 400,
+ message: `Invalid scope:team format: ${scopeTeam}. Expected @scope:team`,
+ })
+ }
+
+ const result = await listTeamPackages(scopeTeam)
+ if (result.exitCode !== 0) {
+ return {
+ success: false,
+ error: result.stderr || 'Failed to list team packages',
+ } as ApiResponse
+ }
+
+ try {
+ const packages = JSON.parse(result.stdout) as Record
+ return {
+ success: true,
+ data: packages,
+ } satisfies ApiResponse
+ } catch {
+ return {
+ success: false,
+ error: 'Failed to parse team packages',
+ } as ApiResponse
+ }
+ })
+
app.get('/package/:pkg/collaborators', async event => {
const auth = event.req.headers.get('authorization')
if (!validateToken(auth)) {
@@ -627,7 +675,10 @@ export function createConnectorApp(expectedToken: string) {
throw new HTTPError({ statusCode: 400, message: pkgValidation.error })
}
- const result = await accessListCollaborators(pkgValidation.data)
+ const pkg = pkgValidation.data
+
+ // Get user collaborators
+ const result = await accessListCollaborators(pkg)
if (result.exitCode !== 0) {
return {
success: false,
@@ -637,6 +688,48 @@ export function createConnectorApp(expectedToken: string) {
try {
const collaborators = JSON.parse(result.stdout) as Record
+
+ // For scoped packages, also fetch team access
+ if (pkg.startsWith('@')) {
+ const orgMatch = pkg.match(/^@([^/]+)\//)
+ if (orgMatch) {
+ const org = orgMatch[1]
+
+ // Get all teams in the org
+ const teamsResult = await teamListTeams(org)
+ if (teamsResult.exitCode === 0) {
+ try {
+ const teams = JSON.parse(teamsResult.stdout) as string[]
+
+ // Check each team's package access
+ await Promise.all(
+ teams.map(async team => {
+ // Add @ prefix to team name since we expect scoped packages
+ team = '@' + team
+ const teamPkgsResult = await listTeamPackages(team)
+ if (teamPkgsResult.exitCode === 0) {
+ try {
+ const teamPkgs = JSON.parse(teamPkgsResult.stdout) as Record<
+ string,
+ 'read-only' | 'read-write'
+ >
+ // If this team has access to the package, add it to collaborators
+ if (teamPkgs[pkg]) {
+ collaborators[team] = teamPkgs[pkg]
+ }
+ } catch {
+ // Ignore parse errors for individual teams
+ }
+ }
+ }),
+ )
+ } catch {
+ // Ignore team list parse errors, return user collaborators only
+ }
+ }
+ }
+ }
+
return {
success: true,
data: collaborators,
diff --git a/cli/src/types.ts b/cli/src/types.ts
index fcd0db2a4..ec15da9d9 100644
--- a/cli/src/types.ts
+++ b/cli/src/types.ts
@@ -141,6 +141,7 @@ export interface ConnectorEndpoints {
'GET /org/:org/users': { body: never; data: Record }
'GET /org/:org/teams': { body: never; data: string[] }
'GET /team/:scopeTeam/users': { body: never; data: string[] }
+ 'GET /team/:scopeTeam/packages': { body: never; data: Record }
'GET /package/:pkg/collaborators': { body: never; data: Record }
'GET /user/packages': { body: never; data: Record }
'GET /user/orgs': { body: never; data: string[] }
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 2aecdcd92..1ce8db7b3 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -311,6 +311,41 @@
"keywords": "Keywords",
"license": "License"
},
+ "bulk": {
+ "select_mode": "Select packages",
+ "exit_select_mode": "Exit selection",
+ "selected_count": "{count} selected | {count} selected",
+ "select_package": "Select {name}",
+ "select_all": "Select all",
+ "grant_access": "Grant team access",
+ "copy_access": "Copy access from...",
+ "clear_selection": "Clear selection",
+ "grant_access_title": "Grant Team Access",
+ "copy_access_title": "Copy Access Pattern",
+ "select_team": "Select team",
+ "choose_team": "Choose a team...",
+ "no_teams_available": "No teams available in this organization",
+ "select_permission": "Permission level",
+ "permission_read": "read-only",
+ "permission_write": "read-write",
+ "selected_packages_count": "{count} package selected | {count} packages selected",
+ "and_more": "+{count} more",
+ "grant_access_button": "Grant access to {count} package | Grant access to {count} packages",
+ "granting": "Granting access...",
+ "operations_queued": "Operations queued",
+ "operations_queued_detail": "{count} operation added to queue | {count} operations added to queue",
+ "review_operations_hint": "Review and execute operations in the connector panel.",
+ "source_package": "Copy access from package",
+ "search_source_package": "Search for a package...",
+ "clear_source": "Clear selection",
+ "loading_access": "Loading access settings...",
+ "teams_to_copy": "Teams to copy",
+ "no_teams_found": "No team access found on this package",
+ "copy_to_packages": "Copy to {count} package | Copy to {count} packages",
+ "copy_access_button": "Copy {count} team access | Copy {count} team accesses",
+ "copying": "Copying access...",
+ "copy_operations_queued_detail": "Copying {teamCount} team access to {packageCount} packages"
+ },
"versions": {
"title": "Versions",
"collapse": "Collapse {tag}",
@@ -383,7 +418,14 @@
"cancel_add": "Cancel adding owner",
"add_owner": "+ Add owner",
"show_more": "(show {count} more)",
- "show_less": "(show fewer)"
+ "show_less": "(show fewer)",
+ "remove": {
+ "title": "Remove Owner",
+ "warning": "This action will revoke ownership.",
+ "impact": "{user} will no longer be able to publish updates to {package}. This operation will be queued and executed when you approve the operation in the connector.",
+ "removing": "Removing...",
+ "confirm": "Remove owner"
+ }
},
"trends": {
"granularity": "Granularity",
@@ -505,7 +547,14 @@
},
"grant_button": "grant",
"cancel_grant": "Cancel granting access",
- "grant_access": "+ Grant team access"
+ "grant_access": "+ Grant team access",
+ "revoke": {
+ "title": "Revoke Team Access",
+ "warning": "This action cannot be undone",
+ "impact": "Team \"{team}\" will lose access to {package}. This operation will be queued and executed when you approve the operation in the connector.",
+ "confirm": "Revoke Access",
+ "revoking": "Revoking..."
+ }
},
"list": {
"filter_label": "Filter packages",
@@ -610,6 +659,19 @@
"cancel_create": "Cancel creating team",
"create_team": "+ Create team"
},
+ "team_access": {
+ "title": "Team Access",
+ "refresh": "Refresh",
+ "select_team": "Select a team",
+ "select_team_hint": "Select a team to view its package access",
+ "filter_packages": "Filter packages",
+ "filter_packages_placeholder": "Filter packages...",
+ "loading_packages": "Loading packages...",
+ "no_packages": "No package access for this team",
+ "no_match": "No packages match \"{query}\"",
+ "packages_list": "Packages with team access",
+ "package_count": "{count} package | {count} packages"
+ },
"members": {
"title": "Members",
"refresh": "Refresh members",
@@ -646,6 +708,7 @@
"packages_title": "Packages",
"members_tab": "Members",
"teams_tab": "Teams",
+ "team_access_tab": "Team Access",
"no_packages": "No public packages found for",
"no_packages_hint": "This organization may not exist or has no public packages.",
"failed_to_load": "Failed to load organization packages",
diff --git a/i18n/schema.json b/i18n/schema.json
index f01ef79e8..596017434 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -937,6 +937,111 @@
},
"additionalProperties": false
},
+ "bulk": {
+ "type": "object",
+ "properties": {
+ "select_mode": {
+ "type": "string"
+ },
+ "exit_select_mode": {
+ "type": "string"
+ },
+ "selected_count": {
+ "type": "string"
+ },
+ "select_package": {
+ "type": "string"
+ },
+ "select_all": {
+ "type": "string"
+ },
+ "grant_access": {
+ "type": "string"
+ },
+ "copy_access": {
+ "type": "string"
+ },
+ "clear_selection": {
+ "type": "string"
+ },
+ "grant_access_title": {
+ "type": "string"
+ },
+ "copy_access_title": {
+ "type": "string"
+ },
+ "select_team": {
+ "type": "string"
+ },
+ "choose_team": {
+ "type": "string"
+ },
+ "no_teams_available": {
+ "type": "string"
+ },
+ "select_permission": {
+ "type": "string"
+ },
+ "permission_read": {
+ "type": "string"
+ },
+ "permission_write": {
+ "type": "string"
+ },
+ "selected_packages_count": {
+ "type": "string"
+ },
+ "and_more": {
+ "type": "string"
+ },
+ "grant_access_button": {
+ "type": "string"
+ },
+ "granting": {
+ "type": "string"
+ },
+ "operations_queued": {
+ "type": "string"
+ },
+ "operations_queued_detail": {
+ "type": "string"
+ },
+ "review_operations_hint": {
+ "type": "string"
+ },
+ "source_package": {
+ "type": "string"
+ },
+ "search_source_package": {
+ "type": "string"
+ },
+ "clear_source": {
+ "type": "string"
+ },
+ "loading_access": {
+ "type": "string"
+ },
+ "teams_to_copy": {
+ "type": "string"
+ },
+ "no_teams_found": {
+ "type": "string"
+ },
+ "copy_to_packages": {
+ "type": "string"
+ },
+ "copy_access_button": {
+ "type": "string"
+ },
+ "copying": {
+ "type": "string"
+ },
+ "copy_operations_queued_detail": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
"versions": {
"type": "object",
"properties": {
@@ -1155,6 +1260,27 @@
},
"show_less": {
"type": "string"
+ },
+ "remove": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "warning": {
+ "type": "string"
+ },
+ "impact": {
+ "type": "string"
+ },
+ "removing": {
+ "type": "string"
+ },
+ "confirm": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
@@ -1521,6 +1647,27 @@
},
"grant_access": {
"type": "string"
+ },
+ "revoke": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "warning": {
+ "type": "string"
+ },
+ "impact": {
+ "type": "string"
+ },
+ "confirm": {
+ "type": "string"
+ },
+ "revoking": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
@@ -1834,6 +1981,45 @@
},
"additionalProperties": false
},
+ "team_access": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "refresh": {
+ "type": "string"
+ },
+ "select_team": {
+ "type": "string"
+ },
+ "select_team_hint": {
+ "type": "string"
+ },
+ "filter_packages": {
+ "type": "string"
+ },
+ "filter_packages_placeholder": {
+ "type": "string"
+ },
+ "loading_packages": {
+ "type": "string"
+ },
+ "no_packages": {
+ "type": "string"
+ },
+ "no_match": {
+ "type": "string"
+ },
+ "packages_list": {
+ "type": "string"
+ },
+ "package_count": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
"members": {
"type": "object",
"properties": {
@@ -1942,6 +2128,9 @@
"teams_tab": {
"type": "string"
},
+ "team_access_tab": {
+ "type": "string"
+ },
"no_packages": {
"type": "string"
},
diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json
index 63b433f80..c4a332ab1 100644
--- a/lunaria/files/en-GB.json
+++ b/lunaria/files/en-GB.json
@@ -310,6 +310,41 @@
"keywords": "Keywords",
"license": "License"
},
+ "bulk": {
+ "select_mode": "Select packages",
+ "exit_select_mode": "Exit selection",
+ "selected_count": "{count} selected | {count} selected",
+ "select_package": "Select {name}",
+ "select_all": "Select all",
+ "grant_access": "Grant team access",
+ "copy_access": "Copy access from...",
+ "clear_selection": "Clear selection",
+ "grant_access_title": "Grant Team Access",
+ "copy_access_title": "Copy Access Pattern",
+ "select_team": "Select team",
+ "choose_team": "Choose a team...",
+ "no_teams_available": "No teams available in this organization",
+ "select_permission": "Permission level",
+ "permission_read": "read-only",
+ "permission_write": "read-write",
+ "selected_packages_count": "{count} package selected | {count} packages selected",
+ "and_more": "+{count} more",
+ "grant_access_button": "Grant access to {count} package | Grant access to {count} packages",
+ "granting": "Granting access...",
+ "operations_queued": "Operations queued",
+ "operations_queued_detail": "{count} operation added to queue | {count} operations added to queue",
+ "review_operations_hint": "Review and execute operations in the connector panel.",
+ "source_package": "Copy access from package",
+ "search_source_package": "Search for a package...",
+ "clear_source": "Clear selection",
+ "loading_access": "Loading access settings...",
+ "teams_to_copy": "Teams to copy",
+ "no_teams_found": "No team access found on this package",
+ "copy_to_packages": "Copy to {count} package | Copy to {count} packages",
+ "copy_access_button": "Copy {count} team access | Copy {count} team accesses",
+ "copying": "Copying access...",
+ "copy_operations_queued_detail": "Copying {teamCount} team access to {packageCount} packages"
+ },
"versions": {
"title": "Versions",
"collapse": "Collapse {tag}",
@@ -382,7 +417,14 @@
"cancel_add": "Cancel adding owner",
"add_owner": "+ Add owner",
"show_more": "(show {count} more)",
- "show_less": "(show fewer)"
+ "show_less": "(show fewer)",
+ "remove": {
+ "title": "Remove Owner",
+ "warning": "This action will revoke ownership.",
+ "impact": "{user} will no longer be able to publish updates to {package}. This operation will be queued and executed when you approve the operation in the connector.",
+ "removing": "Removing...",
+ "confirm": "Remove owner"
+ }
},
"trends": {
"granularity": "Granularity",
@@ -504,7 +546,14 @@
},
"grant_button": "grant",
"cancel_grant": "Cancel granting access",
- "grant_access": "+ Grant team access"
+ "grant_access": "+ Grant team access",
+ "revoke": {
+ "title": "Revoke Team Access",
+ "warning": "This action cannot be undone",
+ "impact": "Team \"{team}\" will lose access to {package}. This operation will be queued and executed when you approve the operation in the connector.",
+ "confirm": "Revoke Access",
+ "revoking": "Revoking..."
+ }
},
"list": {
"filter_label": "Filter packages",
@@ -609,6 +658,19 @@
"cancel_create": "Cancel creating team",
"create_team": "+ Create team"
},
+ "team_access": {
+ "title": "Team Access",
+ "refresh": "Refresh",
+ "select_team": "Select a team",
+ "select_team_hint": "Select a team to view its package access",
+ "filter_packages": "Filter packages",
+ "filter_packages_placeholder": "Filter packages...",
+ "loading_packages": "Loading packages...",
+ "no_packages": "No package access for this team",
+ "no_match": "No packages match \"{query}\"",
+ "packages_list": "Packages with team access",
+ "package_count": "{count} package | {count} packages"
+ },
"members": {
"title": "Members",
"refresh": "Refresh members",
@@ -645,6 +707,7 @@
"packages_title": "Packages",
"members_tab": "Members",
"teams_tab": "Teams",
+ "team_access_tab": "Team Access",
"no_packages": "No public packages found for",
"no_packages_hint": "This organisation may not exist or has no public packages.",
"failed_to_load": "Failed to load organisation packages",
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index 108aacb17..fde68b5f9 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -310,6 +310,41 @@
"keywords": "Keywords",
"license": "License"
},
+ "bulk": {
+ "select_mode": "Select packages",
+ "exit_select_mode": "Exit selection",
+ "selected_count": "{count} selected | {count} selected",
+ "select_package": "Select {name}",
+ "select_all": "Select all",
+ "grant_access": "Grant team access",
+ "copy_access": "Copy access from...",
+ "clear_selection": "Clear selection",
+ "grant_access_title": "Grant Team Access",
+ "copy_access_title": "Copy Access Pattern",
+ "select_team": "Select team",
+ "choose_team": "Choose a team...",
+ "no_teams_available": "No teams available in this organization",
+ "select_permission": "Permission level",
+ "permission_read": "read-only",
+ "permission_write": "read-write",
+ "selected_packages_count": "{count} package selected | {count} packages selected",
+ "and_more": "+{count} more",
+ "grant_access_button": "Grant access to {count} package | Grant access to {count} packages",
+ "granting": "Granting access...",
+ "operations_queued": "Operations queued",
+ "operations_queued_detail": "{count} operation added to queue | {count} operations added to queue",
+ "review_operations_hint": "Review and execute operations in the connector panel.",
+ "source_package": "Copy access from package",
+ "search_source_package": "Search for a package...",
+ "clear_source": "Clear selection",
+ "loading_access": "Loading access settings...",
+ "teams_to_copy": "Teams to copy",
+ "no_teams_found": "No team access found on this package",
+ "copy_to_packages": "Copy to {count} package | Copy to {count} packages",
+ "copy_access_button": "Copy {count} team access | Copy {count} team accesses",
+ "copying": "Copying access...",
+ "copy_operations_queued_detail": "Copying {teamCount} team access to {packageCount} packages"
+ },
"versions": {
"title": "Versions",
"collapse": "Collapse {tag}",
@@ -382,7 +417,14 @@
"cancel_add": "Cancel adding owner",
"add_owner": "+ Add owner",
"show_more": "(show {count} more)",
- "show_less": "(show fewer)"
+ "show_less": "(show fewer)",
+ "remove": {
+ "title": "Remove Owner",
+ "warning": "This action will revoke ownership.",
+ "impact": "{user} will no longer be able to publish updates to {package}. This operation will be queued and executed when you approve the operation in the connector.",
+ "removing": "Removing...",
+ "confirm": "Remove owner"
+ }
},
"trends": {
"granularity": "Granularity",
@@ -504,7 +546,14 @@
},
"grant_button": "grant",
"cancel_grant": "Cancel granting access",
- "grant_access": "+ Grant team access"
+ "grant_access": "+ Grant team access",
+ "revoke": {
+ "title": "Revoke Team Access",
+ "warning": "This action cannot be undone",
+ "impact": "Team \"{team}\" will lose access to {package}. This operation will be queued and executed when you approve the operation in the connector.",
+ "confirm": "Revoke Access",
+ "revoking": "Revoking..."
+ }
},
"list": {
"filter_label": "Filter packages",
@@ -609,6 +658,19 @@
"cancel_create": "Cancel creating team",
"create_team": "+ Create team"
},
+ "team_access": {
+ "title": "Team Access",
+ "refresh": "Refresh",
+ "select_team": "Select a team",
+ "select_team_hint": "Select a team to view its package access",
+ "filter_packages": "Filter packages",
+ "filter_packages_placeholder": "Filter packages...",
+ "loading_packages": "Loading packages...",
+ "no_packages": "No package access for this team",
+ "no_match": "No packages match \"{query}\"",
+ "packages_list": "Packages with team access",
+ "package_count": "{count} package | {count} packages"
+ },
"members": {
"title": "Members",
"refresh": "Refresh members",
@@ -645,6 +707,7 @@
"packages_title": "Packages",
"members_tab": "Members",
"teams_tab": "Teams",
+ "team_access_tab": "Team Access",
"no_packages": "No public packages found for",
"no_packages_hint": "This organization may not exist or has no public packages.",
"failed_to_load": "Failed to load organization packages",
diff --git a/test/nuxt/components/Package/AccessControls.spec.ts b/test/nuxt/components/Package/AccessControls.spec.ts
new file mode 100644
index 000000000..c02c41432
--- /dev/null
+++ b/test/nuxt/components/Package/AccessControls.spec.ts
@@ -0,0 +1,365 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
+import { ref, computed, readonly, nextTick } from 'vue'
+import type { VueWrapper } from '@vue/test-utils'
+import type { PendingOperation } from '../../../../cli/src/types'
+import { PackageAccessControls } from '#components'
+
+// Mock state that will be controlled by tests
+const mockState = ref({
+ connected: false,
+ connecting: false,
+ npmUser: null as string | null,
+ avatar: null as string | null,
+ operations: [] as PendingOperation[],
+ error: null as string | null,
+ lastExecutionTime: null as number | null,
+})
+
+// Mock connector methods
+const mockAddOperation = vi.fn()
+const mockListPackageCollaborators = vi.fn()
+const mockListOrgTeams = vi.fn()
+
+// Create the mock composable function
+function createMockUseConnector() {
+ return {
+ state: readonly(mockState),
+ isConnected: computed(() => mockState.value.connected),
+ isConnecting: computed(() => mockState.value.connecting),
+ npmUser: computed(() => mockState.value.npmUser),
+ avatar: computed(() => mockState.value.avatar),
+ error: computed(() => mockState.value.error),
+ lastExecutionTime: computed(() => mockState.value.lastExecutionTime),
+ operations: computed(() => mockState.value.operations),
+ connect: vi.fn().mockResolvedValue(true),
+ reconnect: vi.fn().mockResolvedValue(true),
+ disconnect: vi.fn(),
+ refreshState: vi.fn().mockResolvedValue(undefined),
+ addOperation: mockAddOperation,
+ addOperations: vi.fn().mockResolvedValue([]),
+ removeOperation: vi.fn().mockResolvedValue(true),
+ clearOperations: vi.fn().mockResolvedValue(0),
+ approveOperation: vi.fn().mockResolvedValue(true),
+ retryOperation: vi.fn().mockResolvedValue(true),
+ approveAll: vi.fn().mockResolvedValue(0),
+ executeOperations: vi.fn().mockResolvedValue({ success: true }),
+ listOrgUsers: vi.fn().mockResolvedValue(null),
+ listOrgTeams: mockListOrgTeams,
+ listTeamUsers: vi.fn().mockResolvedValue(null),
+ listPackageCollaborators: mockListPackageCollaborators,
+ listUserPackages: vi.fn().mockResolvedValue(null),
+ listUserOrgs: vi.fn().mockResolvedValue(null),
+ }
+}
+
+function resetMockState() {
+ mockState.value = {
+ connected: false,
+ connecting: false,
+ npmUser: null,
+ avatar: null,
+ operations: [],
+ error: null,
+ lastExecutionTime: null,
+ }
+ mockAddOperation.mockReset()
+ mockListPackageCollaborators.mockReset()
+ mockListOrgTeams.mockReset()
+}
+
+function simulateConnect() {
+ mockState.value.connected = true
+ mockState.value.npmUser = 'testuser'
+}
+
+mockNuxtImport('useConnector', () => {
+ return createMockUseConnector
+})
+
+// Track current wrapper for cleanup
+let currentWrapper: VueWrapper | null = null
+
+/**
+ * Get the revoke confirmation modal dialog element from the document body.
+ */
+function getRevokeDialog(): HTMLDialogElement | null {
+ return document.body.querySelector('dialog#revoke-access-modal')
+}
+
+/**
+ * Mount the component connected state with collaborators.
+ */
+async function mountConnectedWithCollaborators(
+ collaborators: Record = {},
+ teams: string[] = [],
+) {
+ simulateConnect()
+ mockListPackageCollaborators.mockResolvedValue(collaborators)
+ mockListOrgTeams.mockResolvedValue(teams)
+
+ currentWrapper = await mountSuspended(PackageAccessControls, {
+ props: {
+ packageName: '@myorg/my-package',
+ },
+ attachTo: document.body,
+ })
+
+ // Wait for async data loading
+ await nextTick()
+ await nextTick()
+
+ return currentWrapper
+}
+
+// Reset state before each test
+beforeEach(() => {
+ resetMockState()
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ if (currentWrapper) {
+ currentWrapper.unmount()
+ currentWrapper = null
+ }
+})
+
+describe('PackageAccessControls', () => {
+ describe('Revoke confirmation dialog', () => {
+ it('does not show revoke dialog initially', async () => {
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ const dialog = getRevokeDialog()
+ expect(dialog?.open).toBeFalsy()
+ })
+
+ it('opens revoke dialog when clicking revoke button on a team', async () => {
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ // Find and click the revoke button
+ const revokeBtn = document.querySelector('button[aria-label*="Revoke"]') as HTMLButtonElement
+ expect(revokeBtn).toBeTruthy()
+
+ revokeBtn?.click()
+ await nextTick()
+
+ const dialog = getRevokeDialog()
+ expect(dialog?.open).toBe(true)
+ })
+
+ it('shows team name in the confirmation dialog', async () => {
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ // Open the dialog
+ const revokeBtn = document.querySelector('button[aria-label*="Revoke"]') as HTMLButtonElement
+ revokeBtn?.click()
+ await nextTick()
+
+ const dialog = getRevokeDialog()
+ expect(dialog?.textContent).toContain('myorg:developers')
+ })
+
+ it('shows warning message in the confirmation dialog', async () => {
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ // Open the dialog
+ const revokeBtn = document.querySelector('button[aria-label*="Revoke"]') as HTMLButtonElement
+ revokeBtn?.click()
+ await nextTick()
+
+ const dialog = getRevokeDialog()
+ // Should contain the warning about the action being irreversible
+ expect(dialog?.textContent).toContain('cannot be undone')
+ })
+
+ it('closes dialog when clicking close button', async () => {
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ // Open the dialog
+ const revokeBtn = document.querySelector('button[aria-label*="Revoke"]') as HTMLButtonElement
+ revokeBtn?.click()
+ await nextTick()
+
+ const dialog = getRevokeDialog()
+ expect(dialog?.open).toBe(true)
+
+ // Find and click close button
+ const buttons = dialog?.querySelectorAll('button')
+ const closeBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('close'),
+ ) as HTMLButtonElement
+ closeBtn?.click()
+ await nextTick()
+
+ expect(dialog?.open).toBe(false)
+ })
+
+ it('calls addOperation when confirming revoke', async () => {
+ mockAddOperation.mockResolvedValue({
+ id: '0000000000000001',
+ type: 'access:revoke',
+ params: { scopeTeam: 'myorg:developers', pkg: '@myorg/my-package' },
+ description: 'Revoke myorg:developers access to @myorg/my-package',
+ command: 'npm access revoke myorg:developers @myorg/my-package',
+ status: 'pending',
+ createdAt: Date.now(),
+ })
+
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ // Open the dialog
+ const revokeBtn = document.querySelector('button[aria-label*="Revoke"]') as HTMLButtonElement
+ revokeBtn?.click()
+ await nextTick()
+
+ const dialog = getRevokeDialog()
+
+ // Find and click confirm button
+ const buttons = dialog?.querySelectorAll('button')
+ const confirmBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('revoke'),
+ ) as HTMLButtonElement
+ confirmBtn?.click()
+ await nextTick()
+
+ expect(mockAddOperation).toHaveBeenCalledWith({
+ type: 'access:revoke',
+ params: {
+ scopeTeam: 'myorg:developers',
+ pkg: '@myorg/my-package',
+ },
+ description: 'Revoke myorg:developers access to @myorg/my-package',
+ command: 'npm access revoke myorg:developers @myorg/my-package',
+ })
+ })
+
+ it('closes dialog after successful revoke', async () => {
+ mockAddOperation.mockResolvedValue({
+ id: '0000000000000001',
+ type: 'access:revoke',
+ params: { scopeTeam: 'myorg:developers', pkg: '@myorg/my-package' },
+ description: 'Revoke myorg:developers access to @myorg/my-package',
+ command: 'npm access revoke myorg:developers @myorg/my-package',
+ status: 'pending',
+ createdAt: Date.now(),
+ })
+
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ // Open the dialog
+ const revokeBtn = document.querySelector('button[aria-label*="Revoke"]') as HTMLButtonElement
+ revokeBtn?.click()
+ await nextTick()
+
+ const dialog = getRevokeDialog()
+
+ // Find and click confirm button
+ const buttons = dialog?.querySelectorAll('button')
+ const confirmBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('revoke'),
+ ) as HTMLButtonElement
+ confirmBtn?.click()
+ await nextTick()
+ await nextTick()
+
+ expect(dialog?.open).toBe(false)
+ })
+
+ it('shows error message when revoke fails', async () => {
+ mockAddOperation.mockResolvedValue(null)
+ mockState.value.error = 'Connection failed'
+
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ })
+
+ // Open the dialog
+ const revokeBtn = document.querySelector('button[aria-label*="Revoke"]') as HTMLButtonElement
+ revokeBtn?.click()
+ await nextTick()
+
+ const dialog = getRevokeDialog()
+
+ // Find and click confirm button
+ const buttons = dialog?.querySelectorAll('button')
+ const confirmBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('revoke'),
+ ) as HTMLButtonElement
+ confirmBtn?.click()
+ await nextTick()
+ await nextTick()
+
+ // Dialog should stay open with error
+ expect(dialog?.open).toBe(true)
+ const alert = dialog?.querySelector('[role="alert"]')
+ expect(alert).toBeTruthy()
+ })
+
+ it('does not show revoke button for users (only teams)', async () => {
+ await mountConnectedWithCollaborators({
+ 'myorg:developers': 'read-write',
+ 'testuser': 'read-write',
+ })
+
+ // Should have only one revoke button (for the team)
+ const revokeButtons = document.querySelectorAll('button[aria-label*="Revoke"]')
+ expect(revokeButtons.length).toBe(1)
+ })
+ })
+
+ describe('Component visibility', () => {
+ it('does not render when not connected', async () => {
+ currentWrapper = await mountSuspended(PackageAccessControls, {
+ props: {
+ packageName: '@myorg/my-package',
+ },
+ attachTo: document.body,
+ })
+ await nextTick()
+
+ // The section should not be rendered
+ const section = document.querySelector('section')
+ expect(section).toBeNull()
+ })
+
+ it('does not render for non-scoped packages', async () => {
+ simulateConnect()
+ mockListPackageCollaborators.mockResolvedValue({})
+ mockListOrgTeams.mockResolvedValue([])
+
+ currentWrapper = await mountSuspended(PackageAccessControls, {
+ props: {
+ packageName: 'my-package', // Not scoped
+ },
+ attachTo: document.body,
+ })
+ await nextTick()
+
+ // The section should not be rendered
+ const section = document.querySelector('section')
+ expect(section).toBeNull()
+ })
+
+ it('renders section when connected with scoped package', async () => {
+ await mountConnectedWithCollaborators({})
+
+ const section = document.querySelector('section')
+ expect(section).not.toBeNull()
+ })
+ })
+})
diff --git a/test/nuxt/components/Package/Maintainers.spec.ts b/test/nuxt/components/Package/Maintainers.spec.ts
new file mode 100644
index 000000000..586b55c05
--- /dev/null
+++ b/test/nuxt/components/Package/Maintainers.spec.ts
@@ -0,0 +1,360 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
+import { ref, computed, readonly, nextTick } from 'vue'
+import type { VueWrapper } from '@vue/test-utils'
+import type { PendingOperation } from '../../../../cli/src/types'
+import { PackageMaintainers } from '#components'
+
+// Mock state that will be controlled by tests
+const mockState = ref({
+ connected: false,
+ connecting: false,
+ npmUser: null as string | null,
+ avatar: null as string | null,
+ operations: [] as PendingOperation[],
+ error: null as string | null,
+ lastExecutionTime: null as number | null,
+})
+
+// Mock connector methods
+const mockAddOperation = vi.fn()
+const mockListPackageCollaborators = vi.fn()
+const mockListTeamUsers = vi.fn()
+
+// Create the mock composable function
+function createMockUseConnector() {
+ return {
+ state: readonly(mockState),
+ isConnected: computed(() => mockState.value.connected),
+ isConnecting: computed(() => mockState.value.connecting),
+ npmUser: computed(() => mockState.value.npmUser),
+ avatar: computed(() => mockState.value.avatar),
+ error: computed(() => mockState.value.error),
+ lastExecutionTime: computed(() => mockState.value.lastExecutionTime),
+ operations: computed(() => mockState.value.operations),
+ connect: vi.fn().mockResolvedValue(true),
+ reconnect: vi.fn().mockResolvedValue(true),
+ disconnect: vi.fn(),
+ refreshState: vi.fn().mockResolvedValue(undefined),
+ addOperation: mockAddOperation,
+ addOperations: vi.fn().mockResolvedValue([]),
+ removeOperation: vi.fn().mockResolvedValue(true),
+ clearOperations: vi.fn().mockResolvedValue(0),
+ approveOperation: vi.fn().mockResolvedValue(true),
+ retryOperation: vi.fn().mockResolvedValue(true),
+ approveAll: vi.fn().mockResolvedValue(0),
+ executeOperations: vi.fn().mockResolvedValue({ success: true }),
+ listOrgUsers: vi.fn().mockResolvedValue(null),
+ listOrgTeams: vi.fn().mockResolvedValue(null),
+ listTeamUsers: mockListTeamUsers,
+ listTeamPackages: vi.fn().mockResolvedValue(null),
+ listPackageCollaborators: mockListPackageCollaborators,
+ listUserPackages: vi.fn().mockResolvedValue(null),
+ listUserOrgs: vi.fn().mockResolvedValue(null),
+ }
+}
+
+function resetMockState() {
+ mockState.value = {
+ connected: false,
+ connecting: false,
+ npmUser: null,
+ avatar: null,
+ operations: [],
+ error: null,
+ lastExecutionTime: null,
+ }
+ mockAddOperation.mockReset()
+ mockListPackageCollaborators.mockReset()
+ mockListTeamUsers.mockReset()
+}
+
+function simulateConnect(npmUser = 'testuser') {
+ mockState.value.connected = true
+ mockState.value.npmUser = npmUser
+}
+
+mockNuxtImport('useConnector', () => {
+ return createMockUseConnector
+})
+
+// Track current wrapper for cleanup
+let currentWrapper: VueWrapper | null = null
+
+/**
+ * Get the remove owner confirmation modal dialog element from the document body.
+ */
+function getRemoveDialog(): HTMLDialogElement | null {
+ return document.body.querySelector('dialog#remove-owner-modal')
+}
+
+/**
+ * Mount the component with maintainers.
+ */
+async function mountWithMaintainers(
+ maintainers: Array<{ name?: string; email?: string }>,
+ packageName = '@myorg/my-package',
+ connected = true,
+) {
+ if (connected) {
+ simulateConnect()
+ mockListPackageCollaborators.mockResolvedValue({})
+ }
+
+ currentWrapper = await mountSuspended(PackageMaintainers, {
+ props: {
+ packageName,
+ maintainers,
+ },
+ attachTo: document.body,
+ })
+
+ // Wait for async data loading
+ await nextTick()
+ await nextTick()
+
+ return currentWrapper
+}
+
+// Reset state before each test
+beforeEach(() => {
+ resetMockState()
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ if (currentWrapper) {
+ currentWrapper.unmount()
+ currentWrapper = null
+ }
+})
+
+describe('PackageMaintainers', () => {
+ describe('Remove owner confirmation dialog', () => {
+ it('does not show remove dialog initially', async () => {
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ const dialog = getRemoveDialog()
+ expect(dialog?.open).toBeFalsy()
+ })
+
+ it('opens remove dialog when clicking remove button on a maintainer', async () => {
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ // Find and click the remove button (first one, for developer1)
+ const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement
+ expect(removeBtn).toBeTruthy()
+
+ removeBtn?.click()
+ await nextTick()
+
+ const dialog = getRemoveDialog()
+ expect(dialog?.open).toBe(true)
+ })
+
+ it('shows username in the confirmation dialog', async () => {
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ // Open the dialog for developer1
+ const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement
+ removeBtn?.click()
+ await nextTick()
+
+ const dialog = getRemoveDialog()
+ expect(dialog?.textContent).toContain('developer1')
+ })
+
+ it('shows warning message in the confirmation dialog', async () => {
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ // Open the dialog
+ const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement
+ removeBtn?.click()
+ await nextTick()
+
+ const dialog = getRemoveDialog()
+ // Should contain the warning about revoking ownership
+ expect(dialog?.textContent).toContain('revoke ownership')
+ })
+
+ it('closes dialog when clicking close button', async () => {
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ // Open the dialog
+ const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement
+ removeBtn?.click()
+ await nextTick()
+
+ const dialog = getRemoveDialog()
+ expect(dialog?.open).toBe(true)
+
+ // Find and click close button
+ const buttons = dialog?.querySelectorAll('button')
+ const closeBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('close'),
+ ) as HTMLButtonElement
+ closeBtn?.click()
+ await nextTick()
+
+ expect(dialog?.open).toBe(false)
+ })
+
+ it('calls addOperation when confirming remove', async () => {
+ mockAddOperation.mockResolvedValue({
+ id: '0000000000000001',
+ type: 'owner:rm',
+ params: { user: 'developer1', pkg: '@myorg/my-package' },
+ description: 'Remove @developer1 from @myorg/my-package',
+ command: 'npm owner rm developer1 @myorg/my-package',
+ status: 'pending',
+ createdAt: Date.now(),
+ })
+
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ // Open the dialog
+ const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement
+ removeBtn?.click()
+ await nextTick()
+
+ const dialog = getRemoveDialog()
+
+ // Find and click confirm button
+ const buttons = dialog?.querySelectorAll('button')
+ const confirmBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('remove owner'),
+ ) as HTMLButtonElement
+ confirmBtn?.click()
+ await nextTick()
+
+ expect(mockAddOperation).toHaveBeenCalledWith({
+ type: 'owner:rm',
+ params: {
+ user: 'developer1',
+ pkg: '@myorg/my-package',
+ },
+ description: 'Remove @developer1 from @myorg/my-package',
+ command: 'npm owner rm developer1 @myorg/my-package',
+ })
+ })
+
+ it('closes dialog after successful remove', async () => {
+ mockAddOperation.mockResolvedValue({
+ id: '0000000000000001',
+ type: 'owner:rm',
+ params: { user: 'developer1', pkg: '@myorg/my-package' },
+ description: 'Remove @developer1 from @myorg/my-package',
+ command: 'npm owner rm developer1 @myorg/my-package',
+ status: 'pending',
+ createdAt: Date.now(),
+ })
+
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ // Open the dialog
+ const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement
+ removeBtn?.click()
+ await nextTick()
+
+ const dialog = getRemoveDialog()
+
+ // Find and click confirm button
+ const buttons = dialog?.querySelectorAll('button')
+ const confirmBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('remove owner'),
+ ) as HTMLButtonElement
+ confirmBtn?.click()
+ await nextTick()
+ await nextTick()
+
+ expect(dialog?.open).toBe(false)
+ })
+
+ it('shows error message when remove fails', async () => {
+ mockAddOperation.mockResolvedValue(null)
+ mockState.value.error = 'Connection failed'
+
+ await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }])
+
+ // Open the dialog
+ const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement
+ removeBtn?.click()
+ await nextTick()
+
+ const dialog = getRemoveDialog()
+
+ // Find and click confirm button
+ const buttons = dialog?.querySelectorAll('button')
+ const confirmBtn = Array.from(buttons || []).find(b =>
+ b.textContent?.toLowerCase().includes('remove owner'),
+ ) as HTMLButtonElement
+ confirmBtn?.click()
+ await nextTick()
+ await nextTick()
+
+ // Dialog should stay open with error
+ expect(dialog?.open).toBe(true)
+ const alert = dialog?.querySelector('[role="alert"]')
+ expect(alert).toBeTruthy()
+ })
+
+ it('does not show remove button for self (current user)', async () => {
+ // Connected as testuser
+ simulateConnect('testuser')
+
+ await mountWithMaintainers([{ name: 'testuser' }, { name: 'developer2' }])
+
+ // Should have only one remove button (for developer2, not testuser)
+ const removeButtons = document.querySelectorAll('button[aria-label*="Remove"]')
+ expect(removeButtons.length).toBe(1)
+ })
+
+ it('does not show remove buttons when not connected', async () => {
+ await mountWithMaintainers(
+ [{ name: 'developer1' }, { name: 'developer2' }],
+ '@myorg/my-package',
+ false,
+ )
+
+ const removeButtons = document.querySelectorAll('button[aria-label*="Remove"]')
+ expect(removeButtons.length).toBe(0)
+ })
+ })
+
+ describe('Component visibility', () => {
+ it('renders when maintainers are provided', async () => {
+ await mountWithMaintainers([{ name: 'developer1' }], '@myorg/my-package', false)
+
+ // The section should be rendered
+ const section = document.querySelector('[id="maintainers"]')
+ expect(section).not.toBeNull()
+ })
+
+ it('does not render when no maintainers', async () => {
+ currentWrapper = await mountSuspended(PackageMaintainers, {
+ props: {
+ packageName: '@myorg/my-package',
+ maintainers: [],
+ },
+ attachTo: document.body,
+ })
+ await nextTick()
+
+ // The section should not be rendered
+ const section = document.querySelector('[id="maintainers"]')
+ expect(section).toBeNull()
+ })
+
+ it('renders maintainer list', async () => {
+ await mountWithMaintainers(
+ [{ name: 'developer1' }, { name: 'developer2' }],
+ '@myorg/my-package',
+ false,
+ )
+
+ const list = document.querySelector('ul[aria-label*="maintainers"]')
+ expect(list).not.toBeNull()
+ expect(list?.querySelectorAll('li').length).toBe(2)
+ })
+ })
+})
diff --git a/test/nuxt/composables/use-package-selection.spec.ts b/test/nuxt/composables/use-package-selection.spec.ts
new file mode 100644
index 000000000..3c2918d8d
--- /dev/null
+++ b/test/nuxt/composables/use-package-selection.spec.ts
@@ -0,0 +1,239 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+// Direct import since this composable doesn't use other Nuxt-specific features
+import { usePackageSelection } from '~/composables/usePackageSelection'
+
+describe('usePackageSelection', () => {
+ let selection: ReturnType
+
+ beforeEach(() => {
+ selection = usePackageSelection()
+ })
+
+ describe('initial state', () => {
+ it('starts with empty selection', () => {
+ expect(selection.selectedCount.value).toBe(0)
+ expect(selection.hasSelection.value).toBe(false)
+ expect(selection.selectedPackages.value).toEqual([])
+ })
+
+ it('starts in non-selection mode', () => {
+ expect(selection.isSelectionMode.value).toBe(false)
+ })
+ })
+
+ describe('toggle', () => {
+ it('adds package when not selected', () => {
+ selection.toggle('@nuxt/kit')
+
+ expect(selection.isSelected('@nuxt/kit')).toBe(true)
+ expect(selection.selectedCount.value).toBe(1)
+ })
+
+ it('removes package when already selected', () => {
+ selection.toggle('@nuxt/kit')
+ selection.toggle('@nuxt/kit')
+
+ expect(selection.isSelected('@nuxt/kit')).toBe(false)
+ expect(selection.selectedCount.value).toBe(0)
+ })
+
+ it('handles multiple packages', () => {
+ selection.toggle('@nuxt/kit')
+ selection.toggle('@nuxt/ui')
+ selection.toggle('@nuxt/content')
+
+ expect(selection.selectedCount.value).toBe(3)
+ expect(selection.isSelected('@nuxt/kit')).toBe(true)
+ expect(selection.isSelected('@nuxt/ui')).toBe(true)
+ expect(selection.isSelected('@nuxt/content')).toBe(true)
+ })
+ })
+
+ describe('select/deselect', () => {
+ it('select adds package', () => {
+ selection.select('@nuxt/kit')
+
+ expect(selection.isSelected('@nuxt/kit')).toBe(true)
+ })
+
+ it('select is idempotent', () => {
+ selection.select('@nuxt/kit')
+ selection.select('@nuxt/kit')
+
+ expect(selection.selectedCount.value).toBe(1)
+ })
+
+ it('deselect removes package', () => {
+ selection.select('@nuxt/kit')
+ selection.deselect('@nuxt/kit')
+
+ expect(selection.isSelected('@nuxt/kit')).toBe(false)
+ })
+
+ it('deselect is idempotent', () => {
+ selection.select('@nuxt/kit')
+ selection.deselect('@nuxt/kit')
+ selection.deselect('@nuxt/kit')
+
+ expect(selection.selectedCount.value).toBe(0)
+ })
+ })
+
+ describe('selectAll', () => {
+ it('selects all packages from array', () => {
+ const packages = ['@nuxt/kit', '@nuxt/ui', '@nuxt/content']
+ selection.selectAll(packages)
+
+ expect(selection.selectedCount.value).toBe(3)
+ for (const pkg of packages) {
+ expect(selection.isSelected(pkg)).toBe(true)
+ }
+ })
+
+ it('adds to existing selection', () => {
+ selection.select('@nuxt/devtools')
+ selection.selectAll(['@nuxt/kit', '@nuxt/ui'])
+
+ expect(selection.selectedCount.value).toBe(3)
+ expect(selection.isSelected('@nuxt/devtools')).toBe(true)
+ })
+
+ it('deduplicates when adding already selected packages', () => {
+ selection.select('@nuxt/kit')
+ selection.selectAll(['@nuxt/kit', '@nuxt/ui'])
+
+ expect(selection.selectedCount.value).toBe(2)
+ })
+ })
+
+ describe('deselectAll', () => {
+ it('clears all selections', () => {
+ selection.selectAll(['@nuxt/kit', '@nuxt/ui', '@nuxt/content'])
+ selection.deselectAll()
+
+ expect(selection.selectedCount.value).toBe(0)
+ expect(selection.hasSelection.value).toBe(false)
+ })
+ })
+
+ describe('areAllSelected', () => {
+ it('returns true when all packages are selected', () => {
+ const packages = ['@nuxt/kit', '@nuxt/ui']
+ selection.selectAll(packages)
+
+ expect(selection.areAllSelected(packages)).toBe(true)
+ })
+
+ it('returns false when some packages are not selected', () => {
+ selection.select('@nuxt/kit')
+
+ expect(selection.areAllSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(false)
+ })
+
+ it('returns false for empty array', () => {
+ expect(selection.areAllSelected([])).toBe(false)
+ })
+ })
+
+ describe('areSomeSelected', () => {
+ it('returns true when some but not all are selected', () => {
+ selection.select('@nuxt/kit')
+
+ expect(selection.areSomeSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(true)
+ })
+
+ it('returns false when all are selected', () => {
+ selection.selectAll(['@nuxt/kit', '@nuxt/ui'])
+
+ expect(selection.areSomeSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(false)
+ })
+
+ it('returns false when none are selected', () => {
+ expect(selection.areSomeSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(false)
+ })
+
+ it('returns false for empty array', () => {
+ expect(selection.areSomeSelected([])).toBe(false)
+ })
+ })
+
+ describe('toggleAll', () => {
+ it('selects all when none selected', () => {
+ const packages = ['@nuxt/kit', '@nuxt/ui']
+ selection.toggleAll(packages)
+
+ expect(selection.areAllSelected(packages)).toBe(true)
+ })
+
+ it('deselects all when all selected', () => {
+ const packages = ['@nuxt/kit', '@nuxt/ui']
+ selection.selectAll(packages)
+ selection.toggleAll(packages)
+
+ expect(selection.selectedCount.value).toBe(0)
+ })
+
+ it('selects all when some selected', () => {
+ const packages = ['@nuxt/kit', '@nuxt/ui']
+ selection.select('@nuxt/kit')
+ selection.toggleAll(packages)
+
+ expect(selection.areAllSelected(packages)).toBe(true)
+ })
+ })
+
+ describe('selection mode', () => {
+ it('enterSelectionMode enables selection mode', () => {
+ selection.enterSelectionMode()
+
+ expect(selection.isSelectionMode.value).toBe(true)
+ })
+
+ it('exitSelectionMode disables selection mode and clears selection', () => {
+ selection.enterSelectionMode()
+ selection.selectAll(['@nuxt/kit', '@nuxt/ui'])
+ selection.exitSelectionMode()
+
+ expect(selection.isSelectionMode.value).toBe(false)
+ expect(selection.selectedCount.value).toBe(0)
+ })
+
+ it('toggleSelectionMode toggles mode on and off', () => {
+ selection.toggleSelectionMode()
+ expect(selection.isSelectionMode.value).toBe(true)
+
+ selection.toggleSelectionMode()
+ expect(selection.isSelectionMode.value).toBe(false)
+ })
+
+ it('toggleSelectionMode clears selection when exiting', () => {
+ selection.enterSelectionMode()
+ selection.selectAll(['@nuxt/kit', '@nuxt/ui'])
+ selection.toggleSelectionMode()
+
+ expect(selection.selectedCount.value).toBe(0)
+ })
+ })
+
+ describe('computed values', () => {
+ it('selectedPackages returns array of selected package names', () => {
+ selection.selectAll(['@nuxt/kit', '@nuxt/ui'])
+
+ const packages = selection.selectedPackages.value
+ expect(packages).toContain('@nuxt/kit')
+ expect(packages).toContain('@nuxt/ui')
+ expect(packages.length).toBe(2)
+ })
+
+ it('hasSelection reflects whether any packages are selected', () => {
+ expect(selection.hasSelection.value).toBe(false)
+
+ selection.select('@nuxt/kit')
+ expect(selection.hasSelection.value).toBe(true)
+
+ selection.deselectAll()
+ expect(selection.hasSelection.value).toBe(false)
+ })
+ })
+})
diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts
index 0f18a000e..e92344a03 100644
--- a/test/unit/a11y-component-coverage.spec.ts
+++ b/test/unit/a11y-component-coverage.spec.ts
@@ -46,6 +46,15 @@ const SKIPPED_COMPONENTS: Record = {
'SkeletonBlock.vue': 'Already covered indirectly via other component tests',
'SkeletonInline.vue': 'Already covered indirectly via other component tests',
'Button/Group.vue': "Wrapper component, tests wouldn't make much sense here",
+
+ // Bulk operations components - require connector context and complex mock setup
+ 'Package/BulkActionsToolbar.vue':
+ 'Toolbar component - requires connector context and selection state',
+ 'Package/BulkGrantAccessModal.vue':
+ 'Complex modal - requires connector context and API calls for teams',
+ 'Package/CopyAccessModal.vue':
+ 'Complex modal - requires connector context and API calls for collaborators',
+ 'Org/TeamPackagesPanel.vue': 'Admin panel - requires connector context and API calls for teams',
}
/**
diff --git a/test/unit/cli/mock-state.spec.ts b/test/unit/cli/mock-state.spec.ts
index 899883cab..f2a8ab314 100644
--- a/test/unit/cli/mock-state.spec.ts
+++ b/test/unit/cli/mock-state.spec.ts
@@ -6,6 +6,91 @@ function createManager() {
return new MockConnectorStateManager(data)
}
+describe('MockConnectorStateManager: getTeamPackages', () => {
+ let manager: MockConnectorStateManager
+
+ beforeEach(() => {
+ manager = createManager()
+ manager.connect('test-token')
+ })
+
+ it('returns empty object when no packages have team access', () => {
+ manager.setOrgData('@myorg', { teams: ['developers'] })
+
+ const packages = manager.getTeamPackages('@myorg', 'developers')
+
+ expect(packages).toEqual({})
+ })
+
+ it('returns packages with team access', () => {
+ manager.setOrgData('@myorg', { teams: ['developers'] })
+ manager.setPackageData('@myorg/package-a', {
+ collaborators: { '@myorg:developers': 'read-write' },
+ })
+ manager.setPackageData('@myorg/package-b', {
+ collaborators: { '@myorg:developers': 'read-only' },
+ })
+
+ const packages = manager.getTeamPackages('@myorg', 'developers')
+
+ expect(packages).toEqual({
+ '@myorg/package-a': 'read-write',
+ '@myorg/package-b': 'read-only',
+ })
+ })
+
+ it('filters to only packages with the specified team', () => {
+ manager.setOrgData('@myorg', { teams: ['developers', 'designers'] })
+ manager.setPackageData('@myorg/package-a', {
+ collaborators: { '@myorg:developers': 'read-write' },
+ })
+ manager.setPackageData('@myorg/package-b', {
+ collaborators: { '@myorg:designers': 'read-only' },
+ })
+ manager.setPackageData('@myorg/package-c', {
+ collaborators: {
+ '@myorg:developers': 'read-write',
+ '@myorg:designers': 'read-only',
+ },
+ })
+
+ const devPackages = manager.getTeamPackages('@myorg', 'developers')
+
+ expect(devPackages).toEqual({
+ '@myorg/package-a': 'read-write',
+ '@myorg/package-c': 'read-write',
+ })
+ })
+
+ it('handles scope with or without @ prefix', () => {
+ manager.setPackageData('@myorg/package-a', {
+ collaborators: { '@myorg:developers': 'read-write' },
+ })
+
+ // With @
+ const withAt = manager.getTeamPackages('@myorg', 'developers')
+ // Without @
+ const withoutAt = manager.getTeamPackages('myorg', 'developers')
+
+ expect(withAt).toEqual({ '@myorg/package-a': 'read-write' })
+ expect(withoutAt).toEqual({ '@myorg/package-a': 'read-write' })
+ })
+
+ it('does not include user collaborators', () => {
+ manager.setPackageData('@myorg/package-a', {
+ collaborators: {
+ '@myorg:developers': 'read-write',
+ 'testuser': 'read-write', // User, not team
+ },
+ })
+
+ const packages = manager.getTeamPackages('@myorg', 'developers')
+
+ expect(packages).toEqual({ '@myorg/package-a': 'read-write' })
+ expect(Object.keys(packages)).not.toContain('testuser')
+ })
+})
+
describe('MockConnectorStateManager: executeOperations', () => {
let manager: MockConnectorStateManager
diff --git a/test/unit/cli/server.spec.ts b/test/unit/cli/server.spec.ts
index bd7197f18..110c6a498 100644
--- a/test/unit/cli/server.spec.ts
+++ b/test/unit/cli/server.spec.ts
@@ -36,6 +36,44 @@ describe('connector server', () => {
})
})
+ describe('GET /team/:scopeTeam/packages', () => {
+ it('returns 400 for invalid scope:team format (missing @ prefix)', async () => {
+ const app = createConnectorApp(TEST_TOKEN)
+
+ const response = await app.fetch(
+ new Request('http://localhost/team/netlify%3Adevelopers/packages', {
+ headers: { Authorization: `Bearer ${TEST_TOKEN}` },
+ }),
+ )
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.message).toContain('Invalid scope:team format')
+ })
+
+ it('returns 401 without auth token', async () => {
+ const app = createConnectorApp(TEST_TOKEN)
+
+ const response = await app.fetch(
+ new Request('http://localhost/team/@netlify%3Adevelopers/packages'),
+ )
+
+ expect(response.status).toBe(401)
+ })
+
+ it('returns 401 with invalid auth token', async () => {
+ const app = createConnectorApp(TEST_TOKEN)
+
+ const response = await app.fetch(
+ new Request('http://localhost/team/@netlify%3Adevelopers/packages', {
+ headers: { Authorization: 'Bearer wrong-token' },
+ }),
+ )
+
+ expect(response.status).toBe(401)
+ })
+ })
+
describe('GET /user/packages', () => {
it('returns 401 without auth token', async () => {
const app = createConnectorApp(TEST_TOKEN)
|