@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,29 @@
1
+ <script setup lang="ts">
2
+ // Shows an invite link for a user that already exists. Opened with useOverlay
3
+ // from the Users page.
4
+ defineProps<{
5
+ link: string
6
+ }>()
7
+
8
+ const emit = defineEmits<{ close: [value: boolean] }>()
9
+ const { t } = usePlutoI18n()
10
+ </script>
11
+
12
+ <template>
13
+ <UModal
14
+ :title="t('supabase.access.link.title')"
15
+ :close="{ onClick: () => emit('close', false) }"
16
+ >
17
+ <template #body>
18
+ <AccessLinkField :link="link" />
19
+ </template>
20
+
21
+ <template #footer>
22
+ <div class="flex w-full justify-end">
23
+ <UButton color="neutral" variant="outline" @click="emit('close', false)">
24
+ {{ t('pluto.common.close') }}
25
+ </UButton>
26
+ </div>
27
+ </template>
28
+ </UModal>
29
+ </template>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ // Confirms and removes the roles of one user. The account stays: only the
3
+ // admin access goes. Opened with useOverlay from the Users page.
4
+ import type { AccessUser } from '../../../shared/types/access'
5
+ import { userDisplayName } from '../../utils/access-ui'
6
+
7
+ const props = defineProps<{
8
+ user: AccessUser
9
+ /** True when the user is the signed-in admin. */
10
+ self: boolean
11
+ }>()
12
+
13
+ const emit = defineEmits<{ close: [revoked: boolean] }>()
14
+
15
+ const { t } = usePlutoI18n()
16
+ const toast = useToast()
17
+ const errorText = usePlutoErrorText()
18
+ const permissions = usePlutoPermissions()
19
+
20
+ const working = ref(false)
21
+
22
+ async function revoke() {
23
+ if (working.value) {
24
+ return
25
+ }
26
+
27
+ working.value = true
28
+
29
+ try {
30
+ await $fetch(`/api/access/users/${props.user.id}/roles`, { method: 'DELETE' })
31
+
32
+ toast.add({
33
+ title: t('supabase.access.toasts.revoked'),
34
+ color: 'success',
35
+ icon: 'lucide:circle-check',
36
+ })
37
+
38
+ if (props.self) {
39
+ await permissions.load()
40
+ }
41
+
42
+ emit('close', true)
43
+ } catch (error) {
44
+ toast.add({
45
+ title: t('supabase.access.toasts.failed'),
46
+ description: errorText(error),
47
+ color: 'error',
48
+ icon: 'lucide:circle-x',
49
+ })
50
+ } finally {
51
+ working.value = false
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <UModal
58
+ :title="t('supabase.access.revoke.title')"
59
+ :description="t('supabase.access.revoke.body', { name: userDisplayName(user) })"
60
+ :close="{ onClick: () => emit('close', false) }"
61
+ >
62
+ <template #body>
63
+ <UAlert
64
+ v-if="self"
65
+ :description="t('supabase.access.revoke.self')"
66
+ color="warning"
67
+ variant="subtle"
68
+ icon="lucide:triangle-alert"
69
+ />
70
+ </template>
71
+
72
+ <template #footer>
73
+ <div class="flex w-full justify-end gap-2">
74
+ <UButton color="neutral" variant="outline" @click="emit('close', false)">
75
+ {{ t('pluto.common.cancel') }}
76
+ </UButton>
77
+ <UButton :loading="working" color="error" icon="lucide:user-x" @click="revoke">
78
+ {{ t('supabase.access.revoke.confirm') }}
79
+ </UButton>
80
+ </div>
81
+ </template>
82
+ </UModal>
83
+ </template>
@@ -0,0 +1,251 @@
1
+ <script setup lang="ts">
2
+ // Creates or edits a role. The `admin` role opens read-only: it has full
3
+ // access and the server refuses to change it. Capabilities show as one group
4
+ // of checkboxes for each layer, and an "Other" group for a string that no
5
+ // layer declares.
6
+ import type { AccessRole } from '../../../shared/types/access'
7
+ import {
8
+ ADMIN_ROLE_KEY,
9
+ isValidRoleKey,
10
+ ROLE_KEY_MAX,
11
+ slugifyRoleKey,
12
+ } from '../../utils/access-ui'
13
+
14
+ const props = defineProps<{
15
+ /** The role to edit. Null creates a new role. */
16
+ role: AccessRole | null
17
+ }>()
18
+
19
+ const emit = defineEmits<{ saved: [] }>()
20
+
21
+ const open = defineModel<boolean>('open', { required: true })
22
+
23
+ const { t } = usePlutoI18n()
24
+ const toast = useToast()
25
+ const errorText = usePlutoErrorText()
26
+ const { groupsFor } = usePlutoCapabilities()
27
+
28
+ type CapabilityGroup = ReturnType<typeof groupsFor>[number]
29
+
30
+ const label = ref('')
31
+ const key = ref('')
32
+ const keyTouched = ref(false)
33
+ const description = ref('')
34
+ const selected = ref<string[]>([])
35
+ const saving = ref(false)
36
+ const submitted = ref(false)
37
+
38
+ const creating = computed(() => props.role === null)
39
+ const fullAccess = computed(() => props.role?.key === ADMIN_ROLE_KEY)
40
+
41
+ const title = computed(() => {
42
+ if (creating.value) {
43
+ return t('supabase.access.role.titleCreate')
44
+ }
45
+
46
+ return fullAccess.value
47
+ ? t('supabase.access.role.titleView')
48
+ : t('supabase.access.role.titleEdit')
49
+ })
50
+
51
+ // A fresh form each time the slideover opens.
52
+ watch(
53
+ () => [open.value, props.role?.key] as const,
54
+ ([isOpen]) => {
55
+ if (!isOpen) {
56
+ return
57
+ }
58
+
59
+ label.value = props.role?.label ?? ''
60
+ key.value = props.role?.key ?? ''
61
+ keyTouched.value = false
62
+ description.value = props.role?.description ?? ''
63
+ selected.value = [...(props.role?.capabilities ?? [])]
64
+ submitted.value = false
65
+ },
66
+ { immediate: true }
67
+ )
68
+
69
+ // The key follows the name until the admin edits the key.
70
+ watch(label, (value) => {
71
+ if (creating.value && !keyTouched.value) {
72
+ key.value = slugifyRoleKey(value)
73
+ }
74
+ })
75
+
76
+ const groups = computed<CapabilityGroup[]>(() =>
77
+ groupsFor(props.role?.capabilities ?? [])
78
+ )
79
+
80
+ function groupItems(group: CapabilityGroup) {
81
+ return group.items.map((item) => ({
82
+ label: item.label,
83
+ value: item.key,
84
+ description: item.description,
85
+ }))
86
+ }
87
+
88
+ function groupValue(group: CapabilityGroup): string[] {
89
+ const keys = new Set(group.items.map((item) => item.key))
90
+
91
+ return selected.value.filter((value) => keys.has(value))
92
+ }
93
+
94
+ function setGroup(group: CapabilityGroup, value: string[]) {
95
+ const keys = new Set(group.items.map((item) => item.key))
96
+
97
+ selected.value = [...selected.value.filter((current) => !keys.has(current)), ...value]
98
+ }
99
+
100
+ const keyError = computed(() =>
101
+ creating.value && submitted.value && !isValidRoleKey(key.value)
102
+ ? t('supabase.access.role.keyInvalid')
103
+ : undefined
104
+ )
105
+ const capabilitiesError = computed(() =>
106
+ submitted.value && selected.value.length === 0
107
+ ? t('supabase.access.role.capabilitiesRequired')
108
+ : undefined
109
+ )
110
+
111
+ async function save() {
112
+ submitted.value = true
113
+
114
+ if (
115
+ saving.value
116
+ || fullAccess.value
117
+ || !label.value.trim()
118
+ || keyError.value
119
+ || capabilitiesError.value
120
+ ) {
121
+ return
122
+ }
123
+
124
+ saving.value = true
125
+
126
+ const body = {
127
+ label: label.value.trim(),
128
+ description: description.value.trim(),
129
+ capabilities: selected.value,
130
+ }
131
+
132
+ try {
133
+ if (creating.value) {
134
+ await $fetch('/api/access/roles', {
135
+ method: 'POST',
136
+ body: { ...body, key: key.value },
137
+ })
138
+ } else {
139
+ await $fetch(`/api/access/roles/${props.role!.key}`, { method: 'PUT', body })
140
+ }
141
+
142
+ toast.add({
143
+ title: creating.value
144
+ ? t('supabase.access.toasts.roleCreated')
145
+ : t('supabase.access.toasts.roleSaved'),
146
+ color: 'success',
147
+ icon: 'lucide:circle-check',
148
+ })
149
+ emit('saved')
150
+ open.value = false
151
+ } catch (error) {
152
+ toast.add({
153
+ title: t('supabase.access.toasts.failed'),
154
+ description: errorText(error),
155
+ color: 'error',
156
+ icon: 'lucide:circle-x',
157
+ })
158
+ } finally {
159
+ saving.value = false
160
+ }
161
+ }
162
+ </script>
163
+
164
+ <template>
165
+ <USlideover
166
+ v-model:open="open"
167
+ :title="title"
168
+ :description="t('supabase.access.role.description')"
169
+ >
170
+ <template #body>
171
+ <UAlert
172
+ v-if="fullAccess"
173
+ :title="t('supabase.access.roles.fullAccess')"
174
+ :description="t('supabase.access.role.fullAccessNote')"
175
+ color="neutral"
176
+ variant="subtle"
177
+ icon="lucide:shield-check"
178
+ />
179
+
180
+ <form v-else class="flex flex-col gap-y-5" @submit.prevent="save">
181
+ <UFormField
182
+ :label="t('supabase.access.role.label')"
183
+ :error="submitted && !label.trim() ? t('supabase.access.errors.invalidLabel') : undefined"
184
+ required
185
+ >
186
+ <UInput
187
+ v-model="label"
188
+ :placeholder="t('supabase.access.role.labelPlaceholder')"
189
+ autocomplete="off"
190
+ class="w-full"
191
+ />
192
+ </UFormField>
193
+
194
+ <UFormField
195
+ v-if="creating"
196
+ :label="t('supabase.access.role.key')"
197
+ :help="t('supabase.access.role.keyHint')"
198
+ :error="keyError"
199
+ required
200
+ >
201
+ <UInput
202
+ v-model="key"
203
+ :maxlength="ROLE_KEY_MAX"
204
+ autocomplete="off"
205
+ class="w-full font-mono"
206
+ @input="keyTouched = true"
207
+ />
208
+ </UFormField>
209
+
210
+ <UFormField :label="t('supabase.access.role.about')">
211
+ <UTextarea
212
+ v-model="description"
213
+ :rows="2"
214
+ :placeholder="t('supabase.access.role.aboutPlaceholder')"
215
+ class="w-full"
216
+ />
217
+ </UFormField>
218
+
219
+ <UFormField
220
+ :label="t('supabase.access.role.capabilities')"
221
+ :error="capabilitiesError"
222
+ required
223
+ >
224
+ <div class="flex flex-col gap-y-4">
225
+ <div v-for="group in groups" :key="group.id" class="flex flex-col gap-y-2">
226
+ <h3 class="text-highlighted text-sm font-semibold">{{ group.label }}</h3>
227
+ <UCheckboxGroup
228
+ :model-value="groupValue(group)"
229
+ :items="groupItems(group)"
230
+ @update:model-value="setGroup(group, $event as string[])"
231
+ />
232
+ </div>
233
+ </div>
234
+ </UFormField>
235
+
236
+ <button type="submit" class="hidden" tabindex="-1" aria-hidden="true" />
237
+ </form>
238
+ </template>
239
+
240
+ <template #footer>
241
+ <div class="flex w-full justify-end gap-2">
242
+ <UButton color="neutral" variant="outline" @click="open = false">
243
+ {{ fullAccess ? t('pluto.common.close') : t('pluto.common.cancel') }}
244
+ </UButton>
245
+ <UButton v-if="!fullAccess" :loading="saving" icon="lucide:save" @click="save">
246
+ {{ t('supabase.access.role.save') }}
247
+ </UButton>
248
+ </div>
249
+ </template>
250
+ </USlideover>
251
+ </template>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ // The "Supabase sign-in emails" section of the Email settings page. It is a
3
+ // `mailPanels` entry. Sign-up, password reset and magic link emails go through
4
+ // Supabase's mailer, not through Pluto mail. This guide shows how to send them
5
+ // through Resend. The core page draws the card and the title.
6
+ import {
7
+ RESEND_SMTP,
8
+ RESEND_SUPABASE_INTEGRATION_URL,
9
+ supabaseDashboardLinks,
10
+ supabaseProjectRef,
11
+ } from '../../utils/supabase-project'
12
+
13
+ const { t } = usePlutoI18n()
14
+
15
+ const projectRef = computed(() => supabaseProjectRef(useRuntimeConfig().public.supabase?.url))
16
+ const links = computed(() => (projectRef.value ? supabaseDashboardLinks(projectRef.value) : null))
17
+
18
+ const smtpRows = computed(() => [
19
+ { key: 'host', label: t('supabase.mail.authEmails.host'), value: RESEND_SMTP.host, copy: RESEND_SMTP.host },
20
+ { key: 'port', label: t('supabase.mail.authEmails.port'), value: RESEND_SMTP.port, copy: RESEND_SMTP.port },
21
+ { key: 'username', label: t('supabase.mail.authEmails.username'), value: RESEND_SMTP.username, copy: RESEND_SMTP.username },
22
+ // The password is the API key. Pluto never shows it, so there is nothing to copy.
23
+ { key: 'password', label: t('supabase.mail.authEmails.password'), value: t('supabase.mail.authEmails.passwordValue'), copy: null },
24
+ ])
25
+
26
+ const dashboardLinks = computed(() =>
27
+ links.value
28
+ ? [
29
+ { key: 'smtp', label: t('supabase.mail.authEmails.dashboardSmtp'), to: links.value.smtp, icon: 'lucide:mail' },
30
+ { key: 'templates', label: t('supabase.mail.authEmails.dashboardTemplates'), to: links.value.templates, icon: 'lucide:layout-template' },
31
+ { key: 'rateLimits', label: t('supabase.mail.authEmails.dashboardRateLimits'), to: links.value.rateLimits, icon: 'lucide:gauge' },
32
+ { key: 'project', label: t('supabase.mail.authEmails.dashboardHome'), to: links.value.project, icon: 'lucide:external-link' },
33
+ ]
34
+ : []
35
+ )
36
+ </script>
37
+
38
+ <template>
39
+ <div class="flex flex-col gap-y-5">
40
+ <p class="text-muted text-sm">{{ t('supabase.mail.authEmails.intro') }}</p>
41
+
42
+ <section class="flex flex-col gap-y-2">
43
+ <h3 class="text-highlighted text-sm font-semibold">{{ t('supabase.mail.authEmails.resendTitle') }}</h3>
44
+ <p class="text-muted text-sm">{{ t('supabase.mail.authEmails.resendBody') }}</p>
45
+ <div>
46
+ <UButton
47
+ :to="RESEND_SUPABASE_INTEGRATION_URL"
48
+ target="_blank"
49
+ rel="noopener noreferrer"
50
+ icon="lucide:external-link"
51
+ color="neutral"
52
+ variant="outline"
53
+ >
54
+ {{ t('supabase.mail.authEmails.resendLink') }}
55
+ </UButton>
56
+ </div>
57
+ </section>
58
+
59
+ <section class="flex flex-col gap-y-2">
60
+ <h3 class="text-highlighted text-sm font-semibold">{{ t('supabase.mail.authEmails.manualTitle') }}</h3>
61
+ <p class="text-muted text-sm">{{ t('supabase.mail.authEmails.manualBody') }}</p>
62
+
63
+ <ul class="border-default bg-elevated/50 divide-default divide-y rounded-md border">
64
+ <li v-for="row in smtpRows" :key="row.key" class="flex items-center gap-3 px-3 py-2">
65
+ <span class="text-muted w-24 shrink-0 text-sm">{{ row.label }}</span>
66
+ <code class="min-w-0 flex-1 overflow-x-auto text-sm whitespace-nowrap">{{ row.value }}</code>
67
+ <AccessCopyButton
68
+ v-if="row.copy"
69
+ :text="row.copy"
70
+ :label="t('supabase.mail.authEmails.copyValue', { label: row.label })"
71
+ />
72
+ </li>
73
+ </ul>
74
+ </section>
75
+
76
+ <section class="flex flex-col gap-y-2">
77
+ <h3 class="text-highlighted text-sm font-semibold">{{ t('supabase.mail.authEmails.dashboardTitle') }}</h3>
78
+
79
+ <div v-if="dashboardLinks.length > 0" class="flex flex-wrap gap-2">
80
+ <UButton
81
+ v-for="link in dashboardLinks"
82
+ :key="link.key"
83
+ :to="link.to"
84
+ :icon="link.icon"
85
+ target="_blank"
86
+ rel="noopener noreferrer"
87
+ color="neutral"
88
+ variant="outline"
89
+ size="sm"
90
+ >
91
+ {{ link.label }}
92
+ </UButton>
93
+ </div>
94
+ <p v-else class="text-muted text-sm">{{ t('supabase.mail.authEmails.noProject') }}</p>
95
+
96
+ <p v-if="dashboardLinks.length > 0" class="text-muted text-xs">
97
+ {{ t('supabase.mail.authEmails.dashboardNote') }}
98
+ </p>
99
+ </section>
100
+
101
+ <UAlert
102
+ :description="t('supabase.mail.authEmails.rateLimitHint')"
103
+ color="neutral"
104
+ variant="subtle"
105
+ icon="lucide:info"
106
+ />
107
+ </div>
108
+ </template>
@@ -0,0 +1,155 @@
1
+ <script setup lang="ts">
2
+ import { parseInviteLink } from '../../../utils/invite-link'
3
+
4
+ definePageMeta({
5
+ layout: 'auth',
6
+ })
7
+
8
+ const { t } = usePlutoI18n()
9
+
10
+ useHead({
11
+ title: () => t('supabase.auth.invite.pageTitle'),
12
+ })
13
+
14
+ const route = useRoute()
15
+ const supabase = useSupabaseClient()
16
+ const auth = usePlutoAuth()
17
+
18
+ // The link decides the state. `checking` shows while the page reads it.
19
+ const state = ref<'checking' | 'ready' | 'expired' | 'invalid'>('checking')
20
+
21
+ // Read the link in the browser. The link tokens must not reach the server.
22
+ onMounted(async () => {
23
+ const link = parseInviteLink(
24
+ route.query as Record<string, string | string[] | null | undefined>,
25
+ window.location.hash
26
+ )
27
+
28
+ // Remove the tokens from the address bar and from the history.
29
+ history.replaceState(history.state, '', window.location.pathname)
30
+
31
+ if (link.kind === 'otp') {
32
+ const { error } = await supabase.auth.verifyOtp({
33
+ type: link.type,
34
+ token_hash: link.tokenHash,
35
+ })
36
+ state.value = error ? 'expired' : 'ready'
37
+
38
+ return
39
+ }
40
+
41
+ if (link.kind === 'session') {
42
+ const { error } = await supabase.auth.setSession({
43
+ access_token: link.accessToken,
44
+ refresh_token: link.refreshToken,
45
+ })
46
+ state.value = error ? 'invalid' : 'ready'
47
+
48
+ return
49
+ }
50
+
51
+ if (link.kind === 'error') {
52
+ state.value = 'expired'
53
+
54
+ return
55
+ }
56
+
57
+ const { data } = await supabase.auth.getSession()
58
+ state.value = data.session ? 'ready' : 'invalid'
59
+ })
60
+
61
+ const { isSubmitting, updatePassword } = useAuth()
62
+
63
+ const form = ref({
64
+ password: '',
65
+ confirm_password: '',
66
+ })
67
+
68
+ const passwordMatch = computed(
69
+ () => form.value.password === form.value.confirm_password
70
+ )
71
+
72
+ async function submitForm() {
73
+ if (!passwordMatch.value) {
74
+ return
75
+ }
76
+
77
+ const result = await updatePassword(form.value.password)
78
+
79
+ if (result.success) {
80
+ // Read the session again, so the admin shell sees the signed-in user.
81
+ await auth.driver.value?.refresh?.()
82
+ await navigateTo('/admin/home')
83
+ }
84
+ }
85
+ </script>
86
+
87
+ <template>
88
+ <PlutoAdminAuthCard
89
+ :title="state === 'ready' ? t('supabase.auth.invite.title') : t('supabase.auth.invite.pageTitle')"
90
+ :description="state === 'ready' ? t('supabase.auth.invite.description') : undefined"
91
+ >
92
+ <div v-if="state === 'checking'" class="flex items-center justify-center py-10">
93
+ <UIcon name="lucide:loader-circle" class="size-6 animate-spin text-muted" />
94
+ </div>
95
+
96
+ <div v-else-if="state !== 'ready'" class="space-y-6 py-10 text-center">
97
+ <span class="text-5xl">
98
+ <UIcon name="lucide:link-2-off" class="inline-block" />
99
+ </span>
100
+
101
+ <p>
102
+ {{ state === 'expired' ? t('supabase.auth.invite.expired') : t('supabase.auth.invite.invalid') }}
103
+ </p>
104
+
105
+ <div class="flex items-center justify-center">
106
+ <UButton to="/admin/login" color="primary" icon="lucide:log-in">
107
+ {{ t('supabase.auth.backToLogin') }}
108
+ </UButton>
109
+ </div>
110
+ </div>
111
+
112
+ <form v-else @submit.prevent="submitForm">
113
+ <div class="flex flex-col gap-y-6">
114
+ <UFormField :label="t('supabase.auth.fields.password')">
115
+ <UInput
116
+ v-model="form.password"
117
+ placeholder="••••"
118
+ type="password"
119
+ class="w-full"
120
+ autocomplete="new-password"
121
+ required
122
+ />
123
+ </UFormField>
124
+
125
+ <UFormField
126
+ :error="
127
+ form.confirm_password && !passwordMatch
128
+ ? t('supabase.auth.passwordMismatch')
129
+ : undefined
130
+ "
131
+ :label="t('supabase.auth.fields.confirmPassword')"
132
+ >
133
+ <UInput
134
+ v-model="form.confirm_password"
135
+ placeholder="••••"
136
+ type="password"
137
+ class="w-full"
138
+ autocomplete="new-password"
139
+ required
140
+ />
141
+ </UFormField>
142
+
143
+ <UButton
144
+ :loading="isSubmitting"
145
+ :disabled="!passwordMatch || !form.password"
146
+ type="submit"
147
+ icon="lucide:lock-keyhole"
148
+ class="w-full justify-center"
149
+ >
150
+ {{ t('supabase.auth.invite.submit') }}
151
+ </UButton>
152
+ </div>
153
+ </form>
154
+ </PlutoAdminAuthCard>
155
+ </template>