@plutocms/supabase 0.10.0 → 0.11.1

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,157 @@
1
- import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
2
- import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
1
+ import { defineAsyncComponent } from 'vue'
2
+
3
+ // The three components load on demand. They stay out of the entry chunk. Core
4
+ // shows a skeleton while each chunk loads.
5
+ const AuthSignIn = defineAsyncComponent(
6
+ () => import('../components/auth/AuthSignIn.vue')
7
+ )
8
+ const MigrationsBanner = defineAsyncComponent(
9
+ () => import('../components/migrations/MigrationsBanner.vue')
10
+ )
11
+ const SettingsGeneralPanel = defineAsyncComponent(
12
+ () => import('../components/settings/SettingsGeneralPanel.vue')
13
+ )
3
14
 
4
15
  export default defineNuxtPlugin(() => {
5
16
  // Read at setup, while the Nuxt context exists. The driver below calls it
6
17
  // later, after an await, when the context is gone.
7
18
  const errorText = usePlutoErrorText()
19
+ const supabase = useSupabaseClient()
20
+ const session = useSupabaseSession()
21
+ const claims = useSupabaseUser()
22
+
23
+ // The site settings that the admin bar shows: the site name and the site
24
+ // URL. They load in the browser after a sign-in, because the settings read
25
+ // needs the session cookie. The state starts empty on the server.
26
+ const siteSettings = useState<Record<string, string>>(
27
+ 'supabase-site-settings',
28
+ () => ({})
29
+ )
30
+ const hasSettingsModified = useState<number>('has_settings_modified')
31
+
32
+ async function loadSiteSettings() {
33
+ try {
34
+ const response = await $fetch<{ settings: Record<string, string> }>(
35
+ '/api/settings'
36
+ )
37
+ siteSettings.value = response.settings
38
+ } catch {
39
+ // The bar then shows the fallback name and the "/" URL.
40
+ }
41
+ }
42
+
43
+ // The setup guard. A done setup is cached for the session, so the guard
44
+ // does not fetch on every navigation. A state that is not done is read
45
+ // again each time.
46
+ const setupDone = useState<boolean>('supabase-setup-done', () => false)
47
+
48
+ async function fetchSetupState(): Promise<SetupState> {
49
+ let state: SetupState = 'unknown'
50
+ try {
51
+ state = setupStateFromResponse(
52
+ await $fetch('/api/settings/first_setup')
53
+ )
54
+ } catch (error) {
55
+ if (import.meta.dev) {
56
+ console.error('Error checking first setup status:', error)
57
+ }
58
+ }
59
+
60
+ setupDone.value = state === 'done'
61
+
62
+ return state
63
+ }
64
+
65
+ // On a cold load of an admin path, the request starts here, at plugin setup.
66
+ // The guard then awaits a request that is already in flight. The guard takes
67
+ // this promise once, so a later navigation reads the state again.
68
+ let earlyState: Promise<SetupState> | null =
69
+ import.meta.client &&
70
+ !setupDone.value &&
71
+ /^\/admin(?:\/|$)/.test(window.location.pathname)
72
+ ? fetchSetupState()
73
+ : null
74
+
75
+ async function readSetupState(): Promise<SetupState> {
76
+ if (setupDone.value) {
77
+ return 'done'
78
+ }
79
+
80
+ const early = earlyState
81
+ earlyState = null
82
+
83
+ return early ?? fetchSetupState()
84
+ }
85
+
86
+ if (import.meta.client) {
87
+ watch(
88
+ [() => Boolean(session.value), hasSettingsModified],
89
+ ([signedIn]) => {
90
+ if (signedIn) {
91
+ void loadSiteSettings()
92
+ } else {
93
+ siteSettings.value = {}
94
+ }
95
+ },
96
+ { immediate: true }
97
+ )
98
+ }
8
99
 
9
100
  definePlutoExtension({
10
101
  id: 'supabase',
11
- navbar: { id: 'admin-navbar', component: NavbarAdminProvider },
102
+ // Core renders the navbar, the login page and the route guard from this
103
+ // driver. See the admin-shell skill in @plutocms/pluto.
104
+ authDriver: {
105
+ id: 'supabase-auth',
106
+ // The JWT claims carry the id (`sub`) and the user metadata.
107
+ user: () => mapSupabaseUser(claims.value),
108
+ // The session cookie gives the same answer on the server and in the
109
+ // first client render, so the bar does not cause a hydration mismatch.
110
+ loggedIn: () => Boolean(session.value),
111
+ // The module plugin reads the session before this plugin runs.
112
+ ready: () => Promise.resolve(),
113
+ refresh: async () => {
114
+ try {
115
+ const { data } = await supabase.auth.getSession()
116
+ session.value = data.session
117
+ if (data.session) {
118
+ const { data: claimsData } = await supabase.auth.getClaims()
119
+ claims.value = claimsData?.claims ?? null
120
+ } else {
121
+ claims.value = null
122
+ }
123
+ } catch {
124
+ // Keep the state as it is when the read fails.
125
+ }
126
+ },
127
+ signOut: async () => {
128
+ await supabase.auth.signOut()
129
+ session.value = null
130
+ claims.value = null
131
+ },
132
+ signIn: AuthSignIn,
133
+ signInTitle: 'supabase.auth.login.title',
134
+ // The old login page always showed the sign-up link.
135
+ links: {
136
+ signUp: '/admin/signup',
137
+ forgotPassword: '/admin/forgot-password',
138
+ },
139
+ publicPaths: SUPABASE_PUBLIC_PATHS,
140
+ // Core runs this on `/admin/**`, after ready() and before the sign-in
141
+ // rule, so the first-setup redirect wins over the sign-in redirect.
142
+ // It never rejects.
143
+ guard: async (to) => setupRedirect(to.path, await readSetupState()),
144
+ },
145
+ // The website title and URL settings. An empty value makes core use its
146
+ // own fallback (the runtime config name, and "/" for the URL).
147
+ site: {
148
+ id: 'supabase-site',
149
+ name: () => siteSettings.value.website_title,
150
+ // In development, "View site" opens this dev server, like the old navbar.
151
+ url: () => (import.meta.dev ? null : siteSettings.value.website_url),
152
+ },
153
+ // The pending-migrations notice, shown at the top of the admin pages.
154
+ adminBanners: [{ id: 'migrations', component: MigrationsBanner }],
12
155
  settingsPanels: [
13
156
  {
14
157
  id: 'general',
@@ -32,11 +175,7 @@ export default defineNuxtPlugin(() => {
32
175
  body: patch,
33
176
  })
34
177
 
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')
178
+ // The site info above watches this to read the settings again.
40
179
  hasSettingsModified.value = Date.now()
41
180
  },
42
181
  },