@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.
- package/CHANGELOG.md +14 -0
- package/FEATURES.md +1 -1
- package/README.md +1 -1
- package/app/pages/admin/roles.vue +256 -0
- package/app/pages/admin/users.vue +473 -0
- package/app/plugins/pluto-extension.ts +23 -15
- package/nuxt.config.ts +3 -10
- package/package.json +4 -13
- package/app/pages/admin/settings/roles.vue +0 -230
- package/app/pages/admin/settings/users.vue +0 -447
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.19.0](https://github.com/plutocms/supabase/compare/v0.18.1...v0.19.0) (2026-10-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* a top-level Users menu with Users and Roles ([#158](https://github.com/plutocms/supabase/issues/158)) ([1fc3cdf](https://github.com/plutocms/supabase/commit/1fc3cdfa1859b8e0a512c71ec54bd64cc6cab79b))
|
|
9
|
+
|
|
10
|
+
## [0.18.1](https://github.com/plutocms/supabase/compare/v0.18.0...v0.18.1) (2026-10-31)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* apps that extend the layer no longer get the ESLint module or an install script ([#151](https://github.com/plutocms/supabase/issues/151)) ([818bf93](https://github.com/plutocms/supabase/commit/818bf93a44cea01e6a1b237ace33a325f45de590))
|
|
16
|
+
|
|
3
17
|
## [0.18.0](https://github.com/plutocms/supabase/compare/v0.17.0...v0.18.0) (2026-10-30)
|
|
4
18
|
|
|
5
19
|
|
package/FEATURES.md
CHANGED
|
@@ -11,7 +11,7 @@ policies shared by every project here.
|
|
|
11
11
|
- Permissions storage: role-based capabilities backing server route guards and RLS policies, and
|
|
12
12
|
the admin-only access API (invite, roles) of migration 008.
|
|
13
13
|
@.claude/skills/permissions-storage/SKILL.md
|
|
14
|
-
- Users and Roles: the Users and Roles
|
|
14
|
+
- Users and Roles: the Users item of the admin menu, with the Users and Roles pages (invite, edit roles, revoke, copy invite
|
|
15
15
|
link, roles editor), invites through Pluto mail, and a guide for the Supabase sign-in emails.
|
|
16
16
|
@.claude/skills/users-and-roles/SKILL.md
|
|
17
17
|
- Content adapter: a Supabase/PostgREST-backed `PlutoContentAdapter`, plugging any layer's
|
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ a page of another site cannot call them. A failure is an HTTP error with a trans
|
|
|
116
116
|
|
|
117
117
|
The public content of the Supabase layers is read through the public API of core,
|
|
118
118
|
`GET /api/pluto/v1/content/<type>` (and `/<id or slug>`). See
|
|
119
|
-
[the public API](https://pluto.vulmix.dev/core/public-api). The routes are anonymous. This layer
|
|
119
|
+
[the public API](https://pluto.vulmix.dev/docs/core/public-api). The routes are anonymous. This layer
|
|
120
120
|
reads for them with an anon client that has no user session, so Row Level Security applies as for
|
|
121
121
|
an anonymous visitor, and the answer is the same for everyone. A CDN can cache it. The adapter
|
|
122
122
|
never uses the service role key for a read. The adapter also supports the `filters` of a public
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The Roles page (`/admin/roles`, admin only, a child of the Users item of the
|
|
3
|
+
// admin menu). A role is a set of capabilities. An admin creates, edits and
|
|
4
|
+
// deletes roles. The `admin` role is built in: it opens read-only, and it
|
|
5
|
+
// cannot be deleted. Without admin rights, the page shows an alert and loads
|
|
6
|
+
// nothing. See the users-and-roles skill.
|
|
7
|
+
import type { DropdownMenuItem, TableColumn } from '@nuxt/ui'
|
|
8
|
+
import type { AccessRole } from '../../../shared/types/access'
|
|
9
|
+
import { ADMIN_ROLE_KEY } from '../../utils/access-ui'
|
|
10
|
+
|
|
11
|
+
// The slideover and the modal load on demand. They stay out of the page chunk.
|
|
12
|
+
const RoleSlideover = defineAsyncComponent(
|
|
13
|
+
() => import('../../components/access/RoleSlideover.vue')
|
|
14
|
+
)
|
|
15
|
+
const DeleteRoleModal = defineAsyncComponent(
|
|
16
|
+
() => import('../../components/access/DeleteRoleModal.vue')
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const { t } = usePlutoI18n()
|
|
20
|
+
const overlay = useOverlay()
|
|
21
|
+
const errorText = usePlutoErrorText()
|
|
22
|
+
|
|
23
|
+
useHead({
|
|
24
|
+
title: () => t('supabase.access.roles.title'),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const permissions = usePlutoPermissions()
|
|
28
|
+
const allowed = computed(() => permissions.isAdmin.value)
|
|
29
|
+
|
|
30
|
+
const roles = ref<AccessRole[]>([])
|
|
31
|
+
const status = ref<'loading' | 'ready' | 'error'>('loading')
|
|
32
|
+
const loadError = ref('')
|
|
33
|
+
|
|
34
|
+
async function load() {
|
|
35
|
+
// Keep the table on screen while it reloads after a change.
|
|
36
|
+
if (status.value !== 'ready') {
|
|
37
|
+
status.value = 'loading'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const response = await $fetch<{ roles: AccessRole[] }>(
|
|
42
|
+
'/api/_pluto/supabase/access/roles'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
roles.value = response.roles
|
|
46
|
+
status.value = 'ready'
|
|
47
|
+
} catch (error) {
|
|
48
|
+
loadError.value = errorText(error)
|
|
49
|
+
status.value = 'error'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
watch(
|
|
54
|
+
allowed,
|
|
55
|
+
(ok) => {
|
|
56
|
+
if (ok) {
|
|
57
|
+
load()
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{ immediate: true }
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const columns = computed<TableColumn<AccessRole>[]>(() => [
|
|
64
|
+
{ id: 'role', header: t('supabase.access.roles.columns.role') },
|
|
65
|
+
{ id: 'permissions', header: t('supabase.access.roles.columns.permissions') },
|
|
66
|
+
{ id: 'users', header: t('supabase.access.roles.columns.users') },
|
|
67
|
+
{ id: 'actions', header: '' },
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
// --- Create and edit ---
|
|
71
|
+
const slideoverMounted = ref(false)
|
|
72
|
+
const slideoverOpen = ref(false)
|
|
73
|
+
const editing = ref<AccessRole | null>(null)
|
|
74
|
+
|
|
75
|
+
function openRole(role: AccessRole | null) {
|
|
76
|
+
editing.value = role
|
|
77
|
+
slideoverMounted.value = true
|
|
78
|
+
slideoverOpen.value = true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Delete ---
|
|
82
|
+
async function openDelete(role: AccessRole) {
|
|
83
|
+
const modal = overlay.create(DeleteRoleModal)
|
|
84
|
+
const deleted = await modal.open({ role })
|
|
85
|
+
|
|
86
|
+
if (deleted) {
|
|
87
|
+
await load()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isFullAccess(role: AccessRole): boolean {
|
|
92
|
+
return role.capabilities.includes('*')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function menuItems(role: AccessRole): DropdownMenuItem[][] {
|
|
96
|
+
const readOnly = role.key === ADMIN_ROLE_KEY
|
|
97
|
+
const main: DropdownMenuItem[] = [
|
|
98
|
+
{
|
|
99
|
+
label: readOnly
|
|
100
|
+
? t('supabase.access.roles.menu.view')
|
|
101
|
+
: t('supabase.access.roles.menu.edit'),
|
|
102
|
+
icon: readOnly ? 'lucide:eye' : 'lucide:pencil',
|
|
103
|
+
onSelect: () => openRole(role),
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
// A built-in role cannot be deleted.
|
|
108
|
+
if (role.is_builtin) {
|
|
109
|
+
return [main]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
main,
|
|
114
|
+
[
|
|
115
|
+
{
|
|
116
|
+
label: t('supabase.access.roles.menu.delete'),
|
|
117
|
+
icon: 'lucide:trash-2',
|
|
118
|
+
color: 'error',
|
|
119
|
+
onSelect: () => openDelete(role),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<main>
|
|
128
|
+
<AdminView>
|
|
129
|
+
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
130
|
+
<div>
|
|
131
|
+
<h1 class="text-3xl font-bold lg:text-4xl">
|
|
132
|
+
{{ t('supabase.access.roles.title') }}
|
|
133
|
+
</h1>
|
|
134
|
+
<p class="text-muted text-sm">
|
|
135
|
+
{{ t('supabase.access.roles.description') }}
|
|
136
|
+
</p>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<UButton icon="lucide:plus" @click="openRole(null)">
|
|
140
|
+
{{ t('supabase.access.roles.create') }}
|
|
141
|
+
</UButton>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<UAlert
|
|
145
|
+
v-if="!allowed"
|
|
146
|
+
:title="t('pluto.settings.noPermission.title')"
|
|
147
|
+
:description="
|
|
148
|
+
t('pluto.settings.noPermission.description', {
|
|
149
|
+
page: t('supabase.access.roles.title'),
|
|
150
|
+
})
|
|
151
|
+
"
|
|
152
|
+
icon="lucide:lock"
|
|
153
|
+
color="warning"
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<!-- Loading -->
|
|
157
|
+
<div
|
|
158
|
+
v-else-if="status === 'loading'"
|
|
159
|
+
class="flex flex-col gap-y-3"
|
|
160
|
+
aria-busy="true"
|
|
161
|
+
>
|
|
162
|
+
<USkeleton v-for="row in 3" :key="row" class="h-12 w-full" />
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<!-- Error -->
|
|
166
|
+
<UAlert
|
|
167
|
+
v-else-if="status === 'error'"
|
|
168
|
+
:title="t('supabase.access.roles.error.title')"
|
|
169
|
+
:description="loadError"
|
|
170
|
+
:actions="[
|
|
171
|
+
{
|
|
172
|
+
label: t('pluto.common.tryAgain'),
|
|
173
|
+
color: 'neutral',
|
|
174
|
+
variant: 'outline',
|
|
175
|
+
onClick: load,
|
|
176
|
+
},
|
|
177
|
+
]"
|
|
178
|
+
color="error"
|
|
179
|
+
variant="subtle"
|
|
180
|
+
icon="lucide:circle-x"
|
|
181
|
+
/>
|
|
182
|
+
|
|
183
|
+
<!-- The list -->
|
|
184
|
+
<UTable
|
|
185
|
+
v-else
|
|
186
|
+
:data="roles"
|
|
187
|
+
:columns="columns"
|
|
188
|
+
class="border-default rounded-lg border"
|
|
189
|
+
>
|
|
190
|
+
<template #role-cell="{ row }">
|
|
191
|
+
<div class="flex min-w-0 flex-col">
|
|
192
|
+
<span class="flex items-center gap-2">
|
|
193
|
+
<span class="text-highlighted truncate font-medium">{{
|
|
194
|
+
row.original.label
|
|
195
|
+
}}</span>
|
|
196
|
+
<UBadge
|
|
197
|
+
v-if="row.original.is_builtin"
|
|
198
|
+
:label="t('supabase.access.roles.builtin')"
|
|
199
|
+
color="neutral"
|
|
200
|
+
variant="subtle"
|
|
201
|
+
size="sm"
|
|
202
|
+
/>
|
|
203
|
+
</span>
|
|
204
|
+
<code class="text-muted truncate font-mono text-xs">{{
|
|
205
|
+
row.original.key
|
|
206
|
+
}}</code>
|
|
207
|
+
</div>
|
|
208
|
+
</template>
|
|
209
|
+
|
|
210
|
+
<template #permissions-cell="{ row }">
|
|
211
|
+
<span v-if="isFullAccess(row.original)" class="text-sm">
|
|
212
|
+
{{ t('supabase.access.roles.fullAccess') }}
|
|
213
|
+
</span>
|
|
214
|
+
<span v-else class="text-sm">
|
|
215
|
+
{{
|
|
216
|
+
t(
|
|
217
|
+
'supabase.access.roles.permissionCount',
|
|
218
|
+
row.original.capabilities.length
|
|
219
|
+
)
|
|
220
|
+
}}
|
|
221
|
+
</span>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<template #users-cell="{ row }">
|
|
225
|
+
<span class="text-sm">{{
|
|
226
|
+
t('supabase.access.roles.userCount', row.original.user_count)
|
|
227
|
+
}}</span>
|
|
228
|
+
</template>
|
|
229
|
+
|
|
230
|
+
<template #actions-cell="{ row }">
|
|
231
|
+
<div class="flex justify-end">
|
|
232
|
+
<UDropdownMenu
|
|
233
|
+
:items="menuItems(row.original)"
|
|
234
|
+
:content="{ align: 'end' }"
|
|
235
|
+
>
|
|
236
|
+
<UButton
|
|
237
|
+
:aria-label="row.original.label"
|
|
238
|
+
icon="lucide:ellipsis"
|
|
239
|
+
color="neutral"
|
|
240
|
+
variant="ghost"
|
|
241
|
+
/>
|
|
242
|
+
</UDropdownMenu>
|
|
243
|
+
</div>
|
|
244
|
+
</template>
|
|
245
|
+
</UTable>
|
|
246
|
+
|
|
247
|
+
<!-- Lazy: the chunk loads on the first open. -->
|
|
248
|
+
<RoleSlideover
|
|
249
|
+
v-if="slideoverMounted"
|
|
250
|
+
v-model:open="slideoverOpen"
|
|
251
|
+
:role="editing"
|
|
252
|
+
@saved="load"
|
|
253
|
+
/>
|
|
254
|
+
</AdminView>
|
|
255
|
+
</main>
|
|
256
|
+
</template>
|