@plutocms/supabase 0.14.1 → 0.15.1
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/.prettierignore +6 -0
- package/CHANGELOG.md +23 -0
- package/app/components/access/DeleteRoleModal.vue +17 -4
- package/app/components/access/EditRolesSlideover.vue +11 -5
- package/app/components/access/InviteModal.vue +44 -19
- package/app/components/access/LinkField.vue +14 -3
- package/app/components/access/LinkModal.vue +5 -1
- package/app/components/access/RevokeModal.vue +17 -4
- package/app/components/access/RoleSlideover.vue +32 -11
- package/app/components/mail/AuthEmailsPanel.vue +87 -20
- package/app/components/migrations/MigrationsBanner.vue +5 -4
- package/app/composables/migrations.ts +13 -6
- package/app/pages/admin/(auth)/accept-invite.vue +21 -5
- package/app/pages/admin/(auth)/forgot-password.vue +11 -4
- package/app/pages/admin/(auth)/signup.vue +10 -9
- package/app/pages/admin/(auth)/update-password.vue +8 -3
- package/app/pages/admin/migrations.vue +8 -3
- package/app/pages/admin/settings/roles.vue +58 -15
- package/app/pages/admin/settings/users.vue +105 -30
- package/app/pages/admin/setup.vue +26 -7
- package/app/plugins/pluto-extension.ts +21 -8
- package/app/utils/access-ui.ts +29 -8
- package/app/utils/api-failure.ts +3 -1
- package/app/utils/auth-driver.ts +1 -3
- package/app/utils/auth-error.ts +1 -1
- package/app/utils/invite-link.ts +3 -3
- package/app/utils/supabase-project.ts +8 -3
- package/modules/pluto-migrations.ts +38 -74
- package/package.json +5 -3
- package/server/api/_pluto/supabase/access/users/[id]/resend.post.ts +15 -5
- package/server/api/_pluto/supabase/access/users/[id]/roles.put.ts +4 -1
- package/server/api/_pluto/supabase/access/users/invite-link.post.ts +12 -3
- package/server/api/_pluto/supabase/access/users/invite.post.ts +7 -2
- package/server/api/_pluto/supabase/auth/signup.post.ts +5 -1
- package/server/api/_pluto/supabase/me/capabilities.get.ts +4 -1
- package/server/api/_pluto/supabase/migrations/run.post.ts +9 -2
- package/server/api/_pluto/supabase/setup/index.post.ts +4 -1
- package/server/utils/access-errors.ts +2 -1
- package/server/utils/access-invite.ts +1 -1
- package/server/utils/access-mail.ts +72 -28
- package/server/utils/capability-guard.ts +5 -2
- package/server/utils/content-adapter.ts +75 -24
- package/server/utils/migrations.ts +0 -0
- package/server/utils/pending-migrations.ts +0 -0
- package/server/utils/service-client.ts +3 -1
- package/server/utils/sign-in-link.ts +25 -9
- package/server/utils/typegen.ts +6 -4
- package/server/utils/validate.ts +4 -1
- package/shared/types/migrations.d.ts +3 -4
- package/shared/types/supabase.ts +62 -65
- package/tailwind.lint.css +9 -0
|
@@ -52,9 +52,7 @@ export default defineNuxtPlugin(() => {
|
|
|
52
52
|
async function fetchSetupState(): Promise<SetupState> {
|
|
53
53
|
let state: SetupState = 'unknown'
|
|
54
54
|
try {
|
|
55
|
-
state = setupStateFromResponse(
|
|
56
|
-
await $fetch('/api/_pluto/supabase/setup')
|
|
57
|
-
)
|
|
55
|
+
state = setupStateFromResponse(await $fetch('/api/_pluto/supabase/setup'))
|
|
58
56
|
} catch (error) {
|
|
59
57
|
if (import.meta.dev) {
|
|
60
58
|
console.error('Error checking first setup status:', error)
|
|
@@ -220,8 +218,16 @@ export default defineNuxtPlugin(() => {
|
|
|
220
218
|
// gated by requireAdmin (public.is_admin() directly), never a named
|
|
221
219
|
// capability — see server/utils/admin-guard.ts for why.
|
|
222
220
|
capabilities: [
|
|
223
|
-
{
|
|
224
|
-
|
|
221
|
+
{
|
|
222
|
+
id: 'settings-manage',
|
|
223
|
+
key: 'settings:manage',
|
|
224
|
+
label: 'supabase.capabilities.settingsManage',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: 'users-read',
|
|
228
|
+
key: 'users:read',
|
|
229
|
+
label: 'supabase.capabilities.usersRead',
|
|
230
|
+
},
|
|
225
231
|
],
|
|
226
232
|
// The admin language of the signed-in user, stored in profiles.locale
|
|
227
233
|
// (005_profile_locale.sql). A save error carries the translated server
|
|
@@ -229,12 +235,17 @@ export default defineNuxtPlugin(() => {
|
|
|
229
235
|
localeDriver: {
|
|
230
236
|
id: 'supabase',
|
|
231
237
|
load: async () => {
|
|
232
|
-
const response = await $fetch<{ locale: string | null }>(
|
|
238
|
+
const response = await $fetch<{ locale: string | null }>(
|
|
239
|
+
'/api/_pluto/supabase/me/locale'
|
|
240
|
+
)
|
|
233
241
|
return response.locale
|
|
234
242
|
},
|
|
235
243
|
save: async (locale) => {
|
|
236
244
|
try {
|
|
237
|
-
await $fetch('/api/_pluto/supabase/me/locale', {
|
|
245
|
+
await $fetch('/api/_pluto/supabase/me/locale', {
|
|
246
|
+
method: 'PUT',
|
|
247
|
+
body: { locale },
|
|
248
|
+
})
|
|
238
249
|
} catch (error) {
|
|
239
250
|
throw new Error(errorText(error))
|
|
240
251
|
}
|
|
@@ -243,7 +254,9 @@ export default defineNuxtPlugin(() => {
|
|
|
243
254
|
permissionsDriver: {
|
|
244
255
|
id: 'supabase',
|
|
245
256
|
load: async () => {
|
|
246
|
-
const response = await $fetch<{ capabilities: string[] }>(
|
|
257
|
+
const response = await $fetch<{ capabilities: string[] }>(
|
|
258
|
+
'/api/_pluto/supabase/me/capabilities'
|
|
259
|
+
)
|
|
247
260
|
return response.capabilities
|
|
248
261
|
},
|
|
249
262
|
},
|
package/app/utils/access-ui.ts
CHANGED
|
@@ -27,11 +27,15 @@ export function slugifyRoleKey(label: string): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function isValidRoleKey(key: string): boolean {
|
|
30
|
-
return
|
|
30
|
+
return (
|
|
31
|
+
key.length > 0 && key.length <= ROLE_KEY_MAX && ROLE_KEY_PATTERN.test(key)
|
|
32
|
+
)
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** `invited` until the user accepts the invite, then `active`. */
|
|
34
|
-
export function userStatus(
|
|
36
|
+
export function userStatus(
|
|
37
|
+
user: Pick<AccessUser, 'email_confirmed_at'>
|
|
38
|
+
): 'active' | 'invited' {
|
|
35
39
|
return user.email_confirmed_at ? 'active' : 'invited'
|
|
36
40
|
}
|
|
37
41
|
|
|
@@ -39,13 +43,22 @@ export function userStatus(user: Pick<AccessUser, 'email_confirmed_at'>): 'activ
|
|
|
39
43
|
* True when the user is an administrator. The `admin` role or the profile flag
|
|
40
44
|
* counts, the same as `public.is_admin()`.
|
|
41
45
|
*/
|
|
42
|
-
export function isAdminUser(
|
|
46
|
+
export function isAdminUser(
|
|
47
|
+
user: Pick<AccessUser, 'is_admin' | 'roles'>
|
|
48
|
+
): boolean {
|
|
43
49
|
return user.is_admin || user.roles.includes(ADMIN_ROLE_KEY)
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** The name to show for a user: the display name, the username, else the email. */
|
|
47
|
-
export function userDisplayName(
|
|
48
|
-
|
|
53
|
+
export function userDisplayName(
|
|
54
|
+
user: Pick<AccessUser, 'display_name' | 'username' | 'email'>
|
|
55
|
+
): string {
|
|
56
|
+
return (
|
|
57
|
+
user.display_name?.trim() ||
|
|
58
|
+
user.username?.trim() ||
|
|
59
|
+
user.email?.trim() ||
|
|
60
|
+
''
|
|
61
|
+
)
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/** Looks a role label up by key. A key with no role stays as it is. */
|
|
@@ -58,7 +71,10 @@ export function sameKeys(a: string[], b: string[]): boolean {
|
|
|
58
71
|
const left = [...a].toSorted()
|
|
59
72
|
const right = [...b].toSorted()
|
|
60
73
|
|
|
61
|
-
return
|
|
74
|
+
return (
|
|
75
|
+
left.length === right.length &&
|
|
76
|
+
left.every((key, index) => key === right[index])
|
|
77
|
+
)
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
/**
|
|
@@ -66,7 +82,9 @@ export function sameKeys(a: string[], b: string[]): boolean {
|
|
|
66
82
|
* undefined. An `ofetch` error holds the body in `data`.
|
|
67
83
|
*/
|
|
68
84
|
export function errorKeyOf(error: unknown): string | undefined {
|
|
69
|
-
const body = (
|
|
85
|
+
const body = (
|
|
86
|
+
error as { data?: { data?: { i18n?: { key?: unknown } } } } | null
|
|
87
|
+
)?.data
|
|
70
88
|
const key = body?.data?.i18n?.key
|
|
71
89
|
|
|
72
90
|
return typeof key === 'string' ? key : undefined
|
|
@@ -85,7 +103,10 @@ export const INVITE_MAIL_ERROR_KEYS = [
|
|
|
85
103
|
export function isInviteMailError(error: unknown): boolean {
|
|
86
104
|
const key = errorKeyOf(error)
|
|
87
105
|
|
|
88
|
-
return
|
|
106
|
+
return (
|
|
107
|
+
key !== undefined &&
|
|
108
|
+
(INVITE_MAIL_ERROR_KEYS as readonly string[]).includes(key)
|
|
109
|
+
)
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
/** The name of a language in that language, for example `Português (Brasil)`. */
|
package/app/utils/api-failure.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* error holds the whole response body in `data`, so the extra data sits in
|
|
7
7
|
* `data.data`. Returns `undefined` when the error has none.
|
|
8
8
|
*/
|
|
9
|
-
export function failureData(
|
|
9
|
+
export function failureData(
|
|
10
|
+
error: unknown
|
|
11
|
+
): Record<string, unknown> | undefined {
|
|
10
12
|
const body = (error as { data?: unknown } | null | undefined)?.data
|
|
11
13
|
const data = (body as { data?: unknown } | null | undefined)?.data
|
|
12
14
|
|
package/app/utils/auth-driver.ts
CHANGED
|
@@ -76,9 +76,7 @@ export type SetupState = 'pending' | 'done' | 'unknown'
|
|
|
76
76
|
*/
|
|
77
77
|
export function setupStateFromResponse(data: unknown): SetupState {
|
|
78
78
|
const response = data as
|
|
79
|
-
|
|
80
|
-
| null
|
|
81
|
-
| undefined
|
|
79
|
+
{ success?: boolean; is_first_setup?: boolean } | null | undefined
|
|
82
80
|
|
|
83
81
|
if (!response?.success) {
|
|
84
82
|
return 'unknown'
|
package/app/utils/auth-error.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function authErrorKey(error: unknown): string | undefined {
|
|
|
37
37
|
*/
|
|
38
38
|
export function resolveAuthErrorText(
|
|
39
39
|
error: unknown,
|
|
40
|
-
i18n: { t: (key: string) => string
|
|
40
|
+
i18n: { t: (key: string) => string; te: (key: string) => boolean }
|
|
41
41
|
): string {
|
|
42
42
|
const key = authErrorKey(error)
|
|
43
43
|
|
package/app/utils/invite-link.ts
CHANGED
|
@@ -20,9 +20,9 @@ const OTP_TYPES: readonly string[] = ['invite', 'magiclink', 'signup']
|
|
|
20
20
|
* - `none`: the link holds nothing. The page then looks for a session.
|
|
21
21
|
*/
|
|
22
22
|
export type InviteLink =
|
|
23
|
-
| { kind: 'otp'
|
|
24
|
-
| { kind: 'session'
|
|
25
|
-
| { kind: 'error'
|
|
23
|
+
| { kind: 'otp'; tokenHash: string; type: InviteOtpType }
|
|
24
|
+
| { kind: 'session'; accessToken: string; refreshToken: string }
|
|
25
|
+
| { kind: 'error'; code: string; description?: string }
|
|
26
26
|
| { kind: 'none' }
|
|
27
27
|
|
|
28
28
|
type QueryValue = string | null | undefined | Array<string | null>
|
|
@@ -9,21 +9,26 @@ export const RESEND_SMTP = {
|
|
|
9
9
|
} as const
|
|
10
10
|
|
|
11
11
|
/** The page where Resend connects itself to a Supabase project. */
|
|
12
|
-
export const RESEND_SUPABASE_INTEGRATION_URL =
|
|
12
|
+
export const RESEND_SUPABASE_INTEGRATION_URL =
|
|
13
|
+
'https://resend.com/settings/integrations'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Reads the project ref from the Supabase URL: `https://<ref>.supabase.co`
|
|
16
17
|
* (and `.supabase.red`). A custom domain, a local URL or a bad value gives
|
|
17
18
|
* null.
|
|
18
19
|
*/
|
|
19
|
-
export function supabaseProjectRef(
|
|
20
|
+
export function supabaseProjectRef(
|
|
21
|
+
url: string | null | undefined
|
|
22
|
+
): string | null {
|
|
20
23
|
if (!url) {
|
|
21
24
|
return null
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
try {
|
|
25
28
|
const { hostname } = new URL(url)
|
|
26
|
-
const match = /^([a-z0-9]{10,})\.supabase\.(?:co|red)$/.exec(
|
|
29
|
+
const match = /^([a-z0-9]{10,})\.supabase\.(?:co|red)$/.exec(
|
|
30
|
+
hostname.toLowerCase()
|
|
31
|
+
)
|
|
27
32
|
|
|
28
33
|
return match?.[1] ?? null
|
|
29
34
|
} catch {
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { PlutoMigrationFile } from '../shared/types/migrations'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs'
|
|
4
10
|
import { basename, dirname, join, relative } from 'node:path'
|
|
5
11
|
import { createResolver, defineNuxtModule, getLayerDirectories } from 'nuxt/kit'
|
|
6
12
|
|
|
7
|
-
const schemaPattern = /^schema\.(.+)\.sql$/
|
|
8
13
|
const noTransactionPattern = /^--\s*pluto:no-transaction\b/
|
|
9
14
|
|
|
10
15
|
/**
|
|
@@ -50,7 +55,10 @@ function deriveLayerKey(layerRoot: string): string {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
function computeChecksum(content: string): string {
|
|
53
|
-
return createHash('sha256')
|
|
58
|
+
return createHash('sha256')
|
|
59
|
+
.update(content, 'utf-8')
|
|
60
|
+
.digest('hex')
|
|
61
|
+
.slice(0, 16)
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
|
@@ -63,11 +71,13 @@ function detectNoTransaction(content: string): boolean {
|
|
|
63
71
|
.map((line) => line.trim())
|
|
64
72
|
.find((line) => line.length > 0)
|
|
65
73
|
|
|
66
|
-
return firstNonBlankLine
|
|
74
|
+
return firstNonBlankLine
|
|
75
|
+
? noTransactionPattern.test(firstNonBlankLine)
|
|
76
|
+
: false
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
function readMigrationFile(dir: string,
|
|
70
|
-
const content = readFileSync(join(dir,
|
|
79
|
+
function readMigrationFile(dir: string, name: string): PlutoMigrationFile {
|
|
80
|
+
const content = readFileSync(join(dir, name), 'utf-8')
|
|
71
81
|
|
|
72
82
|
return {
|
|
73
83
|
name,
|
|
@@ -78,10 +88,9 @@ function readMigrationFile(dir: string, fileName: string, name: string): PlutoMi
|
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
/**
|
|
81
|
-
* Discovers `db/migrations/*.sql`
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* so the migrations engine can apply them at server startup.
|
|
91
|
+
* Discovers `db/migrations/*.sql` across all Nuxt layers, reads their
|
|
92
|
+
* content at build time, and populates runtimeConfig so the migrations
|
|
93
|
+
* engine can apply them at server startup.
|
|
85
94
|
*
|
|
86
95
|
* Also seeds the consuming app's shared/types/supabase.ts with a
|
|
87
96
|
* re-export of this layer's own committed snapshot when the consumer
|
|
@@ -153,81 +162,36 @@ export default defineNuxtModule({
|
|
|
153
162
|
for (const layer of layerDirs) {
|
|
154
163
|
const migrationsDir = join(layer.root, 'db/migrations')
|
|
155
164
|
|
|
156
|
-
if (existsSync(migrationsDir)) {
|
|
157
|
-
let fileNames: string[] = []
|
|
158
|
-
try {
|
|
159
|
-
fileNames = readdirSync(migrationsDir).filter((file) =>
|
|
160
|
-
file.endsWith('.sql')
|
|
161
|
-
)
|
|
162
|
-
} catch {
|
|
163
|
-
console.error(
|
|
164
|
-
`[pluto-migrations] Failed to read migrations directory: ${migrationsDir}`
|
|
165
|
-
)
|
|
166
|
-
continue
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
fileNames.sort(compareFileNames)
|
|
170
|
-
|
|
171
|
-
const files: PlutoMigrationFile[] = []
|
|
172
|
-
for (const fileName of fileNames) {
|
|
173
|
-
try {
|
|
174
|
-
files.push(readMigrationFile(migrationsDir, fileName, fileName))
|
|
175
|
-
} catch {
|
|
176
|
-
console.error(
|
|
177
|
-
`[pluto-migrations] Failed to read migration file: ${fileName}`
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
assignLayer(deriveLayerKey(layer.root), files)
|
|
183
|
-
continue
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Legacy fallback for a layer not yet converted to db/migrations/,
|
|
187
|
-
// exactly as before: a single public/schema.sql (always key `core`)
|
|
188
|
-
// and/or public/schema.[name].sql (key comes from the file name, not
|
|
189
|
-
// package.json — this keeps working for an unconverted layer without
|
|
190
|
-
// requiring it to match its package name).
|
|
191
|
-
const publicDir = join(layer.root, 'public')
|
|
192
|
-
|
|
193
|
-
if (!existsSync(publicDir)) {
|
|
165
|
+
if (!existsSync(migrationsDir)) {
|
|
194
166
|
continue
|
|
195
167
|
}
|
|
196
168
|
|
|
197
|
-
let
|
|
169
|
+
let fileNames: string[] = []
|
|
198
170
|
try {
|
|
199
|
-
|
|
171
|
+
fileNames = readdirSync(migrationsDir).filter((file) =>
|
|
172
|
+
file.endsWith('.sql')
|
|
173
|
+
)
|
|
200
174
|
} catch {
|
|
175
|
+
console.error(
|
|
176
|
+
`[pluto-migrations] Failed to read migrations directory: ${migrationsDir}`
|
|
177
|
+
)
|
|
201
178
|
continue
|
|
202
179
|
}
|
|
203
180
|
|
|
204
|
-
|
|
205
|
-
if (fileName === 'schema.sql') {
|
|
206
|
-
try {
|
|
207
|
-
assignLayer('core', [
|
|
208
|
-
readMigrationFile(publicDir, fileName, '001_baseline.sql'),
|
|
209
|
-
])
|
|
210
|
-
} catch {
|
|
211
|
-
console.error(
|
|
212
|
-
`[pluto-migrations] Failed to read schema file: ${fileName}`
|
|
213
|
-
)
|
|
214
|
-
}
|
|
215
|
-
continue
|
|
216
|
-
}
|
|
181
|
+
fileNames.sort(compareFileNames)
|
|
217
182
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
`[pluto-migrations] Failed to read schema file: ${fileName}`
|
|
227
|
-
)
|
|
228
|
-
}
|
|
183
|
+
const files: PlutoMigrationFile[] = []
|
|
184
|
+
for (const fileName of fileNames) {
|
|
185
|
+
try {
|
|
186
|
+
files.push(readMigrationFile(migrationsDir, fileName))
|
|
187
|
+
} catch {
|
|
188
|
+
console.error(
|
|
189
|
+
`[pluto-migrations] Failed to read migration file: ${fileName}`
|
|
190
|
+
)
|
|
229
191
|
}
|
|
230
192
|
}
|
|
193
|
+
|
|
194
|
+
assignLayer(deriveLayerKey(layer.root), files)
|
|
231
195
|
}
|
|
232
196
|
|
|
233
197
|
// Populate runtimeConfig with every layer's ordered migration files.
|
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.15.1",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -39,8 +39,8 @@
|
|
|
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.
|
|
43
|
-
"@plutocms/utils": "^0.
|
|
42
|
+
"@plutocms/pluto": "^0.30.0",
|
|
43
|
+
"@plutocms/utils": "^0.3.0",
|
|
44
44
|
"@supabase/supabase-js": "^2.117.3",
|
|
45
45
|
"postgres": "^3.4.9",
|
|
46
46
|
"supabase": "^2.121.0",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"@types/bun": "^1.4.3",
|
|
52
52
|
"@vueuse/nuxt": "^14.4.0",
|
|
53
53
|
"eslint": "^10.12.0",
|
|
54
|
+
"eslint-config-prettier": "^10.1.8",
|
|
55
|
+
"eslint-plugin-better-tailwindcss": "^4.10.0",
|
|
54
56
|
"nuxt": "^4.6.1",
|
|
55
57
|
"prettier": "^3.9.10",
|
|
56
58
|
"tailwindcss": "^4.3.3",
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
2
|
import { mapAccessError } from '../../../../../../utils/access-errors'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
inviteRedirectUrl,
|
|
5
|
+
isEmailExistsError,
|
|
6
|
+
requireUserId,
|
|
7
|
+
resendBodySchema,
|
|
8
|
+
} from '../../../../../../utils/access-invite'
|
|
4
9
|
import {
|
|
5
10
|
assertInviteLocale,
|
|
6
11
|
generateAcceptLink,
|
|
@@ -122,7 +127,9 @@ export default defineEventHandler(async (event) => {
|
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
const { data: staff } = await client.rpc('list_staff_users')
|
|
125
|
-
const roleKeys = Array.isArray(staff)
|
|
130
|
+
const roleKeys = Array.isArray(staff)
|
|
131
|
+
? (staff.find((row) => row.id === id)?.roles ?? [])
|
|
132
|
+
: []
|
|
126
133
|
const sent = await sendInviteMail({
|
|
127
134
|
client,
|
|
128
135
|
to: email,
|
|
@@ -138,9 +145,12 @@ export default defineEventHandler(async (event) => {
|
|
|
138
145
|
: { status: 'sent' as const, emailSent: false, link: made.link }
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
const { error: inviteError } = await service.auth.admin.inviteUserByEmail(
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
const { error: inviteError } = await service.auth.admin.inviteUserByEmail(
|
|
149
|
+
email,
|
|
150
|
+
{
|
|
151
|
+
redirectTo,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
144
154
|
|
|
145
155
|
if (inviteError) {
|
|
146
156
|
if (isEmailExistsError(inviteError)) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
2
|
import { mapAccessError } from '../../../../../../utils/access-errors'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
requireUserId,
|
|
5
|
+
rolesBodySchema,
|
|
6
|
+
} from '../../../../../../utils/access-invite'
|
|
4
7
|
import { requireAdminRole } from '../../../../../../utils/admin-role-guard'
|
|
5
8
|
import { parseBody } from '../../../../../../utils/validate'
|
|
6
9
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
inviteBodySchema,
|
|
4
|
+
inviteRedirectUrl,
|
|
5
|
+
} from '../../../../../utils/access-invite'
|
|
6
|
+
import {
|
|
7
|
+
provisionAccess,
|
|
8
|
+
serviceKeyRequiredError,
|
|
9
|
+
} from '../../../../../utils/access-mail'
|
|
4
10
|
import { requireAdminRole } from '../../../../../utils/admin-role-guard'
|
|
5
11
|
import { enforceInviteRateLimit } from '../../../../../utils/rate-limit'
|
|
6
12
|
import { optionalServiceClient } from '../../../../../utils/service-client'
|
|
@@ -24,7 +30,10 @@ export default defineEventHandler(async (event) => {
|
|
|
24
30
|
|
|
25
31
|
enforceInviteRateLimit(caller.sub)
|
|
26
32
|
|
|
27
|
-
const { email, roleKeys, displayName } = await parseBody(
|
|
33
|
+
const { email, roleKeys, displayName } = await parseBody(
|
|
34
|
+
event,
|
|
35
|
+
inviteBodySchema
|
|
36
|
+
)
|
|
28
37
|
const service = optionalServiceClient(event)
|
|
29
38
|
|
|
30
39
|
if (!service) {
|
|
@@ -54,7 +54,10 @@ export default defineEventHandler(async (event) => {
|
|
|
54
54
|
|
|
55
55
|
enforceInviteRateLimit(caller.sub)
|
|
56
56
|
|
|
57
|
-
const { email, roleKeys, displayName, locale } = await parseBody(
|
|
57
|
+
const { email, roleKeys, displayName, locale } = await parseBody(
|
|
58
|
+
event,
|
|
59
|
+
inviteBodySchema
|
|
60
|
+
)
|
|
58
61
|
|
|
59
62
|
assertInviteLocale(event, locale)
|
|
60
63
|
|
|
@@ -120,7 +123,9 @@ export default defineEventHandler(async (event) => {
|
|
|
120
123
|
const linkedId = await findUserIdByEmail(client, email)
|
|
121
124
|
|
|
122
125
|
if (!linkedId) {
|
|
123
|
-
console.error(
|
|
126
|
+
console.error(
|
|
127
|
+
'invite: the sign-in link was sent, but no account has this email'
|
|
128
|
+
)
|
|
124
129
|
|
|
125
130
|
throw createPlutoError({
|
|
126
131
|
statusCode: 502,
|
|
@@ -7,7 +7,11 @@ import { parseBody } from '../../../../utils/validate'
|
|
|
7
7
|
// into the same clean 400 shape as every other validation failure, instead
|
|
8
8
|
// of a raw auth-service error reaching the caller.
|
|
9
9
|
const signupSchema = z.object({
|
|
10
|
-
username: z
|
|
10
|
+
username: z
|
|
11
|
+
.string()
|
|
12
|
+
.trim()
|
|
13
|
+
.min(3, 'Username must be at least 3 characters.')
|
|
14
|
+
.max(100),
|
|
11
15
|
display_name: z.string().trim().min(1, 'Display name is required.').max(200),
|
|
12
16
|
email: z.string().trim().email('Email must be a valid email address.'),
|
|
13
17
|
password: z.string().min(6, 'Password must be at least 6 characters.'),
|
|
@@ -26,7 +26,10 @@ export default defineEventHandler(async (event) => {
|
|
|
26
26
|
// Without the fallback, the admin guard would see an admin with no
|
|
27
27
|
// capability and keep them out of /admin/migrations, the one page that
|
|
28
28
|
// can apply the missing migration.
|
|
29
|
-
console.error(
|
|
29
|
+
console.error(
|
|
30
|
+
'my_capabilities() failed, falling back to profiles.is_admin:',
|
|
31
|
+
error.message
|
|
32
|
+
)
|
|
30
33
|
|
|
31
34
|
const { data: profile } = await client
|
|
32
35
|
.from('profiles')
|
|
@@ -3,7 +3,10 @@ import type { MigrationFileResult } from '../../../../utils/migrations'
|
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import { requireAdmin } from '../../../../utils/admin-guard'
|
|
5
5
|
import { persistDatabaseUrl } from '../../../../utils/env-file'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
resolveConnectionString,
|
|
8
|
+
runPendingMigrations,
|
|
9
|
+
} from '../../../../utils/migrations'
|
|
7
10
|
import { scrubConnectionString } from '../../../../utils/scrub-connection-string'
|
|
8
11
|
import { regenerateSupabaseTypes } from '../../../../utils/typegen'
|
|
9
12
|
import { parseBody } from '../../../../utils/validate'
|
|
@@ -12,7 +15,11 @@ import { parseBody } from '../../../../utils/validate'
|
|
|
12
15
|
// caller need not (and, per resolveConnectionString, cannot) supply one.
|
|
13
16
|
const runMigrationsSchema = z
|
|
14
17
|
.object({
|
|
15
|
-
connectionString: z
|
|
18
|
+
connectionString: z
|
|
19
|
+
.string()
|
|
20
|
+
.trim()
|
|
21
|
+
.min(1, 'Connection string must not be empty.')
|
|
22
|
+
.optional(),
|
|
16
23
|
})
|
|
17
24
|
.optional()
|
|
18
25
|
|
|
@@ -11,7 +11,10 @@ import { parseBody } from '../../../../utils/validate'
|
|
|
11
11
|
// DSN, and so on), so this route's real security model is the
|
|
12
12
|
// DATABASE_URL precedence check above, not a stricter format check here.
|
|
13
13
|
const setupSchema = z.object({
|
|
14
|
-
connectionString: z
|
|
14
|
+
connectionString: z
|
|
15
|
+
.string()
|
|
16
|
+
.trim()
|
|
17
|
+
.min(1, 'Connection string must not be empty.'),
|
|
15
18
|
})
|
|
16
19
|
|
|
17
20
|
export default defineEventHandler(async (event) => {
|
|
@@ -136,7 +136,8 @@ export function mapAccessError(error: PgErrorLike) {
|
|
|
136
136
|
if (isMissingAccessFunction(error)) {
|
|
137
137
|
return createPlutoError({
|
|
138
138
|
statusCode: 409,
|
|
139
|
-
message:
|
|
139
|
+
message:
|
|
140
|
+
'Apply the pending database migrations to manage users and roles.',
|
|
140
141
|
key: 'supabase.access.errors.migrationPending',
|
|
141
142
|
})
|
|
142
143
|
}
|
|
@@ -45,7 +45,7 @@ export function inviteRedirectUrl(origin: string): string {
|
|
|
45
45
|
|
|
46
46
|
/** True when GoTrue says that an account with this email already exists. */
|
|
47
47
|
export function isEmailExistsError(
|
|
48
|
-
error: { code?: string
|
|
48
|
+
error: { code?: string; status?: number } | null | undefined
|
|
49
49
|
): boolean {
|
|
50
50
|
return error?.code === 'email_exists' || error?.code === 'user_already_exists'
|
|
51
51
|
}
|