@plutocms/supabase 0.11.3 → 0.13.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 +76 -14
- 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/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
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.13.0](https://github.com/plutocms/supabase/compare/v0.12.0...v0.13.0) (2026-10-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **deps:** raise @plutocms/pluto to ^0.20.0 ([#101](https://github.com/plutocms/supabase/issues/101)) ([ef39fb7](https://github.com/plutocms/supabase/commit/ef39fb717114f4302dd01a0877a8b2a68cf53613))
|
|
9
|
+
|
|
10
|
+
## [0.12.0](https://github.com/plutocms/supabase/compare/v0.11.3...v0.12.0) (2026-10-25)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **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))
|
|
16
|
+
|
|
3
17
|
## [0.11.3](https://github.com/plutocms/supabase/compare/v0.11.2...v0.11.3) (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
|
@@ -14,28 +14,19 @@ SUPABASE_URL=your-supabase-url
|
|
|
14
14
|
SUPABASE_KEY=your-publishable-supabase-key
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
2.
|
|
17
|
+
2. Install the layer. It brings `@nuxtjs/supabase`, `@plutocms/pluto` and `@plutocms/utils`, so you do not add them:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
## Yarn
|
|
24
|
-
yarn add @nuxtjs/supabase supabase
|
|
25
|
-
|
|
26
|
-
## PNPM
|
|
27
|
-
pnpm add @nuxtjs/supabase supabase
|
|
28
|
-
|
|
29
|
-
## Bun
|
|
30
|
-
bun add @nuxtjs/supabase supabase
|
|
20
|
+
npm i @plutocms/supabase
|
|
21
|
+
# or pnpm add @plutocms/supabase, or bun add @plutocms/supabase
|
|
31
22
|
```
|
|
32
23
|
|
|
33
|
-
3. Include the Pluto Supabase layer via `extends` option in your `nuxt.config.ts
|
|
24
|
+
3. Include the Pluto Supabase layer via `extends` option in your `nuxt.config.ts`. It is the only Pluto layer the app lists:
|
|
34
25
|
|
|
35
26
|
```ts
|
|
36
27
|
// nuxt.config.ts
|
|
37
28
|
export default defineNuxtConfig({
|
|
38
|
-
extends: [['
|
|
29
|
+
extends: [['@plutocms/supabase', { install: true }]],
|
|
39
30
|
})
|
|
40
31
|
```
|
|
41
32
|
|
|
@@ -56,6 +47,64 @@ top navbar. This layer registers an auth driver, and core renders the shell from
|
|
|
56
47
|
An app that overrode `NavbarAdmin` or `pages/admin.vue` of an older version must move to registry
|
|
57
48
|
entries. See "Migrating an app" in that skill.
|
|
58
49
|
|
|
50
|
+
### Invite users (access API)
|
|
51
|
+
|
|
52
|
+
An admin invites a user by email and gives the user one or more roles. The routes are under
|
|
53
|
+
`/api/access/**`. Every write needs an admin. This needs the migration
|
|
54
|
+
`008_users_and_roles_admin.sql`, which the admin applies from `/admin/migrations`.
|
|
55
|
+
|
|
56
|
+
Invites need no setup. Check one thing: in the Supabase dashboard (Authentication, then URL
|
|
57
|
+
Configuration), the **Site URL** is the URL of your site. Supabase accepts every link back to that
|
|
58
|
+
host, so `/admin/accept-invite` needs no entry in the Redirect URLs. Add a Redirect URL only for
|
|
59
|
+
another host (for example a preview deploy, or `www.` next to the bare domain).
|
|
60
|
+
|
|
61
|
+
How the invite email goes out:
|
|
62
|
+
|
|
63
|
+
- **Without the service role key** (the default): the server sends a passwordless sign-in link
|
|
64
|
+
with the public key. Supabase creates the account and uses its sign-up or magic link email
|
|
65
|
+
template. Public sign-up must be on (the Supabase default). Supabase sends one email per
|
|
66
|
+
address every 60 seconds.
|
|
67
|
+
- **With the service role key** (optional): set `NUXT_SUPABASE_SECRET_KEY` (the Supabase secret
|
|
68
|
+
key) in the environment of the **running server**. Never put it in the build environment. The
|
|
69
|
+
server then uses Supabase's invite API and the "Invite user" email template. This also works
|
|
70
|
+
when public sign-up is off.
|
|
71
|
+
|
|
72
|
+
Optional: a custom invite email template (Authentication, then Email Templates, then Invite
|
|
73
|
+
user) makes the link work in every browser, because the page then reads a `token_hash` and not
|
|
74
|
+
tokens in the URL hash:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
{{ .SiteURL }}/admin/accept-invite?token_hash={{ .TokenHash }}&type=invite
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The built-in Supabase mailer sends very few emails, and only to members of your Supabase
|
|
81
|
+
organization. To invite any other address, set up custom SMTP (Project Settings, then
|
|
82
|
+
Authentication, then SMTP). A production site needs it anyway, for every auth email. Without it,
|
|
83
|
+
an invite to another address answers 400 with the hint `emailNotAuthorized`.
|
|
84
|
+
|
|
85
|
+
The invited user opens the link, sets a password on `/admin/accept-invite`, and goes to the
|
|
86
|
+
admin. An invite to an email that already has an account adds the roles and sends no email.
|
|
87
|
+
|
|
88
|
+
#### Invites through Pluto mail (optional)
|
|
89
|
+
|
|
90
|
+
With the service role key **and** a working Pluto mail driver, an invite does not use Supabase's
|
|
91
|
+
mailer. The server makes a one-time link with `auth.admin.generateLink`, and Pluto mail sends the
|
|
92
|
+
email. This lifts the team-only address rule and the Supabase email rate limit for invites. Set
|
|
93
|
+
the mail variables in the environment of the **running server** (Resend is the first driver):
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
NUXT_PLUTO_MAIL_RESEND_API_KEY=re_...
|
|
97
|
+
NUXT_PLUTO_MAIL_FROM="Your Site <hello@your-domain.com>"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`Settings > Email` in the admin guides the setup (account, key, domain, test email). The
|
|
101
|
+
`mail` skill of `@plutocms/pluto` lists every `NUXT_PLUTO_MAIL_*` variable. If the send fails,
|
|
102
|
+
the user and the roles stay, and the admin gets the invite link to send by hand.
|
|
103
|
+
|
|
104
|
+
With the key, an admin can also copy the invite link (Users, then the row menu, or the invite
|
|
105
|
+
form). The link works once and expires. Sign-up, password reset and magic link emails still
|
|
106
|
+
come from Supabase. The Email tab has a guide to send them through Resend too.
|
|
107
|
+
|
|
59
108
|
### API Routes
|
|
60
109
|
|
|
61
110
|
The layer includes API routes for interacting with Supabase services, such as authentication and media management.
|
|
@@ -63,6 +112,19 @@ The layer includes API routes for interacting with Supabase services, such as au
|
|
|
63
112
|
- GET `/api/settings`
|
|
64
113
|
- POST `/api/settings/update`
|
|
65
114
|
- POST `/api/auth/signup`
|
|
115
|
+
- GET `/api/users`, GET `/api/users/:id`
|
|
116
|
+
- GET `/api/migrations/status`, POST `/api/migrations/run` (admin)
|
|
117
|
+
- GET `/api/permissions/me`
|
|
118
|
+
- GET and POST `/api/profile/locale`
|
|
119
|
+
- GET `/api/settings/first_setup`, POST `/api/setup/create`
|
|
120
|
+
- GET `/api/access/users`, GET `/api/access/roles` (permission `users:read`)
|
|
121
|
+
- POST `/api/access/users/invite`, POST `/api/access/users/:id/resend` (admin; `locale` is the
|
|
122
|
+
optional language of the email)
|
|
123
|
+
- POST `/api/access/users/invite-link` (admin; needs the service role key; returns a one-time link
|
|
124
|
+
and sends no email)
|
|
125
|
+
- GET `/api/access/capabilities` (admin; says whether the service key and Pluto mail are set)
|
|
126
|
+
- PUT and DELETE `/api/access/users/:id/roles` (admin; DELETE revokes the access)
|
|
127
|
+
- POST `/api/access/roles`, PUT and DELETE `/api/access/roles/:key` (admin)
|
|
66
128
|
|
|
67
129
|
### Types
|
|
68
130
|
|
|
@@ -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>
|