@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.
- package/.env.example +14 -0
- package/CHANGELOG.md +14 -0
- package/FEATURES.md +5 -1
- package/README.md +66 -0
- package/app/components/access/CopyButton.vue +24 -0
- package/app/components/access/DeleteRoleModal.vue +73 -0
- package/app/components/access/EditRolesSlideover.vue +134 -0
- package/app/components/access/InviteModal.vue +288 -0
- package/app/components/access/LinkField.vue +19 -0
- package/app/components/access/LinkModal.vue +29 -0
- package/app/components/access/RevokeModal.vue +83 -0
- package/app/components/access/RoleSlideover.vue +251 -0
- package/app/components/mail/AuthEmailsPanel.vue +108 -0
- package/app/pages/admin/(auth)/accept-invite.vue +155 -0
- package/app/pages/admin/settings/roles.vue +187 -0
- package/app/pages/admin/settings/users.vue +372 -0
- package/app/plugins/pluto-extension.ts +38 -1
- package/app/utils/access-ui.ts +100 -0
- package/app/utils/auth-driver.ts +3 -0
- package/app/utils/invite-link.ts +70 -0
- package/app/utils/supabase-project.ts +54 -0
- package/db/migrations/008_users_and_roles_admin.sql +462 -0
- package/i18n/locales/en.json +194 -1
- package/i18n/locales/pt-BR.json +194 -1
- package/package.json +3 -2
- package/server/api/access/capabilities.get.ts +17 -0
- package/server/api/access/roles/[key].delete.ts +18 -0
- package/server/api/access/roles/[key].put.ts +26 -0
- package/server/api/access/roles.get.ts +17 -0
- package/server/api/access/roles.post.ts +25 -0
- package/server/api/access/users/[id]/resend.post.ts +172 -0
- package/server/api/access/users/[id]/roles.delete.ts +22 -0
- package/server/api/access/users/[id]/roles.put.ts +24 -0
- package/server/api/access/users/invite-link.post.ts +50 -0
- package/server/api/access/users/invite.post.ts +201 -0
- package/server/api/access/users.get.ts +17 -0
- package/server/api/permissions/me.get.ts +15 -9
- package/server/utils/access-errors.ts +148 -0
- package/server/utils/access-invite.ts +66 -0
- package/server/utils/access-mail.ts +354 -0
- package/server/utils/admin-guard.ts +4 -3
- package/server/utils/admin-role-guard.ts +44 -0
- package/server/utils/rate-limit.ts +84 -0
- package/server/utils/service-client.ts +18 -0
- package/server/utils/sign-in-link.ts +129 -0
- package/shared/types/access.d.ts +41 -0
- 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
|
+
}
|
package/shared/types/supabase.ts
CHANGED
|
@@ -355,20 +355,61 @@ export type Database = {
|
|
|
355
355
|
[_ in never]: never
|
|
356
356
|
}
|
|
357
357
|
Functions: {
|
|
358
|
-
|
|
359
|
-
Args: {
|
|
360
|
-
Returns:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
386
|
+
list_staff_users: {
|
|
367
387
|
Args: Record<PropertyKey, never>
|
|
368
|
-
Returns:
|
|
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
|
-
|
|
371
|
-
|
|
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
|
}
|