@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.
Files changed (50) hide show
  1. package/.prettierignore +6 -0
  2. package/CHANGELOG.md +7 -0
  3. package/app/components/access/DeleteRoleModal.vue +17 -4
  4. package/app/components/access/EditRolesSlideover.vue +11 -5
  5. package/app/components/access/InviteModal.vue +44 -19
  6. package/app/components/access/LinkField.vue +14 -3
  7. package/app/components/access/LinkModal.vue +5 -1
  8. package/app/components/access/RevokeModal.vue +17 -4
  9. package/app/components/access/RoleSlideover.vue +32 -11
  10. package/app/components/mail/AuthEmailsPanel.vue +87 -20
  11. package/app/components/migrations/MigrationsBanner.vue +5 -4
  12. package/app/composables/migrations.ts +13 -6
  13. package/app/pages/admin/(auth)/accept-invite.vue +21 -5
  14. package/app/pages/admin/(auth)/forgot-password.vue +11 -4
  15. package/app/pages/admin/(auth)/signup.vue +10 -9
  16. package/app/pages/admin/(auth)/update-password.vue +8 -3
  17. package/app/pages/admin/migrations.vue +8 -3
  18. package/app/pages/admin/settings/roles.vue +58 -15
  19. package/app/pages/admin/settings/users.vue +105 -30
  20. package/app/pages/admin/setup.vue +26 -7
  21. package/app/plugins/pluto-extension.ts +21 -8
  22. package/app/utils/access-ui.ts +29 -8
  23. package/app/utils/api-failure.ts +3 -1
  24. package/app/utils/auth-driver.ts +1 -3
  25. package/app/utils/auth-error.ts +1 -1
  26. package/app/utils/invite-link.ts +3 -3
  27. package/app/utils/supabase-project.ts +8 -3
  28. package/modules/pluto-migrations.ts +14 -3
  29. package/package.json +4 -2
  30. package/server/api/_pluto/supabase/access/users/[id]/resend.post.ts +15 -5
  31. package/server/api/_pluto/supabase/access/users/[id]/roles.put.ts +4 -1
  32. package/server/api/_pluto/supabase/access/users/invite-link.post.ts +12 -3
  33. package/server/api/_pluto/supabase/access/users/invite.post.ts +7 -2
  34. package/server/api/_pluto/supabase/auth/signup.post.ts +5 -1
  35. package/server/api/_pluto/supabase/me/capabilities.get.ts +4 -1
  36. package/server/api/_pluto/supabase/migrations/run.post.ts +9 -2
  37. package/server/api/_pluto/supabase/setup/index.post.ts +4 -1
  38. package/server/utils/access-errors.ts +2 -1
  39. package/server/utils/access-invite.ts +1 -1
  40. package/server/utils/access-mail.ts +72 -28
  41. package/server/utils/capability-guard.ts +5 -2
  42. package/server/utils/content-adapter.ts +75 -24
  43. package/server/utils/migrations.ts +0 -0
  44. package/server/utils/pending-migrations.ts +0 -0
  45. package/server/utils/service-client.ts +3 -1
  46. package/server/utils/sign-in-link.ts +25 -9
  47. package/server/utils/typegen.ts +6 -4
  48. package/server/utils/validate.ts +4 -1
  49. package/shared/types/supabase.ts +62 -65
  50. 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
- { id: 'settings-manage', key: 'settings:manage', label: 'supabase.capabilities.settingsManage' },
224
- { id: 'users-read', key: 'users:read', label: 'supabase.capabilities.usersRead' },
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 }>('/api/_pluto/supabase/me/locale')
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', { method: 'PUT', body: { 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[] }>('/api/_pluto/supabase/me/capabilities')
257
+ const response = await $fetch<{ capabilities: string[] }>(
258
+ '/api/_pluto/supabase/me/capabilities'
259
+ )
247
260
  return response.capabilities
248
261
  },
249
262
  },
@@ -27,11 +27,15 @@ export function slugifyRoleKey(label: string): string {
27
27
  }
28
28
 
