@plutocms/supabase 0.11.3 → 0.12.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.
- package/.env.example +14 -0
- package/CHANGELOG.md +7 -0
- package/FEATURES.md +5 -1
- package/README.md +66 -0
- package/app/components/access/CopyButton.vue +24 -0
- package/app/components/access/DeleteRoleModal.vue +73 -0
- package/app/components/access/EditRolesSlideover.vue +134 -0
- package/app/components/access/InviteModal.vue +288 -0
- package/app/components/access/LinkField.vue +19 -0
- package/app/components/access/LinkModal.vue +29 -0
- package/app/components/access/RevokeModal.vue +83 -0
- package/app/components/access/RoleSlideover.vue +251 -0
- package/app/components/mail/AuthEmailsPanel.vue +108 -0
- package/app/pages/admin/(auth)/accept-invite.vue +155 -0
- package/app/pages/admin/settings/roles.vue +187 -0
- package/app/pages/admin/settings/users.vue +372 -0
- package/app/plugins/pluto-extension.ts +38 -1
- package/app/utils/access-ui.ts +100 -0
- package/app/utils/auth-driver.ts +3 -0
- package/app/utils/invite-link.ts +70 -0
- package/app/utils/supabase-project.ts +54 -0
- package/db/migrations/008_users_and_roles_admin.sql +462 -0
- package/i18n/locales/en.json +194 -1
- package/i18n/locales/pt-BR.json +194 -1
- package/package.json +3 -2
- package/server/api/access/capabilities.get.ts +17 -0
- package/server/api/access/roles/[key].delete.ts +18 -0
- package/server/api/access/roles/[key].put.ts +26 -0
- package/server/api/access/roles.get.ts +17 -0
- package/server/api/access/roles.post.ts +25 -0
- package/server/api/access/users/[id]/resend.post.ts +172 -0
- package/server/api/access/users/[id]/roles.delete.ts +22 -0
- package/server/api/access/users/[id]/roles.put.ts +24 -0
- package/server/api/access/users/invite-link.post.ts +50 -0
- package/server/api/access/users/invite.post.ts +201 -0
- package/server/api/access/users.get.ts +17 -0
- package/server/utils/access-errors.ts +148 -0
- package/server/utils/access-invite.ts +66 -0
- package/server/utils/access-mail.ts +354 -0
- package/server/utils/admin-guard.ts +4 -3
- package/server/utils/admin-role-guard.ts +44 -0
- package/server/utils/rate-limit.ts +84 -0
- package/server/utils/service-client.ts +18 -0
- package/server/utils/sign-in-link.ts +129 -0
- package/shared/types/access.d.ts +41 -0
- package/shared/types/supabase.ts +50 -9
|
@@ -0,0 +1,187 @@
|
|
|
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(() => import('../../../components/access/RoleSlideover.vue'))
|
|
11
|
+
const DeleteRoleModal = defineAsyncComponent(() => import('../../../components/access/DeleteRoleModal.vue'))
|
|
12
|
+
|
|
13
|
+
const { t } = usePlutoI18n()
|
|
14
|
+
const overlay = useOverlay()
|
|
15
|
+
const errorText = usePlutoErrorText()
|
|
16
|
+
|
|
17
|
+
useHead({
|
|
18
|
+
title: () => `${t('supabase.access.roles.title')} - ${t('pluto.pages.settings')}`,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const roles = ref<AccessRole[]>([])
|
|
22
|
+
const status = ref<'loading' | 'ready' | 'error'>('loading')
|
|
23
|
+
const loadError = ref('')
|
|
24
|
+
|
|
25
|
+
async function load() {
|
|
26
|
+
// Keep the table on screen while it reloads after a change.
|
|
27
|
+
if (status.value !== 'ready') {
|
|
28
|
+
status.value = 'loading'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const response = await $fetch<{ roles: AccessRole[] }>('/api/access/roles')
|
|
33
|
+
|
|
34
|
+
roles.value = response.roles
|
|
35
|
+
status.value = 'ready'
|
|
36
|
+
} catch (error) {
|
|
37
|
+
loadError.value = errorText(error)
|
|
38
|
+
status.value = 'error'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onMounted(load)
|
|
43
|
+
|
|
44
|
+
const columns = computed<TableColumn<AccessRole>[]>(() => [
|
|
45
|
+
{ id: 'role', header: t('supabase.access.roles.columns.role') },
|
|
46
|
+
{ id: 'permissions', header: t('supabase.access.roles.columns.permissions') },
|
|
47
|
+
{ id: 'users', header: t('supabase.access.roles.columns.users') },
|
|
48
|
+
{ id: 'actions', header: '' },
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
// --- Create and edit ---
|
|
52
|
+
const slideoverMounted = ref(false)
|
|
53
|
+
const slideoverOpen = ref(false)
|
|
54
|
+
const editing = ref<AccessRole | null>(null)
|
|
55
|
+
|
|
56
|
+
function openRole(role: AccessRole | null) {
|
|
57
|
+
editing.value = role
|
|
58
|
+
slideoverMounted.value = true
|
|
59
|
+
slideoverOpen.value = true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Delete ---
|
|
63
|
+
async function openDelete(role: AccessRole) {
|
|
64
|
+
const modal = overlay.create(DeleteRoleModal)
|
|
65
|
+
const deleted = await modal.open({ role })
|
|
66
|
+
|
|
67
|
+
if (deleted) {
|
|
68
|
+
await load()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isFullAccess(role: AccessRole): boolean {
|
|
73
|
+
return role.capabilities.includes('*')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function menuItems(role: AccessRole): DropdownMenuItem[][] {
|
|
77
|
+
const readOnly = role.key === ADMIN_ROLE_KEY
|
|
78
|
+
const main: DropdownMenuItem[] = [
|
|
79
|
+
{
|
|
80
|
+
label: readOnly ? t('supabase.access.roles.menu.view') : t('supabase.access.roles.menu.edit'),
|
|
81
|
+
icon: readOnly ? 'lucide:eye' : 'lucide:pencil',
|
|
82
|
+
onSelect: () => openRole(role),
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
// A built-in role cannot be deleted.
|
|
87
|
+
if (role.is_builtin) {
|
|
88
|
+
return [main]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
main,
|
|
93
|
+
[
|
|
94
|
+
{
|
|
95
|
+
label: t('supabase.access.roles.menu.delete'),
|
|
96
|
+
icon: 'lucide:trash-2',
|
|
97
|
+
color: 'error',
|
|
98
|
+
onSelect: () => openDelete(role),
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<div class="flex flex-col gap-y-6">
|
|
107
|
+
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
108
|
+
<div>
|
|
109
|
+
<h2 class="text-xl font-semibold">{{ t('supabase.access.roles.title') }}</h2>
|
|
110
|
+
<p class="text-muted text-sm">{{ t('supabase.access.roles.description') }}</p>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<UButton icon="lucide:plus" @click="openRole(null)">
|
|
114
|
+
{{ t('supabase.access.roles.create') }}
|
|
115
|
+
</UButton>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<!-- Loading -->
|
|
119
|
+
<div v-if="status === 'loading'" class="flex flex-col gap-y-3" aria-busy="true">
|
|
120
|
+
<USkeleton v-for="row in 3" :key="row" class="h-12 w-full" />
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<!-- Error -->
|
|
124
|
+
<UAlert
|
|
125
|
+
v-else-if="status === 'error'"
|
|
126
|
+
:title="t('supabase.access.roles.error.title')"
|
|
127
|
+
:description="loadError"
|
|
128
|
+
:actions="[{ label: t('pluto.common.tryAgain'), color: 'neutral', variant: 'outline', onClick: load }]"
|
|
129
|
+
color="error"
|
|
130
|
+
variant="subtle"
|
|
131
|
+
icon="lucide:circle-x"
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
<!-- The list -->
|
|
135
|
+
<UTable v-else :data="roles" :columns="columns" class="border-default rounded-lg border">
|
|
136
|
+
<template #role-cell="{ row }">
|
|
137
|
+
<div class="flex min-w-0 flex-col">
|
|
138
|
+
<span class="flex items-center gap-2">
|
|
139
|
+
<span class="text-highlighted truncate font-medium">{{ row.original.label }}</span>
|
|
140
|
+
<UBadge
|
|
141
|
+
v-if="row.original.is_builtin"
|
|
142
|
+
:label="t('supabase.access.roles.builtin')"
|
|
143
|
+
color="neutral"
|
|
144
|
+
variant="subtle"
|
|
145
|
+
size="sm"
|
|
146
|
+
/>
|
|
147
|
+
</span>
|
|
148
|
+
<code class="text-muted truncate font-mono text-xs">{{ row.original.key }}</code>
|
|
149
|
+
</div>
|
|
150
|
+
</template>
|
|
151
|
+
|
|
152
|
+
<template #permissions-cell="{ row }">
|
|
153
|
+
<span v-if="isFullAccess(row.original)" class="text-sm">
|
|
154
|
+
{{ t('supabase.access.roles.fullAccess') }}
|
|
155
|
+
</span>
|
|
156
|
+
<span v-else class="text-sm">
|
|
157
|
+
{{ t('supabase.access.roles.permissionCount', row.original.capabilities.length) }}
|
|
158
|
+
</span>
|
|
159
|
+
</template>
|
|
160
|
+
|
|
161
|
+
<template #users-cell="{ row }">
|
|
162
|
+
<span class="text-sm">{{ t('supabase.access.roles.userCount', row.original.user_count) }}</span>
|
|
163
|
+
</template>
|
|
164
|
+
|
|
165
|
+
<template #actions-cell="{ row }">
|
|
166
|
+
<div class="flex justify-end">
|
|
167
|
+
<UDropdownMenu :items="menuItems(row.original)" :content="{ align: 'end' }">
|
|
168
|
+
<UButton
|
|
169
|
+
:aria-label="row.original.label"
|
|
170
|
+
icon="lucide:ellipsis"
|
|
171
|
+
color="neutral"
|
|
172
|
+
variant="ghost"
|
|
173
|
+
/>
|
|
174
|
+
</UDropdownMenu>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
177
|
+
</UTable>
|
|
178
|
+
|
|
179
|
+
<!-- Lazy: the chunk loads on the first open. -->
|
|
180
|
+
<RoleSlideover
|
|
181
|
+
v-if="slideoverMounted"
|
|
182
|
+
v-model:open="slideoverOpen"
|
|
183
|
+
:role="editing"
|
|
184
|
+
@saved="load"
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
@@ -0,0 +1,372 @@
|
|
|
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(() => import('../../../components/access/InviteModal.vue'))
|
|
21
|
+
const EditRolesSlideover = defineAsyncComponent(() => import('../../../components/access/EditRolesSlideover.vue'))
|
|
22
|
+
const RevokeModal = defineAsyncComponent(() => import('../../../components/access/RevokeModal.vue'))
|
|
23
|
+
const LinkModal = defineAsyncComponent(() => import('../../../components/access/LinkModal.vue'))
|
|
24
|
+
|
|
25
|
+
const { t, d, locale } = usePlutoI18n()
|
|
26
|
+
const toast = useToast()
|
|
27
|
+
const overlay = useOverlay()
|
|
28
|
+
const errorText = usePlutoErrorText()
|
|
29
|
+
const permissions = usePlutoPermissions()
|
|
30
|
+
const auth = usePlutoAuth()
|
|
31
|
+
|
|
32
|
+
useHead({
|
|
33
|
+
title: () => `${t('supabase.access.users.title')} - ${t('pluto.pages.settings')}`,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const users = ref<AccessUser[]>([])
|
|
37
|
+
const roles = ref<AccessRole[]>([])
|
|
38
|
+
const status = ref<'loading' | 'ready' | 'error'>('loading')
|
|
39
|
+
const loadError = ref('')
|
|
40
|
+
const capabilities = ref<AccessCapabilities | null>(null)
|
|
41
|
+
|
|
42
|
+
const isAdmin = computed(() => permissions.isAdmin.value)
|
|
43
|
+
const selfId = computed(() => auth.user.value?.id ?? '')
|
|
44
|
+
|
|
45
|
+
async function load() {
|
|
46
|
+
// Keep the table on screen while it reloads after a change.
|
|
47
|
+
if (status.value !== 'ready') {
|
|
48
|
+
status.value = 'loading'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const [usersResponse, rolesResponse] = await Promise.all([
|
|
53
|
+
$fetch<{ users: AccessUser[] }>('/api/access/users'),
|
|
54
|
+
$fetch<{ roles: AccessRole[] }>('/api/access/roles'),
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
users.value = usersResponse.users
|
|
58
|
+
roles.value = rolesResponse.roles
|
|
59
|
+
status.value = 'ready'
|
|
60
|
+
} catch (error) {
|
|
61
|
+
loadError.value = errorText(error)
|
|
62
|
+
status.value = 'error'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadCapabilities() {
|
|
67
|
+
try {
|
|
68
|
+
capabilities.value = await $fetch<AccessCapabilities>('/api/access/capabilities')
|
|
69
|
+
} catch {
|
|
70
|
+
// The invite works without it. Only the "Copy invite link" action hides.
|
|
71
|
+
capabilities.value = null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onMounted(load)
|
|
76
|
+
|
|
77
|
+
// Only an admin can read this, so ask after the permissions are known.
|
|
78
|
+
watch(isAdmin, (admin) => {
|
|
79
|
+
if (admin && !capabilities.value) {
|
|
80
|
+
void loadCapabilities()
|
|
81
|
+
}
|
|
82
|
+
}, { immediate: true })
|
|
83
|
+
|
|
84
|
+
const columns = computed<TableColumn<AccessUser>[]>(() => [
|
|
85
|
+
{ id: 'user', header: t('supabase.access.users.columns.user') },
|
|
86
|
+
{ id: 'roles', header: t('supabase.access.users.columns.roles') },
|
|
87
|
+
{ id: 'status', header: t('supabase.access.users.columns.status') },
|
|
88
|
+
{ id: 'lastSignIn', header: t('supabase.access.users.columns.lastSignIn') },
|
|
89
|
+
{ id: 'actions', header: '' },
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
function dateText(value: string | null, options: Intl.DateTimeFormatOptions): string {
|
|
93
|
+
return value ? d(new Date(value), options) : ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Invite ---
|
|
97
|
+
const inviteMounted = ref(false)
|
|
98
|
+
const inviteOpen = ref(false)
|
|
99
|
+
|
|
100
|
+
function openInvite() {
|
|
101
|
+
inviteMounted.value = true
|
|
102
|
+
inviteOpen.value = true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Edit roles ---
|
|
106
|
+
const editMounted = ref(false)
|
|
107
|
+
const editOpen = ref(false)
|
|
108
|
+
const editUser = ref<AccessUser | null>(null)
|
|
109
|
+
|
|
110
|
+
function openEdit(user: AccessUser) {
|
|
111
|
+
editUser.value = user
|
|
112
|
+
editMounted.value = true
|
|
113
|
+
editOpen.value = true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Revoke ---
|
|
117
|
+
async function openRevoke(user: AccessUser) {
|
|
118
|
+
const modal = overlay.create(RevokeModal)
|
|
119
|
+
const revoked = await modal.open({ user, self: user.id === selfId.value })
|
|
120
|
+
|
|
121
|
+
if (revoked) {
|
|
122
|
+
await load()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Invite link ---
|
|
127
|
+
async function showLink(link: string) {
|
|
128
|
+
await overlay.create(LinkModal).open({ link })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function copyLink(user: AccessUser) {
|
|
132
|
+
if (!user.email) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const result = await $fetch<{ link: string | null }>('/api/access/users/invite-link', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
body: { email: user.email, roleKeys: user.roles },
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// A confirmed account gets no link: it signs in with its own password.
|
|
143
|
+
if (!result.link) {
|
|
144
|
+
toast.add({
|
|
145
|
+
title: t('supabase.access.toasts.noLinkConfirmed'),
|
|
146
|
+
color: 'info',
|
|
147
|
+
icon: 'lucide:info',
|
|
148
|
+
})
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await showLink(result.link)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
toast.add({
|
|
155
|
+
title: t('supabase.access.toasts.failed'),
|
|
156
|
+
description: errorText(error),
|
|
157
|
+
color: 'error',
|
|
158
|
+
icon: 'lucide:circle-x',
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Resend ---
|
|
164
|
+
async function resend(user: AccessUser) {
|
|
165
|
+
try {
|
|
166
|
+
const result = await $fetch<{ emailSent?: boolean, link?: string }>(
|
|
167
|
+
`/api/access/users/${user.id}/resend`,
|
|
168
|
+
{ method: 'POST', body: { locale: locale.value } }
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if (result.emailSent === false && result.link) {
|
|
172
|
+
toast.add({
|
|
173
|
+
title: t('supabase.access.toasts.resentNoEmail'),
|
|
174
|
+
color: 'warning',
|
|
175
|
+
icon: 'lucide:triangle-alert',
|
|
176
|
+
})
|
|
177
|
+
await showLink(result.link)
|
|
178
|
+
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
toast.add({
|
|
183
|
+
title: t('supabase.access.toasts.resent', { email: user.email ?? '' }),
|
|
184
|
+
color: 'success',
|
|
185
|
+
icon: 'lucide:circle-check',
|
|
186
|
+
})
|
|
187
|
+
} catch (error) {
|
|
188
|
+
toast.add({
|
|
189
|
+
title: t('supabase.access.toasts.failed'),
|
|
190
|
+
description: errorText(error),
|
|
191
|
+
color: 'error',
|
|
192
|
+
icon: 'lucide:circle-x',
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function menuItems(user: AccessUser): DropdownMenuItem[][] {
|
|
198
|
+
const pending = userStatus(user) === 'invited'
|
|
199
|
+
const main: DropdownMenuItem[] = [
|
|
200
|
+
{
|
|
201
|
+
label: t('supabase.access.users.menu.editRoles'),
|
|
202
|
+
icon: 'lucide:shield',
|
|
203
|
+
onSelect: () => openEdit(user),
|
|
204
|
+
},
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
if (pending) {
|
|
208
|
+
main.push({
|
|
209
|
+
label: t('supabase.access.users.menu.resend'),
|
|
210
|
+
icon: 'lucide:send',
|
|
211
|
+
onSelect: () => resend(user),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (capabilities.value?.serviceKey && user.roles.length > 0) {
|
|
215
|
+
main.push({
|
|
216
|
+
label: t('supabase.access.users.menu.copyLink'),
|
|
217
|
+
icon: 'lucide:link',
|
|
218
|
+
onSelect: () => copyLink(user),
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return [
|
|
224
|
+
main,
|
|
225
|
+
[
|
|
226
|
+
{
|
|
227
|
+
label: t('supabase.access.users.menu.revoke'),
|
|
228
|
+
icon: 'lucide:user-x',
|
|
229
|
+
color: 'error',
|
|
230
|
+
onSelect: () => openRevoke(user),
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
236
|
+
|
|
237
|
+
<template>
|
|
238
|
+
<div class="flex flex-col gap-y-6">
|
|
239
|
+
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
240
|
+
<div>
|
|
241
|
+
<h2 class="text-xl font-semibold">{{ t('supabase.access.users.title') }}</h2>
|
|
242
|
+
<p class="text-muted text-sm">{{ t('supabase.access.users.description') }}</p>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<UButton v-if="isAdmin" icon="lucide:user-plus" @click="openInvite">
|
|
246
|
+
{{ t('supabase.access.users.invite') }}
|
|
247
|
+
</UButton>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<!-- Loading -->
|
|
251
|
+
<div v-if="status === 'loading'" class="flex flex-col gap-y-3" aria-busy="true">
|
|
252
|
+
<USkeleton v-for="row in 4" :key="row" class="h-12 w-full" />
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<!-- Error -->
|
|
256
|
+
<UAlert
|
|
257
|
+
v-else-if="status === 'error'"
|
|
258
|
+
:title="t('supabase.access.users.error.title')"
|
|
259
|
+
:description="loadError"
|
|
260
|
+
:actions="[{ label: t('pluto.common.tryAgain'), color: 'neutral', variant: 'outline', onClick: load }]"
|
|
261
|
+
color="error"
|
|
262
|
+
variant="subtle"
|
|
263
|
+
icon="lucide:circle-x"
|
|
264
|
+
/>
|
|
265
|
+
|
|
266
|
+
<!-- Empty -->
|
|
267
|
+
<UEmpty
|
|
268
|
+
v-else-if="users.length === 0"
|
|
269
|
+
:title="t('supabase.access.users.empty.title')"
|
|
270
|
+
:description="t('supabase.access.users.empty.description')"
|
|
271
|
+
:actions="isAdmin ? [{ label: t('supabase.access.users.invite'), icon: 'lucide:user-plus', onClick: openInvite }] : []"
|
|
272
|
+
icon="lucide:users"
|
|
273
|
+
/>
|
|
274
|
+
|
|
275
|
+
<!-- The list -->
|
|
276
|
+
<UTable v-else :data="users" :columns="columns" class="border-default rounded-lg border">
|
|
277
|
+
<template #user-cell="{ row }">
|
|
278
|
+
<div class="flex min-w-0 flex-col">
|
|
279
|
+
<span class="flex items-center gap-2">
|
|
280
|
+
<span class="text-highlighted truncate font-medium">{{ userDisplayName(row.original) }}</span>
|
|
281
|
+
<UBadge
|
|
282
|
+
v-if="row.original.id === selfId"
|
|
283
|
+
:label="t('supabase.access.users.you')"
|
|
284
|
+
color="primary"
|
|
285
|
+
variant="subtle"
|
|
286
|
+
size="sm"
|
|
287
|
+
/>
|
|
288
|
+
</span>
|
|
289
|
+
<span v-if="row.original.email" class="text-muted truncate text-sm">{{ row.original.email }}</span>
|
|
290
|
+
</div>
|
|
291
|
+
</template>
|
|
292
|
+
|
|
293
|
+
<template #roles-cell="{ row }">
|
|
294
|
+
<div class="flex flex-wrap gap-1">
|
|
295
|
+
<UBadge
|
|
296
|
+
v-if="isAdminUser(row.original)"
|
|
297
|
+
:label="t('supabase.access.users.administrator')"
|
|
298
|
+
color="primary"
|
|
299
|
+
variant="subtle"
|
|
300
|
+
/>
|
|
301
|
+
<template v-else>
|
|
302
|
+
<UBadge
|
|
303
|
+
v-for="key in row.original.roles"
|
|
304
|
+
:key="key"
|
|
305
|
+
:label="roleLabelOf(roles, key)"
|
|
306
|
+
color="neutral"
|
|
307
|
+
variant="subtle"
|
|
308
|
+
/>
|
|
309
|
+
</template>
|
|
310
|
+
</div>
|
|
311
|
+
</template>
|
|
312
|
+
|
|
313
|
+
<template #status-cell="{ row }">
|
|
314
|
+
<div class="flex flex-col items-start gap-1">
|
|
315
|
+
<UBadge
|
|
316
|
+
v-if="userStatus(row.original) === 'active'"
|
|
317
|
+
:label="t('supabase.access.users.status.active')"
|
|
318
|
+
color="success"
|
|
319
|
+
variant="subtle"
|
|
320
|
+
/>
|
|
321
|
+
<template v-else>
|
|
322
|
+
<UBadge
|
|
323
|
+
:label="t('supabase.access.users.status.invited')"
|
|
324
|
+
color="warning"
|
|
325
|
+
variant="subtle"
|
|
326
|
+
/>
|
|
327
|
+
<span v-if="row.original.invited_at" class="text-muted text-xs">
|
|
328
|
+
{{ t('supabase.access.users.status.invitedOn', { date: dateText(row.original.invited_at, { dateStyle: 'medium' }) }) }}
|
|
329
|
+
</span>
|
|
330
|
+
</template>
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
333
|
+
|
|
334
|
+
<template #lastSignIn-cell="{ row }">
|
|
335
|
+
<span v-if="row.original.last_sign_in_at" class="text-sm">
|
|
336
|
+
{{ dateText(row.original.last_sign_in_at, { dateStyle: 'medium', timeStyle: 'short' }) }}
|
|
337
|
+
</span>
|
|
338
|
+
<span v-else class="text-muted text-sm">{{ t('supabase.access.users.neverSignedIn') }}</span>
|
|
339
|
+
</template>
|
|
340
|
+
|
|
341
|
+
<template #actions-cell="{ row }">
|
|
342
|
+
<div v-if="isAdmin" class="flex justify-end">
|
|
343
|
+
<UDropdownMenu :items="menuItems(row.original)" :content="{ align: 'end' }">
|
|
344
|
+
<UButton
|
|
345
|
+
:aria-label="t('supabase.access.users.actionsLabel', { name: userDisplayName(row.original) })"
|
|
346
|
+
icon="lucide:ellipsis"
|
|
347
|
+
color="neutral"
|
|
348
|
+
variant="ghost"
|
|
349
|
+
/>
|
|
350
|
+
</UDropdownMenu>
|
|
351
|
+
</div>
|
|
352
|
+
</template>
|
|
353
|
+
</UTable>
|
|
354
|
+
|
|
355
|
+
<!-- Lazy: the chunk loads on the first open. -->
|
|
356
|
+
<InviteModal
|
|
357
|
+
v-if="inviteMounted"
|
|
358
|
+
v-model:open="inviteOpen"
|
|
359
|
+
:roles="roles"
|
|
360
|
+
:capabilities="capabilities"
|
|
361
|
+
@done="load"
|
|
362
|
+
/>
|
|
363
|
+
<EditRolesSlideover
|
|
364
|
+
v-if="editMounted"
|
|
365
|
+
v-model:open="editOpen"
|
|
366
|
+
:user="editUser"
|
|
367
|
+
:roles="roles"
|
|
368
|
+
:self="editUser?.id === selfId"
|
|
369
|
+
@saved="load"
|
|
370
|
+
/>
|
|
371
|
+
</div>
|
|
372
|
+
</template>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineAsyncComponent } from 'vue'
|
|
2
2
|
|
|
3
|
-
// The
|
|
3
|
+
// The four components load on demand. They stay out of the entry chunk. Core
|
|
4
4
|
// shows a skeleton while each chunk loads.
|
|
5
5
|
const AuthSignIn = defineAsyncComponent(
|
|
6
6
|
() => import('../components/auth/AuthSignIn.vue')
|
|
@@ -11,6 +11,10 @@ const MigrationsBanner = defineAsyncComponent(
|
|
|
11
11
|
const SettingsGeneralPanel = defineAsyncComponent(
|
|
12
12
|
() => import('../components/settings/SettingsGeneralPanel.vue')
|
|
13
13
|
)
|
|
14
|
+
// The guide for the Supabase sign-in emails, a section of the Email tab.
|
|
15
|
+
const AuthEmailsPanel = defineAsyncComponent(
|
|
16
|
+
() => import('../components/mail/AuthEmailsPanel.vue')
|
|
17
|
+
)
|
|
14
18
|
|
|
15
19
|
export default defineNuxtPlugin(() => {
|
|
16
20
|
// Read at setup, while the Nuxt context exists. The driver below calls it
|
|
@@ -161,6 +165,37 @@ export default defineNuxtPlugin(() => {
|
|
|
161
165
|
component: SettingsGeneralPanel,
|
|
162
166
|
},
|
|
163
167
|
],
|
|
168
|
+
// The Users and Roles tabs of Settings. The page files are in
|
|
169
|
+
// app/pages/admin/settings/. Users shows for `users:read` (an admin always
|
|
170
|
+
// has it). Roles is admin only: '*' is the admin-only capability.
|
|
171
|
+
settingsPages: [
|
|
172
|
+
{
|
|
173
|
+
id: 'users',
|
|
174
|
+
order: 10,
|
|
175
|
+
label: 'supabase.access.users.title',
|
|
176
|
+
icon: 'lucide:users',
|
|
177
|
+
to: '/admin/settings/users',
|
|
178
|
+
capability: 'users:read',
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'roles',
|
|
182
|
+
order: 20,
|
|
183
|
+
label: 'supabase.access.roles.title',
|
|
184
|
+
icon: 'lucide:shield',
|
|
185
|
+
to: '/admin/settings/roles',
|
|
186
|
+
capability: '*',
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
// A guide on the Email tab: the sign-in emails come from Supabase.
|
|
190
|
+
mailPanels: [
|
|
191
|
+
{
|
|
192
|
+
id: 'auth-emails',
|
|
193
|
+
title: 'supabase.mail.authEmails.title',
|
|
194
|
+
description: 'supabase.mail.authEmails.description',
|
|
195
|
+
icon: 'lucide:mail',
|
|
196
|
+
component: AuthEmailsPanel,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
164
199
|
settingsDriver: {
|
|
165
200
|
id: 'supabase',
|
|
166
201
|
load: async () => {
|
|
@@ -179,6 +214,8 @@ export default defineNuxtPlugin(() => {
|
|
|
179
214
|
hasSettingsModified.value = Date.now()
|
|
180
215
|
},
|
|
181
216
|
},
|
|
217
|
+
// Core groups these capabilities in the Roles editor under the label
|
|
218
|
+
// 'supabase.capabilities.group' (the `<owner>.capabilities.group` key).
|
|
182
219
|
// No 'system:migrate' capability: applying migrations stays admin-only,
|
|
183
220
|
// gated by requireAdmin (public.is_admin() directly), never a named
|
|
184
221
|
// capability — see server/utils/admin-guard.ts for why.
|