@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
package/.env.example CHANGED
@@ -1,2 +1,16 @@
1
1
  SUPABASE_URL=
2
2
  SUPABASE_KEY=
3
+
4
+ # Optional run-time secret of the server. With it, invites use Supabase's
5
+ # invite API and the "Invite user" email. Without it, invites send a
6
+ # sign-in link. Set it in the environment of the running server only.
7
+ # Never put it in the build environment, and never commit it.
8
+ # NUXT_SUPABASE_SECRET_KEY=
9
+
10
+ # Optional Pluto mail (run-time secrets of the server, never in the build
11
+ # environment). With the service role key above AND a working mail driver,
12
+ # invites go out through Pluto mail, not through Supabase's mailer. Set these
13
+ # in the environment of the running server. See the `mail` skill of
14
+ # @plutocms/pluto for every variable.
15
+ # NUXT_PLUTO_MAIL_RESEND_API_KEY=
16
+ # NUXT_PLUTO_MAIL_FROM="Your Site <hello@your-domain.com>"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/plutocms/supabase/compare/v0.11.3...v0.12.0) (2026-10-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * **access:** manage users and roles, with invites through Pluto mail ([#98](https://github.com/plutocms/supabase/issues/98)) ([0d2ffbf](https://github.com/plutocms/supabase/commit/0d2ffbf0b3f7ad14e1ceeac6e396e01a6bb63279))
9
+
10
+ ## [0.11.3](https://github.com/plutocms/supabase/compare/v0.11.2...v0.11.3) (2026-10-22)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **permissions:** treat a profile admin as admin when my_capabilities() is missing ([#96](https://github.com/plutocms/supabase/issues/96)) ([f0e4980](https://github.com/plutocms/supabase/commit/f0e49808d0a7ceb9ffec45947e9ba5a1a033b9aa))
16
+
3
17
  ## [0.11.2](https://github.com/plutocms/supabase/compare/v0.11.1...v0.11.2) (2026-10-22)
4
18
 
5
19
 
package/FEATURES.md CHANGED
@@ -8,8 +8,12 @@ policies shared by every project here.
8
8
 
9
9
  - Layer migrations: apply pending, versioned, per-file database migrations from the admin UI,
10
10
  after initial setup. @.claude/skills/layer-migrations/SKILL.md
11
- - Permissions storage: role-based capabilities backing server route guards and RLS policies.
11
+ - Permissions storage: role-based capabilities backing server route guards and RLS policies, and
12
+ the admin-only access API (invite, roles) of migration 008.
12
13
  @.claude/skills/permissions-storage/SKILL.md
14
+ - Users and Roles: the Users and Roles tabs of Settings (invite, edit roles, revoke, copy invite
15
+ link, roles editor), invites through Pluto mail, and a guide for the Supabase sign-in emails.
16
+ @.claude/skills/users-and-roles/SKILL.md
13
17
  - Content adapter: a Supabase/PostgREST-backed `PlutoContentAdapter`, plugging any layer's
14
18
  declared content type into `@plutocms/pluto`'s generic content routes.
15
19
  @.claude/skills/content-adapter/SKILL.md
package/README.md CHANGED
@@ -56,6 +56,64 @@ top navbar. This layer registers an auth driver, and core renders the shell from
56
56
  An app that overrode `NavbarAdmin` or `pages/admin.vue` of an older version must move to registry
57
57
  entries. See "Migrating an app" in that skill.
58
58
 
