Skip to content

Commit 0da290c

Browse files
committed
Bulk Actions for Team and Package management
- Team Access panel for viewing team access to packages and which permissions they have - Bulk actions toolbar added to packages table to allow bulk actions to be taken on packages Allow for copying access patterns from one package and applying them on another.
1 parent 5e94226 commit 0da290c

19 files changed

Lines changed: 1799 additions & 7 deletions
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
<script setup lang="ts">
2+
import type { AccessPermission } from '#cli/types'
3+
import { buildScopeTeam } from '~/utils/npm/common'
4+
5+
const props = defineProps<{
6+
orgName: string
7+
}>()
8+
9+
const {
10+
isConnected,
11+
lastExecutionTime,
12+
listOrgTeams,
13+
listTeamPackages,
14+
error: connectorError,
15+
} = useConnector()
16+
17+
// Teams data
18+
const teams = shallowRef<string[]>([])
19+
const isLoadingTeams = shallowRef(false)
20+
const teamsError = shallowRef<string | null>(null)
21+
22+
// Selected team
23+
const selectedTeam = shallowRef<string | null>(null)
24+
25+
// Packages data
26+
const packages = shallowRef<Record<string, AccessPermission>>({})
27+
const isLoadingPackages = shallowRef(false)
28+
const packagesError = shallowRef<string | null>(null)
29+
30+
// Search/filter
31+
const searchQuery = shallowRef('')
32+
33+
// Filtered packages
34+
const filteredPackages = computed(() => {
35+
const entries = Object.entries(packages.value)
36+
37+
if (!searchQuery.value.trim()) {
38+
return entries
39+
}
40+
41+
const query = searchQuery.value.toLowerCase()
42+
return entries.filter(([name]) => name.toLowerCase().includes(query))
43+
})
44+
45+
// Load teams
46+
async function loadTeams() {
47+
if (!isConnected.value) return
48+
49+
isLoadingTeams.value = true
50+
teamsError.value = null
51+
52+
try {
53+
const result = await listOrgTeams(props.orgName)
54+
if (result) {
55+
// Teams come as "org:team" format, extract just the team name
56+
teams.value = result.map((t: string) => t.replace(`${props.orgName}:`, ''))
57+
} else {
58+
teamsError.value = connectorError.value || 'Failed to load teams'
59+
}
60+
} finally {
61+
isLoadingTeams.value = false
62+
}
63+
}
64+
65+
// Load packages for selected team
66+
async function loadPackages() {
67+
if (!isConnected.value || !selectedTeam.value) return
68+
69+
isLoadingPackages.value = true
70+
packagesError.value = null
71+
72+
try {
73+
const scopeTeam = buildScopeTeam(props.orgName, selectedTeam.value)
74+
const result = await listTeamPackages(scopeTeam)
75+
if (result) {
76+
packages.value = result
77+
} else {
78+
packagesError.value = connectorError.value || 'Failed to load packages'
79+
}
80+
} finally {
81+
isLoadingPackages.value = false
82+
}
83+
}
84+
85+
// Watch for team selection changes
86+
watch(selectedTeam, () => {
87+
packages.value = {}
88+
if (selectedTeam.value) {
89+
loadPackages()
90+
}
91+
})
92+
93+
// Load on mount when connected
94+
watch(
95+
isConnected,
96+
connected => {
97+
if (connected) {
98+
loadTeams()
99+
}
100+
},
101+
{ immediate: true },
102+
)
103+
104+
// Refresh data when operations complete
105+
watch(lastExecutionTime, () => {
106+
if (isConnected.value) {
107+
loadTeams()
108+
if (selectedTeam.value) {
109+
loadPackages()
110+
}
111+
}
112+
})
113+
114+
// Get permission badge class
115+
function getPermissionClass(permission: AccessPermission) {
116+
return permission === 'read-write'
117+
? 'bg-green-500/10 text-green-400 border-green-500/20'
118+
: 'bg-blue-500/10 text-blue-400 border-blue-500/20'
119+
}
120+
</script>
121+
122+
<template>
123+
<section v-if="isConnected" class="bg-bg-subtle border border-border rounded-lg overflow-hidden">
124+
<!-- Header -->
125+
<div class="flex items-center justify-start p-4 border-b border-border">
126+
<h2 id="team-packages-heading" class="font-mono text-sm font-medium flex items-center gap-2">
127+
<span class="i-lucide:package w-4 h-4 text-fg-muted" aria-hidden="true" />
128+
{{ $t('org.team_access.title') }}
129+
</h2>
130+
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
131+
<button
132+
type="button"
133+
class="p-1.5 text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
134+
:aria-label="$t('org.team_access.refresh')"
135+
:disabled="isLoadingTeams || isLoadingPackages"
136+
@click="selectedTeam ? loadPackages() : loadTeams()"
137+
>
138+
<span
139+
class="i-lucide:refresh-ccw w-4 h-4"
140+
:class="{ 'animate-spin': isLoadingTeams || isLoadingPackages }"
141+
aria-hidden="true"
142+
/>
143+
</button>
144+
</div>
145+
146+
<!-- Team selector -->
147+
<div class="p-3 border-b border-border bg-bg">
148+
<label for="team-packages-select" class="sr-only">{{
149+
$t('org.team_access.select_team')
150+
}}</label>
151+
<select
152+
id="team-packages-select"
153+
v-model="selectedTeam"
154+
:disabled="isLoadingTeams || teams.length === 0"
155+
class="w-full px-3 py-2 font-mono text-sm text-fg bg-bg border border-border rounded-md transition-colors duration-200 hover:border-border-hover focus:outline-none focus:ring-2 focus:ring-accent-fallback/50 disabled:opacity-50"
156+
>
157+
<option :value="null">{{ $t('org.team_access.select_team') }}</option>
158+
<option v-for="team in teams" :key="team" :value="team">@{{ orgName }}:{{ team }}</option>
159+
</select>
160+
</div>
161+
162+
<!-- Loading teams state -->
163+
<div v-if="isLoadingTeams && teams.length === 0" class="p-8 text-center">
164+
<span class="i-svg-spinners:ring-resize w-5 h-5 text-fg-muted mx-auto" aria-hidden="true" />
165+
<p class="font-mono text-sm text-fg-muted mt-2">{{ $t('org.teams.loading') }}</p>
166+
</div>
167+
168+
<!-- Teams error state -->
169+
<div v-else-if="teamsError" class="p-4 text-center" role="alert">
170+
<p class="font-mono text-sm text-red-400">
171+
{{ teamsError }}
172+
</p>
173+
<button
174+
type="button"
175+
class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
176+
@click="loadTeams"
177+
>
178+
{{ $t('common.try_again') }}
179+
</button>
180+
</div>
181+
182+
<!-- No teams state -->
183+
<div v-else-if="teams.length === 0 && !isLoadingTeams" class="p-8 text-center">
184+
<p class="font-mono text-sm text-fg-muted">{{ $t('org.teams.no_teams') }}</p>
185+
</div>
186+
187+
<!-- No team selected -->
188+
<div v-else-if="!selectedTeam" class="p-8 text-center">
189+
<p class="font-mono text-sm text-fg-muted">{{ $t('org.team_access.select_team_hint') }}</p>
190+
</div>
191+
192+
<!-- Team selected - show packages -->
193+
<template v-else>
194+
<!-- Search filter -->
195+
<div class="p-3 border-b border-border bg-bg">
196+
<div class="relative">
197+
<span
198+
class="absolute inset-is-2 top-1/2 -translate-y-1/2 i-lucide:search w-3.5 h-3.5 text-fg-subtle"
199+
aria-hidden="true"
200+
/>
201+
<label for="team-packages-search" class="sr-only">{{
202+
$t('org.team_access.filter_packages')
203+
}}</label>
204+
<InputBase
205+
id="team-packages-search"
206+
v-model="searchQuery"
207+
type="search"
208+
name="team-packages-search"
209+
:placeholder="$t('org.team_access.filter_packages_placeholder')"
210+
no-correct
211+
class="w-full min-w-25 ps-7"
212+
size="medium"
213+
/>
214+
</div>
215+
</div>
216+
217+
<!-- Loading packages state -->
218+
<div v-if="isLoadingPackages" class="p-8 text-center">
219+
<span class="i-svg-spinners:ring-resize w-5 h-5 text-fg-muted mx-auto" aria-hidden="true" />
220+
<p class="font-mono text-sm text-fg-muted mt-2">
221+
{{ $t('org.team_access.loading_packages') }}
222+
</p>
223+
</div>
224+
225+
<!-- Packages error state -->
226+
<div v-else-if="packagesError" class="p-4 text-center" role="alert">
227+
<p class="font-mono text-sm text-red-400">
228+
{{ packagesError }}
229+
</p>
230+
<button
231+
type="button"
232+
class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
233+
@click="loadPackages"
234+
>
235+
{{ $t('common.try_again') }}
236+
</button>
237+
</div>
238+
239+
<!-- No packages state -->
240+
<div v-else-if="Object.keys(packages).length === 0" class="p-8 text-center">
241+
<p class="font-mono text-sm text-fg-muted">{{ $t('org.team_access.no_packages') }}</p>
242+
</div>
243+
244+
<!-- Packages list -->
245+
<ul
246+
v-else
247+
class="divide-y divide-border max-h-80 overflow-y-auto"
248+
:aria-label="$t('org.team_access.packages_list')"
249+
>
250+
<li
251+
v-for="[pkgName, permission] in filteredPackages"
252+
:key="pkgName"
253+
class="flex items-center justify-start p-3 hover:bg-bg transition-colors duration-200"
254+
>
255+
<NuxtLink
256+
:to="packageRoute(pkgName)"
257+
class="font-mono text-sm text-fg hover:text-accent-fallback transition-colors duration-200 truncate flex-1"
258+
dir="ltr"
259+
>
260+
{{ pkgName }}
261+
</NuxtLink>
262+
<span
263+
class="ms-2 px-2 py-0.5 text-xs font-mono rounded border shrink-0"
264+
:class="getPermissionClass(permission)"
265+
>
266+
{{ permission }}
267+
</span>
268+
</li>
269+
</ul>
270+
271+
<!-- No results -->
272+
<div
273+
v-if="Object.keys(packages).length > 0 && filteredPackages.length === 0"
274+
class="p-4 text-center"
275+
>
276+
<p class="font-mono text-sm text-fg-muted">
277+
{{ $t('org.team_access.no_match', { query: searchQuery }) }}
278+
</p>
279+
</div>
280+
281+
<!-- Package count footer -->
282+
<div v-if="Object.keys(packages).length > 0" class="p-3 border-t border-border bg-bg-subtle">
283+
<p class="text-xs text-fg-muted font-mono">
284+
{{
285+
$t(
286+
'org.team_access.package_count',
287+
{ count: Object.keys(packages).length },
288+
Object.keys(packages).length,
289+
)
290+
}}
291+
</p>
292+
</div>
293+
</template>
294+
</section>
295+
</template>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
/** Number of selected packages */
4+
selectedCount: number
5+
/** Org name for displaying in the UI */
6+
orgName?: string
7+
}>()
8+
9+
const emit = defineEmits<{
10+
/** Clear all selections */
11+
clearSelection: []
12+
/** Open the grant access modal */
13+
grantAccess: []
14+
/** Open the copy access modal */
15+
copyAccess: []
16+
}>()
17+
</script>
18+
19+
<template>
20+
<Transition
21+
enter-active-class="transition-all duration-200 ease-out"
22+
leave-active-class="transition-all duration-150 ease-in"
23+
enter-from-class="opacity-0 -translate-y-2"
24+
leave-to-class="opacity-0 -translate-y-2"
25+
>
26+
<div
27+
v-if="selectedCount > 0"
28+
class="sticky top-0 z-20 bg-bg-elevated border border-border rounded-lg shadow-lg mb-4 p-3"
29+
>
30+
<div class="flex flex-wrap items-center gap-3">
31+
<!-- Selection count -->
32+
<div class="flex items-center gap-2 text-sm font-mono">
33+
<span class="i-lucide:check-square w-4 h-4 text-accent-fallback" aria-hidden="true" />
34+
<span class="text-fg">
35+
{{ $t('package.bulk.selected_count', { count: selectedCount }, selectedCount) }}
36+
</span>
37+
</div>
38+
39+
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
40+
41+
<!-- Action buttons -->
42+
<div class="flex items-center gap-2">
43+
<!-- Grant access button -->
44+
<button
45+
type="button"
46+
class="inline-flex items-center gap-1.5 px-3 py-1.5 font-mono text-sm text-bg bg-accent-fallback rounded transition-colors duration-200 hover:bg-accent-fallback/90 focus-visible:outline-accent/70"
47+
@click="emit('grantAccess')"
48+
>
49+
<span class="i-lucide:shield-plus w-4 h-4" aria-hidden="true" />
50+
{{ $t('package.bulk.grant_access') }}
51+
</button>
52+
53+
<!-- Copy access button -->
54+
<button
55+
type="button"
56+
class="inline-flex items-center gap-1.5 px-3 py-1.5 font-mono text-sm text-fg bg-bg border border-border rounded transition-colors duration-200 hover:bg-bg-muted hover:border-border-hover focus-visible:outline-accent/70"
57+
@click="emit('copyAccess')"
58+
>
59+
<span class="i-lucide:copy w-4 h-4" aria-hidden="true" />
60+
{{ $t('package.bulk.copy_access') }}
61+
</button>
62+
63+
<!-- Clear selection button -->
64+
<button
65+
type="button"
66+
class="inline-flex items-center gap-1.5 px-3 py-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
67+
:aria-label="$t('package.bulk.clear_selection')"
68+
@click="emit('clearSelection')"
69+
>
70+
<span class="i-lucide:x w-4 h-4" aria-hidden="true" />
71+
<span class="hidden sm:inline">{{ $t('package.bulk.clear_selection') }}</span>
72+
</button>
73+
</div>
74+
</div>
75+
</div>
76+
</Transition>
77+
</template>

0 commit comments

Comments
 (0)