@plutocms/supabase 0.11.3 → 0.13.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.
Files changed (46) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +14 -0
  3. package/FEATURES.md +5 -1
  4. package/README.md +76 -14
  5. package/app/components/access/CopyButton.vue +24 -0
  6. package/app/components/access/DeleteRoleModal.vue +73 -0
  7. package/app/components/access/EditRolesSlideover.vue +134 -0
  8. package/app/components/access/InviteModal.vue +288 -0
  9. package/app/components/access/LinkField.vue +19 -0
  10. package/app/components/access/LinkModal.vue +29 -0
  11. package/app/components/access/RevokeModal.vue +83 -0
  12. package/app/components/access/RoleSlideover.vue +251 -0
  13. package/app/components/mail/AuthEmailsPanel.vue +108 -0
  14. package/app/pages/admin/(auth)/accept-invite.vue +155 -0
  15. package/app/pages/admin/settings/roles.vue +187 -0
  16. package/app/pages/admin/settings/users.vue +372 -0
  17. package/app/plugins/pluto-extension.ts +38 -1
  18. package/app/utils/access-ui.ts +100 -0
  19. package/app/utils/auth-driver.ts +3 -0
  20. package/app/utils/invite-link.ts +70 -0
  21. package/app/utils/supabase-project.ts +54 -0
  22. package/db/migrations/008_users_and_roles_admin.sql +462 -0
  23. package/i18n/locales/en.json +194 -1
  24. package/i18n/locales/pt-BR.json +194 -1
  25. package/package.json +3 -2
  26. package/server/api/access/capabilities.get.ts +17 -0
  27. package/server/api/access/roles/[key].delete.ts +18 -0
  28. package/server/api/access/roles/[key].put.ts +26 -0
  29. package/server/api/access/roles.get.ts +17 -0
  30. package/server/api/access/roles.post.ts +25 -0
  31. package/server/api/access/users/[id]/resend.post.ts +172 -0
  32. package/server/api/access/users/[id]/roles.delete.ts +22 -0
  33. package/server/api/access/users/[id]/roles.put.ts +24 -0
  34. package/server/api/access/users/invite-link.post.ts +50 -0
  35. package/server/api/access/users/invite.post.ts +201 -0
  36. package/server/api/access/users.get.ts +17 -0
  37. package/server/utils/access-errors.ts +148 -0
  38. package/server/utils/access-invite.ts +66 -0
  39. package/server/utils/access-mail.ts +354 -0
  40. package/server/utils/admin-guard.ts +4 -3
  41. package/server/utils/admin-role-guard.ts +44 -0
  42. package/server/utils/rate-limit.ts +84 -0
  43. package/server/utils/service-client.ts +18 -0
  44. package/server/utils/sign-in-link.ts +129 -0
  45. package/shared/types/access.d.ts +41 -0
  46. package/shared/types/supabase.ts +50 -9
