@plutocms/supabase 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,230 +0,0 @@
1
- <script setup lang="ts">
2
- // The Roles tab of Settings (admin only). A role is a set of capabilities. An
3
- // admin creates, edits and deletes roles. The `admin` role is built in: it
4
- // opens read-only, and it cannot be deleted. See the users-and-roles skill.
5
- import type { DropdownMenuItem, TableColumn } from '@nuxt/ui'
6
- import type { AccessRole } from '../../../../shared/types/access'
7
- import { ADMIN_ROLE_KEY } from '../../../utils/access-ui'
8
-
9
- // The slideover and the modal load on demand. They stay out of the page chunk.
10
- const RoleSlideover = defineAsyncComponent(
11
- () => import('../../../components/access/RoleSlideover.vue')
12
- )
13
- const DeleteRoleModal = defineAsyncComponent(
14
- () => import('../../../components/access/DeleteRoleModal.vue')
15
- )
16
-
17
- const { t } = usePlutoI18n()
18
- const overlay = useOverlay()
19
- const errorText = usePlutoErrorText()
20
-
21
- useHead({
22
- title: () =>
23
- `${t('supabase.access.roles.title')} - ${t('pluto.pages.settings')}`,
24
- })
25
-
26
- const roles = ref<AccessRole[]>([])
27
- const status = ref<'loading' | 'ready' | 'error'>('loading')
28
- const loadError = ref('')
29
-
30
- async function load() {
31
- // Keep the table on screen while it reloads after a change.
32
- if (status.value !== 'ready') {
33
- status.value = 'loading'
34
- }
35
-
36
- try {
37
- const response = await $fetch<{ roles: AccessRole[] }>(
38
- '/api/_pluto/supabase/access/roles'
39
- )
40
-
41
- roles.value = response.roles
42
- status.value = 'ready'
43
- } catch (error) {
44
- loadError.value = errorText(error)
45
- status.value = 'error'
46
- }
47
- }
48
-
49
- onMounted(load)
50
-
51
- const columns = computed<TableColumn<AccessRole>[]>(() => [
52
- { id: 'role', header: t('supabase.access.roles.columns.role') },
53
- { id: 'permissions', header: t('supabase.access.roles.columns.permissions') },
54
- { id: 'users', header: t('supabase.access.roles.columns.users') },
55
- { id: 'actions', header: '' },
56
- ])
57
-
58
- // --- Create and edit ---
59
- const slideoverMounted = ref(false)
60
- const slideoverOpen = ref(false)
61
- const editing = ref<AccessRole | null>(null)
62
-
63
- function openRole(role: AccessRole | null) {
64
- editing.value = role
65
- slideoverMounted.value = true
66
- slideoverOpen.value = true
67
- }
68
-
69
- // --- Delete ---
70
- async function openDelete(role: AccessRole) {
71
- const modal = overlay.create(DeleteRoleModal)
72
- const deleted = await modal.open({ role })
73
-
74
- if (deleted) {
75
- await load()
76
- }
77
- }
78
-
79
- function isFullAccess(role: AccessRole): boolean {
80
- return role.capabilities.includes('*')
81
- }
82
-
83
- function menuItems(role: AccessRole): DropdownMenuItem[][] {
84
- const readOnly = role.key === ADMIN_ROLE_KEY
85
- const main: DropdownMenuItem[] = [
86
- {
87
- label: readOnly
88
- ? t('supabase.access.roles.menu.view')
89
- : t('supabase.access.roles.menu.edit'),
90
- icon: readOnly ? 'lucide:eye' : 'lucide:pencil',
91
- onSelect: () => openRole(role),
92
- },
93
- ]
94
-
95
- // A built-in role cannot be deleted.
96
- if (role.is_builtin) {
97
- return [main]
98
- }
99
-
100
- return [
101
- main,
102
- [
103
- {
104
- label: t('supabase.access.roles.menu.delete'),
105
- icon: 'lucide:trash-2',
106
- color: 'error',
107
- onSelect: () => openDelete(role),
108
- },
109
- ],
110
- ]
111
- }
112
- </script>
113
-
114
- <template>
115
- <div class="flex flex-col gap-y-6">
116
- <div class="flex flex-wrap items-start justify-between gap-4">
117
- <div>
118
- <h2 class="text-xl font-semibold">
119
- {{ t('supabase.access.roles.title') }}
120
- </h2>
121
- <p class="text-muted text-sm">
122
- {{ t('supabase.access.roles.description') }}
123
- </p>
124
- </div>
125
-
126
- <UButton icon="lucide:plus" @click="openRole(null)">
127
- {{ t('supabase.access.roles.create') }}
128
- </UButton>
129
- </div>
130
-
131
- <!-- Loading -->
132
- <div
133
- v-if="status === 'loading'"
134
- class="flex flex-col gap-y-3"
135
- aria-busy="true"
136
- >
137
- <USkeleton v-for="row in 3" :key="row" class="h-12 w-full" />
138
- </div>
139
-
140
- <!-- Error -->
141
- <UAlert
142
- v-else-if="status === 'error'"
143
- :title="t('supabase.access.roles.error.title')"
144
- :description="loadError"
145
- :actions="[
146
- {
147
- label: t('pluto.common.tryAgain'),
148
- color: 'neutral',
149
- variant: 'outline',
150
- onClick: load,
151
- },
152
- ]"
153
- color="error"
154
- variant="subtle"
155
- icon="lucide:circle-x"
156
- />
157
-
158
- <!-- The list -->
159
- <UTable
160
- v-else
161
- :data="roles"
162
- :columns="columns"
163
- class="border-default rounded-lg border"
164
- >
165
- <template #role-cell="{ row }">
166
- <div class="flex min-w-0 flex-col">
167
- <span class="flex items-center gap-2">
168
- <span class="text-highlighted truncate font-medium">{{
169
- row.original.label
170
- }}</span>
171
- <UBadge
172
- v-if="row.original.is_builtin"
173
- :label="t('supabase.access.roles.builtin')"
174
- color="neutral"
175
- variant="subtle"
176
- size="sm"
177
- />
178
- </span>
179
- <code class="text-muted truncate font-mono text-xs">{{
180
- row.original.key
181
- }}</code>
182
- </div>
183
- </template>
184
-
185
- <template #permissions-cell="{ row }">
186
- <span v-if="isFullAccess(row.original)" class="text-sm">
187
- {{ t('supabase.access.roles.fullAccess') }}
188
- </span>
189
- <span v-else class="text-sm">
190
- {{
191
- t(
192
- 'supabase.access.roles.permissionCount',
193
- row.original.capabilities.length
194
- )
195
- }}
196
- </span>
197
- </template>
198
-
199
- <template #users-cell="{ row }">
200
- <span class="text-sm">{{
201
- t('supabase.access.roles.userCount', row.original.user_count)
202
- }}</span>
203
- </template>
204
-
205
- <template #actions-cell="{ row }">
206
- <div class="flex justify-end">
207
- <UDropdownMenu
208
- :items="menuItems(row.original)"
209
- :content="{ align: 'end' }"
210
- >
211
- <UButton
212
- :aria-label="row.original.label"
213
- icon="lucide:ellipsis"
214
- color="neutral"
215
- variant="ghost"
216
- />
217
- </UDropdownMenu>
218
- </div>
219
- </template>
220
- </UTable>
221
-
222
- <!-- Lazy: the chunk loads on the first open. -->
223
- <RoleSlideover
224
- v-if="slideoverMounted"
225
- v-model:open="slideoverOpen"
226
- :role="editing"
227
- @saved="load"
228
- />
229
- </div>
230
- </template>
@@ -1,447 +0,0 @@
1
- <script setup lang="ts">
2
- // The Users tab of Settings. It lists the staff (users with a role). An admin
3
- // invites users, changes their roles, resends an invite, copies an invite link
4
- // and revokes access. The parent page (core `settings.vue`) draws the title and
5
- // the tabs. See the users-and-roles skill.
6
- import type { DropdownMenuItem, TableColumn } from '@nuxt/ui'
7
- import type {
8
- AccessCapabilities,
9
- AccessRole,
10
- AccessUser,
11
- } from '../../../../shared/types/access'
12
- import {
13
- isAdminUser,
14
- roleLabelOf,
15
- userDisplayName,
16
- userStatus,
17
- } from '../../../utils/access-ui'
18
-
19
- // The modals and the slideover load on demand. They stay out of the page chunk.
20
- const InviteModal = defineAsyncComponent(
21
- () => import('../../../components/access/InviteModal.vue')
22
- )
23
- const EditRolesSlideover = defineAsyncComponent(
24
- () => import('../../../components/access/EditRolesSlideover.vue')
25
- )
26
- const RevokeModal = defineAsyncComponent(
27
- () => import('../../../components/access/RevokeModal.vue')
28
- )
29
- const LinkModal = defineAsyncComponent(
30
- () => import('../../../components/access/LinkModal.vue')
31
- )
32
-
33
- const { t, d, locale } = usePlutoI18n()
34
- const toast = useToast()
35
- const overlay = useOverlay()
36
- const errorText = usePlutoErrorText()
37
- const permissions = usePlutoPermissions()
38
- const auth = usePlutoAuth()
39
-
40
- useHead({
41
- title: () =>
42
- `${t('supabase.access.users.title')} - ${t('pluto.pages.settings')}`,
43
- })
44
-
45
- const users = ref<AccessUser[]>([])
46
- const roles = ref<AccessRole[]>([])
47
- const status = ref<'loading' | 'ready' | 'error'>('loading')
48
- const loadError = ref('')
49
- const capabilities = ref<AccessCapabilities | null>(null)
50
-
51
- const isAdmin = computed(() => permissions.isAdmin.value)
52
- const selfId = computed(() => auth.user.value?.id ?? '')
53
-
54
- async function load() {
55
- // Keep the table on screen while it reloads after a change.
56
- if (status.value !== 'ready') {
57
- status.value = 'loading'
58
- }
59
-
60
- try {
61
- const [usersResponse, rolesResponse] = await Promise.all([
62
- $fetch<{ users: AccessUser[] }>('/api/_pluto/supabase/access/users'),
63
- $fetch<{ roles: AccessRole[] }>('/api/_pluto/supabase/access/roles'),
64
- ])
65
-
66
- users.value = usersResponse.users
67
- roles.value = rolesResponse.roles
68
- status.value = 'ready'
69
- } catch (error) {
70
- loadError.value = errorText(error)
71
- status.value = 'error'
72
- }
73
- }
74
-
75
- async function loadCapabilities() {
76
- try {
77
- capabilities.value = await $fetch<AccessCapabilities>(
78
- '/api/_pluto/supabase/access/capabilities'
79
- )
80
- } catch {
81
- // The invite works without it. Only the "Copy invite link" action hides.
82
- capabilities.value = null
83
- }
84
- }
85
-
86
- onMounted(load)
87
-
88
- // Only an admin can read this, so ask after the permissions are known.
89
- watch(
90
- isAdmin,
91
- (admin) => {
92
- if (admin && !capabilities.value) {
93
- void loadCapabilities()
94
- }
95
- },
96
- { immediate: true }
97
- )
98
-
99
- const columns = computed<TableColumn<AccessUser>[]>(() => [
100
- { id: 'user', header: t('supabase.access.users.columns.user') },
101
- { id: 'roles', header: t('supabase.access.users.columns.roles') },
102
- { id: 'status', header: t('supabase.access.users.columns.status') },
103
- { id: 'lastSignIn', header: t('supabase.access.users.columns.lastSignIn') },
104
- { id: 'actions', header: '' },
105
- ])
106
-
107
- function dateText(
108
- value: string | null,
109
- options: Intl.DateTimeFormatOptions
110
- ): string {
111
- return value ? d(new Date(value), options) : ''
112
- }
113
-
114
- // --- Invite ---
115
- const inviteMounted = ref(false)
116
- const inviteOpen = ref(false)
117
-
118
- function openInvite() {
119
- inviteMounted.value = true
120
- inviteOpen.value = true
121
- }
122
-
123
- // --- Edit roles ---
124
- const editMounted = ref(false)
125
- const editOpen = ref(false)
126
- const editUser = ref<AccessUser | null>(null)
127
-
128
- function openEdit(user: AccessUser) {
129
- editUser.value = user
130
- editMounted.value = true
131
- editOpen.value = true
132
- }
133
-
134
- // --- Revoke ---
135
- async function openRevoke(user: AccessUser) {
136
- const modal = overlay.create(RevokeModal)
137
- const revoked = await modal.open({ user, self: user.id === selfId.value })
138
-
139
- if (revoked) {
140
- await load()
141
- }
142
- }
143
-
144
- // --- Invite link ---
145
- async function showLink(link: string) {
146
- await overlay.create(LinkModal).open({ link })
147
- }
148
-
149
- async function copyLink(user: AccessUser) {
150
- if (!user.email) {
151
- return
152
- }
153
-
154
- try {
155
- const result = await $fetch<{ link: string | null }>(
156
- '/api/_pluto/supabase/access/users/invite-link',
157
- {
158
- method: 'POST',
159
- body: { email: user.email, roleKeys: user.roles },
160
- }
161
- )
162
-
163
- // A confirmed account gets no link: it signs in with its own password.
164
- if (!result.link) {
165
- toast.add({
166
- title: t('supabase.access.toasts.noLinkConfirmed'),
167
- color: 'info',
168
- icon: 'lucide:info',
169
- })
170
- return
171
- }
172
-
173
- await showLink(result.link)
174
- } catch (error) {
175
- toast.add({
176
- title: t('supabase.access.toasts.failed'),
177
- description: errorText(error),
178
- color: 'error',
179
- icon: 'lucide:circle-x',
180
- })
181
- }
182
- }
183
-
184
- // --- Resend ---
185
- async function resend(user: AccessUser) {
186
- try {
187
- const result = await $fetch<{ emailSent?: boolean; link?: string }>(
188
- `/api/_pluto/supabase/access/users/${user.id}/resend`,
189
- { method: 'POST', body: { locale: locale.value } }
190
- )
191
-
192
- if (result.emailSent === false && result.link) {
193
- toast.add({
194
- title: t('supabase.access.toasts.resentNoEmail'),
195
- color: 'warning',
196
- icon: 'lucide:triangle-alert',
197
- })
198
- await showLink(result.link)
199
-
200
- return
201
- }
202
-
203
- toast.add({
204
- title: t('supabase.access.toasts.resent', { email: user.email ?? '' }),
205
- color: 'success',
206
- icon: 'lucide:circle-check',
207
- })
208
- } catch (error) {
209
- toast.add({
210
- title: t('supabase.access.toasts.failed'),
211
- description: errorText(error),
212
- color: 'error',
213
- icon: 'lucide:circle-x',
214
- })
215
- }
216
- }
217
-
218
- function menuItems(user: AccessUser): DropdownMenuItem[][] {
219
- const pending = userStatus(user) === 'invited'
220
- const main: DropdownMenuItem[] = [
221
- {
222
- label: t('supabase.access.users.menu.editRoles'),
223
- icon: 'lucide:shield',
224
- onSelect: () => openEdit(user),
225
- },
226
- ]
227
-
228
- if (pending) {
229
- main.push({
230
- label: t('supabase.access.users.menu.resend'),
231
- icon: 'lucide:send',
232
- onSelect: () => resend(user),
233
- })
234
-
235
- if (capabilities.value?.serviceKey && user.roles.length > 0) {
236
- main.push({
237
- label: t('supabase.access.users.menu.copyLink'),
238
- icon: 'lucide:link',
239
- onSelect: () => copyLink(user),
240
- })
241
- }
242
- }
243
-
244
- return [
245
- main,
246
- [
247
- {
248
- label: t('supabase.access.users.menu.revoke'),
249
- icon: 'lucide:user-x',
250
- color: 'error',
251
- onSelect: () => openRevoke(user),
252
- },
253
- ],
254
- ]
255
- }
256
- </script>
257
-
258
- <template>
259
- <div class="flex flex-col gap-y-6">
260
- <div class="flex flex-wrap items-start justify-between gap-4">
261
- <div>
262
- <h2 class="text-xl font-semibold">
263
- {{ t('supabase.access.users.title') }}
264
- </h2>
265
- <p class="text-muted text-sm">
266
- {{ t('supabase.access.users.description') }}
267
- </p>
268
- </div>
269
-
270
- <UButton v-if="isAdmin" icon="lucide:user-plus" @click="openInvite">
271
- {{ t('supabase.access.users.invite') }}
272
- </UButton>
273
- </div>
274
-
275
- <!-- Loading -->
276
- <div
277
- v-if="status === 'loading'"
278
- class="flex flex-col gap-y-3"
279
- aria-busy="true"
280
- >
281
- <USkeleton v-for="row in 4" :key="row" class="h-12 w-full" />
282
- </div>
283
-
284
- <!-- Error -->
285
- <UAlert
286
- v-else-if="status === 'error'"
287
- :title="t('supabase.access.users.error.title')"
288
- :description="loadError"
289
- :actions="[
290
- {
291
- label: t('pluto.common.tryAgain'),
292
- color: 'neutral',
293
- variant: 'outline',
294
- onClick: load,
295
- },
296
- ]"
297
- color="error"
298
- variant="subtle"
299
- icon="lucide:circle-x"
300
- />
301
-
302
- <!-- Empty -->
303
- <UEmpty
304
- v-else-if="users.length === 0"
305
- :title="t('supabase.access.users.empty.title')"
306
- :description="t('supabase.access.users.empty.description')"
307
- :actions="
308
- isAdmin
309
- ? [
310
- {
311
- label: t('supabase.access.users.invite'),
312
- icon: 'lucide:user-plus',
313
- onClick: openInvite,
314
- },
315
- ]
316
- : []
317
- "
318
- icon="lucide:users"
319
- />
320
-
321
- <!-- The list -->
322
- <UTable
323
- v-else
324
- :data="users"
325
- :columns="columns"
326
- class="border-default rounded-lg border"
327
- >
328
- <template #user-cell="{ row }">
329
- <div class="flex min-w-0 flex-col">
330
- <span class="flex items-center gap-2">
331
- <span class="text-highlighted truncate font-medium">{{
332
- userDisplayName(row.original)
333
- }}</span>
334
- <UBadge
335
- v-if="row.original.id === selfId"
336
- :label="t('supabase.access.users.you')"
337
- color="primary"
338
- variant="subtle"
339
- size="sm"
340
- />
341
- </span>
342
- <span v-if="row.original.email" class="text-muted truncate text-sm">{{
343
- row.original.email
344
- }}</span>
345
- </div>
346
- </template>
347
-
348
- <template #roles-cell="{ row }">
349
- <div class="flex flex-wrap gap-1">
350
- <UBadge
351
- v-if="isAdminUser(row.original)"
352
- :label="t('supabase.access.users.administrator')"
353
- color="primary"
354
- variant="subtle"
355
- />
356
- <template v-else>
357
- <UBadge
358
- v-for="key in row.original.roles"
359
- :key="key"
360
- :label="roleLabelOf(roles, key)"
361
- color="neutral"
362
- variant="subtle"
363
- />
364
- </template>
365
- </div>
366
- </template>
367
-
368
- <template #status-cell="{ row }">
369
- <div class="flex flex-col items-start gap-1">
370
- <UBadge
371
- v-if="userStatus(row.original) === 'active'"
372
- :label="t('supabase.access.users.status.active')"
373
- color="success"
374
- variant="subtle"
375
- />
376
- <template v-else>
377
- <UBadge
378
- :label="t('supabase.access.users.status.invited')"
379
- color="warning"
380
- variant="subtle"
381
- />
382
- <span v-if="row.original.invited_at" class="text-muted text-xs">
383
- {{
384
- t('supabase.access.users.status.invitedOn', {
385
- date: dateText(row.original.invited_at, {
386
- dateStyle: 'medium',
387
- }),
388
- })
389
- }}
390
- </span>
391
- </template>
392
- </div>
393
- </template>
394
-
395
- <template #lastSignIn-cell="{ row }">
396
- <span v-if="row.original.last_sign_in_at" class="text-sm">
397
- {{
398
- dateText(row.original.last_sign_in_at, {
399
- dateStyle: 'medium',
400
- timeStyle: 'short',
401
- })
402
- }}
403
- </span>
404
- <span v-else class="text-muted text-sm">{{
405
- t('supabase.access.users.neverSignedIn')
406
- }}</span>
407
- </template>
408
-
409
- <template #actions-cell="{ row }">
410
- <div v-if="isAdmin" class="flex justify-end">
411
- <UDropdownMenu
412
- :items="menuItems(row.original)"
413
- :content="{ align: 'end' }"
414
- >
415
- <UButton
416
- :aria-label="
417
- t('supabase.access.users.actionsLabel', {
418
- name: userDisplayName(row.original),
419
- })
420
- "
421
- icon="lucide:ellipsis"
422
- color="neutral"
423
- variant="ghost"
424
- />
425
- </UDropdownMenu>
426
- </div>
427
- </template>
428
- </UTable>
429
-
430
- <!-- Lazy: the chunk loads on the first open. -->
431
- <InviteModal
432
- v-if="inviteMounted"
433
- v-model:open="inviteOpen"
434
- :roles="roles"
435
- :capabilities="capabilities"
436
- @done="load"
437
- />
438
- <EditRolesSlideover
439
- v-if="editMounted"
440
- v-model:open="editOpen"
441
- :user="editUser"
442
- :roles="roles"
443
- :self="editUser?.id === selfId"
444
- @saved="load"
445
- />
446
- </div>
447
- </template>