@plutocms/supabase 0.11.3 → 0.12.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 +7 -0
  3. package/FEATURES.md +5 -1
  4. package/README.md +66 -0
  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,172 @@
1
+ import { serverSupabaseClient } from '#supabase/server'
2
+ import { mapAccessError } from '../../../../utils/access-errors'
3
+ import { inviteRedirectUrl, isEmailExistsError, requireUserId, resendBodySchema } from '../../../../utils/access-invite'
4
+ import {
5
+ assertInviteLocale,
6
+ generateAcceptLink,
7
+ inviterNameOf,
8
+ plutoMailReady,
9
+ sendInviteMail,
10
+ } from '../../../../utils/access-mail'
11
+ import { requireAdminRole } from '../../../../utils/admin-role-guard'
12
+ import { enforceInviteRateLimit } from '../../../../utils/rate-limit'
13
+ import { optionalServiceClient } from '../../../../utils/service-client'
14
+ import {
15
+ emailInvalidError,
16
+ emailNotAuthorizedError,
17
+ emailRateLimitError,
18
+ isEmailInvalidError,
19
+ isEmailNotAuthorizedError,
20
+ isEmailRateLimitError,
21
+ sendSignInLink,
22
+ signInLinkError,
23
+ } from '../../../../utils/sign-in-link'
24
+ import { parseBody } from '../../../../utils/validate'
25
+
26
+ function notFound() {
27
+ return createPlutoError({
28
+ statusCode: 404,
29
+ message: 'This user does not exist.',
30
+ key: 'supabase.access.errors.userNotFound',
31
+ })
32
+ }
33
+
34
+ function alreadyAccepted() {
35
+ return createPlutoError({
36
+ statusCode: 409,
37
+ message: 'This user already accepted the invite.',
38
+ key: 'supabase.access.errors.alreadyAccepted',
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Sends the invite email again. It works only for a user who has not
44
+ * accepted the invite yet (`email_confirmed_at` is empty).
45
+ *
46
+ * With the service role key and Pluto mail set up, GoTrue makes a one-time
47
+ * link and Pluto mail sends the email. If the send fails, the answer holds
48
+ * the link (`emailSent: false`). With the key and no Pluto mail, GoTrue
49
+ * sends the invite email. Without the key it
50
+ * reads the user from `list_staff_users` (an invited user has a role) and
51
+ * sends a sign-in link that never creates an account.
52
+ */
53
+ export default defineEventHandler(async (event) => {
54
+ const caller = await requireAdminRole(event)
55
+
56
+ enforceInviteRateLimit(caller.sub)
57
+
58
+ const id = requireUserId(event.context.params?.id)
59
+ const locale = (await parseBody(event, resendBodySchema))?.locale
60
+
61
+ assertInviteLocale(event, locale)
62
+
63
+ const { origin } = getRequestURL(event)
64
+ const redirectTo = inviteRedirectUrl(origin)
65
+ const service = optionalServiceClient(event)
66
+
67
+ if (!service) {
68
+ const client = await serverSupabaseClient<Database>(event)
69
+ const { data: staff, error } = await client.rpc('list_staff_users')
70
+
71
+ if (error) {
72
+ throw mapAccessError(error)
73
+ }
74
+
75
+ const target = staff?.find((row) => row.id === id)
76
+
77
+ if (!target?.email) {
78
+ throw notFound()
79
+ }
80
+
81
+ if (target.email_confirmed_at) {
82
+ throw alreadyAccepted()
83
+ }
84
+
85
+ const { error: linkError } = await sendSignInLink(event, target.email, {
86
+ redirectTo,
87
+ createUser: false,
88
+ })
89
+
90
+ if (linkError) {
91
+ throw signInLinkError(linkError)
92
+ }
93
+
94
+ return { status: 'sent' as const }
95
+ }
96
+
97
+ const { data, error } = await service.auth.admin.getUserById(id)
98
+
99
+ if (error || !data.user) {
100
+ throw notFound()
101
+ }
102
+
103
+ const { email, email_confirmed_at: confirmedAt } = data.user
104
+
105
+ if (confirmedAt || !email) {
106
+ throw alreadyAccepted()
107
+ }
108
+
109
+ if (await plutoMailReady()) {
110
+ const client = await serverSupabaseClient<Database>(event)
111
+ const made = await generateAcceptLink(service, {
112
+ email,
113
+ type: 'invite',
114
+ redirectTo,
115
+ origin,
116
+ })
117
+
118
+ // A pending user cannot be "already registered" for an invite. It means
119
+ // the user accepted the invite in the meantime.
120
+ if (made.kind !== 'link') {
121
+ throw alreadyAccepted()
122
+ }
123
+
124
+ const { data: staff } = await client.rpc('list_staff_users')
125
+ const roleKeys = Array.isArray(staff) ? (staff.find((row) => row.id === id)?.roles ?? []) : []
126
+ const sent = await sendInviteMail({
127
+ client,
128
+ to: email,
129
+ link: made.link,
130
+ userId: id,
131
+ roleKeys,
132
+ inviterName: inviterNameOf(caller),
133
+ locale,
134
+ })
135
+
136
+ return sent
137
+ ? { status: 'sent' as const, emailSent: true }
138
+ : { status: 'sent' as const, emailSent: false, link: made.link }
139
+ }
140
+
141
+ const { error: inviteError } = await service.auth.admin.inviteUserByEmail(email, {
142
+ redirectTo,
143
+ })
144
+
145
+ if (inviteError) {
146
+ if (isEmailExistsError(inviteError)) {
147
+ throw alreadyAccepted()
148
+ }
149
+
150
+ if (isEmailNotAuthorizedError(inviteError)) {
151
+ throw emailNotAuthorizedError()
152
+ }
153
+
154
+ if (isEmailRateLimitError(inviteError)) {
155
+ throw emailRateLimitError()
156
+ }
157
+
158
+ if (isEmailInvalidError(inviteError)) {
159
+ throw emailInvalidError()
160
+ }
161
+
162
+ console.error('resend: inviteUserByEmail failed:', inviteError.message)
163
+
164
+ throw createPlutoError({
165
+ statusCode: 502,
166
+ message: 'The invite email could not be sent.',
167
+ key: 'supabase.access.errors.inviteFailed',
168
+ })
169
+ }
170
+
171
+ return { status: 'sent' as const }
172
+ })
@@ -0,0 +1,22 @@
1
+ import { serverSupabaseClient } from '#supabase/server'
2
+ import { mapAccessError } from '../../../../utils/access-errors'
3
+ import { requireUserId } from '../../../../utils/access-invite'
4
+ import { requireAdminRole } from '../../../../utils/admin-role-guard'
5
+
6
+ /** Revokes the access of a user: it removes every role. The account stays. */
7
+ export default defineEventHandler(async (event) => {
8
+ await requireAdminRole(event)
9
+
10
+ const id = requireUserId(event.context.params?.id)
11
+ const client = await serverSupabaseClient<Database>(event)
12
+ const { error } = await client.rpc('set_user_roles', {
13
+ target: id,
14
+ role_keys: [],
15
+ })
16
+
17
+ if (error) {
18
+ throw mapAccessError(error)
19
+ }
20
+
21
+ return { id }
22
+ })
@@ -0,0 +1,24 @@
1
+ import { serverSupabaseClient } from '#supabase/server'
2
+ import { mapAccessError } from '../../../../utils/access-errors'
3
+ import { requireUserId, rolesBodySchema } from '../../../../utils/access-invite'
4
+ import { requireAdminRole } from '../../../../utils/admin-role-guard'
5
+ import { parseBody } from '../../../../utils/validate'
6
+
7
+ /** Replaces the roles of a user. An empty list revokes the access. */
8
+ export default defineEventHandler(async (event) => {
9
+ await requireAdminRole(event)
10
+
11
+ const id = requireUserId(event.context.params?.id)
12
+ const { roleKeys } = await parseBody(event, rolesBodySchema)
13
+ const client = await serverSupabaseClient<Database>(event)
14
+ const { error } = await client.rpc('set_user_roles', {
15
+ target: id,
16
+ role_keys: roleKeys,
17
+ })
18
+
19
+ if (error) {
20
+ throw mapAccessError(error)
21
+ }
22
+
23
+ return { id }
24
+ })
@@ -0,0 +1,50 @@
1
+ import { serverSupabaseClient } from '#supabase/server'
2
+ import { inviteBodySchema, inviteRedirectUrl } from '../../../utils/access-invite'
3
+ import { provisionAccess, serviceKeyRequiredError } from '../../../utils/access-mail'
4
+ import { requireAdminRole } from '../../../utils/admin-role-guard'
5
+ import { enforceInviteRateLimit } from '../../../utils/rate-limit'
6
+ import { optionalServiceClient } from '../../../utils/service-client'
7
+ import { parseBody } from '../../../utils/validate'
8
+
9
+ /**
10
+ * Grants the roles and returns a one-time sign-in link. No email is sent, so
11
+ * the Supabase mailer and its limits are not used. The admin copies the link
12
+ * and sends it to the user.
13
+ *
14
+ * It needs the service role key (`auth.admin.generateLink`). Without it, the
15
+ * answer is 503. The link expires, and it works once.
16
+ *
17
+ * - An email with an account: the roles are added, and the link is a
18
+ * magic link for that account.
19
+ * - A new email: the invite link makes the user, then the roles are set.
20
+ * If the roles fail, the new user is deleted.
21
+ */
22
+ export default defineEventHandler(async (event) => {
23
+ const caller = await requireAdminRole(event)
24
+
25
+ enforceInviteRateLimit(caller.sub)
26
+
27
+ const { email, roleKeys, displayName } = await parseBody(event, inviteBodySchema)
28
+ const service = optionalServiceClient(event)
29
+
30
+ if (!service) {
31
+ throw serviceKeyRequiredError()
32
+ }
33
+
34
+ const client = await serverSupabaseClient<Database>(event)
35
+ const { origin } = getRequestURL(event)
36
+ const result = await provisionAccess({
37
+ client,
38
+ service,
39
+ email,
40
+ roleKeys,
41
+ displayName,
42
+ redirectTo: inviteRedirectUrl(origin),
43
+ origin,
44
+ })
45
+
46
+ return {
47
+ status: result.created ? ('invited' as const) : ('granted' as const),
48
+ link: result.link,
49
+ }
50
+ })
@@ -0,0 +1,201 @@
1
+ import { serverSupabaseClient } from '#supabase/server'
2
+ import { mapAccessError } from '../../../utils/access-errors'
3
+ import {
4
+ inviteBodySchema,
5
+ inviteRedirectUrl,
6
+ isEmailExistsError,
7
+ } from '../../../utils/access-invite'
8
+ import {
9
+ addRoles,
10
+ assertInviteLocale,
11
+ findUserIdByEmail,
12
+ inviterNameOf,
13
+ plutoMailReady,
14
+ provisionAccess,
15
+ sendInviteMail,
16
+ } from '../../../utils/access-mail'
17
+ import { requireAdminRole } from '../../../utils/admin-role-guard'
18
+ import { enforceInviteRateLimit } from '../../../utils/rate-limit'
19
+ import { optionalServiceClient } from '../../../utils/service-client'
20
+ import {
21
+ emailInvalidError,
22
+ emailNotAuthorizedError,
23
+ emailRateLimitError,
24
+ isEmailInvalidError,
25
+ isEmailNotAuthorizedError,
26
+ isEmailRateLimitError,
27
+ sendSignInLink,
28
+ signInLinkError,
29
+ } from '../../../utils/sign-in-link'
30
+ import { parseBody } from '../../../utils/validate'
31
+
32
+ /**
33
+ * Invites a user by email and grants the roles.
34
+ *
35
+ * - An email that has an account (confirmed or not): the roles are added.
36
+ * No email is sent. The account is never deleted.
37
+ * - A new email, with the service role key and Pluto mail set up: GoTrue
38
+ * makes the user and a one-time link (no email). The roles are set at
39
+ * once. Pluto mail sends the invite email. If the send fails, the user
40
+ * and the roles stay, and the answer holds the link (`emailSent: false`).
41
+ * - A new email, with the service role key and no Pluto mail: GoTrue creates
42
+ * the user and sends the invite email. The roles are set at once. If the roles fail,
43
+ * the new user is deleted.
44
+ * - A new email, without the key: a sign-in link with the anon key creates
45
+ * the user and sends the email (the sign-up or magic link template). The
46
+ * roles are set at once. If the roles fail, the account stays with no
47
+ * role (the same as a sign-up), because only the key can delete a user.
48
+ *
49
+ * The role writes use the session of the caller (`auth.uid()` is the
50
+ * admin). Only the invite call uses the service role key.
51
+ */
52
+ export default defineEventHandler(async (event) => {
53
+ const caller = await requireAdminRole(event)
54
+
55
+ enforceInviteRateLimit(caller.sub)
56
+
57
+ const { email, roleKeys, displayName, locale } = await parseBody(event, inviteBodySchema)
58
+
59
+ assertInviteLocale(event, locale)
60
+
61
+ const service = optionalServiceClient(event)
62
+ const client = await serverSupabaseClient<Database>(event)
63
+ const { origin } = getRequestURL(event)
64
+ const redirectTo = inviteRedirectUrl(origin)
65
+
66
+ // The key and a working mail driver: Pluto sends the email, not Supabase.
67
+ if (service && (await plutoMailReady())) {
68
+ const result = await provisionAccess({
69
+ client,
70
+ service,
71
+ email,
72
+ roleKeys,
73
+ displayName,
74
+ redirectTo,
75
+ origin,
76
+ linkForExisting: false,
77
+ })
78
+
79
+ // An account that exists only gets the roles, and no email.
80
+ if (!result.created || !result.link) {
81
+ return { status: 'granted' as const }
82
+ }
83
+
84
+ const sent = await sendInviteMail({
85
+ client,
86
+ to: email,
87
+ link: result.link,
88
+ userId: result.userId,
89
+ roleKeys,
90
+ inviterName: inviterNameOf(caller),
91
+ locale,
92
+ })
93
+
94
+ return sent
95
+ ? { status: 'invited' as const, emailSent: true }
96
+ : { status: 'invited' as const, emailSent: false, link: result.link }
97
+ }
98
+
99
+ // Look the email up first. An account that exists (confirmed or not) gets
100
+ // the roles added, and no email. The undo below must never delete it: an
101
+ // unconfirmed account can be a customer who did not confirm yet.
102
+ const existingId = await findUserIdByEmail(client, email)
103
+
104
+ if (existingId) {
105
+ await addRoles(client, existingId, roleKeys)
106
+ return { status: 'granted' as const }
107
+ }
108
+
109
+ if (!service) {
110
+ const { error: linkError } = await sendSignInLink(event, email, {
111
+ redirectTo,
112
+ displayName,
113
+ createUser: true,
114
+ })
115
+
116
+ if (linkError) {
117
+ throw signInLinkError(linkError)
118
+ }
119
+
120
+ const linkedId = await findUserIdByEmail(client, email)
121
+
122
+ if (!linkedId) {
123
+ console.error('invite: the sign-in link was sent, but no account has this email')
124
+
125
+ throw createPlutoError({
126
+ statusCode: 502,
127
+ message: 'The invite email could not be sent.',
128
+ key: 'supabase.access.errors.inviteFailed',
129
+ })
130
+ }
131
+
132
+ // An account can appear between the lookup and the link (a race). Adding
133
+ // keeps its roles; for a new account it is the same as setting them.
134
+ await addRoles(client, linkedId, roleKeys)
135
+ return { status: 'invited' as const }
136
+ }
137
+
138
+ const { data, error } = await service.auth.admin.inviteUserByEmail(email, {
139
+ redirectTo,
140
+ data: displayName ? { display_name: displayName } : {},
141
+ })
142
+
143
+ if (error) {
144
+ if (isEmailNotAuthorizedError(error)) {
145
+ throw emailNotAuthorizedError()
146
+ }
147
+
148
+ if (isEmailRateLimitError(error)) {
149
+ throw emailRateLimitError()
150
+ }
151
+
152
+ if (isEmailInvalidError(error)) {
153
+ throw emailInvalidError()
154
+ }
155
+
156
+ if (!isEmailExistsError(error)) {
157
+ console.error('invite: inviteUserByEmail failed:', error.message)
158
+
159
+ throw createPlutoError({
160
+ statusCode: 502,
161
+ message: 'The invite email could not be sent.',
162
+ key: 'supabase.access.errors.inviteFailed',
163
+ })
164
+ }
165
+
166
+ // The account appeared after the lookup above (a race). Add the roles.
167
+ const racedId = await findUserIdByEmail(client, email)
168
+
169
+ if (!racedId) {
170
+ throw createPlutoError({
171
+ statusCode: 502,
172
+ message: 'The invite email could not be sent.',
173
+ key: 'supabase.access.errors.inviteFailed',
174
+ })
175
+ }
176
+
177
+ await addRoles(client, racedId, roleKeys)
178
+ return { status: 'granted' as const }
179
+ }
180
+
181
+ // The lookup found no account, so GoTrue created this one now. Only this
182
+ // new account may be deleted when the role write fails.
183
+ const newId = data.user.id
184
+ const { error: setError } = await client.rpc('set_user_roles', {
185
+ target: newId,
186
+ role_keys: roleKeys,
187
+ })
188
+
189
+ if (setError) {
190
+ // Undo the invite, so a failed role write leaves no staff-less account.
191
+ const { error: undoError } = await service.auth.admin.deleteUser(newId)
192
+
193
+ if (undoError) {
194
+ console.error('invite: could not delete the new user:', undoError.message)
195
+ }
196
+
197
+ throw mapAccessError(setError)
198
+ }
199
+
200
+ return { status: 'invited' as const }
201
+ })
@@ -0,0 +1,17 @@
1
+ import { serverSupabaseClient } from '#supabase/server'
2
+ import { mapAccessError } from '../../utils/access-errors'
3
+ import { requireCapability } from '../../utils/capability-guard'
4
+
5
+ /** The staff: users with a role, or with the admin flag. */
6
+ export default defineEventHandler(async (event) => {
7
+ await requireCapability(event, 'users:read')
8
+
9
+ const client = await serverSupabaseClient<Database>(event)
10
+ const { data, error } = await client.rpc('list_staff_users')
11
+
12
+ if (error) {
13
+ throw mapAccessError(error)
14
+ }
15
+
16
+ return { users: data ?? [] }
17
+ })
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Maps the errors of the access functions (migration 008) to Pluto errors.
3
+ *
4
+ * A rule error from the database is `P0001` with the message
5
+ * `pluto:<code>`. A missing right is `42501` with `pluto:forbidden`.
6
+ * `createPlutoError` is a Nitro auto-import.
7
+ */
8
+
9
+ interface AccessErrorDefinition {
10
+ statusCode: number
11
+ /** English text. It becomes the `statusMessage`. */
12
+ message: string
13
+ /** The translation key of the same message. */
14
+ key: string
15
+ }
16
+
17
+ /** The known rule codes, with the HTTP status and the English text. */
18
+ export const ACCESS_ERRORS = {
19
+ forbidden: {
20
+ statusCode: 403,
21
+ message: 'Only an admin can do this.',
22
+ key: 'supabase.access.errors.forbidden',
23
+ },
24
+ last_admin: {
25
+ statusCode: 409,
26
+ message: 'This is the last admin. Make another user an admin first.',
27
+ key: 'supabase.access.errors.lastAdmin',
28
+ },
29
+ role_exists: {
30
+ statusCode: 409,
31
+ message: 'A role with this key already exists.',
32
+ key: 'supabase.access.errors.roleExists',
33
+ },
34
+ builtin_role: {
35
+ statusCode: 400,
36
+ message: 'A built-in role cannot be changed or deleted.',
37
+ key: 'supabase.access.errors.builtinRole',
38
+ },
39
+ wildcard: {
40
+ statusCode: 400,
41
+ message: 'A new role cannot hold the wildcard permission.',
42
+ key: 'supabase.access.errors.wildcard',
43
+ },
44
+ empty_role: {
45
+ statusCode: 400,
46
+ message: 'A role needs at least one permission.',
47
+ key: 'supabase.access.errors.emptyRole',
48
+ },
49
+ unknown_role: {
50
+ statusCode: 400,
51
+ message: 'One of the roles does not exist.',
52
+ key: 'supabase.access.errors.unknownRole',
53
+ },
54
+ invalid_key: {
55
+ statusCode: 400,
56
+ message: 'The role key can use only a-z, 0-9 and an underscore.',
57
+ key: 'supabase.access.errors.invalidKey',
58
+ },
59
+ invalid_label: {
60
+ statusCode: 400,
61
+ message: 'The role needs a name.',
62
+ key: 'supabase.access.errors.invalidLabel',
63
+ },
64
+ invalid_capability: {
65
+ statusCode: 400,
66
+ message: 'One of the permissions is not valid.',
67
+ key: 'supabase.access.errors.invalidCapability',
68
+ },
69
+ user_not_found: {
70
+ statusCode: 404,
71
+ message: 'This user does not exist.',
72
+ key: 'supabase.access.errors.userNotFound',
73
+ },
74
+ role_not_found: {
75
+ statusCode: 404,
76
+ message: 'This role does not exist.',
77
+ key: 'supabase.access.errors.roleNotFound',
78
+ },
79
+ } as const satisfies Record<string, AccessErrorDefinition>
80
+
81
+ export type AccessErrorCode = keyof typeof ACCESS_ERRORS
82
+
83
+ interface PgErrorLike {
84
+ code?: string
85
+ message?: string
86
+ }
87
+
88
+ /**
89
+ * Reads the rule code of a database error. It returns undefined when the
90
+ * error is not a rule error of the access functions.
91
+ */
92
+ export function parseAccessCode(
93
+ error: PgErrorLike | null | undefined
94
+ ): AccessErrorCode | undefined {
95
+ const code = error?.message?.match(/^pluto:([a-z_]+)$/)?.[1]
96
+
97
+ if (code && code in ACCESS_ERRORS) {
98
+ return code as AccessErrorCode
99
+ }
100
+
101
+ // Postgres refuses the call by itself, for example a missing grant.
102
+ if (error?.code === '42501') {
103
+ return 'forbidden'
104
+ }
105
+
106
+ return undefined
107
+ }
108
+
109
+ /** True when the database has no access function yet (008 is pending). */
110
+ export function isMissingAccessFunction(error: PgErrorLike | null | undefined) {
111
+ return (
112
+ error?.code === 'PGRST202' ||
113
+ error?.code === '42883' ||
114
+ /could not find the function/i.test(error?.message ?? '')
115
+ )
116
+ }
117
+
118
+ /**
119
+ * Builds the error to throw for a failed access call. A known rule code gets
120
+ * its status and its `supabase.access.errors.<code>` key. A pending 008
121
+ * migration gets 409. Any other error is a plain 500.
122
+ */
123
+ export function mapAccessError(error: PgErrorLike) {
124
+ const code = parseAccessCode(error)
125
+
126
+ if (code) {
127
+ const { statusCode, message, key } = ACCESS_ERRORS[code]
128
+
129
+ return createPlutoError({
130
+ statusCode,
131
+ message,
132
+ key,
133
+ })
134
+ }
135
+
136
+ if (isMissingAccessFunction(error)) {
137
+ return createPlutoError({
138
+ statusCode: 409,
139
+ message: 'Apply the pending database migrations to manage users and roles.',
140
+ key: 'supabase.access.errors.migrationPending',
141
+ })
142
+ }
143
+
144
+ return createError({
145
+ statusCode: 500,
146
+ statusMessage: error.message || 'Unknown error.',
147
+ })
148
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod'
2
+
3
+ /** The page where an invited user sets a password. */
4
+ export const INVITE_ACCEPT_PATH = '/admin/accept-invite'
5
+
6
+ /** Role and capability keys: lower-case letters, digits and an underscore. */
7
+ export const ROLE_KEY_PATTERN = /^[a-z0-9_]+$/
8
+
9
+ export const inviteBodySchema = z.object({
10
+ email: z.string().trim().toLowerCase().pipe(z.email()),
11
+ roleKeys: z.array(z.string().regex(ROLE_KEY_PATTERN)).min(1).max(20),
12
+ displayName: z.string().trim().max(200).optional(),
13
+ /** The language of the invite email. Checked against the admin languages. */
14
+ locale: z.string().trim().min(1).max(35).optional(),
15
+ })
16
+
17
+ /** The body of the resend route. It is optional. */
18
+ export const resendBodySchema = z
19
+ .object({
20
+ locale: z.string().trim().min(1).max(35).optional(),
21
+ })
22
+ .nullish()
23
+
24
+ export const rolesBodySchema = z.object({
25
+ roleKeys: z.array(z.string().regex(ROLE_KEY_PATTERN)).max(20),
26
+ })
27
+
28
+ export const roleBodySchema = z.object({
29
+ label: z.string().trim().min(1).max(100),
30
+ description: z.string().trim().max(500).optional(),
31
+ capabilities: z
32
+ .array(z.string().regex(/^[a-z0-9_]+:[a-z0-9_]+$/))
33
+ .min(1)
34
+ .max(200),
35
+ })
36
+
37
+ export const createRoleBodySchema = roleBodySchema.extend({
38
+ key: z.string().regex(ROLE_KEY_PATTERN).max(50),
39
+ })
40
+
41
+ /** The link target of the invite email, on the origin of this request. */
42
+ export function inviteRedirectUrl(origin: string): string {
43
+ return new URL(INVITE_ACCEPT_PATH, origin).href
44
+ }
45
+
46
+ /** True when GoTrue says that an account with this email already exists. */
47
+ export function isEmailExistsError(
48
+ error: { code?: string, status?: number } | null | undefined
49
+ ): boolean {
50
+ return error?.code === 'email_exists' || error?.code === 'user_already_exists'
51
+ }
52
+
53
+ /** Reads a user id from a route param. Throws a 400 when it is not a UUID. */
54
+ export function requireUserId(value: unknown): string {
55
+ const parsed = z.uuid().safeParse(value)
56
+
57
+ if (!parsed.success) {
58
+ throw createPlutoError({
59
+ statusCode: 400,
60
+ message: 'The user id is not valid.',
61
+ key: 'supabase.access.errors.invalidUserId',
62
+ })
63
+ }
64
+
65
+ return parsed.data
66
+ }