@plutocms/supabase 0.10.0 → 0.11.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/CHANGELOG.md +11 -0
- package/FEATURES.md +3 -0
- package/README.md +10 -1
- package/app/components/auth/AuthSignIn.vue +56 -0
- package/app/components/migrations/MigrationsBanner.vue +26 -12
- package/app/composables/auth.ts +22 -88
- package/app/pages/admin/(auth)/confirm.vue +17 -21
- package/app/pages/admin/(auth)/forgot-password.vue +57 -64
- package/app/pages/admin/(auth)/signup.vue +104 -107
- package/app/pages/admin/(auth)/update-password.vue +75 -82
- package/app/pages/admin/setup.vue +0 -1
- package/app/plugins/pluto-extension.ts +119 -7
- package/app/utils/auth-driver.ts +110 -0
- package/i18n/locales/en.json +2 -18
- package/i18n/locales/pt-BR.json +2 -18
- package/package.json +2 -2
- package/app/components/navbar/NavbarAdmin.vue +0 -219
- package/app/components/navbar/NavbarAdminProvider.vue +0 -18
- package/app/middleware/auth.ts +0 -34
- package/app/middleware/setup-check.ts +0 -44
- package/app/pages/admin/(auth)/login.vue +0 -95
- package/app/pages/admin.vue +0 -57
- package/app/plugins/pluto-locale-sync.client.ts +0 -30
- package/app/plugins/pluto-permissions-sync.ts +0 -38
|
@@ -70,130 +70,127 @@ async function submitForm() {
|
|
|
70
70
|
</script>
|
|
71
71
|
|
|
72
72
|
<template>
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
</UAlert>
|
|
104
|
-
</div>
|
|
73
|
+
<PlutoAdminAuthCard
|
|
74
|
+
:title="isEmailVerificationMessageVisible ? t('supabase.auth.signup.almostThere') : t('supabase.auth.signup.title')"
|
|
75
|
+
>
|
|
76
|
+
<div v-if="isEmailVerificationMessageVisible" class="space-y-6 py-10">
|
|
77
|
+
<div class="text-center space-y-4">
|
|
78
|
+
<hgroup class="flex flex-col gap-y-1">
|
|
79
|
+
<span class="text-5xl">
|
|
80
|
+
<UIcon name="lucide:mail-warning" class="inline-block" />
|
|
81
|
+
</span>
|
|
82
|
+
</hgroup>
|
|
83
|
+
|
|
84
|
+
<p>
|
|
85
|
+
{{ t('supabase.auth.signup.verificationSent') }}
|
|
86
|
+
</p>
|
|
87
|
+
|
|
88
|
+
<UAlert icon="lucide:check" color="success" variant="outline">
|
|
89
|
+
<template #description>
|
|
90
|
+
{{ t('supabase.auth.signup.closePage') }}
|
|
91
|
+
</template>
|
|
92
|
+
</UAlert>
|
|
93
|
+
|
|
94
|
+
<UAlert
|
|
95
|
+
icon="lucide:alert-triangle"
|
|
96
|
+
color="warning"
|
|
97
|
+
variant="outline"
|
|
98
|
+
>
|
|
99
|
+
<template #description>
|
|
100
|
+
{{ t('supabase.auth.signup.checkSpam') }}
|
|
101
|
+
</template>
|
|
102
|
+
</UAlert>
|
|
105
103
|
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<form v-else @submit.prevent="submitForm">
|
|
107
|
+
<div class="flex flex-col gap-y-6">
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
<UFormField :label="t('supabase.auth.fields.email')" required>
|
|
110
|
+
<UInput
|
|
111
|
+
v-model="form.email"
|
|
112
|
+
type="email"
|
|
113
|
+
placeholder="victor@example.com"
|
|
114
|
+
class="w-full"
|
|
115
|
+
required
|
|
116
|
+
/>
|
|
117
|
+
</UFormField>
|
|
110
118
|
|
|
111
|
-
|
|
119
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
120
|
+
<UFormField :label="t('supabase.auth.fields.username')" required>
|
|
112
121
|
<UInput
|
|
113
|
-
v-model="form.
|
|
114
|
-
|
|
115
|
-
placeholder="victor@example.com"
|
|
122
|
+
v-model="form.username"
|
|
123
|
+
:placeholder="t('supabase.auth.signup.usernamePlaceholder')"
|
|
116
124
|
class="w-full"
|
|
125
|
+
minlength="3"
|
|
117
126
|
required
|
|
118
127
|
/>
|
|
119
128
|
</UFormField>
|
|
120
129
|
|
|
121
|
-
<
|
|
122
|
-
<UFormField :label="t('supabase.auth.fields.username')" required>
|
|
123
|
-
<UInput
|
|
124
|
-
v-model="form.username"
|
|
125
|
-
:placeholder="t('supabase.auth.signup.usernamePlaceholder')"
|
|
126
|
-
class="w-full"
|
|
127
|
-
minlength="3"
|
|
128
|
-
required
|
|
129
|
-
/>
|
|
130
|
-
</UFormField>
|
|
131
|
-
|
|
132
|
-
<UFormField :label="t('supabase.auth.fields.displayName')" required>
|
|
133
|
-
<UInput
|
|
134
|
-
v-model="form.display_name"
|
|
135
|
-
:placeholder="t('supabase.auth.signup.displayNamePlaceholder')"
|
|
136
|
-
class="w-full"
|
|
137
|
-
required
|
|
138
|
-
/>
|
|
139
|
-
</UFormField>
|
|
140
|
-
</div>
|
|
141
|
-
|
|
142
|
-
<UFormField
|
|
143
|
-
:error="!passwordMatch"
|
|
144
|
-
:label="t('supabase.auth.fields.password')"
|
|
145
|
-
required
|
|
146
|
-
>
|
|
130
|
+
<UFormField :label="t('supabase.auth.fields.displayName')" required>
|
|
147
131
|
<UInput
|
|
148
|
-
v-model="form.
|
|
149
|
-
placeholder="
|
|
132
|
+
v-model="form.display_name"
|
|
133
|
+
:placeholder="t('supabase.auth.signup.displayNamePlaceholder')"
|
|
150
134
|
class="w-full"
|
|
151
|
-
type="password"
|
|
152
135
|
required
|
|
153
136
|
/>
|
|
154
137
|
</UFormField>
|
|
138
|
+
</div>
|
|
155
139
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
140
|
+
<UFormField
|
|
141
|
+
:error="!passwordMatch"
|
|
142
|
+
:label="t('supabase.auth.fields.password')"
|
|
143
|
+
required
|
|
144
|
+
>
|
|
145
|
+
<UInput
|
|
146
|
+
v-model="form.password"
|
|
147
|
+
placeholder="••••"
|
|
148
|
+
class="w-full"
|
|
149
|
+
type="password"
|
|
159
150
|
required
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
151
|
+
/>
|
|
152
|
+
</UFormField>
|
|
153
|
+
|
|
154
|
+
<UFormField
|
|
155
|
+
:error="!passwordMatch"
|
|
156
|
+
:label="t('supabase.auth.fields.confirmPassword')"
|
|
157
|
+
required
|
|
158
|
+
>
|
|
159
|
+
<UInput
|
|
160
|
+
v-model="form.confirm_password"
|
|
161
|
+
placeholder="••••"
|
|
162
|
+
class="w-full"
|
|
163
|
+
type="password"
|
|
164
|
+
required
|
|
165
|
+
/>
|
|
166
|
+
</UFormField>
|
|
167
|
+
|
|
168
|
+
<div
|
|
169
|
+
class="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-x-4"
|
|
170
|
+
>
|
|
171
|
+
<UFormField>
|
|
172
|
+
<UButton
|
|
173
|
+
to="/admin/login"
|
|
174
|
+
icon="lucide:arrow-left"
|
|
175
|
+
variant="link"
|
|
176
|
+
class="px-0"
|
|
177
|
+
>
|
|
178
|
+
{{ t('supabase.auth.backToLogin') }}
|
|
179
|
+
</UButton>
|
|
168
180
|
</UFormField>
|
|
169
181
|
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
{{ t('supabase.auth.backToLogin') }}
|
|
181
|
-
</UButton>
|
|
182
|
-
</UFormField>
|
|
183
|
-
|
|
184
|
-
<UFormField>
|
|
185
|
-
<UButton
|
|
186
|
-
:disabled="isSubmitting || !passwordMatch"
|
|
187
|
-
:loading="isSubmitting"
|
|
188
|
-
type="submit"
|
|
189
|
-
class="w-full justify-center sm:w-auto"
|
|
190
|
-
>
|
|
191
|
-
{{ t('supabase.auth.signup.submit') }}
|
|
192
|
-
</UButton>
|
|
193
|
-
</UFormField>
|
|
194
|
-
</div>
|
|
182
|
+
<UFormField>
|
|
183
|
+
<UButton
|
|
184
|
+
:disabled="isSubmitting || !passwordMatch"
|
|
185
|
+
:loading="isSubmitting"
|
|
186
|
+
type="submit"
|
|
187
|
+
class="w-full justify-center sm:w-auto"
|
|
188
|
+
>
|
|
189
|
+
{{ t('supabase.auth.signup.submit') }}
|
|
190
|
+
</UButton>
|
|
191
|
+
</UFormField>
|
|
195
192
|
</div>
|
|
196
|
-
</
|
|
197
|
-
</
|
|
198
|
-
</
|
|
193
|
+
</div>
|
|
194
|
+
</form>
|
|
195
|
+
</PlutoAdminAuthCard>
|
|
199
196
|
</template>
|
|
@@ -21,7 +21,7 @@ onMounted(async () => {
|
|
|
21
21
|
}
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
const { isSubmitting, updatePassword } =
|
|
24
|
+
const { isSubmitting, updatePassword } = useAuth()
|
|
25
25
|
|
|
26
26
|
const form = ref({
|
|
27
27
|
password: '',
|
|
@@ -50,91 +50,84 @@ async function submitForm() {
|
|
|
50
50
|
</script>
|
|
51
51
|
|
|
52
52
|
<template>
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
</div>
|
|
53
|
+
<PlutoAdminAuthCard
|
|
54
|
+
:title="isPasswordUpdated ? t('supabase.auth.update.doneTitle') : t('supabase.auth.update.title')"
|
|
55
|
+
:description="isPasswordUpdated ? undefined : t('supabase.auth.update.description')"
|
|
56
|
+
>
|
|
57
|
+
<div v-if="isPasswordUpdated" class="space-y-6 py-10">
|
|
58
|
+
<div class="text-center space-y-4">
|
|
59
|
+
<hgroup class="flex flex-col gap-y-1">
|
|
60
|
+
<span class="text-5xl">
|
|
61
|
+
<UIcon name="lucide:lock-keyhole" class="inline-block" />
|
|
62
|
+
</span>
|
|
63
|
+
</hgroup>
|
|
64
|
+
|
|
65
|
+
<p>
|
|
66
|
+
{{ t('supabase.auth.update.doneDescription') }}
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<div class="flex items-center justify-center">
|
|
70
|
+
<UButton to="/admin/login" color="primary" icon="lucide:log-in">
|
|
71
|
+
{{ t('supabase.auth.update.goToLogin') }}
|
|
72
|
+
</UButton>
|
|
74
73
|
</div>
|
|
75
74
|
</div>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<form v-else @submit.prevent="submitForm">
|
|
78
|
+
<div class="flex flex-col gap-y-6">
|
|
79
|
+
|
|
80
|
+
<UFormField :label="t('supabase.auth.update.newPassword')">
|
|
81
|
+
<UInput
|
|
82
|
+
v-model="form.password"
|
|
83
|
+
placeholder="••••"
|
|
84
|
+
type="password"
|
|
85
|
+
class="w-full"
|
|
86
|
+
autocomplete="new-password"
|
|
87
|
+
required
|
|
88
|
+
/>
|
|
89
|
+
</UFormField>
|
|
90
|
+
|
|
91
|
+
<UFormField
|
|
92
|
+
:error="
|
|
93
|
+
form.confirm_password && !passwordMatch
|
|
94
|
+
? t('supabase.auth.passwordMismatch')
|
|
95
|
+
: undefined
|
|
96
|
+
"
|
|
97
|
+
:label="t('supabase.auth.update.confirmNewPassword')"
|
|
98
|
+
>
|
|
99
|
+
<UInput
|
|
100
|
+
v-model="form.confirm_password"
|
|
101
|
+
placeholder="••••"
|
|
102
|
+
type="password"
|
|
103
|
+
class="w-full"
|
|
104
|
+
autocomplete="new-password"
|
|
105
|
+
required
|
|
106
|
+
/>
|
|
107
|
+
</UFormField>
|
|
108
|
+
|
|
109
|
+
<div
|
|
110
|
+
class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-x-4"
|
|
111
|
+
>
|
|
112
|
+
<UFormField>
|
|
113
|
+
<UButton
|
|
114
|
+
:loading="isSubmitting"
|
|
115
|
+
:disabled="!passwordMatch"
|
|
116
|
+
type="submit"
|
|
117
|
+
icon="lucide:lock-keyhole"
|
|
118
|
+
class="w-full justify-center sm:w-auto"
|
|
119
|
+
>
|
|
120
|
+
{{ t('supabase.auth.update.submit') }}
|
|
121
|
+
</UButton>
|
|
95
122
|
</UFormField>
|
|
96
123
|
|
|
97
|
-
<UFormField
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
: undefined
|
|
102
|
-
"
|
|
103
|
-
:label="t('supabase.auth.update.confirmNewPassword')"
|
|
104
|
-
>
|
|
105
|
-
<UInput
|
|
106
|
-
v-model="form.confirm_password"
|
|
107
|
-
placeholder="••••"
|
|
108
|
-
type="password"
|
|
109
|
-
class="w-full"
|
|
110
|
-
autocomplete="new-password"
|
|
111
|
-
required
|
|
112
|
-
/>
|
|
124
|
+
<UFormField>
|
|
125
|
+
<UButton variant="link" to="/admin/login" class="px-0">
|
|
126
|
+
{{ t('supabase.auth.backToLogin') }}
|
|
127
|
+
</UButton>
|
|
113
128
|
</UFormField>
|
|
114
|
-
|
|
115
|
-
<div
|
|
116
|
-
class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-x-4"
|
|
117
|
-
>
|
|
118
|
-
<UFormField>
|
|
119
|
-
<UButton
|
|
120
|
-
:loading="isSubmitting"
|
|
121
|
-
:disabled="!passwordMatch"
|
|
122
|
-
type="submit"
|
|
123
|
-
icon="lucide:lock-keyhole"
|
|
124
|
-
class="w-full justify-center sm:w-auto"
|
|
125
|
-
>
|
|
126
|
-
{{ t('supabase.auth.update.submit') }}
|
|
127
|
-
</UButton>
|
|
128
|
-
</UFormField>
|
|
129
|
-
|
|
130
|
-
<UFormField>
|
|
131
|
-
<UButton variant="link" to="/admin/login" class="px-0">
|
|
132
|
-
{{ t('supabase.auth.backToLogin') }}
|
|
133
|
-
</UButton>
|
|
134
|
-
</UFormField>
|
|
135
|
-
</div>
|
|
136
129
|
</div>
|
|
137
|
-
</
|
|
138
|
-
</
|
|
139
|
-
</
|
|
130
|
+
</div>
|
|
131
|
+
</form>
|
|
132
|
+
</PlutoAdminAuthCard>
|
|
140
133
|
</template>
|
|
@@ -1,14 +1,130 @@
|
|
|
1
|
-
import
|
|
1
|
+
import AuthSignIn from '../components/auth/AuthSignIn.vue'
|
|
2
|
+
import MigrationsBanner from '../components/migrations/MigrationsBanner.vue'
|
|
2
3
|
import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
|
|
3
4
|
|
|
4
5
|
export default defineNuxtPlugin(() => {
|
|
5
6
|
// Read at setup, while the Nuxt context exists. The driver below calls it
|
|
6
7
|
// later, after an await, when the context is gone.
|
|
7
8
|
const errorText = usePlutoErrorText()
|
|
9
|
+
const supabase = useSupabaseClient()
|
|
10
|
+
const session = useSupabaseSession()
|
|
11
|
+
const claims = useSupabaseUser()
|
|
12
|
+
|
|
13
|
+
// The site settings that the admin bar shows: the site name and the site
|
|
14
|
+
// URL. They load in the browser after a sign-in, because the settings read
|
|
15
|
+
// needs the session cookie. The state starts empty on the server.
|
|
16
|
+
const siteSettings = useState<Record<string, string>>(
|
|
17
|
+
'supabase-site-settings',
|
|
18
|
+
() => ({})
|
|
19
|
+
)
|
|
20
|
+
const hasSettingsModified = useState<number>('has_settings_modified')
|
|
21
|
+
|
|
22
|
+
async function loadSiteSettings() {
|
|
23
|
+
try {
|
|
24
|
+
const response = await $fetch<{ settings: Record<string, string> }>(
|
|
25
|
+
'/api/settings'
|
|
26
|
+
)
|
|
27
|
+
siteSettings.value = response.settings
|
|
28
|
+
} catch {
|
|
29
|
+
// The bar then shows the fallback name and the "/" URL.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The setup guard. A done setup is cached for the session, so the guard
|
|
34
|
+
// does not fetch on every navigation. A state that is not done is read
|
|
35
|
+
// again each time.
|
|
36
|
+
const setupDone = useState<boolean>('supabase-setup-done', () => false)
|
|
37
|
+
|
|
38
|
+
async function readSetupState(): Promise<SetupState> {
|
|
39
|
+
if (setupDone.value) {
|
|
40
|
+
return 'done'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let state: SetupState = 'unknown'
|
|
44
|
+
try {
|
|
45
|
+
state = setupStateFromResponse(
|
|
46
|
+
await $fetch('/api/settings/first_setup')
|
|
47
|
+
)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (import.meta.dev) {
|
|
50
|
+
console.error('Error checking first setup status:', error)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setupDone.value = state === 'done'
|
|
55
|
+
|
|
56
|
+
return state
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (import.meta.client) {
|
|
60
|
+
watch(
|
|
61
|
+
[() => Boolean(session.value), hasSettingsModified],
|
|
62
|
+
([signedIn]) => {
|
|
63
|
+
if (signedIn) {
|
|
64
|
+
void loadSiteSettings()
|
|
65
|
+
} else {
|
|
66
|
+
siteSettings.value = {}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{ immediate: true }
|
|
70
|
+
)
|
|
71
|
+
}
|
|
8
72
|
|
|
9
73
|
definePlutoExtension({
|
|
10
74
|
id: 'supabase',
|
|
11
|
-
|
|
75
|
+
// Core renders the navbar, the login page and the route guard from this
|
|
76
|
+
// driver. See the admin-shell skill in @plutocms/pluto.
|
|
77
|
+
authDriver: {
|
|
78
|
+
id: 'supabase-auth',
|
|
79
|
+
// The JWT claims carry the id (`sub`) and the user metadata.
|
|
80
|
+
user: () => mapSupabaseUser(claims.value),
|
|
81
|
+
// The session cookie gives the same answer on the server and in the
|
|
82
|
+
// first client render, so the bar does not cause a hydration mismatch.
|
|
83
|
+
loggedIn: () => Boolean(session.value),
|
|
84
|
+
// The module plugin reads the session before this plugin runs.
|
|
85
|
+
ready: () => Promise.resolve(),
|
|
86
|
+
refresh: async () => {
|
|
87
|
+
try {
|
|
88
|
+
const { data } = await supabase.auth.getSession()
|
|
89
|
+
session.value = data.session
|
|
90
|
+
if (data.session) {
|
|
91
|
+
const { data: claimsData } = await supabase.auth.getClaims()
|
|
92
|
+
claims.value = claimsData?.claims ?? null
|
|
93
|
+
} else {
|
|
94
|
+
claims.value = null
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Keep the state as it is when the read fails.
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
signOut: async () => {
|
|
101
|
+
await supabase.auth.signOut()
|
|
102
|
+
session.value = null
|
|
103
|
+
claims.value = null
|
|
104
|
+
},
|
|
105
|
+
signIn: AuthSignIn,
|
|
106
|
+
signInTitle: 'supabase.auth.login.title',
|
|
107
|
+
// The old login page always showed the sign-up link.
|
|
108
|
+
links: {
|
|
109
|
+
signUp: '/admin/signup',
|
|
110
|
+
forgotPassword: '/admin/forgot-password',
|
|
111
|
+
},
|
|
112
|
+
publicPaths: SUPABASE_PUBLIC_PATHS,
|
|
113
|
+
// Core runs this on `/admin/**`, after ready() and before the sign-in
|
|
114
|
+
// rule, so the first-setup redirect wins over the sign-in redirect.
|
|
115
|
+
// It never rejects.
|
|
116
|
+
guard: async (to) => setupRedirect(to.path, await readSetupState()),
|
|
117
|
+
},
|
|
118
|
+
// The website title and URL settings. An empty value makes core use its
|
|
119
|
+
// own fallback (the runtime config name, and "/" for the URL).
|
|
120
|
+
site: {
|
|
121
|
+
id: 'supabase-site',
|
|
122
|
+
name: () => siteSettings.value.website_title,
|
|
123
|
+
// In development, "View site" opens this dev server, like the old navbar.
|
|
124
|
+
url: () => (import.meta.dev ? null : siteSettings.value.website_url),
|
|
125
|
+
},
|
|
126
|
+
// The pending-migrations notice, shown at the top of the admin pages.
|
|
127
|
+
adminBanners: [{ id: 'migrations', component: MigrationsBanner }],
|
|
12
128
|
settingsPanels: [
|
|
13
129
|
{
|
|
14
130
|
id: 'general',
|
|
@@ -32,11 +148,7 @@ export default defineNuxtPlugin(() => {
|
|
|
32
148
|
body: patch,
|
|
33
149
|
})
|
|
34
150
|
|
|
35
|
-
//
|
|
36
|
-
// read (it shows the site's own URL) — the settings page saving
|
|
37
|
-
// through the new registry must still trigger it, exactly like
|
|
38
|
-
// the old page did.
|
|
39
|
-
const hasSettingsModified = useState<number>('has_settings_modified')
|
|
151
|
+
// The site info above watches this to read the settings again.
|
|
40
152
|
hasSettingsModified.value = Date.now()
|
|
41
153
|
},
|
|
42
154
|
},
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Pure helpers of the supabase auth driver. This file does not use Nuxt
|
|
2
|
+
// auto-imports, so plain Vitest can import it. The `PlutoAuth*` types are
|
|
3
|
+
// global types of the pluto layer.
|
|
4
|
+
/**
|
|
5
|
+
* The fields of a Supabase user that the driver reads. It fits the JWT claims
|
|
6
|
+
* (`sub`, `email`, `user_metadata`) and a `User` object (`id`).
|
|
7
|
+
*/
|
|
8
|
+
export interface SupabaseIdentity {
|
|
9
|
+
sub?: string
|
|
10
|
+
id?: string
|
|
11
|
+
email?: string | null
|
|
12
|
+
user_metadata?: Record<string, unknown> | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function text(value: unknown): string | undefined {
|
|
16
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
17
|
+
? value.trim()
|
|
18
|
+
: undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Maps a Supabase user to the user of the admin shell. The name follows the
|
|
23
|
+
* order of the old navbar: display_name, full_name, name. In the JWT claims,
|
|
24
|
+
* the user id is `sub`. Returns null when there is no id.
|
|
25
|
+
*/
|
|
26
|
+
export function mapSupabaseUser(
|
|
27
|
+
source: SupabaseIdentity | null | undefined
|
|
28
|
+
): PlutoAuthUser | null {
|
|
29
|
+
const id = text(source?.sub) ?? text(source?.id)
|
|
30
|
+
|
|
31
|
+
if (!source || !id) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const meta = source.user_metadata ?? {}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
name: text(meta.display_name) ?? text(meta.full_name) ?? text(meta.name),
|
|
40
|
+
username: text(meta.username),
|
|
41
|
+
email: text(source.email),
|
|
42
|
+
avatarUrl: text(meta.avatar_url) ?? text(meta.picture),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The admin paths that a signed-out visitor may open, with what happens to a
|
|
48
|
+
* signed-in user. `/admin/login` is always public in core.
|
|
49
|
+
*
|
|
50
|
+
* - `signup` and `forgot-password` send a signed-in user to the admin home.
|
|
51
|
+
* - `confirm` and `update-password` stay open: a recovery or confirm link
|
|
52
|
+
* signs the user in, and the page must still show.
|
|
53
|
+
* - `setup` stays open for the auth guard. The setup guard
|
|
54
|
+
* (`guard` in `pluto-extension.ts`) blocks it when the setup is done.
|
|
55
|
+
*/
|
|
56
|
+
export const SUPABASE_PUBLIC_PATHS: PlutoAuthPublicPath[] = [
|
|
57
|
+
{ path: '/admin/signup', signedIn: 'redirect' },
|
|
58
|
+
{ path: '/admin/forgot-password', signedIn: 'redirect' },
|
|
59
|
+
{ path: '/admin/confirm', signedIn: 'allow' },
|
|
60
|
+
{ path: '/admin/update-password', signedIn: 'allow' },
|
|
61
|
+
{ path: '/admin/setup', signedIn: 'allow' },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
/** The page that opens after a sign-in with no (safe) redirect. */
|
|
65
|
+
export const SUPABASE_SIGN_IN_FALLBACK = '/admin/home'
|
|
66
|
+
|
|
67
|
+
/** The setup state that the setup guard reads from `/api/settings/first_setup`. */
|
|
68
|
+
export type SetupState = 'pending' | 'done' | 'unknown'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads the answer of `/api/settings/first_setup`. A failed call, or a
|
|
72
|
+
* response with no `is_first_setup` flag, is `unknown`.
|
|
73
|
+
*/
|
|
74
|
+
export function setupStateFromResponse(data: unknown): SetupState {
|
|
75
|
+
const response = data as
|
|
76
|
+
| { success?: boolean, is_first_setup?: boolean }
|
|
77
|
+
| null
|
|
78
|
+
| undefined
|
|
79
|
+
|
|
80
|
+
if (!response?.success) {
|
|
81
|
+
return 'unknown'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return response.is_first_setup ? 'pending' : 'done'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Decides the setup redirect for an admin path. It gives a path to open, or
|
|
89
|
+
* undefined to go on.
|
|
90
|
+
*
|
|
91
|
+
* - `pending` and `unknown` (the state cannot be read): only the setup page
|
|
92
|
+
* is open. Every other admin path goes to `/admin/setup`.
|
|
93
|
+
* - `done`: the setup page is closed, and goes to the sign-in page.
|
|
94
|
+
*
|
|
95
|
+
* Keep the `done` redirect, even after layer migrations move to
|
|
96
|
+
* /admin/migrations. The setup page prompts an unauthenticated visitor for a
|
|
97
|
+
* raw database connection string, and it is safe only because it is
|
|
98
|
+
* unreachable once setup is done. Reopening it for a routine layer addition
|
|
99
|
+
* would let an anonymous visitor submit a connection string to the server.
|
|
100
|
+
*/
|
|
101
|
+
export function setupRedirect(
|
|
102
|
+
path: string,
|
|
103
|
+
state: SetupState
|
|
104
|
+
): string | undefined {
|
|
105
|
+
if (state === 'done') {
|
|
106
|
+
return path === '/admin/setup' ? '/admin/login' : undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return path === '/admin/setup' ? undefined : '/admin/setup'
|
|
110
|
+
}
|