59
+ ### Invite users (access API)
60
+
61
+ An admin invites a user by email and gives the user one or more roles. The routes are under
62
+ `/api/access/**`. Every write needs an admin. This needs the migration
63
+ `008_users_and_roles_admin.sql`, which the admin applies from `/admin/migrations`.
64
+
65
+ Invites need no setup. Check one thing: in the Supabase dashboard (Authentication, then URL
66
+ Configuration), the **Site URL** is the URL of your site. Supabase accepts every link back to that
67
+ host, so `/admin/accept-invite` needs no entry in the Redirect URLs. Add a Redirect URL only for
68
+ another host (for example a preview deploy, or `www.` next to the bare domain).
69
+
70
+ How the invite email goes out:
71
+
72
+ - **Without the service role key** (the default): the server sends a passwordless sign-in link
73
+ with the public key. Supabase creates the account and uses its sign-up or magic link email
74
+ template. Public sign-up must be on (the Supabase default). Supabase sends one email per
75
+ address every 60 seconds.
76
+ - **With the service role key** (optional): set `NUXT_SUPABASE_SECRET_KEY` (the Supabase secret
77
+ key) in the environment of the **running server**. Never put it in the build environment. The
78
+ server then uses Supabase's invite API and the "Invite user" email template. This also works
79
+ when public sign-up is off.
80
+
81
+ Optional: a custom invite email template (Authentication, then Email Templates, then Invite
82
+ user) makes the link work in every browser, because the page then reads a `token_hash` and not
83
+ tokens in the URL hash:
84
+
85
+ ```
86
+ {{ .SiteURL }}/admin/accept-invite?token_hash={{ .TokenHash }}&type=invite
87
+ ```
88
+
89
+ The built-in Supabase mailer sends very few emails, and only to members of your Supabase
90
+ organization. To invite any other address, set up custom SMTP (Project Settings, then
91
+ Authentication, then SMTP). A production site needs it anyway, for every auth email. Without it,
92
+ an invite to another address answers 400 with the hint `emailNotAuthorized`.
93
+
94
+ The invited user opens the link, sets a password on `/admin/accept-invite`, and goes to the
95
+ admin. An invite to an email that already has an account adds the roles and sends no email.
96
+
97
+ #### Invites through Pluto mail (optional)
98
+
99
+ With the service role key **and** a working Pluto mail driver, an invite does not use Supabase's
100
+ mailer. The server makes a one-time link with `auth.admin.generateLink`, and Pluto mail sends the
101
+ email. This lifts the team-only address rule and the Supabase email rate limit for invites. Set
102
+ the mail variables in the environment of the **running server** (Resend is the first driver):
103
+
104
+ ```
105
+ NUXT_PLUTO_MAIL_RESEND_API_KEY=re_...
106
+ NUXT_PLUTO_MAIL_FROM="Your Site <hello@your-domain.com>"
107
+ ```
108
+
109
+ `Settings > Email` in the admin guides the setup (account, key, domain, test email). The
110
+ `mail` skill of `@plutocms/pluto` lists every `NUXT_PLUTO_MAIL_*` variable. If the send fails,
111
+ the user and the roles stay, and the admin gets the invite link to send by hand.
112
+
113
+ With the key, an admin can also copy the invite link (Users, then the row menu, or the invite
114
+ form). The link works once and expires. Sign-up, password reset and magic link emails still
115
+ come from Supabase. The Email tab has a guide to send them through Resend too.
116
+
59
117
  ### API Routes
60
118
 
61
119
  The layer includes API routes for interacting with Supabase services, such as authentication and media management.
@@ -63,6 +121,14 @@ The layer includes API routes for interacting with Supabase services, such as au
63
121
  - GET `/api/settings`
64
122
  - POST `/api/settings/update`
65
123
  - POST `/api/auth/signup`
124
+ - GET `/api/access/users`, GET `/api/access/roles` (permission `users:read`)
125
+ - POST `/api/access/users/invite`, POST `/api/access/users/:id/resend` (admin; `locale` is the
126
+ optional language of the email)
127
+ - POST `/api/access/users/invite-link` (admin; needs the service role key; returns a one-time link
128
+ and sends no email)
129
+ - GET `/api/access/capabilities` (admin; says whether the service key and Pluto mail are set)
130
+ - PUT and DELETE `/api/access/users/:id/roles` (admin; DELETE revokes the access)
131
+ - POST `/api/access/roles`, PUT and DELETE `/api/access/roles/:key` (admin)
66
132
 
67
133
  ### Types
