@plutocms/supabase 0.11.2 → 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 (47) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +14 -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/api/permissions/me.get.ts +15 -9
  38. package/server/utils/access-errors.ts +148 -0
  39. package/server/utils/access-invite.ts +66 -0
  40. package/server/utils/access-mail.ts +354 -0
  41. package/server/utils/admin-guard.ts +4 -3
  42. package/server/utils/admin-role-guard.ts +44 -0
  43. package/server/utils/rate-limit.ts +84 -0
  44. package/server/utils/service-client.ts +18 -0
  45. package/server/utils/sign-in-link.ts +129 -0
  46. package/shared/types/access.d.ts +41 -0
  47. package/shared/types/supabase.ts +50 -9
@@ -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
+ }
@@ -355,20 +355,61 @@ export type Database = {
355
355
  [_ in never]: never
356
356
  }
357
357
  Functions: {
358
- has_capability: {
359
- Args: { cap: string }
360
- Returns: boolean
358
+ add_user_roles: {
359
+ Args: { role_keys: string[]; target: string }
360
+ Returns: undefined
361
+ }
362
+ create_role: {
363
+ Args: {
364
+ p_capabilities: string[]
365
+ p_description: string
366
+ p_key: string
367
+ p_label: string
368
+ }
369
+ Returns: undefined
361
370
  }
362
- my_capabilities: {
371
+ delete_role: { Args: { p_key: string }; Returns: number }
372
+ find_user_id_by_email: { Args: { p_email: string }; Returns: string }
373
+ has_capability: { Args: { cap: string }; Returns: boolean }
374
+ is_admin: { Args: Record<PropertyKey, never>; Returns: boolean }
375
+ list_roles: {
363
376
  Args: Record<PropertyKey, never>
364
- Returns: string[]
377
+ Returns: {
378
+ capabilities: string[]
379
+ description: string
380
+ is_builtin: boolean
381
+ key: string
382
+ label: string
383
+ user_count: number
384
+ }[]
365
385
  }
366
- is_admin: {
386
+ list_staff_users: {
367
387
  Args: Record<PropertyKey, never>
368
- Returns: boolean
388
+ Returns: {
389
+ display_name: string
390
+ email: string
391
+ email_confirmed_at: string
392
+ id: string
393
+ invited_at: string
394
+ is_admin: boolean
395
+ last_sign_in_at: string
396
+ roles: string[]
397
+ username: string
398
+ }[]
369
399
  }
370
- set_my_locale: {
371
- Args: { new_locale: string }
400
+ my_capabilities: { Args: Record<PropertyKey, never>; Returns: string[] }
401
+ set_my_locale: { Args: { new_locale: string }; Returns: undefined }
402
+ set_user_roles: {
403
+ Args: { role_keys: string[]; target: string }
404
+ Returns: undefined
405
+ }
406
+ update_role: {
407
+ Args: {
408
+ p_capabilities: string[]
409
+ p_description: string
410
+ p_key: string
411
+ p_label: string
412
+ }
372
413
  Returns: undefined
373
414
  }
374
415
  }