29
29
  export function isValidRoleKey(key: string): boolean {
30
- return key.length > 0 && key.length <= ROLE_KEY_MAX && ROLE_KEY_PATTERN.test(key)
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(user: Pick<AccessUser, 'email_confirmed_at'>): 'active' | 'invited' {
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(user: Pick<AccessUser, 'is_admin' | 'roles'>): boolean {
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(user: Pick<AccessUser, 'display_name' | 'username' | 'email'>): string {
48
- return user.display_name?.trim() || user.username?.trim() || user.email?.trim() || ''
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 left.length === right.length && left.every((key, index) => key === right[index])
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 = (error as { data?: { data?: { i18n?: { key?: unknown } } } } | null)?.data
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 key !== undefined && (INVITE_MAIL_ERROR_KEYS as readonly string[]).includes(key)
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)`. */
@@ -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(error: unknown): Record<string, unknown> | undefined {
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
 
@@ -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
- | { success?: boolean, is_first_setup?: boolean }
80
- | null
81
- | undefined
79
+ { success?: boolean; is_first_setup?: boolean } | null | undefined
82
80
 
83
81
  if (!response?.success) {
84
82
  return 'unknown'
@@ -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, te: (key: string) => boolean }
40
+ i18n: { t: (key: string) => string; te: (key: string) => boolean }
41
41
  ): string {
42
42
  const key = authErrorKey(error)
43
43
 
@@ -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', tokenHash: string, type: InviteOtpType }
24
- | { kind: 'session', accessToken: string, refreshToken: string }
25
- | { kind: 'error', code: string, description?: string }
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 = 'https://resend.com/settings/integrations'
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(url: string | null | undefined): string | null {
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(hostname.toLowerCase())
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,6 +1,12 @@
1
1
  import type { PlutoMigrationFile } from '../shared/types/migrations'
2
2
  import { createHash } from 'node:crypto'
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
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
 
@@ -49,7 +55,10 @@ function deriveLayerKey(layerRoot: string): string {
49
55
  }
50
56
 
51
57
  function computeChecksum(content: string): string {
52
- return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16)
58
+ return createHash('sha256')
59
+ .update(content, 'utf-8')
60
+ .digest('hex')
61
+ .slice(0, 16)
53
62
  }
54
63
 
55
64
  /**
@@ -62,7 +71,9 @@ function detectNoTransaction(content: string): boolean {
62
71
  .map((line) => line.trim())
63
72
  .find((line) => line.length > 0)
64
73
 
65
- return firstNonBlankLine ? noTransactionPattern.test(firstNonBlankLine) : false
74
+ return firstNonBlankLine
75
+ ? noTransactionPattern.test(firstNonBlankLine)
76
+ : false
66
77
  }
67
78
 
68
79
  function readMigrationFile(dir: string, name: string): PlutoMigrationFile {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.15.0",
4
+ "version": "0.15.1",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -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.29.0",
42
+ "@plutocms/pluto": "^0.30.0",
43
43
  "@plutocms/utils": "^0.3.0",
44
44
  "@supabase/supabase-js": "^2.117.3",
45
45
  "postgres": "^3.4.9",
@@ -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 { inviteRedirectUrl, isEmailExistsError, requireUserId, resendBodySchema } from '../../../../../../utils/access-invite'
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) ? (staff.find((row) => row.id === id)?.roles ?? []) : []
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(email, {
142
- redirectTo,
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 { requireUserId, rolesBodySchema } from '../../../../../../utils/access-invite'
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 { inviteBodySchema, inviteRedirectUrl } from '../../../../../utils/access-invite'
3
- import { provisionAccess, serviceKeyRequiredError } from '../../../../../utils/access-mail'
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(event, inviteBodySchema)
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(event, inviteBodySchema)
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('invite: the sign-in link was sent, but no account has this email')
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.string().trim().min(3, 'Username must be at least 3 characters.').max(100),
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('my_capabilities() failed, falling back to profiles.is_admin:', error.message)
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 { resolveConnectionString, runPendingMigrations } from '../../../../utils/migrations'
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.string().trim().min(1, 'Connection string must not be empty.').optional(),
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.string().trim().min(1, 'Connection string must not be empty.'),
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: 'Apply the pending database migrations to manage users and roles.',
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, status?: number } | null | undefined
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
  }