@plutocms/supabase 0.13.0 → 0.14.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 +11 -0
- package/FEATURES.md +2 -1
- package/README.md +31 -19
- package/app/components/access/DeleteRoleModal.vue +1 -1
- package/app/components/access/EditRolesSlideover.vue +1 -1
- package/app/components/access/InviteModal.vue +2 -2
- package/app/components/access/RevokeModal.vue +1 -1
- package/app/components/access/RoleSlideover.vue +2 -2
- package/app/composables/migrations.ts +25 -5
- package/app/pages/admin/(auth)/signup.vue +10 -3
- package/app/pages/admin/settings/roles.vue +1 -1
- package/app/pages/admin/settings/users.vue +5 -5
- package/app/pages/admin/setup.vue +18 -16
- package/app/plugins/pluto-extension.ts +8 -8
- package/app/utils/api-failure.ts +16 -0
- package/app/utils/auth-driver.ts +2 -2
- package/i18n/locales/en.json +8 -3
- package/i18n/locales/pt-BR.json +8 -3
- package/package.json +3 -3
- package/server/api/{access → _pluto/supabase/access}/capabilities.get.ts +3 -3
- package/server/api/{access → _pluto/supabase/access}/roles/[key].delete.ts +2 -2
- package/server/api/{access → _pluto/supabase/access}/roles/[key].put.ts +4 -4
- package/server/api/{access → _pluto/supabase/access}/roles.get.ts +2 -2
- package/server/api/{access → _pluto/supabase/access}/roles.post.ts +4 -4
- package/server/api/{access → _pluto/supabase/access}/users/[id]/resend.post.ts +8 -8
- package/server/api/{access → _pluto/supabase/access}/users/[id]/roles.delete.ts +3 -3
- package/server/api/{access → _pluto/supabase/access}/users/[id]/roles.put.ts +4 -4
- package/server/api/{access → _pluto/supabase/access}/users/invite-link.post.ts +6 -6
- package/server/api/{access → _pluto/supabase/access}/users/invite.post.ts +8 -8
- package/server/api/{access → _pluto/supabase/access}/users.get.ts +2 -2
- package/server/api/{auth → _pluto/supabase/auth}/signup.post.ts +10 -5
- package/server/api/{profile → _pluto/supabase/me}/locale.get.ts +1 -1
- package/server/api/{profile/locale.post.ts → _pluto/supabase/me/locale.put.ts} +7 -2
- package/server/api/{migrations/status.get.ts → _pluto/supabase/migrations/index.get.ts} +2 -2
- package/server/api/{migrations → _pluto/supabase/migrations}/run.post.ts +21 -16
- package/server/api/{settings/update.post.ts → _pluto/supabase/settings/index.put.ts} +8 -4
- package/server/api/{settings/first_setup.get.ts → _pluto/supabase/setup/index.get.ts} +6 -5
- package/server/api/{setup/create.post.ts → _pluto/supabase/setup/index.post.ts} +13 -10
- package/server/utils/admin-guard.ts +1 -1
- package/server/utils/content-adapter.ts +111 -6
- package/server/utils/pending-migrations.ts +0 -0
- package/server/api/users/[id].get.ts +0 -26
- package/server/api/users/index.get.ts +0 -21
- /package/server/api/{permissions/me.get.ts → _pluto/supabase/me/capabilities.get.ts} +0 -0
- /package/server/api/{settings → _pluto/supabase/settings}/index.get.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0](https://github.com/plutocms/supabase/compare/v0.13.0...v0.14.0) (2026-10-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* the routes `/api/access/**`, `/api/auth/signup`, `/api/migrations/**`, `/api/permissions/me`, `/api/profile/locale`, `/api/settings/**`, `/api/setup/create` and `/api/users/**` moved under `/api/_pluto/supabase/` (see the README), and failures are HTTP errors.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* move the internal routes under /api/_pluto/supabase, and support the public API ([#104](https://github.com/plutocms/supabase/issues/104)) ([e7bf91c](https://github.com/plutocms/supabase/commit/e7bf91cd5a802247b4ec3b6938389b94a06ce3e3))
|
|
13
|
+
|
|
3
14
|
## [0.13.0](https://github.com/plutocms/supabase/compare/v0.12.0...v0.13.0) (2026-10-25)
|
|
4
15
|
|
|
5
16
|
|
package/FEATURES.md
CHANGED
|
@@ -15,7 +15,8 @@ policies shared by every project here.
|
|
|
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
|
|
18
|
-
declared content type into `@plutocms/pluto`'s generic content routes.
|
|
18
|
+
declared content type into `@plutocms/pluto`'s generic content routes. It supports query
|
|
19
|
+
filters, puts the timestamps on items, and reads the public API of core with an anonymous client.
|
|
19
20
|
@.claude/skills/content-adapter/SKILL.md
|
|
20
21
|
- Auth driver: plugs Supabase sign-in into the admin shell that pluto core owns (navbar, sign-in
|
|
21
22
|
page, route guard), and keeps the setup guard in the driver.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Supabase Layer for Pluto
|
|
1
|
+
# Supabase Layer for Pluto
|
|
2
2
|
|
|
3
|
-
This layer integrates Supabase as a backend service for Pluto
|
|
3
|
+
This layer integrates Supabase as a backend service for Pluto, providing a robust and scalable solution for managing content and data.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -50,7 +50,7 @@ entries. See "Migrating an app" in that skill.
|
|
|
50
50
|
### Invite users (access API)
|
|
51
51
|
|
|
52
52
|
An admin invites a user by email and gives the user one or more roles. The routes are under
|
|
53
|
-
`/api/access/**`. Every write needs an admin. This needs the migration
|
|
53
|
+
`/api/_pluto/supabase/access/**`. Every write needs an admin. This needs the migration
|
|
54
54
|
`008_users_and_roles_admin.sql`, which the admin applies from `/admin/migrations`.
|
|
55
55
|
|
|
56
56
|
Invites need no setup. Check one thing: in the Supabase dashboard (Authentication, then URL
|
|
@@ -107,24 +107,36 @@ come from Supabase. The Email tab has a guide to send them through Resend too.
|
|
|
107
107
|
|
|
108
108
|
### API Routes
|
|
109
109
|
|
|
110
|
-
The layer includes API routes for interacting with Supabase services, such as authentication and
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
110
|
+
The layer includes API routes for interacting with Supabase services, such as authentication and settings.
|
|
111
|
+
Every route lives under `/api/_pluto/supabase/`. Core blocks a cross-site write to `/api/_pluto/**`, so
|
|
112
|
+
a page of another site cannot call them. A failure is an HTTP error with a translation key, never a
|
|
113
|
+
`200` with `success: false`. These routes serve the admin. They are not a public API.
|
|
114
|
+
|
|
115
|
+
### Public content API
|
|
116
|
+
|
|
117
|
+
The public content of the Supabase layers is read through the public API of core,
|
|
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
|
|
120
|
+
reads for them with an anon client that has no user session, so Row Level Security applies as for
|
|
121
|
+
an anonymous visitor, and the answer is the same for everyone. A CDN can cache it. The adapter
|
|
122
|
+
never uses the service role key for a read. The adapter also supports the `filters` of a public
|
|
123
|
+
query, and puts the created, updated and published-at columns on each item, so a public list can
|
|
124
|
+
sort by date.
|
|
125
|
+
|
|
126
|
+
- GET and PUT `/api/_pluto/supabase/settings` (PUT needs `settings:manage`; it answers `{ message, data }`)
|
|
127
|
+
- POST `/api/_pluto/supabase/auth/signup`
|
|
128
|
+
- GET `/api/_pluto/supabase/migrations`, POST `/api/_pluto/supabase/migrations/run` (admin)
|
|
129
|
+
- GET `/api/_pluto/supabase/me/capabilities`
|
|
130
|
+
- GET and PUT `/api/_pluto/supabase/me/locale`
|
|
131
|
+
- GET `/api/_pluto/supabase/setup` (the setup state), POST `/api/_pluto/supabase/setup` (runs the first setup)
|
|
132
|
+
- GET `/api/_pluto/supabase/access/users`, GET `/api/_pluto/supabase/access/roles` (permission `users:read`)
|
|
133
|
+
- POST `/api/_pluto/supabase/access/users/invite`, POST `/api/_pluto/supabase/access/users/:id/resend` (admin; `locale` is the
|
|
122
134
|
optional language of the email)
|
|
123
|
-
- POST `/api/access/users/invite-link` (admin; needs the service role key; returns a one-time link
|
|
135
|
+
- POST `/api/_pluto/supabase/access/users/invite-link` (admin; needs the service role key; returns a one-time link
|
|
124
136
|
and sends no email)
|
|
125
|
-
- GET `/api/access/capabilities` (admin; says whether the service key and Pluto mail are set)
|
|
126
|
-
- PUT and DELETE `/api/access/users/:id/roles` (admin; DELETE revokes the access)
|
|
127
|
-
- POST `/api/access/roles`, PUT and DELETE `/api/access/roles/:key` (admin)
|
|
137
|
+
- GET `/api/_pluto/supabase/access/capabilities` (admin; says whether the service key and Pluto mail are set)
|
|
138
|
+
- PUT and DELETE `/api/_pluto/supabase/access/users/:id/roles` (admin; DELETE revokes the access)
|
|
139
|
+
- POST `/api/_pluto/supabase/access/roles`, PUT and DELETE `/api/_pluto/supabase/access/roles/:key` (admin)
|
|
128
140
|
|
|
129
141
|
### Types
|
|
130
142
|
|
|
@@ -23,7 +23,7 @@ async function remove() {
|
|
|
23
23
|
working.value = true
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
|
-
await $fetch(`/api/access/roles/${props.role.key}`, { method: 'DELETE' })
|
|
26
|
+
await $fetch(`/api/_pluto/supabase/access/roles/${props.role.key}`, { method: 'DELETE' })
|
|
27
27
|
|
|
28
28
|
toast.add({
|
|
29
29
|
title: t('supabase.access.toasts.roleDeleted'),
|
|
@@ -61,7 +61,7 @@ async function save() {
|
|
|
61
61
|
saving.value = true
|
|
62
62
|
|
|
63
63
|
try {
|
|
64
|
-
await $fetch(`/api/access/users/${props.user.id}/roles`, {
|
|
64
|
+
await $fetch(`/api/_pluto/supabase/access/users/${props.user.id}/roles`, {
|
|
65
65
|
method: 'PUT',
|
|
66
66
|
body: { roleKeys: roleKeys.value },
|
|
67
67
|
})
|
|
@@ -85,7 +85,7 @@ async function submit() {
|
|
|
85
85
|
mailProblem.value = null
|
|
86
86
|
|
|
87
87
|
try {
|
|
88
|
-
const result = await $fetch<AccessInviteResult>('/api/access/users/invite', {
|
|
88
|
+
const result = await $fetch<AccessInviteResult>('/api/_pluto/supabase/access/users/invite', {
|
|
89
89
|
method: 'POST',
|
|
90
90
|
body: { ...body(), locale: emailLocale.value },
|
|
91
91
|
})
|
|
@@ -140,7 +140,7 @@ async function copyLink() {
|
|
|
140
140
|
|
|
141
141
|
try {
|
|
142
142
|
const result = await $fetch<{ status: 'invited' | 'granted', link: string | null }>(
|
|
143
|
-
'/api/access/users/invite-link',
|
|
143
|
+
'/api/_pluto/supabase/access/users/invite-link',
|
|
144
144
|
{ method: 'POST', body: body() }
|
|
145
145
|
)
|
|
146
146
|
|
|
@@ -27,7 +27,7 @@ async function revoke() {
|
|
|
27
27
|
working.value = true
|
|
28
28
|
|
|
29
29
|
try {
|
|
30
|
-
await $fetch(`/api/access/users/${props.user.id}/roles`, { method: 'DELETE' })
|
|
30
|
+
await $fetch(`/api/_pluto/supabase/access/users/${props.user.id}/roles`, { method: 'DELETE' })
|
|
31
31
|
|
|
32
32
|
toast.add({
|
|
33
33
|
title: t('supabase.access.toasts.revoked'),
|
|
@@ -131,12 +131,12 @@ async function save() {
|
|
|
131
131
|
|
|
132
132
|
try {
|
|
133
133
|
if (creating.value) {
|
|
134
|
-
await $fetch('/api/access/roles', {
|
|
134
|
+
await $fetch('/api/_pluto/supabase/access/roles', {
|
|
135
135
|
method: 'POST',
|
|
136
136
|
body: { ...body, key: key.value },
|
|
137
137
|
})
|
|
138
138
|
} else {
|
|
139
|
-
await $fetch(`/api/access/roles/${props.role!.key}`, { method: 'PUT', body })
|
|
139
|
+
await $fetch(`/api/_pluto/supabase/access/roles/${props.role!.key}`, { method: 'PUT', body })
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
toast.add({
|
|
@@ -53,7 +53,7 @@ export function useMigrations() {
|
|
|
53
53
|
error,
|
|
54
54
|
status: fetchStatus,
|
|
55
55
|
refresh,
|
|
56
|
-
} = useFetch<MigrationsStatusResponse>('/api/migrations
|
|
56
|
+
} = useFetch<MigrationsStatusResponse>('/api/_pluto/supabase/migrations', {
|
|
57
57
|
key: 'pluto-migrations-status',
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -70,10 +70,30 @@ export function useMigrations() {
|
|
|
70
70
|
const pendingCount = computed(() => status.value?.pending.length ?? 0)
|
|
71
71
|
|
|
72
72
|
async function runMigrations(connectionString?: string) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
let result: RunMigrationsResponse
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
result = await $fetch<RunMigrationsResponse>('/api/_pluto/supabase/migrations/run', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
body: connectionString ? { connectionString } : undefined,
|
|
79
|
+
})
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// The route answers a failed run with an HTTP error that carries
|
|
82
|
+
// `needsConnectionString` or `error` in its data. Turn it back into
|
|
83
|
+
// a failed result, so the page shows it as before. Any other error
|
|
84
|
+
// (for example a dropped connection) goes on to the caller.
|
|
85
|
+
const failure = failureData(error)
|
|
86
|
+
|
|
87
|
+
if (failure?.needsConnectionString !== true && typeof failure?.error !== 'string') {
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
result = {
|
|
92
|
+
success: false,
|
|
93
|
+
needsConnectionString: failure.needsConnectionString === true ? true : undefined,
|
|
94
|
+
error: typeof failure.error === 'string' ? failure.error : undefined,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
77
97
|
|
|
78
98
|
await refresh()
|
|
79
99
|
|
|
@@ -3,7 +3,7 @@ definePageMeta({
|
|
|
3
3
|
layout: 'auth',
|
|
4
4
|
})
|
|
5
5
|
|
|
6
|
-
const { t } = usePlutoI18n()
|
|
6
|
+
const { t, te } = usePlutoI18n()
|
|
7
7
|
const errorText = usePlutoErrorText()
|
|
8
8
|
|
|
9
9
|
useHead({
|
|
@@ -36,7 +36,7 @@ async function submitForm() {
|
|
|
36
36
|
isSubmitting.value = true
|
|
37
37
|
|
|
38
38
|
try {
|
|
39
|
-
await $fetch('/api/auth/signup', {
|
|
39
|
+
await $fetch('/api/_pluto/supabase/auth/signup', {
|
|
40
40
|
method: 'POST',
|
|
41
41
|
body: {
|
|
42
42
|
email: form.value.email,
|
|
@@ -59,7 +59,14 @@ async function submitForm() {
|
|
|
59
59
|
|
|
60
60
|
toast.add({
|
|
61
61
|
title: t('supabase.auth.signup.failed'),
|
|
62
|
-
description:
|
|
62
|
+
description: resolveAuthErrorText(
|
|
63
|
+
{
|
|
64
|
+
code: failureData(error)?.code,
|
|
65
|
+
message: (error as { data?: { statusMessage?: string } }).data?.statusMessage
|
|
66
|
+
?? errorText(error, 'supabase.auth.errors.unknown'),
|
|
67
|
+
},
|
|
68
|
+
{ t, te }
|
|
69
|
+
),
|
|
63
70
|
icon: 'lucide:circle-x',
|
|
64
71
|
color: 'error',
|
|
65
72
|
})
|
|
@@ -29,7 +29,7 @@ async function load() {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
try {
|
|
32
|
-
const response = await $fetch<{ roles: AccessRole[] }>('/api/access/roles')
|
|
32
|
+
const response = await $fetch<{ roles: AccessRole[] }>('/api/_pluto/supabase/access/roles')
|
|
33
33
|
|
|
34
34
|
roles.value = response.roles
|
|
35
35
|
status.value = 'ready'
|
|
@@ -50,8 +50,8 @@ async function load() {
|
|
|
50
50
|
|
|
51
51
|
try {
|
|
52
52
|
const [usersResponse, rolesResponse] = await Promise.all([
|
|
53
|
-
$fetch<{ users: AccessUser[] }>('/api/access/users'),
|
|
54
|
-
$fetch<{ roles: AccessRole[] }>('/api/access/roles'),
|
|
53
|
+
$fetch<{ users: AccessUser[] }>('/api/_pluto/supabase/access/users'),
|
|
54
|
+
$fetch<{ roles: AccessRole[] }>('/api/_pluto/supabase/access/roles'),
|
|
55
55
|
])
|
|
56
56
|
|
|
57
57
|
users.value = usersResponse.users
|
|
@@ -65,7 +65,7 @@ async function load() {
|
|
|
65
65
|
|
|
66
66
|
async function loadCapabilities() {
|
|
67
67
|
try {
|
|
68
|
-
capabilities.value = await $fetch<AccessCapabilities>('/api/access/capabilities')
|
|
68
|
+
capabilities.value = await $fetch<AccessCapabilities>('/api/_pluto/supabase/access/capabilities')
|
|
69
69
|
} catch {
|
|
70
70
|
// The invite works without it. Only the "Copy invite link" action hides.
|
|
71
71
|
capabilities.value = null
|
|
@@ -134,7 +134,7 @@ async function copyLink(user: AccessUser) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
try {
|
|
137
|
-
const result = await $fetch<{ link: string | null }>('/api/access/users/invite-link', {
|
|
137
|
+
const result = await $fetch<{ link: string | null }>('/api/_pluto/supabase/access/users/invite-link', {
|
|
138
138
|
method: 'POST',
|
|
139
139
|
body: { email: user.email, roleKeys: user.roles },
|
|
140
140
|
})
|
|
@@ -164,7 +164,7 @@ async function copyLink(user: AccessUser) {
|
|
|
164
164
|
async function resend(user: AccessUser) {
|
|
165
165
|
try {
|
|
166
166
|
const result = await $fetch<{ emailSent?: boolean, link?: string }>(
|
|
167
|
-
`/api/access/users/${user.id}/resend`,
|
|
167
|
+
`/api/_pluto/supabase/access/users/${user.id}/resend`,
|
|
168
168
|
{ method: 'POST', body: { locale: locale.value } }
|
|
169
169
|
)
|
|
170
170
|
|
|
@@ -74,7 +74,7 @@ const currentStepSubmitLabel = computed(() => {
|
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Polls /api/
|
|
77
|
+
* Polls /api/_pluto/supabase/setup until the dev server responds again, and
|
|
78
78
|
* advances the stepper once it does.
|
|
79
79
|
*
|
|
80
80
|
* Used only to wait out the restart described in the `catch` block of
|
|
@@ -89,7 +89,7 @@ async function waitForServerAndAdvance(): Promise<boolean> {
|
|
|
89
89
|
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
90
90
|
|
|
91
91
|
try {
|
|
92
|
-
await $fetch('/api/
|
|
92
|
+
await $fetch('/api/_pluto/supabase/setup')
|
|
93
93
|
|
|
94
94
|
toast.add({
|
|
95
95
|
title: t('supabase.connection.reconnected'),
|
|
@@ -118,24 +118,13 @@ async function completeDatabaseSetup() {
|
|
|
118
118
|
encodeURIComponent(databaseForm.value.password)
|
|
119
119
|
)
|
|
120
120
|
|
|
121
|
-
const data = await $fetch<any>('/api/setup
|
|
121
|
+
const data = await $fetch<any>('/api/_pluto/supabase/setup', {
|
|
122
122
|
method: 'POST',
|
|
123
123
|
body: {
|
|
124
124
|
connectionString,
|
|
125
125
|
},
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
-
if (data.success === false) {
|
|
129
|
-
toast.add({
|
|
130
|
-
title: t('supabase.setup.failed'),
|
|
131
|
-
description: t('supabase.setup.failedDescription'),
|
|
132
|
-
icon: 'lucide:circle-x',
|
|
133
|
-
color: 'error',
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
|
|
139
128
|
if (data.persisted === false) {
|
|
140
129
|
envNotPersisted.value = true
|
|
141
130
|
submittedConnectionString.value = connectionString
|
|
@@ -150,7 +139,20 @@ async function completeDatabaseSetup() {
|
|
|
150
139
|
|
|
151
140
|
stepper.value?.next()
|
|
152
141
|
} catch (error) {
|
|
153
|
-
//
|
|
142
|
+
// The route answers a failed setup with an HTTP error that carries the
|
|
143
|
+
// scrubbed database error in its data.
|
|
144
|
+
if (typeof failureData(error)?.error === 'string') {
|
|
145
|
+
toast.add({
|
|
146
|
+
title: t('supabase.setup.failed'),
|
|
147
|
+
description: t('supabase.setup.failedDescription'),
|
|
148
|
+
icon: 'lucide:circle-x',
|
|
149
|
+
color: 'error',
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Writing DATABASE_URL to .env (the last step of /api/_pluto/supabase/setup,
|
|
154
156
|
// after the schema and every layer migration already succeeded)
|
|
155
157
|
// restarts the dev server. The response can be cut off by that
|
|
156
158
|
// restart even though the setup itself worked. A dropped connection
|
|
@@ -326,7 +328,7 @@ function handleStepChange(step: number) {
|
|
|
326
328
|
>
|
|
327
329
|
<template #route>
|
|
328
330
|
<ULink
|
|
329
|
-
to="https://github.com/plutocms/supabase/blob/
|
|
331
|
+
to="https://github.com/plutocms/supabase/blob/main/server/api/_pluto/supabase/setup/index.post.ts"
|
|
330
332
|
target="_blank"
|
|
331
333
|
class="underline"
|
|
332
334
|
>
|
|
@@ -36,7 +36,7 @@ export default defineNuxtPlugin(() => {
|
|
|
36
36
|
async function loadSiteSettings() {
|
|
37
37
|
try {
|
|
38
38
|
const response = await $fetch<{ settings: Record<string, string> }>(
|
|
39
|
-
'/api/settings'
|
|
39
|
+
'/api/_pluto/supabase/settings'
|
|
40
40
|
)
|
|
41
41
|
siteSettings.value = response.settings
|
|
42
42
|
} catch {
|
|
@@ -53,7 +53,7 @@ export default defineNuxtPlugin(() => {
|
|
|
53
53
|
let state: SetupState = 'unknown'
|
|
54
54
|
try {
|
|
55
55
|
state = setupStateFromResponse(
|
|
56
|
-
await $fetch('/api/
|
|
56
|
+
await $fetch('/api/_pluto/supabase/setup')
|
|
57
57
|
)
|
|
58
58
|
} catch (error) {
|
|
59
59
|
if (import.meta.dev) {
|
|
@@ -200,13 +200,13 @@ export default defineNuxtPlugin(() => {
|
|
|
200
200
|
id: 'supabase',
|
|
201
201
|
load: async () => {
|
|
202
202
|
const response = await $fetch<{ settings: Record<string, string> }>(
|
|
203
|
-
'/api/settings'
|
|
203
|
+
'/api/_pluto/supabase/settings'
|
|
204
204
|
)
|
|
205
205
|
return response.settings
|
|
206
206
|
},
|
|
207
207
|
save: async (patch) => {
|
|
208
|
-
await $fetch('/api/settings
|
|
209
|
-
method: '
|
|
208
|
+
await $fetch('/api/_pluto/supabase/settings', {
|
|
209
|
+
method: 'PUT',
|
|
210
210
|
body: patch,
|
|
211
211
|
})
|
|
212
212
|
|
|
@@ -229,12 +229,12 @@ export default defineNuxtPlugin(() => {
|
|
|
229
229
|
localeDriver: {
|
|
230
230
|
id: 'supabase',
|
|
231
231
|
load: async () => {
|
|
232
|
-
const response = await $fetch<{ locale: string | null }>('/api/
|
|
232
|
+
const response = await $fetch<{ locale: string | null }>('/api/_pluto/supabase/me/locale')
|
|
233
233
|
return response.locale
|
|
234
234
|
},
|
|
235
235
|
save: async (locale) => {
|
|
236
236
|
try {
|
|
237
|
-
await $fetch('/api/
|
|
237
|
+
await $fetch('/api/_pluto/supabase/me/locale', { method: 'PUT', body: { locale } })
|
|
238
238
|
} catch (error) {
|
|
239
239
|
throw new Error(errorText(error))
|
|
240
240
|
}
|
|
@@ -243,7 +243,7 @@ export default defineNuxtPlugin(() => {
|
|
|
243
243
|
permissionsDriver: {
|
|
244
244
|
id: 'supabase',
|
|
245
245
|
load: async () => {
|
|
246
|
-
const response = await $fetch<{ capabilities: string[] }>('/api/
|
|
246
|
+
const response = await $fetch<{ capabilities: string[] }>('/api/_pluto/supabase/me/capabilities')
|
|
247
247
|
return response.capabilities
|
|
248
248
|
},
|
|
249
249
|
},
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Reads the extra data of a failed call to this layer's routes. This file does
|
|
2
|
+
// not use Nuxt auto-imports, so plain Vitest can import it.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the `data` that a route put in its `createPlutoError`. A `$fetch`
|
|
6
|
+
* error holds the whole response body in `data`, so the extra data sits in
|
|
7
|
+
* `data.data`. Returns `undefined` when the error has none.
|
|
8
|
+
*/
|
|
9
|
+
export function failureData(error: unknown): Record<string, unknown> | undefined {
|
|
10
|
+
const body = (error as { data?: unknown } | null | undefined)?.data
|
|
11
|
+
const data = (body as { data?: unknown } | null | undefined)?.data
|
|
12
|
+
|
|
13
|
+
return typeof data === 'object' && data !== null
|
|
14
|
+
? (data as Record<string, unknown>)
|
|
15
|
+
: undefined
|
|
16
|
+
}
|
package/app/utils/auth-driver.ts
CHANGED
|
@@ -67,11 +67,11 @@ export const SUPABASE_PUBLIC_PATHS: PlutoAuthPublicPath[] = [
|
|
|
67
67
|
/** The page that opens after a sign-in with no (safe) redirect. */
|
|
68
68
|
export const SUPABASE_SIGN_IN_FALLBACK = '/admin/home'
|
|
69
69
|
|
|
70
|
-
/** The setup state that the setup guard reads from `/api/
|
|
70
|
+
/** The setup state that the setup guard reads from `/api/_pluto/supabase/setup`. */
|
|
71
71
|
export type SetupState = 'pending' | 'done' | 'unknown'
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Reads the answer of `/api/
|
|
74
|
+
* Reads the answer of `/api/_pluto/supabase/setup`. A failed call, or a
|
|
75
75
|
* response with no `is_first_setup` flag, is `unknown`.
|
|
76
76
|
*/
|
|
77
77
|
export function setupStateFromResponse(data: unknown): SetupState {
|
package/i18n/locales/en.json
CHANGED
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
"missingPermission": "Your account is missing the \"{capability}\" permission.",
|
|
10
10
|
"setupCompleted": "Setup has already been completed.",
|
|
11
11
|
"coreSchemaMissing": "Core schema not found. Reinstall @plutocms/supabase and restart the dev server.",
|
|
12
|
-
"fetchUsers": "Error fetching users: {message}",
|
|
13
|
-
"fetchUser": "Error fetching user: {message}",
|
|
14
12
|
"localeUnsupported": "This language is not supported.",
|
|
15
13
|
"duplicateRecord": "A record with this value already exists.",
|
|
16
14
|
"missingReference": "This references a record that does not exist.",
|
|
17
|
-
"migrationStatus": "Error reading migration status: {message}"
|
|
15
|
+
"migrationStatus": "Error reading migration status: {message}",
|
|
16
|
+
"signupFailed": "Could not create the account.",
|
|
17
|
+
"connectionStringRequired": "A database connection string is required.",
|
|
18
|
+
"migrationsFailed": "Migrations failed: {message}",
|
|
19
|
+
"setupFailed": "Database setup failed: {message}",
|
|
20
|
+
"setupStateRead": "Could not read the setup state: {message}",
|
|
21
|
+
"settingsSave": "Could not save the settings: {message}",
|
|
22
|
+
"localeSave": "Could not save your language: {message}"
|
|
18
23
|
},
|
|
19
24
|
"auth": {
|
|
20
25
|
"backToLogin": "Back to Login",
|
package/i18n/locales/pt-BR.json
CHANGED
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
"missingPermission": "Sua conta não tem a permissão \"{capability}\".",
|
|
10
10
|
"setupCompleted": "A configuração já foi concluída.",
|
|
11
11
|
"coreSchemaMissing": "Esquema principal não encontrado. Reinstale @plutocms/supabase e reinicie o servidor de desenvolvimento.",
|
|
12
|
-
"fetchUsers": "Erro ao buscar os usuários: {message}",
|
|
13
|
-
"fetchUser": "Erro ao buscar o usuário: {message}",
|
|
14
12
|
"localeUnsupported": "Este idioma não está disponível.",
|
|
15
13
|
"duplicateRecord": "Já existe um registro com este valor.",
|
|
16
14
|
"missingReference": "Isto aponta para um registro que não existe.",
|
|
17
|
-
"migrationStatus": "Erro ao ler o status das migrações: {message}"
|
|
15
|
+
"migrationStatus": "Erro ao ler o status das migrações: {message}",
|
|
16
|
+
"signupFailed": "Não foi possível criar a conta.",
|
|
17
|
+
"connectionStringRequired": "Informe a string de conexão do banco de dados.",
|
|
18
|
+
"migrationsFailed": "As migrações falharam: {message}",
|
|
19
|
+
"setupFailed": "A configuração do banco de dados falhou: {message}",
|
|
20
|
+
"setupStateRead": "Não foi possível ler o estado da configuração: {message}",
|
|
21
|
+
"settingsSave": "Não foi possível salvar as configurações: {message}",
|
|
22
|
+
"localeSave": "Não foi possível salvar o seu idioma: {message}"
|
|
18
23
|
},
|
|
19
24
|
"auth": {
|
|
20
25
|
"backToLogin": "Voltar para o login",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.0",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"unrs-resolver",
|
|
12
12
|
"vue-demi"
|
|
13
13
|
],
|
|
14
|
-
"description": "Supabase backend layer for Pluto
|
|
14
|
+
"description": "Supabase backend layer for Pluto",
|
|
15
15
|
"author": "Victor Ribeiro",
|
|
16
16
|
"homepage": "https://github.com/plutocms/supabase#readme",
|
|
17
17
|
"repository": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@nuxt/eslint": "^1.17.0",
|
|
40
40
|
"@nuxt/ui": "^4.12.0",
|
|
41
41
|
"@nuxtjs/supabase": "^2.1.0",
|
|
42
|
-
"@plutocms/pluto": "^0.
|
|
42
|
+
"@plutocms/pluto": "^0.27.0",
|
|
43
43
|
"@plutocms/utils": "^0.2.1",
|
|
44
44
|
"@supabase/supabase-js": "^2.117.3",
|
|
45
45
|
"postgres": "^3.4.9",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { plutoMailReady } from '
|
|
2
|
-
import { requireAdminRole } from '
|
|
3
|
-
import { optionalServiceClient } from '
|
|
1
|
+
import { plutoMailReady } from '../../../../utils/access-mail'
|
|
2
|
+
import { requireAdminRole } from '../../../../utils/admin-role-guard'
|
|
3
|
+
import { optionalServiceClient } from '../../../../utils/service-client'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* What the invite UI can offer, for an admin: whether the service role key is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { requireAdminRole } from '
|
|
2
|
+
import { mapAccessError } from '../../../../../utils/access-errors'
|
|
3
|
+
import { requireAdminRole } from '../../../../../utils/admin-role-guard'
|
|
4
4
|
|
|
5
5
|
/** Deletes a role. It answers with the number of users who lost the role. */
|
|
6
6
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { roleBodySchema } from '
|
|
4
|
-
import { requireAdminRole } from '
|
|
5
|
-
import { parseBody } from '
|
|
2
|
+
import { mapAccessError } from '../../../../../utils/access-errors'
|
|
3
|
+
import { roleBodySchema } from '../../../../../utils/access-invite'
|
|
4
|
+
import { requireAdminRole } from '../../../../../utils/admin-role-guard'
|
|
5
|
+
import { parseBody } from '../../../../../utils/validate'
|
|
6
6
|
|
|
7
7
|
/** Edits a role and replaces its capabilities. The `admin` role is refused. */
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { requireCapability } from '
|
|
2
|
+
import { mapAccessError } from '../../../../utils/access-errors'
|
|
3
|
+
import { requireCapability } from '../../../../utils/capability-guard'
|
|
4
4
|
|
|
5
5
|
/** The roles, with their capabilities and the number of users. */
|
|
6
6
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { createRoleBodySchema } from '
|
|
4
|
-
import { requireAdminRole } from '
|
|
5
|
-
import { parseBody } from '
|
|
2
|
+
import { mapAccessError } from '../../../../utils/access-errors'
|
|
3
|
+
import { createRoleBodySchema } from '../../../../utils/access-invite'
|
|
4
|
+
import { requireAdminRole } from '../../../../utils/admin-role-guard'
|
|
5
|
+
import { parseBody } from '../../../../utils/validate'
|
|
6
6
|
|
|
7
7
|
/** Creates a role. A new role cannot hold the wildcard. */
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { inviteRedirectUrl, isEmailExistsError, requireUserId, resendBodySchema } from '
|
|
2
|
+
import { mapAccessError } from '../../../../../../utils/access-errors'
|
|
3
|
+
import { inviteRedirectUrl, isEmailExistsError, requireUserId, resendBodySchema } from '../../../../../../utils/access-invite'
|
|
4
4
|
import {
|
|
5
5
|
assertInviteLocale,
|
|
6
6
|
generateAcceptLink,
|
|
7
7
|
inviterNameOf,
|
|
8
8
|
plutoMailReady,
|
|
9
9
|
sendInviteMail,
|
|
10
|
-
} from '
|
|
11
|
-
import { requireAdminRole } from '
|
|
12
|
-
import { enforceInviteRateLimit } from '
|
|
13
|
-
import { optionalServiceClient } from '
|
|
10
|
+
} from '../../../../../../utils/access-mail'
|
|
11
|
+
import { requireAdminRole } from '../../../../../../utils/admin-role-guard'
|
|
12
|
+
import { enforceInviteRateLimit } from '../../../../../../utils/rate-limit'
|
|
13
|
+
import { optionalServiceClient } from '../../../../../../utils/service-client'
|
|
14
14
|
import {
|
|
15
15
|
emailInvalidError,
|
|
16
16
|
emailNotAuthorizedError,
|
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
isEmailRateLimitError,
|
|
21
21
|
sendSignInLink,
|
|
22
22
|
signInLinkError,
|
|
23
|
-
} from '
|
|
24
|
-
import { parseBody } from '
|
|
23
|
+
} from '../../../../../../utils/sign-in-link'
|
|
24
|
+
import { parseBody } from '../../../../../../utils/validate'
|
|
25
25
|
|
|
26
26
|
function notFound() {
|
|
27
27
|
return createPlutoError({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { requireUserId } from '
|
|
4
|
-
import { requireAdminRole } from '
|
|
2
|
+
import { mapAccessError } from '../../../../../../utils/access-errors'
|
|
3
|
+
import { requireUserId } from '../../../../../../utils/access-invite'
|
|
4
|
+
import { requireAdminRole } from '../../../../../../utils/admin-role-guard'
|
|
5
5
|
|
|
6
6
|
/** Revokes the access of a user: it removes every role. The account stays. */
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { requireUserId, rolesBodySchema } from '
|
|
4
|
-
import { requireAdminRole } from '
|
|
5
|
-
import { parseBody } from '
|
|
2
|
+
import { mapAccessError } from '../../../../../../utils/access-errors'
|
|
3
|
+
import { requireUserId, rolesBodySchema } from '../../../../../../utils/access-invite'
|
|
4
|
+
import { requireAdminRole } from '../../../../../../utils/admin-role-guard'
|
|
5
|
+
import { parseBody } from '../../../../../../utils/validate'
|
|
6
6
|
|
|
7
7
|
/** Replaces the roles of a user. An empty list revokes the access. */
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { inviteBodySchema, inviteRedirectUrl } from '
|
|
3
|
-
import { provisionAccess, serviceKeyRequiredError } from '
|
|
4
|
-
import { requireAdminRole } from '
|
|
5
|
-
import { enforceInviteRateLimit } from '
|
|
6
|
-
import { optionalServiceClient } from '
|
|
7
|
-
import { parseBody } from '
|
|
2
|
+
import { inviteBodySchema, inviteRedirectUrl } from '../../../../../utils/access-invite'
|
|
3
|
+
import { provisionAccess, serviceKeyRequiredError } from '../../../../../utils/access-mail'
|
|
4
|
+
import { requireAdminRole } from '../../../../../utils/admin-role-guard'
|
|
5
|
+
import { enforceInviteRateLimit } from '../../../../../utils/rate-limit'
|
|
6
|
+
import { optionalServiceClient } from '../../../../../utils/service-client'
|
|
7
|
+
import { parseBody } from '../../../../../utils/validate'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Grants the roles and returns a one-time sign-in link. No email is sent, so
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
2
|
+
import { mapAccessError } from '../../../../../utils/access-errors'
|
|
3
3
|
import {
|
|
4
4
|
inviteBodySchema,
|
|
5
5
|
inviteRedirectUrl,
|
|
6
6
|
isEmailExistsError,
|
|
7
|
-
} from '
|
|
7
|
+
} from '../../../../../utils/access-invite'
|
|
8
8
|
import {
|
|
9
9
|
addRoles,
|
|
10
10
|
assertInviteLocale,
|
|
@@ -13,10 +13,10 @@ import {
|
|
|
13
13
|
plutoMailReady,
|
|
14
14
|
provisionAccess,
|
|
15
15
|
sendInviteMail,
|
|
16
|
-
} from '
|
|
17
|
-
import { requireAdminRole } from '
|
|
18
|
-
import { enforceInviteRateLimit } from '
|
|
19
|
-
import { optionalServiceClient } from '
|
|
16
|
+
} from '../../../../../utils/access-mail'
|
|
17
|
+
import { requireAdminRole } from '../../../../../utils/admin-role-guard'
|
|
18
|
+
import { enforceInviteRateLimit } from '../../../../../utils/rate-limit'
|
|
19
|
+
import { optionalServiceClient } from '../../../../../utils/service-client'
|
|
20
20
|
import {
|
|
21
21
|
emailInvalidError,
|
|
22
22
|
emailNotAuthorizedError,
|
|
@@ -26,8 +26,8 @@ import {
|
|
|
26
26
|
isEmailRateLimitError,
|
|
27
27
|
sendSignInLink,
|
|
28
28
|
signInLinkError,
|
|
29
|
-
} from '
|
|
30
|
-
import { parseBody } from '
|
|
29
|
+
} from '../../../../../utils/sign-in-link'
|
|
30
|
+
import { parseBody } from '../../../../../utils/validate'
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Invites a user by email and grants the roles.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { mapAccessError } from '
|
|
3
|
-
import { requireCapability } from '
|
|
2
|
+
import { mapAccessError } from '../../../../utils/access-errors'
|
|
3
|
+
import { requireCapability } from '../../../../utils/capability-guard'
|
|
4
4
|
|
|
5
5
|
/** The staff: users with a role, or with the admin flag. */
|
|
6
6
|
export default defineEventHandler(async (event) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { serverSupabaseClient } from '#supabase/server'
|
|
3
|
-
import { parseBody } from '
|
|
3
|
+
import { parseBody } from '../../../../utils/validate'
|
|
4
4
|
|
|
5
5
|
// Supabase Auth (GoTrue) rejects a password under 6 characters with its own
|
|
6
6
|
// opaque error. Matching that minimum here turns an empty/short password
|
|
@@ -30,11 +30,16 @@ export default defineEventHandler(async (event) => {
|
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
if (error) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// A 4xx from Supabase Auth keeps its status (for example 429 for a rate
|
|
34
|
+
// limit). Any other status becomes a 502. The code goes in `data`, so
|
|
35
|
+
// the signup page can show the translated text of a known code.
|
|
36
|
+
const status = error.status ?? 400
|
|
37
|
+
throw createPlutoError({
|
|
38
|
+
statusCode: status >= 400 && status < 500 ? status : 502,
|
|
35
39
|
message: error.message,
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
key: 'supabase.errors.signupFailed',
|
|
41
|
+
data: { code: error.code },
|
|
42
|
+
})
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
return {
|
|
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
|
|
27
27
|
// upgraded this package but not yet applied that migration has no such
|
|
28
28
|
// column. That state is expected and temporary, so degrade to
|
|
29
29
|
// "Automatic" instead of a 500. The same choice is made in
|
|
30
|
-
// server/api/
|
|
30
|
+
// server/api/_pluto/supabase/me/capabilities.get.ts.
|
|
31
31
|
console.error(
|
|
32
32
|
'profiles.locale read failed, returning no locale:',
|
|
33
33
|
error.message
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
3
|
-
import { parseBody } from '
|
|
3
|
+
import { parseBody } from '../../../../utils/validate'
|
|
4
4
|
|
|
5
5
|
const localeSchema = z.object({
|
|
6
6
|
locale: z.string().nullable(),
|
|
@@ -64,7 +64,12 @@ export default defineEventHandler(async (event) => {
|
|
|
64
64
|
})
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
throw
|
|
67
|
+
throw createPlutoError({
|
|
68
|
+
statusCode: 500,
|
|
69
|
+
message: `Could not save your language: ${error.message}`,
|
|
70
|
+
key: 'supabase.errors.localeSave',
|
|
71
|
+
params: { message: error.message },
|
|
72
|
+
})
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
return { locale }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { requireAdmin } from '
|
|
2
|
-
import { getMigrationStatus } from '
|
|
1
|
+
import { requireAdmin } from '../../../../utils/admin-guard'
|
|
2
|
+
import { getMigrationStatus } from '../../../../utils/pending-migrations'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
// requireAdmin specifically, not a named capability — see admin-guard.ts
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { PlutoMigrationFile } from '
|
|
2
|
-
import type { MigrationFileResult } from '
|
|
1
|
+
import type { PlutoMigrationFile } from '../../../../../shared/types/migrations'
|
|
2
|
+
import type { MigrationFileResult } from '../../../../utils/migrations'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
-
import { requireAdmin } from '
|
|
5
|
-
import { persistDatabaseUrl } from '
|
|
6
|
-
import { resolveConnectionString, runPendingMigrations } from '
|
|
7
|
-
import { scrubConnectionString } from '
|
|
8
|
-
import { regenerateSupabaseTypes } from '
|
|
9
|
-
import { parseBody } from '
|
|
4
|
+
import { requireAdmin } from '../../../../utils/admin-guard'
|
|
5
|
+
import { persistDatabaseUrl } from '../../../../utils/env-file'
|
|
6
|
+
import { resolveConnectionString, runPendingMigrations } from '../../../../utils/migrations'
|
|
7
|
+
import { scrubConnectionString } from '../../../../utils/scrub-connection-string'
|
|
8
|
+
import { regenerateSupabaseTypes } from '../../../../utils/typegen'
|
|
9
|
+
import { parseBody } from '../../../../utils/validate'
|
|
10
10
|
|
|
11
11
|
// The whole body is optional: an already-configured DATABASE_URL means the
|
|
12
12
|
// caller need not (and, per resolveConnectionString, cannot) supply one.
|
|
@@ -33,10 +33,12 @@ export default defineEventHandler(async (event) => {
|
|
|
33
33
|
resolveConnectionString(body?.connectionString)
|
|
34
34
|
|
|
35
35
|
if (!connStr) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
throw createPlutoError({
|
|
37
|
+
statusCode: 400,
|
|
38
|
+
message: 'A database connection string is required.',
|
|
39
|
+
key: 'supabase.errors.connectionStringRequired',
|
|
40
|
+
data: { needsConnectionString: true },
|
|
41
|
+
})
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
const config = useRuntimeConfig()
|
|
@@ -102,9 +104,12 @@ export default defineEventHandler(async (event) => {
|
|
|
102
104
|
|
|
103
105
|
console.error('Error applying migrations:', errorMessage)
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
throw createPlutoError({
|
|
108
|
+
statusCode: 500,
|
|
109
|
+
message: `Migrations failed: ${errorMessage}`,
|
|
110
|
+
key: 'supabase.errors.migrationsFailed',
|
|
111
|
+
params: { message: errorMessage },
|
|
112
|
+
data: { error: errorMessage },
|
|
113
|
+
})
|
|
109
114
|
}
|
|
110
115
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { serverSupabaseClient } from '#supabase/server'
|
|
3
|
-
import { requireCapability } from '
|
|
4
|
-
import { parseBody } from '
|
|
3
|
+
import { requireCapability } from '../../../../utils/capability-guard'
|
|
4
|
+
import { parseBody } from '../../../../utils/validate'
|
|
5
5
|
|
|
6
6
|
// A key is lowercase segments separated by dots, e.g. 'website_title' or
|
|
7
7
|
// 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
|
|
@@ -39,12 +39,16 @@ export default defineEventHandler(async (event) => {
|
|
|
39
39
|
.select()
|
|
40
40
|
|
|
41
41
|
if (error) {
|
|
42
|
-
throw
|
|
42
|
+
throw createPlutoError({
|
|
43
|
+
statusCode: 500,
|
|
44
|
+
message: `Could not save the settings: ${error.message}`,
|
|
45
|
+
key: 'supabase.errors.settingsSave',
|
|
46
|
+
params: { message: error.message },
|
|
47
|
+
})
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
return {
|
|
46
51
|
message: 'Settings updated successfully.',
|
|
47
|
-
statusCode: 200,
|
|
48
52
|
data,
|
|
49
53
|
}
|
|
50
54
|
})
|
|
@@ -9,11 +9,12 @@ export default defineEventHandler(async (event) => {
|
|
|
9
9
|
const { data, error } = await client.from('healthcheck').select('*')
|
|
10
10
|
|
|
11
11
|
if (error) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
message:
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
throw createPlutoError({
|
|
13
|
+
statusCode: 500,
|
|
14
|
+
message: `Could not read the setup state: ${error.message}`,
|
|
15
|
+
key: 'supabase.errors.setupStateRead',
|
|
16
|
+
params: { message: error.message },
|
|
17
|
+
})
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
// @ts-expect-error: Object.fromEntries may not infer the correct type
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { PlutoMigrationFile } from '
|
|
1
|
+
import type { PlutoMigrationFile } from '../../../../../shared/types/migrations'
|
|
2
2
|
import postgres from 'postgres'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
-
import { persistDatabaseUrl } from '
|
|
5
|
-
import { runPendingMigrations } from '
|
|
6
|
-
import { scrubConnectionString } from '
|
|
7
|
-
import { parseBody } from '
|
|
4
|
+
import { persistDatabaseUrl } from '../../../../utils/env-file'
|
|
5
|
+
import { runPendingMigrations } from '../../../../utils/migrations'
|
|
6
|
+
import { scrubConnectionString } from '../../../../utils/scrub-connection-string'
|
|
7
|
+
import { parseBody } from '../../../../utils/validate'
|
|
8
8
|
|
|
9
9
|
// Only checks that a connection string was actually sent. A Postgres
|
|
10
10
|
// connection string can take many valid forms (postgres:// URL, key=value
|
|
@@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
|
|
20
20
|
// before setup creates the profiles table — so once real setup has run,
|
|
21
21
|
// this check is the only thing stopping anyone from re-running it against
|
|
22
22
|
// an arbitrary database. Mirrors the same precedence rule in
|
|
23
|
-
// server/api/migrations/run.post.ts, which calls it "the whole security
|
|
23
|
+
// server/api/_pluto/supabase/migrations/run.post.ts, which calls it "the whole security
|
|
24
24
|
// model for this endpoint."
|
|
25
25
|
if (process.env.DATABASE_URL) {
|
|
26
26
|
throw createPlutoError({
|
|
@@ -120,9 +120,12 @@ export default defineEventHandler(async (event) => {
|
|
|
120
120
|
|
|
121
121
|
console.error('Error setting up the database:', message)
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
throw createPlutoError({
|
|
124
|
+
statusCode: 500,
|
|
125
|
+
message: `Database setup failed: ${message}`,
|
|
126
|
+
key: 'supabase.errors.setupFailed',
|
|
127
|
+
params: { message },
|
|
128
|
+
data: { error: message },
|
|
129
|
+
})
|
|
127
130
|
}
|
|
128
131
|
})
|
|
@@ -10,7 +10,7 @@ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
|
10
10
|
* `004_roles_and_capabilities.sql` respectively); a genuinely fresh site
|
|
11
11
|
* that has never applied any migration beyond the original baseline
|
|
12
12
|
* schema — an expected, normal state, not a broken one — has neither
|
|
13
|
-
* function. `/api/migrations/*` (the only routes that call `requireAdmin`
|
|
13
|
+
* function. `/api/_pluto/supabase/migrations/*` (the only routes that call `requireAdmin`
|
|
14
14
|
* today) are the one place this actually bites: they are the routes that
|
|
15
15
|
* apply a pending migration, so they must work with zero migrations
|
|
16
16
|
* applied, not just with `002`+ already in place. An earlier version of
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PostgrestError, SupabaseClient } from '@supabase/supabase-js'
|
|
2
2
|
import type { H3Event } from 'h3'
|
|
3
|
+
import { createClient } from '@supabase/supabase-js'
|
|
3
4
|
import { serverSupabaseClient } from '#supabase/server'
|
|
4
5
|
import { requireCapability } from './capability-guard'
|
|
5
6
|
|
|
@@ -31,9 +32,44 @@ import { requireCapability } from './capability-guard'
|
|
|
31
32
|
* type, not to assert against one.
|
|
32
33
|
*/
|
|
33
34
|
async function getClient(event: H3Event): Promise<SupabaseClient> {
|
|
35
|
+
if (isPublicRead(event)) {
|
|
36
|
+
return getAnonymousClient(event)
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
return (await serverSupabaseClient(event)) as unknown as SupabaseClient
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
/** The anonymous client and the url/key pair it was built from. */
|
|
43
|
+
let anonymousClient: { url: string, key: string, client: SupabaseClient } | undefined
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A Supabase client with no user session, for public reads of the public
|
|
47
|
+
* API. It uses the anon key from the runtime config of `@nuxtjs/supabase`,
|
|
48
|
+
* reads no cookies and keeps no session. Row Level Security then applies as
|
|
49
|
+
* for an anonymous visitor, so the answer is the same for everyone and a CDN
|
|
50
|
+
* can cache it. It never uses the service role key. One client serves the
|
|
51
|
+
* whole process.
|
|
52
|
+
*/
|
|
53
|
+
function getAnonymousClient(event: H3Event): SupabaseClient {
|
|
54
|
+
const { url, key } = useRuntimeConfig(event).public.supabase as { url: string, key: string }
|
|
55
|
+
|
|
56
|
+
if (!anonymousClient || anonymousClient.url !== url || anonymousClient.key !== key) {
|
|
57
|
+
anonymousClient = {
|
|
58
|
+
url,
|
|
59
|
+
key,
|
|
60
|
+
client: createClient(url, key, {
|
|
61
|
+
auth: {
|
|
62
|
+
persistSession: false,
|
|
63
|
+
autoRefreshToken: false,
|
|
64
|
+
detectSessionInUrl: false,
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return anonymousClient.client
|
|
71
|
+
}
|
|
72
|
+
|
|
37
73
|
/**
|
|
38
74
|
* Resolves the storage column for a content type's created/updated
|
|
39
75
|
* timestamp. Returns `undefined` when timestamps are disabled entirely
|
|
@@ -70,6 +106,70 @@ export function resolveStatusColumn(type: PlutoContentType): string | undefined
|
|
|
70
106
|
return type.status.column ?? 'status'
|
|
71
107
|
}
|
|
72
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Resolves a sort field to its column. A timestamp column (the created, the
|
|
111
|
+
* updated or the published-at column) sorts directly, because it is not a
|
|
112
|
+
* declared field. Any other name must be a declared field.
|
|
113
|
+
*/
|
|
114
|
+
function resolveSortColumn(type: PlutoContentType, fieldName: string): string {
|
|
115
|
+
if (timestampColumns(type).includes(fieldName)) {
|
|
116
|
+
return fieldName
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return resolveFieldColumn(type, fieldName, 'sort')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The columns of the created, updated and published-at timestamps that this type has. */
|
|
123
|
+
function timestampColumns(type: PlutoContentType): string[] {
|
|
124
|
+
const { created, updated, published } = timestampKeys(type)
|
|
125
|
+
|
|
126
|
+
return [created, updated, published].filter((column): column is string => column !== undefined)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The item keys of the timestamps of a type. They are the column names,
|
|
131
|
+
* because a timestamp is not a declared field. A key is missing when its
|
|
132
|
+
* timestamp is off. Core uses this to sort and to show the public dates.
|
|
133
|
+
*/
|
|
134
|
+
function timestampKeys(type: PlutoContentType): { created?: string, updated?: string, published?: string } {
|
|
135
|
+
const keys: { created?: string, updated?: string, published?: string } = {}
|
|
136
|
+
|
|
137
|
+
const created = resolveTimestampColumn(type, 'created')
|
|
138
|
+
if (created) {
|
|
139
|
+
keys.created = created
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const updated = resolveTimestampColumn(type, 'updated')
|
|
143
|
+
if (updated) {
|
|
144
|
+
keys.updated = updated
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const published = type.status ? type.status.publishedAtColumn : undefined
|
|
148
|
+
if (published) {
|
|
149
|
+
keys.published = published
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return keys
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Maps a row to an item, then copies the timestamp columns onto it under
|
|
157
|
+
* their column names. `mapColumnsToFields` drops every column that no field
|
|
158
|
+
* declares, and a timestamp is not a field. A field with the same name as a
|
|
159
|
+
* timestamp column keeps its own value.
|
|
160
|
+
*/
|
|
161
|
+
function toItem(type: PlutoContentType, row: Record<string, unknown>): PlutoContentItem {
|
|
162
|
+
const item = mapColumnsToFields(type, row)
|
|
163
|
+
|
|
164
|
+
for (const column of timestampColumns(type)) {
|
|
165
|
+
if (!Object.hasOwn(item, column) && Object.hasOwn(row, column)) {
|
|
166
|
+
item[column] = row[column]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return item
|
|
171
|
+
}
|
|
172
|
+
|
|
73
173
|
/**
|
|
74
174
|
* Maps a raw PostgREST error to an H3/Nuxt error safe to send to a
|
|
75
175
|
* client. Logs the raw error first, so the real message always reaches
|
|
@@ -128,6 +228,10 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
|
|
|
128
228
|
builder = builder.eq(statusColumn, publishedValue)
|
|
129
229
|
}
|
|
130
230
|
|
|
231
|
+
for (const [field, value] of Object.entries(query.filters ?? {})) {
|
|
232
|
+
builder = builder.eq(resolveFieldColumn(ctx.type, field, 'filter'), value)
|
|
233
|
+
}
|
|
234
|
+
|
|
131
235
|
if (query.search) {
|
|
132
236
|
const titleColumn = resolveFieldColumn(ctx.type, ctx.type.titleField, 'titleField')
|
|
133
237
|
builder = builder.ilike(titleColumn, `%${query.search}%`)
|
|
@@ -135,7 +239,7 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
|
|
|
135
239
|
|
|
136
240
|
const sort = query.sort ?? ctx.type.defaultSort
|
|
137
241
|
if (sort) {
|
|
138
|
-
const sortColumn =
|
|
242
|
+
const sortColumn = resolveSortColumn(ctx.type, sort.field)
|
|
139
243
|
builder = builder.order(sortColumn, { ascending: sort.direction !== 'desc' })
|
|
140
244
|
} else {
|
|
141
245
|
const createdColumn = resolveTimestampColumn(ctx.type, 'created')
|
|
@@ -156,7 +260,7 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
|
|
|
156
260
|
}
|
|
157
261
|
|
|
158
262
|
return {
|
|
159
|
-
data: (data ?? []).map((row: Record<string, unknown>) =>
|
|
263
|
+
data: (data ?? []).map((row: Record<string, unknown>) => toItem(ctx.type, row)),
|
|
160
264
|
total: count ?? undefined,
|
|
161
265
|
}
|
|
162
266
|
}
|
|
@@ -190,7 +294,7 @@ async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise
|
|
|
190
294
|
}
|
|
191
295
|
|
|
192
296
|
if (data) {
|
|
193
|
-
return
|
|
297
|
+
return toItem(ctx.type, data)
|
|
194
298
|
}
|
|
195
299
|
|
|
196
300
|
if (ctx.type.slug) {
|
|
@@ -207,7 +311,7 @@ async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise
|
|
|
207
311
|
}
|
|
208
312
|
|
|
209
313
|
if (bySlug.data) {
|
|
210
|
-
return
|
|
314
|
+
return toItem(ctx.type, bySlug.data)
|
|
211
315
|
}
|
|
212
316
|
}
|
|
213
317
|
|
|
@@ -252,7 +356,7 @@ async function create(ctx: PlutoContentContext, values: Record<string, unknown>)
|
|
|
252
356
|
throw toContentError(error)
|
|
253
357
|
}
|
|
254
358
|
|
|
255
|
-
return
|
|
359
|
+
return toItem(ctx.type, data)
|
|
256
360
|
}
|
|
257
361
|
|
|
258
362
|
async function update(
|
|
@@ -319,7 +423,7 @@ async function update(
|
|
|
319
423
|
throw toContentError(error)
|
|
320
424
|
}
|
|
321
425
|
|
|
322
|
-
return
|
|
426
|
+
return toItem(ctx.type, data)
|
|
323
427
|
}
|
|
324
428
|
|
|
325
429
|
async function remove(ctx: PlutoContentContext, id: string | number): Promise<void> {
|
|
@@ -378,5 +482,6 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
|
378
482
|
remove,
|
|
379
483
|
authorize,
|
|
380
484
|
countCreated,
|
|
485
|
+
timestampKeys,
|
|
381
486
|
}
|
|
382
487
|
}
|
|
Binary file
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { requireCapability } from '../../utils/capability-guard'
|
|
3
|
-
|
|
4
|
-
export default defineEventHandler(async (event) => {
|
|
5
|
-
await requireCapability(event, 'users:read')
|
|
6
|
-
|
|
7
|
-
const client = await serverSupabaseClient<Database>(event)
|
|
8
|
-
|
|
9
|
-
const id = event.context.params?.id as string
|
|
10
|
-
|
|
11
|
-
const { data: user, error } = await client
|
|
12
|
-
.from('profiles')
|
|
13
|
-
.select('*')
|
|
14
|
-
.eq('id', id)
|
|
15
|
-
|
|
16
|
-
if (error) {
|
|
17
|
-
throw createPlutoError({
|
|
18
|
-
statusCode: 500,
|
|
19
|
-
message: `Error fetching user: ${error.message}`,
|
|
20
|
-
key: 'supabase.errors.fetchUser',
|
|
21
|
-
params: { message: error.message },
|
|
22
|
-
})
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return user
|
|
26
|
-
})
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import { requireCapability } from '../../utils/capability-guard'
|
|
3
|
-
|
|
4
|
-
export default defineEventHandler(async (event) => {
|
|
5
|
-
await requireCapability(event, 'users:read')
|
|
6
|
-
|
|
7
|
-
const client = await serverSupabaseClient<Database>(event)
|
|
8
|
-
|
|
9
|
-
const { data: users, error } = await client.from('profiles').select('*')
|
|
10
|
-
|
|
11
|
-
if (error) {
|
|
12
|
-
throw createPlutoError({
|
|
13
|
-
statusCode: 500,
|
|
14
|
-
message: `Error fetching users: ${error.message}`,
|
|
15
|
-
key: 'supabase.errors.fetchUsers',
|
|
16
|
-
params: { message: error.message },
|
|
17
|
-
})
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return users
|
|
21
|
-
})
|
|
File without changes
|
|
File without changes
|