@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.
@@ -70,130 +70,127 @@ async function submitForm() {
70
70
  </script>
71
71
 
72
72
  <template>
73
- <div class="grid min-h-dvh place-items-center p-4">
74
- <UCard class="w-full max-w-120">
75
- <div v-if="isEmailVerificationMessageVisible" class="space-y-6 py-10">
76
- <div class="text-center space-y-4">
77
- <hgroup class="flex flex-col gap-y-1">
78
- <span class="text-5xl">
79
- <UIcon name="lucide:mail-warning" class="inline-block" />
80
- </span>
81
-
82
- <h1 class="text-3xl font-bold">{{ t('supabase.auth.signup.almostThere') }}</h1>
83
- </hgroup>
84
-
85
- <p>
86
- {{ t('supabase.auth.signup.verificationSent') }}
87
- </p>
88
-
89
- <UAlert icon="lucide:check" color="success" variant="outline">
90
- <template #description>
91
- {{ t('supabase.auth.signup.closePage') }}
92
- </template>
93
- </UAlert>
94
-
95
- <UAlert
96
- icon="lucide:alert-triangle"
97
- color="warning"
98
- variant="outline"
99
- >
100
- <template #description>
101
- {{ t('supabase.auth.signup.checkSpam') }}
102
- </template>
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
- <form v-else @submit.prevent="submitForm">
108
- <div class="flex flex-col gap-y-6">
109
- <h1 class="text-3xl font-bold">{{ t('supabase.auth.signup.title') }}</h1>
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
- <UFormField :label="t('supabase.auth.fields.email')" required>
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.email"
114
- type="email"
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
- <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
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.password"
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
- <UFormField
157
- :error="!passwordMatch"
158
- :label="t('supabase.auth.fields.confirmPassword')"
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
- <UInput
162
- v-model="form.confirm_password"
163
- placeholder="••••"
164
- class="w-full"
165
- type="password"
166
- required
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
- <div
171
- class="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-x-4"
172
- >
173
- <UFormField>
174
- <UButton
175
- to="/admin/login"
176
- icon="lucide:arrow-left"
177
- variant="link"
178
- class="px-0"
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
- </form>
197
- </UCard>
198
- </div>
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 } = await useAuth()
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
- <div class="grid min-h-dvh place-items-center p-4">
54
- <UCard class="w-full max-w-100">
55
- <div v-if="isPasswordUpdated" class="space-y-6 py-10">
56
- <div class="text-center space-y-4">
57
- <hgroup class="flex flex-col gap-y-1">
58
- <span class="text-5xl">
59
- <UIcon name="lucide:lock-keyhole" class="inline-block" />
60
- </span>
61
-
62
- <h1 class="text-3xl font-bold">{{ t('supabase.auth.update.doneTitle') }}</h1>
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>
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
- <form v-else @submit.prevent="submitForm">
78
- <div class="flex flex-col gap-y-6">
79
- <hgroup class="flex flex-col gap-y-1">
80
- <h1 class="text-3xl font-bold">{{ t('supabase.auth.update.title') }}</h1>
81
- <p class="text-sm text-muted">
82
- {{ t('supabase.auth.update.description') }}
83
- </p>
84
- </hgroup>
85
-
86
- <UFormField :label="t('supabase.auth.update.newPassword')">
87
- <UInput
88
- v-model="form.password"
89
- placeholder="••••"
90
- type="password"
91
- class="w-full"
92
- autocomplete="new-password"
93
- required
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
- :error="
99
- form.confirm_password && !passwordMatch
100
- ? t('supabase.auth.passwordMismatch')
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
- </form>
138
- </UCard>
139
- </div>
130
+ </div>
131
+ </form>
132
+ </PlutoAdminAuthCard>
140
133
  </template>
@@ -2,7 +2,6 @@
2
2
  import type { StepperItem } from '@nuxt/ui'
3
3
 
4
4
  definePageMeta({
5
- middleware: ['setup-check'],
6
5
  layout: 'setup',
7
6
  })
8
7
 
@@ -1,14 +1,130 @@
1
- import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
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
- navbar: { id: 'admin-navbar', component: NavbarAdminProvider },
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
- // NavbarAdmin.vue watches this to refresh its own /api/settings
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
+ }