68
134
 
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ // A small button that copies a text. The tooltip says "Copied" for 2 seconds.
3
+ const props = defineProps<{
4
+ text: string
5
+ /** The accessible name of the button. */
6
+ label: string
7
+ }>()
8
+
9
+ const { t } = usePlutoI18n()
10
+ const { copy, copied } = useClipboard({ copiedDuring: 2000 })
11
+ </script>
12
+
13
+ <template>
14
+ <UTooltip :text="copied ? t('supabase.access.link.copied') : props.label">
15
+ <UButton
16
+ :icon="copied ? 'lucide:check' : 'lucide:copy'"
17
+ :aria-label="props.label"
18
+ :color="copied ? 'success' : 'neutral'"
19
+ variant="ghost"
20
+ size="xs"
21
+ @click="copy(props.text)"
22
+ />
23
+ </UTooltip>
24
+ </template>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ // Confirms and deletes a role. It says how many users lose the role. Opened
3
+ // with useOverlay from the Roles page.
4
+ import type { AccessRole } from '../../../shared/types/access'
5
+
6
+ const props = defineProps<{
7
+ role: AccessRole
8
+ }>()
9
+
10
+ const emit = defineEmits<{ close: [deleted: boolean] }>()
11
+
12
+ const { t } = usePlutoI18n()
13
+ const toast = useToast()
14
+ const errorText = usePlutoErrorText()
15
+
16
+ const working = ref(false)
17
+
18
+ async function remove() {
19
+ if (working.value) {
20
+ return
21
+ }
22
+
23
+ working.value = true
24
+
25
+ try {
26
+ await $fetch(`/api/access/roles/${props.role.key}`, { method: 'DELETE' })
27
+
28
+ toast.add({
29
+ title: t('supabase.access.toasts.roleDeleted'),
30
+ color: 'success',
31
+ icon: 'lucide:circle-check',
32
+ })
33
+ emit('close', true)
34
+ } catch (error) {
35
+ toast.add({
36
+ title: t('supabase.access.toasts.failed'),
37
+ description: errorText(error),
38
+ color: 'error',
39
+ icon: 'lucide:circle-x',
40
+ })
41
+ } finally {
42
+ working.value = false
43
+ }
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <UModal
49
+ :title="t('supabase.access.deleteRole.title')"
50
+ :description="t('supabase.access.deleteRole.body', { name: role.label })"
51
+ :close="{ onClick: () => emit('close', false) }"
52
+ >
53
+ <template #body>
54
+ <UAlert
55
+ :description="t('supabase.access.deleteRole.usersLose', role.user_count)"
56
+ :color="role.user_count > 0 ? 'warning' : 'neutral'"
57
+ variant="subtle"
58
+ icon="lucide:users"
59
+ />
60
+ </template>
61
+
62
+ <template #footer>
63
+ <div class="flex w-full justify-end gap-2">
64
+ <UButton color="neutral" variant="outline" @click="emit('close', false)">
65
+ {{ t('pluto.common.cancel') }}
66
+ </UButton>
67
+ <UButton :loading="working" color="error" icon="lucide:trash-2" @click="remove">
68
+ {{ t('supabase.access.deleteRole.confirm') }}
69
+ </UButton>
70
+ </div>
71
+ </template>
72
+ </UModal>
73
+ </template>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ // Changes the roles of one user. The save replaces the roles. A change to the
3
+ // roles of the signed-in admin loads the permissions again, so the menu and
4
+ // the tabs follow at once.
5
+ import type { AccessRole, AccessUser } from '../../../shared/types/access'
6
+ import { ADMIN_ROLE_KEY, sameKeys, userDisplayName } from '../../utils/access-ui'
7
+
8
+ const props = defineProps<{
9
+ user: AccessUser | null
10
+ roles: AccessRole[]
11
+ /** True when the user is the signed-in admin. */
12
+ self: boolean
13
+ }>()
14
+
15
+ const emit = defineEmits<{ saved: [] }>()
16
+
17
+ const open = defineModel<boolean>('open', { required: true })
18
+
19
+ const { t } = usePlutoI18n()
20
+ const toast = useToast()
21
+ const errorText = usePlutoErrorText()
22
+ const permissions = usePlutoPermissions()
23
+
24
+ const roleKeys = ref<string[]>([])
25
+ const saving = ref(false)
26
+
27
+ watch(
28
+ () => [open.value, props.user?.id] as const,
29
+ ([isOpen]) => {
30
+ if (isOpen && props.user) {
31
+ roleKeys.value = [...props.user.roles]
32
+ }
33
+ },
34
+ { immediate: true }
35
+ )
36
+
37
+ const items = computed(() =>
38
+ props.roles.map((role) => ({
39
+ label: role.label,
40
+ value: role.key,
41
+ description: role.description || undefined,
42
+ }))
43
+ )
44
+
45
+ const name = computed(() => (props.user ? userDisplayName(props.user) : ''))
46
+ const unchanged = computed(() => !props.user || sameKeys(roleKeys.value, props.user.roles))
47
+
48
+ // The warning shows when the admin removes their own admin role.
49
+ const removesOwnAdmin = computed(
50
+ () =>
51
+ props.self
52
+ && (props.user?.roles.includes(ADMIN_ROLE_KEY) ?? false)
53
+ && !roleKeys.value.includes(ADMIN_ROLE_KEY)
54
+ )
55
+
56
+ async function save() {
57
+ if (!props.user || saving.value || roleKeys.value.length === 0) {
58
+ return
59
+ }
60
+
61
+ saving.value = true
62
+
63
+ try {
64
+ await $fetch(`/api/access/users/${props.user.id}/roles`, {
65
+ method: 'PUT',
66
+ body: { roleKeys: roleKeys.value },
67
+ })
68
+
69
+ toast.add({
70
+ title: t('supabase.access.toasts.rolesSaved'),
71
+ color: 'success',
72
+ icon: 'lucide:circle-check',
73
+ })
74
+
75
+ if (props.self) {
76
+ await permissions.load()
77
+ }
78
+
79
+ emit('saved')
80
+ open.value = false
81
+ } catch (error) {
82
+ toast.add({
83
+ title: t('supabase.access.toasts.failed'),
84
+ description: errorText(error),
85
+ color: 'error',
86
+ icon: 'lucide:circle-x',
87
+ })
88
+ } finally {
89
+ saving.value = false
90
+ }
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <USlideover
96
+ v-model:open="open"
97
+ :title="t('supabase.access.editRoles.title')"
98
+ :description="t('supabase.access.editRoles.description', { name })"
99
+ >
100
+ <template #body>
101
+ <div class="flex flex-col gap-y-4">
102
+ <UAlert
103
+ v-if="removesOwnAdmin"
104
+ :description="t('supabase.access.editRoles.selfAdmin')"
105
+ color="warning"
106
+ variant="subtle"
107
+ icon="lucide:triangle-alert"
108
+ />
109
+
110
+ <UCheckboxGroup v-model="roleKeys" :items="items" />
111
+
112
+ <p v-if="roleKeys.length === 0" class="text-muted text-sm">
113
+ {{ t('supabase.access.editRoles.emptyHint') }}
114
+ </p>
115
+ </div>
116
+ </template>
117
+
118
+ <template #footer>
119
+ <div class="flex w-full justify-end gap-2">
120
+ <UButton color="neutral" variant="outline" @click="open = false">
121
+ {{ t('pluto.common.cancel') }}
122
+ </UButton>
123
+ <UButton
124
+ :loading="saving"
125
+ :disabled="unchanged || roleKeys.length === 0"
126
+ icon="lucide:save"
127
+ @click="save"
128
+ >
129
+ {{ t('supabase.access.editRoles.save') }}
130
+ </UButton>
131
+ </div>
132
+ </template>
133
+ </USlideover>
134
+ </template>
@@ -0,0 +1,288 @@
1
+ <script setup lang="ts">
2
+ // The invite form. It sends the invite, or copies the invite link when the
3
+ // server has the service role key. When Supabase's mailer refuses the invite
4
+ // (a team-only address, a rate limit, a bad address), an inline alert shows the
5
+ // cause with two ways out: copy the link, or set up email.
6
+ import type {
7
+ AccessCapabilities,
8
+ AccessInviteResult,
9
+ AccessRole,
10
+ } from '../../../shared/types/access'
11
+ import { isInviteMailError, languageName } from '../../utils/access-ui'
12
+
13
+ const props = defineProps<{
14
+ roles: AccessRole[]
15
+ /** What the server offers. Null while it is not known. */
16
+ capabilities: AccessCapabilities | null
17
+ }>()
18
+
19
+ const emit = defineEmits<{ done: [] }>()
20
+
21
+ const open = defineModel<boolean>('open', { required: true })
22
+
23
+ const { t, locale, locales } = usePlutoI18n()
24
+ const toast = useToast()
25
+ const errorText = usePlutoErrorText()
26
+
27
+ const email = ref('')
28
+ const name = ref('')
29
+ const roleKeys = ref<string[]>([])
30
+ const emailLocale = ref(locale.value)
31
+ const submitting = ref(false)
32
+ const showRolesError = ref(false)
33
+ // The cause shown in the inline alert, when the mailer refused the invite.
34
+ const mailProblem = ref<string | null>(null)
35
+ // The link of the finished invite, when the user must send it by hand.
36
+ const link = ref<string | null>(null)
37
+
38
+ const roleItems = computed(() =>
39
+ props.roles.map((role) => ({
40
+ label: role.label,
41
+ value: role.key,
42
+ description: role.description || undefined,
43
+ }))
44
+ )
45
+
46
+ const localeItems = computed(() =>
47
+ locales.value.map((code) => ({ label: languageName(code), value: code }))
48
+ )
49
+
50
+ const canCopyLink = computed(() => props.capabilities?.serviceKey === true)
51
+
52
+ // A fresh form each time the modal opens.
53
+ watch(open, (isOpen) => {
54
+ if (isOpen) {
55
+ email.value = ''
56
+ name.value = ''
57
+ roleKeys.value = []
58
+ emailLocale.value = locale.value
59
+ showRolesError.value = false
60
+ mailProblem.value = null
61
+ link.value = null
62
+ }
63
+ })
64
+
65
+ function body() {
66
+ return {
67
+ email: email.value.trim(),
68
+ roleKeys: roleKeys.value,
69
+ displayName: name.value.trim() || undefined,
70
+ }
71
+ }
72
+
73
+ function valid(): boolean {
74
+ showRolesError.value = roleKeys.value.length === 0
75
+
76
+ return !showRolesError.value
77
+ }
78
+
79
+ async function submit() {
80
+ if (submitting.value || !valid()) {
81
+ return
82
+ }
83
+
84
+ submitting.value = true
85
+ mailProblem.value = null
86
+
87
+ try {
88
+ const result = await $fetch<AccessInviteResult>('/api/access/users/invite', {
89
+ method: 'POST',
90
+ body: { ...body(), locale: emailLocale.value },
91
+ })
92
+
93
+ emit('done')
94
+
95
+ if (result.status === 'granted') {
96
+ toast.add({
97
+ title: t('supabase.access.toasts.granted', { email: email.value.trim() }),
98
+ color: 'success',
99
+ icon: 'lucide:circle-check',
100
+ })
101
+ open.value = false
102
+ } else if (result.emailSent === false && result.link) {
103
+ // The user exists and has the roles. Only the email failed.
104
+ link.value = result.link
105
+ toast.add({
106
+ title: t('supabase.access.toasts.emailFailed', { email: email.value.trim() }),
107
+ color: 'warning',
108
+ icon: 'lucide:triangle-alert',
109
+ })
110
+ } else {
111
+ toast.add({
112
+ title: t('supabase.access.toasts.invited', { email: email.value.trim() }),
113
+ color: 'success',
114
+ icon: 'lucide:circle-check',
115
+ })
116
+ open.value = false
117
+ }
118
+ } catch (error) {
119
+ if (isInviteMailError(error)) {
120
+ mailProblem.value = errorText(error)
121
+ } else {
122
+ toast.add({
123
+ title: t('supabase.access.toasts.failed'),
124
+ description: errorText(error),
125
+ color: 'error',
126
+ icon: 'lucide:circle-x',
127
+ })
128
+ }
129
+ } finally {
130
+ submitting.value = false
131
+ }
132
+ }
133
+
134
+ async function copyLink() {
135
+ if (submitting.value || !valid()) {
136
+ return
137
+ }
138
+
139
+ submitting.value = true
140
+
141
+ try {
142
+ const result = await $fetch<{ status: 'invited' | 'granted', link: string | null }>(
143
+ '/api/access/users/invite-link',
144
+ { method: 'POST', body: body() }
145
+ )
146
+
147
+ mailProblem.value = null
148
+ emit('done')
149
+
150
+ // A confirmed account gets no link: it signs in with its own password.
151
+ if (!result.link) {
152
+ toast.add({
153
+ title: t('supabase.access.toasts.granted', { email: body().email }),
154
+ color: 'success',
155
+ icon: 'lucide:circle-check',
156
+ })
157
+ open.value = false
158
+ return
159
+ }
160
+
161
+ link.value = result.link
162
+ } catch (error) {
163
+ toast.add({
164
+ title: t('supabase.access.toasts.failed'),
165
+ description: errorText(error),
166
+ color: 'error',
167
+ icon: 'lucide:circle-x',
168
+ })
169
+ } finally {
170
+ submitting.value = false
171
+ }
172
+ }
173
+
174
+ async function setupEmail() {
175
+ open.value = false
176
+ await navigateTo('/admin/settings/email?reason=invite')
177
+ }
178
+ </script>
179
+
180
+ <template>
181
+ <UModal
182
+ v-model:open="open"
183
+ :title="link ? t('supabase.access.invite.linkTitle') : t('supabase.access.invite.title')"
184
+ :description="link ? t('supabase.access.invite.linkDescription') : t('supabase.access.invite.description')"
185
+ >
186
+ <template #body>
187
+ <AccessLinkField v-if="link" :link="link" />
188
+
189
+ <form v-else class="flex flex-col gap-y-4" @submit.prevent="submit">
190
+ <UAlert
191
+ v-if="mailProblem"
192
+ :title="t('supabase.access.invite.mailProblem')"
193
+ :description="mailProblem"
194
+ color="warning"
195
+ variant="subtle"
196
+ icon="lucide:mail-warning"
197
+ >
198
+ <template #actions>
199
+ <UButton
200
+ v-if="canCopyLink"
201
+ :loading="submitting"
202
+ color="neutral"
203
+ variant="outline"
204
+ size="xs"
205
+ icon="lucide:link"
206
+ @click="copyLink"
207
+ >
208
+ {{ t('supabase.access.invite.copyLink') }}
209
+ </UButton>
210
+ <UButton
211
+ color="neutral"
212
+ variant="outline"
213
+ size="xs"
214
+ icon="lucide:settings"
215
+ @click="setupEmail"
216
+ >
217
+ {{ t('supabase.access.invite.setupEmail') }}
218
+ </UButton>
219
+ </template>
220
+ </UAlert>
221
+
222
+ <p
223
+ v-if="mailProblem && !canCopyLink"
224
+ class="text-muted text-sm"
225
+ >
226
+ {{ t('supabase.access.invite.needsKey') }}
227
+ </p>
228
+
229
+ <UFormField :label="t('supabase.access.invite.email')" required>
230
+ <UInput
231
+ v-model="email"
232
+ :placeholder="t('supabase.access.invite.emailPlaceholder')"
233
+ type="email"
234
+ autocomplete="off"
235
+ class="w-full"
236
+ required
237
+ />
238
+ </UFormField>
239
+
240
+ <UFormField :label="t('supabase.access.invite.name')">
241
+ <UInput
242
+ v-model="name"
243
+ :placeholder="t('supabase.access.invite.namePlaceholder')"
244
+ autocomplete="off"
245
+ class="w-full"
246
+ />
247
+ </UFormField>
248
+
249
+ <UFormField
250
+ :label="t('supabase.access.invite.roles')"
251
+ :error="showRolesError && roleKeys.length === 0 ? t('supabase.access.invite.rolesRequired') : undefined"
252
+ required
253
+ >
254
+ <UCheckboxGroup v-model="roleKeys" :items="roleItems" />
255
+ </UFormField>
256
+
257
+ <UFormField
258
+ :label="t('supabase.access.invite.locale')"
259
+ :description="t('supabase.access.invite.localeHint')"
260
+ >
261
+ <USelect v-model="emailLocale" :items="localeItems" class="w-full sm:w-64" />
262
+ </UFormField>
263
+
264
+ <!-- Enter in a field submits the form. -->
265
+ <button type="submit" class="hidden" tabindex="-1" aria-hidden="true" />
266
+ </form>
267
+ </template>
268
+
269
+ <template #footer>
270
+ <div class="flex w-full justify-end gap-2">
271
+ <template v-if="link">
272
+ <UButton @click="open = false">
273
+ {{ t('supabase.access.invite.done') }}
274
+ </UButton>
275
+ </template>
276
+
277
+ <template v-else>
278
+ <UButton color="neutral" variant="outline" @click="open = false">
279
+ {{ t('pluto.common.cancel') }}
280
+ </UButton>
281
+ <UButton :loading="submitting" icon="lucide:send" @click="submit">
282
+ {{ t('supabase.access.invite.submit') }}
283
+ </UButton>
284
+ </template>
285
+ </div>
286
+ </template>
287
+ </UModal>
288
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ // The invite link in a read-only field with a copy button, and the note: the
3
+ // link works once and expires.
4
+ defineProps<{
5
+ link: string
6
+ }>()
7
+
8
+ const { t } = usePlutoI18n()
9
+ </script>
10
+
11
+ <template>
12
+ <UFormField :label="t('supabase.access.link.label')" :help="t('supabase.access.link.note')">
13
+ <UInput :model-value="link" class="w-full" readonly @focus="($event.target as HTMLInputElement).select()">
14
+ <template #trailing>
15
+ <AccessCopyButton :text="link" :label="t('supabase.access.link.copy')" />
16
+ </template>
17
+ </UInput>
18
+ </UFormField>
19
+ </template>