@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.
- package/.env.example +14 -0
- package/CHANGELOG.md +7 -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/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,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>
|