@plutocms/supabase 0.15.0 → 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 +7 -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 +14 -3
- package/package.json +4 -2
- 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/supabase.ts +62 -65
- package/tailwind.lint.css +9 -0
|
@@ -21,8 +21,13 @@ function inviteFailed() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Finds the id of the account with this email, or null. */
|
|
24
|
-
export async function findUserIdByEmail(
|
|
25
|
-
|
|
24
|
+
export async function findUserIdByEmail(
|
|
25
|
+
client: UserClient,
|
|
26
|
+
email: string
|
|
27
|
+
): Promise<string | null> {
|
|
28
|
+
const { data, error } = await client.rpc('find_user_id_by_email', {
|
|
29
|
+
p_email: email,
|
|
30
|
+
})
|
|
26
31
|
|
|
27
32
|
if (error) {
|
|
28
33
|
throw mapAccessError(error)
|
|
@@ -32,8 +37,15 @@ export async function findUserIdByEmail(client: UserClient, email: string): Prom
|
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/** Adds roles to a user. It keeps the roles the user has. */
|
|
35
|
-
export async function addRoles(
|
|
36
|
-
|
|
40
|
+
export async function addRoles(
|
|
41
|
+
client: UserClient,
|
|
42
|
+
target: string,
|
|
43
|
+
roleKeys: string[]
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const { error } = await client.rpc('add_user_roles', {
|
|
46
|
+
target,
|
|
47
|
+
role_keys: roleKeys,
|
|
48
|
+
})
|
|
37
49
|
|
|
38
50
|
if (error) {
|
|
39
51
|
throw mapAccessError(error)
|
|
@@ -44,7 +56,11 @@ export async function addRoles(client: UserClient, target: string, roleKeys: str
|
|
|
44
56
|
* The URL that the user opens to accept an invite. The accept-invite page
|
|
45
57
|
* reads `token_hash` and `type`, then calls `verifyOtp`.
|
|
46
58
|
*/
|
|
47
|
-
export function acceptInviteUrl(
|
|
59
|
+
export function acceptInviteUrl(
|
|
60
|
+
origin: string,
|
|
61
|
+
hashedToken: string,
|
|
62
|
+
type: AcceptLinkType
|
|
63
|
+
): string {
|
|
48
64
|
const url = new URL(INVITE_ACCEPT_PATH, origin)
|
|
49
65
|
|
|
50
66
|
url.searchParams.set('token_hash', hashedToken)
|
|
@@ -62,8 +78,7 @@ interface GenerateLinkParams {
|
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
type GeneratedLink =
|
|
65
|
-
|
|
66
|
-
| { kind: 'exists' }
|
|
81
|
+
{ kind: 'link'; userId: string; link: string } | { kind: 'exists' }
|
|
67
82
|
|
|
68
83
|
/**
|
|
69
84
|
* Makes a one-time link with the service role key. GoTrue sends no email and
|
|
@@ -78,17 +93,23 @@ export async function generateAcceptLink(
|
|
|
78
93
|
params: GenerateLinkParams
|
|
79
94
|
): Promise<GeneratedLink> {
|
|
80
95
|
const options = { redirectTo: params.redirectTo }
|
|
81
|
-
const { data, error } =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
const { data, error } =
|
|
97
|
+
params.type === 'invite'
|
|
98
|
+
? await service.auth.admin.generateLink({
|
|
99
|
+
type: 'invite',
|
|
100
|
+
email: params.email,
|
|
101
|
+
options: {
|
|
102
|
+
...options,
|
|
103
|
+
data: params.displayName
|
|
104
|
+
? { display_name: params.displayName }
|
|
105
|
+
: {},
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
: await service.auth.admin.generateLink({
|
|
109
|
+
type: 'magiclink',
|
|
110
|
+
email: params.email,
|
|
111
|
+
options,
|
|
112
|
+
})
|
|
92
113
|
|
|
93
114
|
if (error) {
|
|
94
115
|
if (error.code === 'email_exists' || error.code === 'user_already_exists') {
|
|
@@ -118,7 +139,11 @@ export async function generateAcceptLink(
|
|
|
118
139
|
? (verification as AcceptLinkType)
|
|
119
140
|
: params.type
|
|
120
141
|
|
|
121
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
kind: 'link',
|
|
144
|
+
userId,
|
|
145
|
+
link: acceptInviteUrl(params.origin, hashedToken, type),
|
|
146
|
+
}
|
|
122
147
|
}
|
|
123
148
|
|
|
124
149
|
interface ProvisionParams {
|
|
@@ -160,7 +185,9 @@ export function serviceKeyRequiredError() {
|
|
|
160
185
|
* If the roles fail, the new user is deleted.
|
|
161
186
|
* - The account appears after the lookup (a race): the roles are added.
|
|
162
187
|
*/
|
|
163
|
-
export async function provisionAccess(
|
|
188
|
+
export async function provisionAccess(
|
|
189
|
+
params: ProvisionParams
|
|
190
|
+
): Promise<ProvisionResult> {
|
|
164
191
|
const { client, service, email, roleKeys } = params
|
|
165
192
|
const link = (type: 'invite' | 'magiclink') =>
|
|
166
193
|
generateAcceptLink(service, { ...params, type })
|
|
@@ -177,7 +204,8 @@ export async function provisionAccess(params: ProvisionParams): Promise<Provisio
|
|
|
177
204
|
// admin from taking over a customer's or another admin's account. Only an
|
|
178
205
|
// account that never confirmed (a pending invite, or a sign-up with no
|
|
179
206
|
// confirmation) gets a link, because it cannot sign in without one.
|
|
180
|
-
const { data: found, error: lookupError } =
|
|
207
|
+
const { data: found, error: lookupError } =
|
|
208
|
+
await service.auth.admin.getUserById(userId)
|
|
181
209
|
|
|
182
210
|
if (lookupError || !found.user) {
|
|
183
211
|
console.error('invite: getUserById failed for an existing account')
|
|
@@ -224,7 +252,9 @@ export async function provisionAccess(params: ProvisionParams): Promise<Provisio
|
|
|
224
252
|
})
|
|
225
253
|
|
|
226
254
|
if (setError) {
|
|
227
|
-
const { error: undoError } = await service.auth.admin.deleteUser(
|
|
255
|
+
const { error: undoError } = await service.auth.admin.deleteUser(
|
|
256
|
+
made.userId
|
|
257
|
+
)
|
|
228
258
|
|
|
229
259
|
if (undoError) {
|
|
230
260
|
console.error('invite: could not delete the new user:', undoError.message)
|
|
@@ -266,7 +296,10 @@ export async function inviteSiteName(client: UserClient): Promise<string> {
|
|
|
266
296
|
}
|
|
267
297
|
|
|
268
298
|
/** The labels of the roles, in the order of the keys. A key with no label stays as it is. */
|
|
269
|
-
async function roleLabels(
|
|
299
|
+
async function roleLabels(
|
|
300
|
+
client: UserClient,
|
|
301
|
+
roleKeys: string[]
|
|
302
|
+
): Promise<string[]> {
|
|
270
303
|
try {
|
|
271
304
|
const { data } = await client.rpc('list_roles')
|
|
272
305
|
const labels = new Map((data ?? []).map((role) => [role.key, role.label]))
|
|
@@ -292,7 +325,9 @@ interface InviteMailParams {
|
|
|
292
325
|
* fails. The caller keeps the user and the roles, and gives the link to the
|
|
293
326
|
* admin. The log holds the error code only: no recipient, link or key.
|
|
294
327
|
*/
|
|
295
|
-
export async function sendInviteMail(
|
|
328
|
+
export async function sendInviteMail(
|
|
329
|
+
params: InviteMailParams
|
|
330
|
+
): Promise<boolean> {
|
|
296
331
|
try {
|
|
297
332
|
const [siteName, roles] = await Promise.all([
|
|
298
333
|
inviteSiteName(params.client),
|
|
@@ -314,14 +349,19 @@ export async function sendInviteMail(params: InviteMailParams): Promise<boolean>
|
|
|
314
349
|
} catch (error) {
|
|
315
350
|
const code = (error as { code?: unknown } | null)?.code
|
|
316
351
|
|
|
317
|
-
console.error(
|
|
352
|
+
console.error(
|
|
353
|
+
'invite: the Pluto mail send failed:',
|
|
354
|
+
typeof code === 'string' ? code : 'unknown'
|
|
355
|
+
)
|
|
318
356
|
|
|
319
357
|
return false
|
|
320
358
|
}
|
|
321
359
|
}
|
|
322
360
|
|
|
323
361
|
/** The name of the admin who invites, for the email. Empty when unknown. */
|
|
324
|
-
export function inviterNameOf(user: {
|
|
362
|
+
export function inviterNameOf(user: {
|
|
363
|
+
user_metadata?: Record<string, unknown> | null
|
|
364
|
+
}): string | undefined {
|
|
325
365
|
const meta = user.user_metadata ?? {}
|
|
326
366
|
|
|
327
367
|
for (const value of [meta.display_name, meta.full_name, meta.name]) {
|
|
@@ -337,12 +377,16 @@ export function inviterNameOf(user: { user_metadata?: Record<string, unknown> |
|
|
|
337
377
|
* Checks the optional email language against the admin languages. Throws a
|
|
338
378
|
* 400 for a language the admin does not have.
|
|
339
379
|
*/
|
|
340
|
-
export function assertInviteLocale(
|
|
380
|
+
export function assertInviteLocale(
|
|
381
|
+
event: H3Event,
|
|
382
|
+
locale: string | undefined
|
|
383
|
+
): void {
|
|
341
384
|
if (locale === undefined) {
|
|
342
385
|
return
|
|
343
386
|
}
|
|
344
387
|
|
|
345
|
-
const adminLocales =
|
|
388
|
+
const adminLocales =
|
|
389
|
+
useRuntimeConfig(event).public.plutoI18n?.adminLocales ?? []
|
|
346
390
|
|
|
347
391
|
if (!adminLocales.includes(locale)) {
|
|
348
392
|
throw createPlutoError({
|
|
@@ -44,7 +44,9 @@ export async function requireCapability(
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const client = await serverSupabaseClient<Database>(event)
|
|
47
|
-
const { data, error } = await client.rpc('has_capability', {
|
|
47
|
+
const { data, error } = await client.rpc('has_capability', {
|
|
48
|
+
cap: capability,
|
|
49
|
+
})
|
|
48
50
|
|
|
49
51
|
if (error || data !== true) {
|
|
50
52
|
// A custom `message` without a `messageKey` has no translation, so it
|
|
@@ -56,7 +58,8 @@ export async function requireCapability(
|
|
|
56
58
|
throw createPlutoError({
|
|
57
59
|
statusCode: 403,
|
|
58
60
|
message:
|
|
59
|
-
options?.message ??
|
|
61
|
+
options?.message ??
|
|
62
|
+
`Your account is missing the "${capability}" permission.`,
|
|
60
63
|
key: options?.messageKey ?? 'supabase.errors.missingPermission',
|
|
61
64
|
params: { capability },
|
|
62
65
|
})
|
|
@@ -40,7 +40,8 @@ async function getClient(event: H3Event): Promise<SupabaseClient> {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** The anonymous client and the url/key pair it was built from. */
|
|
43
|
-
let anonymousClient:
|
|
43
|
+
let anonymousClient:
|
|
44
|
+
{ url: string; key: string; client: SupabaseClient } | undefined
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* A Supabase client with no user session, for public reads of the public
|
|
@@ -51,9 +52,16 @@ let anonymousClient: { url: string, key: string, client: SupabaseClient } | unde
|
|
|
51
52
|
* whole process.
|
|
52
53
|
*/
|
|
53
54
|
function getAnonymousClient(event: H3Event): SupabaseClient {
|
|
54
|
-
const { url, key } = useRuntimeConfig(event).public.supabase as {
|
|
55
|
+
const { url, key } = useRuntimeConfig(event).public.supabase as {
|
|
56
|
+
url: string
|
|
57
|
+
key: string
|
|
58
|
+
}
|
|
55
59
|
|
|
56
|
-
if (
|
|
60
|
+
if (
|
|
61
|
+
!anonymousClient ||
|
|
62
|
+
anonymousClient.url !== url ||
|
|
63
|
+
anonymousClient.key !== key
|
|
64
|
+
) {
|
|
57
65
|
anonymousClient = {
|
|
58
66
|
url,
|
|
59
67
|
key,
|
|
@@ -98,7 +106,9 @@ export function resolveTimestampColumn(
|
|
|
98
106
|
* `undefined` when status is disabled entirely (`type.status === false`
|
|
99
107
|
* or unset).
|
|
100
108
|
*/
|
|
101
|
-
export function resolveStatusColumn(
|
|
109
|
+
export function resolveStatusColumn(
|
|
110
|
+
type: PlutoContentType
|
|
111
|
+
): string | undefined {
|
|
102
112
|
if (!type.status) {
|
|
103
113
|
return undefined
|
|
104
114
|
}
|
|
@@ -123,7 +133,9 @@ function resolveSortColumn(type: PlutoContentType, fieldName: string): string {
|
|
|
123
133
|
function timestampColumns(type: PlutoContentType): string[] {
|
|
124
134
|
const { created, updated, published } = timestampKeys(type)
|
|
125
135
|
|
|
126
|
-
return [created, updated, published].filter(
|
|
136
|
+
return [created, updated, published].filter(
|
|
137
|
+
(column): column is string => column !== undefined
|
|
138
|
+
)
|
|
127
139
|
}
|
|
128
140
|
|
|
129
141
|
/**
|
|
@@ -131,8 +143,12 @@ function timestampColumns(type: PlutoContentType): string[] {
|
|
|
131
143
|
* because a timestamp is not a declared field. A key is missing when its
|
|
132
144
|
* timestamp is off. Core uses this to sort and to show the public dates.
|
|
133
145
|
*/
|
|
134
|
-
function timestampKeys(type: PlutoContentType): {
|
|
135
|
-
|
|
146
|
+
function timestampKeys(type: PlutoContentType): {
|
|
147
|
+
created?: string
|
|
148
|
+
updated?: string
|
|
149
|
+
published?: string
|
|
150
|
+
} {
|
|
151
|
+
const keys: { created?: string; updated?: string; published?: string } = {}
|
|
136
152
|
|
|
137
153
|
const created = resolveTimestampColumn(type, 'created')
|
|
138
154
|
if (created) {
|
|
@@ -158,7 +174,10 @@ function timestampKeys(type: PlutoContentType): { created?: string, updated?: st
|
|
|
158
174
|
* declares, and a timestamp is not a field. A field with the same name as a
|
|
159
175
|
* timestamp column keeps its own value.
|
|
160
176
|
*/
|
|
161
|
-
function toItem(
|
|
177
|
+
function toItem(
|
|
178
|
+
type: PlutoContentType,
|
|
179
|
+
row: Record<string, unknown>
|
|
180
|
+
): PlutoContentItem {
|
|
162
181
|
const item = mapColumnsToFields(type, row)
|
|
163
182
|
|
|
164
183
|
for (const column of timestampColumns(type)) {
|
|
@@ -202,7 +221,11 @@ export function toContentError(error: PostgrestError) {
|
|
|
202
221
|
}
|
|
203
222
|
|
|
204
223
|
/** Resolves a field's storage column by name, warning and falling back to the raw name when the field is missing. This signals a misconfigured content type. */
|
|
205
|
-
function resolveFieldColumn(
|
|
224
|
+
function resolveFieldColumn(
|
|
225
|
+
type: PlutoContentType,
|
|
226
|
+
fieldName: string,
|
|
227
|
+
context: string
|
|
228
|
+
): string {
|
|
206
229
|
const field = type.fields.find((candidate) => candidate.name === fieldName)
|
|
207
230
|
|
|
208
231
|
if (field) {
|
|
@@ -216,7 +239,10 @@ function resolveFieldColumn(type: PlutoContentType, fieldName: string, context:
|
|
|
216
239
|
return fieldName
|
|
217
240
|
}
|
|
218
241
|
|
|
219
|
-
async function list(
|
|
242
|
+
async function list(
|
|
243
|
+
ctx: PlutoContentContext,
|
|
244
|
+
query: PlutoContentQuery
|
|
245
|
+
): Promise<PlutoContentListResult> {
|
|
220
246
|
const client = await getClient(ctx.event)
|
|
221
247
|
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
222
248
|
|
|
@@ -224,7 +250,8 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
|
|
|
224
250
|
|
|
225
251
|
const statusColumn = resolveStatusColumn(ctx.type)
|
|
226
252
|
if (statusColumn && query.includeUnpublished !== true) {
|
|
227
|
-
const publishedValue =
|
|
253
|
+
const publishedValue =
|
|
254
|
+
(ctx.type.status && ctx.type.status.publishedValue) || 'published'
|
|
228
255
|
builder = builder.eq(statusColumn, publishedValue)
|
|
229
256
|
}
|
|
230
257
|
|
|
@@ -233,14 +260,20 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
|
|
|
233
260
|
}
|
|
234
261
|
|
|
235
262
|
if (query.search) {
|
|
236
|
-
const titleColumn = resolveFieldColumn(
|
|
263
|
+
const titleColumn = resolveFieldColumn(
|
|
264
|
+
ctx.type,
|
|
265
|
+
ctx.type.titleField,
|
|
266
|
+
'titleField'
|
|
267
|
+
)
|
|
237
268
|
builder = builder.ilike(titleColumn, `%${query.search}%`)
|
|
238
269
|
}
|
|
239
270
|
|
|
240
271
|
const sort = query.sort ?? ctx.type.defaultSort
|
|
241
272
|
if (sort) {
|
|
242
273
|
const sortColumn = resolveSortColumn(ctx.type, sort.field)
|
|
243
|
-
builder = builder.order(sortColumn, {
|
|
274
|
+
builder = builder.order(sortColumn, {
|
|
275
|
+
ascending: sort.direction !== 'desc',
|
|
276
|
+
})
|
|
244
277
|
} else {
|
|
245
278
|
const createdColumn = resolveTimestampColumn(ctx.type, 'created')
|
|
246
279
|
builder = builder.order(createdColumn ?? pkColumn, { ascending: false })
|
|
@@ -260,7 +293,9 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
|
|
|
260
293
|
}
|
|
261
294
|
|
|
262
295
|
return {
|
|
263
|
-
data: (data ?? []).map((row: Record<string, unknown>) =>
|
|
296
|
+
data: (data ?? []).map((row: Record<string, unknown>) =>
|
|
297
|
+
toItem(ctx.type, row)
|
|
298
|
+
),
|
|
264
299
|
total: count ?? undefined,
|
|
265
300
|
}
|
|
266
301
|
}
|
|
@@ -279,7 +314,10 @@ function isInvalidIdShape(error: PostgrestError): boolean {
|
|
|
279
314
|
return error.code === '22P02'
|
|
280
315
|
}
|
|
281
316
|
|
|
282
|
-
async function get(
|
|
317
|
+
async function get(
|
|
318
|
+
ctx: PlutoContentContext,
|
|
319
|
+
idOrSlug: string | number
|
|
320
|
+
): Promise<PlutoContentItem | null> {
|
|
283
321
|
const client = await getClient(ctx.event)
|
|
284
322
|
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
285
323
|
|
|
@@ -298,7 +336,11 @@ async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise
|
|
|
298
336
|
}
|
|
299
337
|
|
|
300
338
|
if (ctx.type.slug) {
|
|
301
|
-
const slugColumn = resolveFieldColumn(
|
|
339
|
+
const slugColumn = resolveFieldColumn(
|
|
340
|
+
ctx.type,
|
|
341
|
+
ctx.type.slug.field,
|
|
342
|
+
'slug.field'
|
|
343
|
+
)
|
|
302
344
|
|
|
303
345
|
const bySlug = await client
|
|
304
346
|
.from(ctx.type.source)
|
|
@@ -318,7 +360,10 @@ async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise
|
|
|
318
360
|
return null
|
|
319
361
|
}
|
|
320
362
|
|
|
321
|
-
async function create(
|
|
363
|
+
async function create(
|
|
364
|
+
ctx: PlutoContentContext,
|
|
365
|
+
values: Record<string, unknown>
|
|
366
|
+
): Promise<PlutoContentItem> {
|
|
322
367
|
const client = await getClient(ctx.event)
|
|
323
368
|
const row = mapFieldsToColumns(ctx.type, values)
|
|
324
369
|
|
|
@@ -387,10 +432,10 @@ async function update(
|
|
|
387
432
|
// edit route: stamp published-at the first time a row is published, but
|
|
388
433
|
// never overwrite an already-set value on a later edit.
|
|
389
434
|
if (
|
|
390
|
-
statusColumn
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
435
|
+
statusColumn &&
|
|
436
|
+
ctx.type.status &&
|
|
437
|
+
ctx.type.status.publishedAtColumn &&
|
|
438
|
+
row[statusColumn] === ctx.type.status.publishedValue
|
|
394
439
|
) {
|
|
395
440
|
const publishedAtColumn = ctx.type.status.publishedAtColumn
|
|
396
441
|
// Selects the whole row rather than the one dynamic column name:
|
|
@@ -407,7 +452,10 @@ async function update(
|
|
|
407
452
|
throw toContentError(fetchError)
|
|
408
453
|
}
|
|
409
454
|
|
|
410
|
-
if (
|
|
455
|
+
if (
|
|
456
|
+
existing &&
|
|
457
|
+
(existing as Record<string, unknown>)[publishedAtColumn] == null
|
|
458
|
+
) {
|
|
411
459
|
row[publishedAtColumn] = new Date().toISOString()
|
|
412
460
|
}
|
|
413
461
|
}
|
|
@@ -426,7 +474,10 @@ async function update(
|
|
|
426
474
|
return toItem(ctx.type, data)
|
|
427
475
|
}
|
|
428
476
|
|
|
429
|
-
async function remove(
|
|
477
|
+
async function remove(
|
|
478
|
+
ctx: PlutoContentContext,
|
|
479
|
+
id: string | number
|
|
480
|
+
): Promise<void> {
|
|
430
481
|
const client = await getClient(ctx.event)
|
|
431
482
|
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
432
483
|
|
|
@@ -445,7 +496,7 @@ async function remove(ctx: PlutoContentContext, id: string | number): Promise<vo
|
|
|
445
496
|
*/
|
|
446
497
|
async function countCreated(
|
|
447
498
|
ctx: PlutoContentContext,
|
|
448
|
-
range: { from: string
|
|
499
|
+
range: { from: string; to: string }
|
|
449
500
|
): Promise<number | null> {
|
|
450
501
|
const createdColumn = resolveTimestampColumn(ctx.type, 'created')
|
|
451
502
|
if (!createdColumn) {
|
|
Binary file
|
|
Binary file
|
|
@@ -15,4 +15,6 @@ export function optionalServiceClient(event: H3Event) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** The client of `optionalServiceClient`, when the key is set. */
|
|
18
|
-
export type ServiceClient = NonNullable<
|
|
18
|
+
export type ServiceClient = NonNullable<
|
|
19
|
+
ReturnType<typeof optionalServiceClient>
|
|
20
|
+
>
|
|
@@ -40,7 +40,9 @@ export async function sendSignInLink(
|
|
|
40
40
|
options: {
|
|
41
41
|
shouldCreateUser: options.createUser,
|
|
42
42
|
emailRedirectTo: options.redirectTo,
|
|
43
|
-
data: options.displayName
|
|
43
|
+
data: options.displayName
|
|
44
|
+
? { display_name: options.displayName }
|
|
45
|
+
: undefined,
|
|
44
46
|
},
|
|
45
47
|
})
|
|
46
48
|
|
|
@@ -52,14 +54,17 @@ export async function sendSignInLink(
|
|
|
52
54
|
* SMTP, Supabase sends auth emails only to members of the project's
|
|
53
55
|
* organization.
|
|
54
56
|
*/
|
|
55
|
-
export function isEmailNotAuthorizedError(
|
|
57
|
+
export function isEmailNotAuthorizedError(
|
|
58
|
+
error: { code?: string } | null | undefined
|
|
59
|
+
): boolean {
|
|
56
60
|
return error?.code === 'email_address_not_authorized'
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
export function emailNotAuthorizedError() {
|
|
60
64
|
return createPlutoError({
|
|
61
65
|
statusCode: 400,
|
|
62
|
-
message:
|
|
66
|
+
message:
|
|
67
|
+
"Supabase's built-in email sends only to members of your Supabase organization. Set up custom SMTP in Supabase to invite other addresses.",
|
|
63
68
|
key: 'supabase.access.errors.emailNotAuthorized',
|
|
64
69
|
})
|
|
65
70
|
}
|
|
@@ -69,14 +74,17 @@ export function emailNotAuthorizedError() {
|
|
|
69
74
|
* 60 seconds, and a few per hour for the project with the built-in mailer.
|
|
70
75
|
* This is not the in-memory limit of `rate-limit.ts`.
|
|
71
76
|
*/
|
|
72
|
-
export function isEmailRateLimitError(
|
|
77
|
+
export function isEmailRateLimitError(
|
|
78
|
+
error: { code?: string; status?: number } | null | undefined
|
|
79
|
+
): boolean {
|
|
73
80
|
return error?.status === 429 || error?.code === 'over_email_send_rate_limit'
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
export function emailRateLimitError() {
|
|
77
84
|
return createPlutoError({
|
|
78
85
|
statusCode: 429,
|
|
79
|
-
message:
|
|
86
|
+
message:
|
|
87
|
+
'Supabase sent too many emails. Wait a minute for the same address, or up to an hour with the built-in mailer. Custom SMTP in Supabase raises this limit.',
|
|
80
88
|
key: 'supabase.access.errors.emailRateLimited',
|
|
81
89
|
})
|
|
82
90
|
}
|
|
@@ -85,20 +93,27 @@ export function emailRateLimitError() {
|
|
|
85
93
|
* True when Supabase refuses the address itself, for example an example or
|
|
86
94
|
* test domain (`example.com`), or an address it cannot deliver to.
|
|
87
95
|
*/
|
|
88
|
-
export function isEmailInvalidError(
|
|
96
|
+
export function isEmailInvalidError(
|
|
97
|
+
error: { code?: string } | null | undefined
|
|
98
|
+
): boolean {
|
|
89
99
|
return error?.code === 'email_address_invalid'
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
export function emailInvalidError() {
|
|
93
103
|
return createPlutoError({
|
|
94
104
|
statusCode: 400,
|
|
95
|
-
message:
|
|
105
|
+
message:
|
|
106
|
+
'Supabase does not accept this email address. Use a real address: Supabase refuses example and test domains.',
|
|
96
107
|
key: 'supabase.access.errors.emailInvalid',
|
|
97
108
|
})
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
/** Maps a GoTrue error of `signInWithOtp` to a Pluto error. */
|
|
101
|
-
export function signInLinkError(error: {
|
|
112
|
+
export function signInLinkError(error: {
|
|
113
|
+
code?: string
|
|
114
|
+
status?: number
|
|
115
|
+
message: string
|
|
116
|
+
}) {
|
|
102
117
|
if (isEmailNotAuthorizedError(error)) {
|
|
103
118
|
return emailNotAuthorizedError()
|
|
104
119
|
}
|
|
@@ -114,7 +129,8 @@ export function signInLinkError(error: { code?: string, status?: number, message
|
|
|
114
129
|
if (error.code === 'signup_disabled' || error.code === 'otp_disabled') {
|
|
115
130
|
return createPlutoError({
|
|
116
131
|
statusCode: 503,
|
|
117
|
-
message:
|
|
132
|
+
message:
|
|
133
|
+
'Public sign-up is off. Set NUXT_SUPABASE_SECRET_KEY on the server to send invites.',
|
|
118
134
|
key: 'supabase.access.errors.signupDisabled',
|
|
119
135
|
})
|
|
120
136
|
}
|
package/server/utils/typegen.ts
CHANGED
|
@@ -15,9 +15,10 @@ const execFileAsync = promisify(execFile)
|
|
|
15
15
|
*/
|
|
16
16
|
function resolveSupabaseBin(rootDir: string): string {
|
|
17
17
|
// On Windows, npm installs `supabase.cmd` and Bun installs `supabase.exe`.
|
|
18
|
-
const binNames =
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const binNames =
|
|
19
|
+
process.platform === 'win32'
|
|
20
|
+
? ['supabase.cmd', 'supabase.exe']
|
|
21
|
+
: ['supabase']
|
|
21
22
|
const localBin = binNames
|
|
22
23
|
.map((name) => join(rootDir, 'node_modules/.bin', name))
|
|
23
24
|
.find((path) => existsSync(path))
|
|
@@ -25,7 +26,8 @@ function resolveSupabaseBin(rootDir: string): string {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// The block, with its leading comment lines. The block has no nested braces.
|
|
28
|
-
const INTERNAL_BLOCK =
|
|
29
|
+
const INTERNAL_BLOCK =
|
|
30
|
+
/(?:^[ \t]*\/\/[^\n]*\n)*^[ \t]*__InternalSupabase:\s*\{[^{}]*\}[ \t]*\n/m
|
|
29
31
|
// The block itself. The name alone is not enough: the output also has it in
|
|
30
32
|
// `Omit<Database, '__InternalSupabase'>`.
|
|
31
33
|
const HAS_BLOCK = /^[ \t]*__InternalSupabase:\s*\{/m
|
package/server/utils/validate.ts
CHANGED
|
@@ -27,7 +27,10 @@ export interface ValidationError {
|
|
|
27
27
|
* A nested field path (for example `address.zip`) is joined with `.` to
|
|
28
28
|
* build `field`.
|
|
29
29
|
*/
|
|
30
|
-
export async function parseBody<T>(
|
|
30
|
+
export async function parseBody<T>(
|
|
31
|
+
event: H3Event,
|
|
32
|
+
schema: ZodSchema<T>
|
|
33
|
+
): Promise<T> {
|
|
31
34
|
const body = await readBody(event)
|
|
32
35
|
const result = schema.safeParse(body)
|
|
33
36
|
|