@@ -0,0 +1,354 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { serverSupabaseClient } from '#supabase/server'
3
+ import type { ServiceClient } from './service-client'
4
+ import { mapAccessError } from './access-errors'
5
+ import { INVITE_ACCEPT_PATH } from './access-invite'
6
+ import { emailInvalidError, isEmailInvalidError } from './sign-in-link'
7
+
8
+ type UserClient = Awaited<ReturnType<typeof serverSupabaseClient<Database>>>
9
+
10
+ /** The link types that the accept-invite page can verify. */
11
+ export type AcceptLinkType = 'invite' | 'magiclink' | 'signup'
12
+
13
+ const ACCEPT_LINK_TYPES: readonly string[] = ['invite', 'magiclink', 'signup']
14
+
15
+ function inviteFailed() {
16
+ return createPlutoError({
17
+ statusCode: 502,
18
+ message: 'The invite email could not be sent.',
19
+ key: 'supabase.access.errors.inviteFailed',
20
+ })
21
+ }
22
+
23
+ /** Finds the id of the account with this email, or null. */
24
+ export async function findUserIdByEmail(client: UserClient, email: string): Promise<string | null> {
25
+ const { data, error } = await client.rpc('find_user_id_by_email', { p_email: email })
26
+
27
+ if (error) {
28
+ throw mapAccessError(error)
29
+ }
30
+
31
+ return data ?? null
32
+ }
33
+
34
+ /** Adds roles to a user. It keeps the roles the user has. */
35
+ export async function addRoles(client: UserClient, target: string, roleKeys: string[]): Promise<void> {
36
+ const { error } = await client.rpc('add_user_roles', { target, role_keys: roleKeys })
37
+
38
+ if (error) {
39
+ throw mapAccessError(error)
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The URL that the user opens to accept an invite. The accept-invite page
45
+ * reads `token_hash` and `type`, then calls `verifyOtp`.
46
+ */
47
+ export function acceptInviteUrl(origin: string, hashedToken: string, type: AcceptLinkType): string {
48
+ const url = new URL(INVITE_ACCEPT_PATH, origin)
49
+
50
+ url.searchParams.set('token_hash', hashedToken)
51
+ url.searchParams.set('type', type)
52
+
53
+ return url.href
54
+ }
55
+
56
+ interface GenerateLinkParams {
57
+ email: string
58
+ type: 'invite' | 'magiclink'
59
+ redirectTo: string
60
+ origin: string
61
+ displayName?: string
62
+ }
63
+
64
+ type GeneratedLink =
65
+ | { kind: 'link', userId: string, link: string }
66
+ | { kind: 'exists' }
67
+
68
+ /**
69
+ * Makes a one-time link with the service role key. GoTrue sends no email and
70
+ * skips the email rate limit. An `invite` link creates the user when the
71
+ * email has no account.
72
+ *
73
+ * GoTrue says which type the link has (`verification_type`). The link uses
74
+ * that type when the accept-invite page knows it, else the asked type.
75
+ */
76
+ export async function generateAcceptLink(
77
+ service: ServiceClient,
78
+ params: GenerateLinkParams
79
+ ): Promise<GeneratedLink> {
80
+ const options = { redirectTo: params.redirectTo }
81
+ const { data, error } = params.type === 'invite'
82
+ ? await service.auth.admin.generateLink({
83
+ type: 'invite',
84
+ email: params.email,
85
+ options: { ...options, data: params.displayName ? { display_name: params.displayName } : {} },
86
+ })
87
+ : await service.auth.admin.generateLink({
88
+ type: 'magiclink',
89
+ email: params.email,
90
+ options,
91
+ })
92
+
93
+ if (error) {
94
+ if (error.code === 'email_exists' || error.code === 'user_already_exists') {
95
+ return { kind: 'exists' }
96
+ }
97
+
98
+ if (isEmailInvalidError(error)) {
99
+ throw emailInvalidError()
100
+ }
101
+
102
+ console.error('invite: generateLink failed:', error.message)
103
+
104
+ throw inviteFailed()
105
+ }
106
+
107
+ const hashedToken = data?.properties?.hashed_token
108
+ const userId = data?.user?.id
109
+
110
+ if (!hashedToken || !userId) {
111
+ console.error('invite: generateLink returned no token')
112
+
113
+ throw inviteFailed()
114
+ }
115
+
116
+ const verification = String(data.properties.verification_type ?? '')
117
+ const type = ACCEPT_LINK_TYPES.includes(verification)
118
+ ? (verification as AcceptLinkType)
119
+ : params.type
120
+
121
+ return { kind: 'link', userId, link: acceptInviteUrl(params.origin, hashedToken, type) }
122
+ }
123
+
124
+ interface ProvisionParams {
125
+ client: UserClient
126
+ service: ServiceClient
127
+ email: string
128
+ roleKeys: string[]
129
+ displayName?: string
130
+ redirectTo: string
131
+ origin: string
132
+ /** Make a link for an email that has an account. Default: true. */
133
+ linkForExisting?: boolean
134
+ }
135
+
136
+ export interface ProvisionResult {
137
+ userId: string
138
+ /** Null when the account existed and `linkForExisting` is false. */
139
+ link: string | null
140
+ /** True when this call made the account. False when it had one. */
141
+ created: boolean
142
+ }
143
+
144
+ /** The error of a route that needs the service role key. */
145
+ export function serviceKeyRequiredError() {
146
+ return createPlutoError({
147
+ statusCode: 503,
148
+ message: 'Set NUXT_SUPABASE_SECRET_KEY on the server to make invite links.',
149
+ key: 'supabase.access.errors.serviceKeyRequired',
150
+ })
151
+ }
152
+
153
+ /**
154
+ * Grants the roles and makes a sign-in link, with no email. It needs the
155
+ * service role key.
156
+ *
157
+ * - An email with an account (confirmed or not): the roles are added, and the
158
+ * link is a `magiclink`. The account is never deleted.
159
+ * - A new email: the `invite` link creates the user, then the roles are set.
160
+ * If the roles fail, the new user is deleted.
161
+ * - The account appears after the lookup (a race): the roles are added.
162
+ */
163
+ export async function provisionAccess(params: ProvisionParams): Promise<ProvisionResult> {
164
+ const { client, service, email, roleKeys } = params
165
+ const link = (type: 'invite' | 'magiclink') =>
166
+ generateAcceptLink(service, { ...params, type })
167
+
168
+ async function forExisting(userId: string): Promise<ProvisionResult> {
169
+ await addRoles(client, userId, roleKeys)
170
+
171
+ if (params.linkForExisting === false) {
172
+ return { userId, link: null, created: false }
173
+ }
174
+
175
+ // A magic link signs in AS that account. A confirmed account has its own
176
+ // password and needs no link, so the admin never gets one: this keeps an
177
+ // admin from taking over a customer's or another admin's account. Only an
178
+ // account that never confirmed (a pending invite, or a sign-up with no
179
+ // confirmation) gets a link, because it cannot sign in without one.
180
+ const { data: found, error: lookupError } = await service.auth.admin.getUserById(userId)
181
+
182
+ if (lookupError || !found.user) {
183
+ console.error('invite: getUserById failed for an existing account')
184
+
185
+ throw inviteFailed()
186
+ }
187
+
188
+ if (found.user.email_confirmed_at) {
189
+ return { userId, link: null, created: false }
190
+ }
191
+
192
+ const made = await link('magiclink')
193
+
194
+ if (made.kind !== 'link') {
195
+ throw inviteFailed()
196
+ }
197
+
198
+ return { userId, link: made.link, created: false }
199
+ }
200
+
201
+ const existingId = await findUserIdByEmail(client, email)
202
+
203
+ if (existingId) {
204
+ return forExisting(existingId)
205
+ }
206
+
207
+ const made = await link('invite')
208
+
209
+ if (made.kind === 'exists') {
210
+ const racedId = await findUserIdByEmail(client, email)
211
+
212
+ if (!racedId) {
213
+ throw inviteFailed()
214
+ }
215
+
216
+ return forExisting(racedId)
217
+ }
218
+
219
+ // The lookup found no account, so this call made it. Only this new account
220
+ // may be deleted when the role write fails.
221
+ const { error: setError } = await client.rpc('set_user_roles', {
222
+ target: made.userId,
223
+ role_keys: roleKeys,
224
+ })
225
+
226
+ if (setError) {
227
+ const { error: undoError } = await service.auth.admin.deleteUser(made.userId)
228
+
229
+ if (undoError) {
230
+ console.error('invite: could not delete the new user:', undoError.message)
231
+ }
232
+
233
+ throw mapAccessError(setError)
234
+ }
235
+
236
+ return { userId: made.userId, link: made.link, created: true }
237
+ }
238
+
239
+ /** True when Pluto mail can send. A failed check counts as not ready. */
240
+ export async function plutoMailReady(): Promise<boolean> {
241
+ try {
242
+ return (await getPlutoMailStatus()).configured === true
243
+ } catch {
244
+ return false
245
+ }
246
+ }
247
+
248
+ /** The site name for the email: the `website_title` setting, else the config name. */
249
+ export async function inviteSiteName(client: UserClient): Promise<string> {
250
+ try {
251
+ const { data } = await client
252
+ .from('settings')
253
+ .select('setting_value')
254
+ .eq('setting_key', 'website_title')
255
+ .maybeSingle()
256
+ const title = data?.setting_value?.trim()
257
+
258
+ if (title) {
259
+ return title
260
+ }
261
+ } catch {
262
+ // The setting is not readable. Use the config name below.
263
+ }
264
+
265
+ return String(useRuntimeConfig().public.websiteName ?? 'Pluto')
266
+ }
267
+
268
+ /** The labels of the roles, in the order of the keys. A key with no label stays as it is. */
269
+ async function roleLabels(client: UserClient, roleKeys: string[]): Promise<string[]> {
270
+ try {
271
+ const { data } = await client.rpc('list_roles')
272
+ const labels = new Map((data ?? []).map((role) => [role.key, role.label]))
273
+
274
+ return roleKeys.map((key) => labels.get(key) || key)
275
+ } catch {
276
+ return roleKeys
277
+ }
278
+ }
279
+
280
+ interface InviteMailParams {
281
+ client: UserClient
282
+ to: string
283
+ link: string
284
+ userId: string
285
+ roleKeys: string[]
286
+ inviterName?: string
287
+ locale?: string
288
+ }
289
+
290
+ /**
291
+ * Sends the invite email with Pluto mail. It returns false when the send
292
+ * fails. The caller keeps the user and the roles, and gives the link to the
293
+ * admin. The log holds the error code only: no recipient, link or key.
294
+ */
295
+ export async function sendInviteMail(params: InviteMailParams): Promise<boolean> {
296
+ try {
297
+ const [siteName, roles] = await Promise.all([
298
+ inviteSiteName(params.client),
299
+ roleLabels(params.client, params.roleKeys),
300
+ ])
301
+ const email = await renderPlutoEmail(
302
+ 'invite',
303
+ { url: params.link, inviterName: params.inviterName, roles },
304
+ { locale: params.locale, siteName }
305
+ )
306
+
307
+ await sendPlutoMail({
308
+ to: params.to,
309
+ ...email,
310
+ idempotencyKey: `invite/${params.userId}/${globalThis.crypto.randomUUID()}`,
311
+ })
312
+
313
+ return true
314
+ } catch (error) {
315
+ const code = (error as { code?: unknown } | null)?.code
316
+
317
+ console.error('invite: the Pluto mail send failed:', typeof code === 'string' ? code : 'unknown')
318
+
319
+ return false
320
+ }
321
+ }
322
+
323
+ /** The name of the admin who invites, for the email. Empty when unknown. */
324
+ export function inviterNameOf(user: { user_metadata?: Record<string, unknown> | null }): string | undefined {
325
+ const meta = user.user_metadata ?? {}
326
+
327
+ for (const value of [meta.display_name, meta.full_name, meta.name]) {
328
+ if (typeof value === 'string' && value.trim() !== '') {
329
+ return value.trim()
330
+ }
331
+ }
332
+
333
+ return undefined
334
+ }
335
+
336
+ /**
337
+ * Checks the optional email language against the admin languages. Throws a
338
+ * 400 for a language the admin does not have.
339
+ */
340
+ export function assertInviteLocale(event: H3Event, locale: string | undefined): void {
341
+ if (locale === undefined) {
342
+ return
343
+ }
344
+
345
+ const adminLocales = useRuntimeConfig(event).public.plutoI18n?.adminLocales ?? []
346
+
347
+ if (!adminLocales.includes(locale)) {
348
+ throw createPlutoError({
349
+ statusCode: 400,
350
+ message: 'This language is not supported.',
351
+ key: 'supabase.errors.localeUnsupported',
352
+ })
353
+ }
354
+ }
@@ -22,15 +22,16 @@ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
22
22
  * so querying it directly has no migration dependency at all.
23
23
  *
24
24
  * The trade-off: a user granted the `admin` role only through
25
- * `user_roles` (see `permissions-storage/SKILL.md`'s "Granting a role by
25
+ * `user_roles` by hand (see `permissions-storage/SKILL.md`'s "Granting a role by
26
26
  * hand"), with `profiles.is_admin` left `false`, cannot pass this check —
27
27
  * only RLS and `has_capability()`-gated routes recognize a role-only
28
28
  * admin. That is an acceptable, narrow limitation: `user_roles` cannot
29
29
  * exist before `004` has been applied, so a role-only admin can only
30
30
  * exist on a site that has already applied every current migration, at
31
31
  * which point there is nothing left for that admin to need
32
- * `/admin/migrations` for. If a future migration ever needs applying by
33
- * a role-only admin, grant them `profiles.is_admin = true` too.
32
+ * `/admin/migrations` for. Migration 008 also closes the gap: `set_user_roles`
33
+ * and `add_user_roles` keep `profiles.is_admin` in step with the `admin`
34
+ * role. After a hand-made grant, set `profiles.is_admin = true` too.
34
35
  *
35
36
  * `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
36
37
  * `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
@@ -0,0 +1,44 @@
1
+ import type { H3Event } from 'h3'
2
+ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
3
+
4
+ /**
5
+ * Guards a server route so only an admin can call it, through
6
+ * `public.is_admin()`. That function reads `profiles.is_admin` and the
7
+ * `admin` role, so it also accepts an admin who has the role only.
8
+ *
9
+ * Use it for the access API (migration 008). Keep `requireAdmin` for the
10
+ * migrations routes: it needs no migration at all.
11
+ *
12
+ * The rpc runs with the session of the caller, so `auth.uid()` is the caller.
13
+ * Throws a 401 with no user, or a 403 for a user who is not an admin.
14
+ * Returns the claims (the user id is `sub`).
15
+ */
16
+ export async function requireAdminRole(event: H3Event) {
17
+ const user = await serverSupabaseUser(event)
18
+
19
+ if (!user) {
20
+ throw createPlutoError({
21
+ statusCode: 401,
22
+ message: 'You must be logged in.',
23
+ key: 'supabase.errors.notLoggedIn',
24
+ })
25
+ }
26
+
27
+ const client = await serverSupabaseClient<Database>(event)
28
+ const { data, error } = await client.rpc('is_admin')
29
+
30
+ if (error) {
31
+ // Logged, never returned: the 403 must not tell the caller why.
32
+ console.error('requireAdminRole: is_admin() failed:', error.message)
33
+ }
34
+
35
+ if (error || data !== true) {
36
+ throw createPlutoError({
37
+ statusCode: 403,
38
+ message: 'Your account is not an admin.',
39
+ key: 'supabase.errors.notAdmin',
40
+ })
41
+ }
42
+
43
+ return user
44
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A small in-memory rate limiter. It keeps the hit times of each key in a
3
+ * sliding window. The state lives in one server process, so it is a brake
4
+ * for a single instance, not a global limit.
5
+ */
6
+
7
+ export interface RateLimitOptions {
8
+ /** The number of hits allowed in the window. */
9
+ limit: number
10
+ /** The window length, in milliseconds. */
11
+ windowMs: number
12
+ }
13
+
14
+ export interface RateLimitResult {
15
+ ok: boolean
16
+ /** Seconds until the next hit is allowed. 0 when `ok`. */
17
+ retryAfter: number
18
+ }
19
+
20
+ export function createRateLimiter(
21
+ options: RateLimitOptions,
22
+ now: () => number = Date.now
23
+ ) {
24
+ const hits = new Map<string, number[]>()
25
+
26
+ return {
27
+ /** Counts one hit for the key. It does not count a refused hit. */
28
+ hit(key: string): RateLimitResult {
29
+ const time = now()
30
+ const recent = (hits.get(key) ?? []).filter(
31
+ (stamp) => time - stamp < options.windowMs
32
+ )
33
+
34
+ if (recent.length >= options.limit) {
35
+ hits.set(key, recent)
36
+
37
+ return {
38
+ ok: false,
39
+ retryAfter: Math.max(
40
+ 1,
41
+ Math.ceil((recent[0]! + options.windowMs - time) / 1000)
42
+ ),
43
+ }
44
+ }
45
+
46
+ recent.push(time)
47
+ hits.set(key, recent)
48
+
49
+ // Drop the keys that have no recent hit, so the map does not grow.
50
+ if (hits.size > 1000) {
51
+ for (const [other, stamps] of hits) {
52
+ if (stamps.every((stamp) => time - stamp >= options.windowMs)) {
53
+ hits.delete(other)
54
+ }
55
+ }
56
+ }
57
+
58
+ return { ok: true, retryAfter: 0 }
59
+ },
60
+ reset() {
61
+ hits.clear()
62
+ },
63
+ }
64
+ }
65
+
66
+ /** 10 invites per 10 minutes for each caller. */
67
+ const inviteLimiter = createRateLimiter({ limit: 10, windowMs: 10 * 60 * 1000 })
68
+
69
+ /**
70
+ * Throws a 429 when the caller sent too many invites. `sub` is the user id
71
+ * of the caller (the JWT `sub` claim).
72
+ */
73
+ export function enforceInviteRateLimit(sub: string) {
74
+ const result = inviteLimiter.hit(sub)
75
+
76
+ if (!result.ok) {
77
+ throw createPlutoError({
78
+ statusCode: 429,
79
+ message: 'Too many invites. Wait a few minutes and try again.',
80
+ key: 'supabase.access.errors.rateLimited',
81
+ data: { retryAfter: result.retryAfter },
82
+ })
83
+ }
84
+ }
@@ -0,0 +1,18 @@
1
+ import type { H3Event } from 'h3'
2
+ import { serverSupabaseServiceRole } from '#supabase/server'
3
+
4
+ /**
5
+ * Returns the Supabase client with the service role key, or null when the
6
+ * key is not set. The key is optional for invites: without it, the invite
7
+ * routes send a sign-in link instead (see `sign-in-link.ts`).
8
+ */
9
+ export function optionalServiceClient(event: H3Event) {
10
+ try {
11
+ return serverSupabaseServiceRole<Database>(event)
12
+ } catch {
13
+ return null
14
+ }
15
+ }
16
+
17
+ /** The client of `optionalServiceClient`, when the key is set. */
18
+ export type ServiceClient = NonNullable<ReturnType<typeof optionalServiceClient>>
@@ -0,0 +1,129 @@
1
+ import type { H3Event } from 'h3'
2
+ import { createClient } from '@supabase/supabase-js'
3
+
4
+ interface SignInLinkOptions {
5
+ redirectTo: string
6
+ displayName?: string
7
+ /** Create the account when the email has none. */
8
+ createUser: boolean
9
+ }
10
+
11
+ /**
12
+ * Sends a passwordless sign-in link with the anon key. The invite routes use
13
+ * it when the service role key is not set, so an invite needs no setup.
14
+ *
15
+ * - A new client per call, with no session storage, so the admin's own
16
+ * session is never touched.
17
+ * - The implicit flow: the link comes back with the tokens in the URL hash.
18
+ * The PKCE flow cannot work here, because its code verifier would stay in
19
+ * this server-side client. The accept-invite page reads the hash.
20
+ * - With `createUser`, GoTrue creates the account (public sign-up must be
21
+ * on). The trigger makes the profile from `display_name`.
22
+ */
23
+ export async function sendSignInLink(
24
+ event: H3Event,
25
+ email: string,
26
+ options: SignInLinkOptions
27
+ ) {
28
+ const { url, key } = useRuntimeConfig(event).public.supabase
29
+ const client = createClient(url, key, {
30
+ auth: {
31
+ flowType: 'implicit',
32
+ persistSession: false,
33
+ autoRefreshToken: false,
34
+ detectSessionInUrl: false,
35
+ },
36
+ })
37
+
38
+ const { error } = await client.auth.signInWithOtp({
39
+ email,
40
+ options: {
41
+ shouldCreateUser: options.createUser,
42
+ emailRedirectTo: options.redirectTo,
43
+ data: options.displayName ? { display_name: options.displayName } : undefined,
44
+ },
45
+ })
46
+
47
+ return { error }
48
+ }
49
+
50
+ /**
51
+ * True when Supabase's built-in mailer refuses the address. Without custom
52
+ * SMTP, Supabase sends auth emails only to members of the project's
53
+ * organization.
54
+ */
55
+ export function isEmailNotAuthorizedError(error: { code?: string } | null | undefined): boolean {
56
+ return error?.code === 'email_address_not_authorized'
57
+ }
58
+
59
+ export function emailNotAuthorizedError() {
60
+ return createPlutoError({
61
+ statusCode: 400,
62
+ message: 'Supabase\'s built-in email sends only to members of your Supabase organization. Set up custom SMTP in Supabase to invite other addresses.',
63
+ key: 'supabase.access.errors.emailNotAuthorized',
64
+ })
65
+ }
66
+
67
+ /**
68
+ * True when Supabase refuses to send more auth emails: one per address each
69
+ * 60 seconds, and a few per hour for the project with the built-in mailer.
70
+ * This is not the in-memory limit of `rate-limit.ts`.
71
+ */
72
+ export function isEmailRateLimitError(error: { code?: string, status?: number } | null | undefined): boolean {
73
+ return error?.status === 429 || error?.code === 'over_email_send_rate_limit'
74
+ }
75
+
76
+ export function emailRateLimitError() {
77
+ return createPlutoError({
78
+ statusCode: 429,
79
+ message: '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
+ key: 'supabase.access.errors.emailRateLimited',
81
+ })
82
+ }
83
+
84
+ /**
85
+ * True when Supabase refuses the address itself, for example an example or
86
+ * test domain (`example.com`), or an address it cannot deliver to.
87
+ */
88
+ export function isEmailInvalidError(error: { code?: string } | null | undefined): boolean {
89
+ return error?.code === 'email_address_invalid'
90
+ }
91
+
92
+ export function emailInvalidError() {
93
+ return createPlutoError({
94
+ statusCode: 400,
95
+ message: 'Supabase does not accept this email address. Use a real address: Supabase refuses example and test domains.',
96
+ key: 'supabase.access.errors.emailInvalid',
97
+ })
98
+ }
99
+
100
+ /** Maps a GoTrue error of `signInWithOtp` to a Pluto error. */
101
+ export function signInLinkError(error: { code?: string, status?: number, message: string }) {
102
+ if (isEmailNotAuthorizedError(error)) {
103
+ return emailNotAuthorizedError()
104
+ }
105
+
106
+ if (isEmailInvalidError(error)) {
107
+ return emailInvalidError()
108
+ }
109
+
110
+ if (isEmailRateLimitError(error)) {
111
+ return emailRateLimitError()
112
+ }
113
+
114
+ if (error.code === 'signup_disabled' || error.code === 'otp_disabled') {
115
+ return createPlutoError({
116
+ statusCode: 503,
117
+ message: 'Public sign-up is off. Set NUXT_SUPABASE_SECRET_KEY on the server to send invites.',
118
+ key: 'supabase.access.errors.signupDisabled',
119
+ })
120
+ }
121
+
122
+ console.error('invite: signInWithOtp failed:', error.message)
123
+
124
+ return createPlutoError({
125
+ statusCode: 502,
126
+ message: 'The invite email could not be sent.',
127
+ key: 'supabase.access.errors.inviteFailed',
128
+ })
129
+ }
@@ -0,0 +1,41 @@
1
+ /** A staff user, as `GET /api/access/users` returns it (`list_staff_users`). */
2
+ export interface AccessUser {
3
+ id: string
4
+ email: string | null
5
+ display_name: string | null
6
+ username: string | null
7
+ /** The `profiles.is_admin` flag. */
8
+ is_admin: boolean
9
+ /** The role keys of the user. */
10
+ roles: string[]
11
+ invited_at: string | null
12
+ /** Empty until the user accepts the invite. */
13
+ email_confirmed_at: string | null
14
+ last_sign_in_at: string | null
15
+ }
16
+
17
+ /** A role, as `GET /api/access/roles` returns it (`list_roles`). */
18
+ export interface AccessRole {
19
+ key: string
20
+ label: string
21
+ description: string
22
+ is_builtin: boolean
23
+ capabilities: string[]
24
+ user_count: number
25
+ }
26
+
27
+ /** The answer of `GET /api/access/capabilities`. */
28
+ export interface AccessCapabilities {
29
+ /** True when the service role key is set on the server. */
30
+ serviceKey: boolean
31
+ mail: { configured: boolean }
32
+ }
33
+
34
+ /** The answer of `POST /api/access/users/invite`. */
35
+ export interface AccessInviteResult {
36
+ status: 'invited' | 'granted'
37
+ /** Present on the Pluto mail path. False when the send failed. */
38
+ emailSent?: boolean
39
+ /** Present when `emailSent` is false. */
40
+ link?: string
41
+ }