@plutocms/supabase 0.9.0 → 0.10.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 (35) hide show
  1. package/.prettierignore +2 -0
  2. package/CHANGELOG.md +15 -0
  3. package/FEATURES.md +6 -0
  4. package/app/components/EnvPersistWarning.vue +30 -13
  5. package/app/components/SupabaseMessageParts.vue +29 -0
  6. package/app/components/migrations/MigrationsBanner.vue +3 -4
  7. package/app/components/navbar/NavbarAdmin.vue +15 -9
  8. package/app/components/settings/SettingsGeneralPanel.vue +21 -6
  9. package/app/composables/auth.ts +17 -14
  10. package/app/pages/admin/(auth)/confirm.vue +6 -5
  11. package/app/pages/admin/(auth)/forgot-password.vue +15 -11
  12. package/app/pages/admin/(auth)/login.vue +10 -8
  13. package/app/pages/admin/(auth)/signup.vue +30 -22
  14. package/app/pages/admin/(auth)/update-password.vue +15 -12
  15. package/app/pages/admin/migrations.vue +87 -51
  16. package/app/pages/admin/setup.vue +88 -74
  17. package/app/pages/admin.vue +3 -2
  18. package/app/plugins/pluto-extension.ts +24 -3
  19. package/app/plugins/pluto-locale-sync.client.ts +30 -0
  20. package/app/utils/auth-error.ts +53 -0
  21. package/db/migrations/005_profile_locale.sql +44 -0
  22. package/i18n/locales/en.json +209 -0
  23. package/i18n/locales/pt-BR.json +209 -0
  24. package/package.json +3 -3
  25. package/server/api/profile/locale.get.ts +39 -0
  26. package/server/api/profile/locale.post.ts +71 -0
  27. package/server/api/setup/create.post.ts +6 -4
  28. package/server/api/users/[id].get.ts +4 -2
  29. package/server/api/users/index.get.ts +4 -2
  30. package/server/utils/admin-guard.ts +10 -2
  31. package/server/utils/capability-guard.ts +18 -4
  32. package/server/utils/content-adapter.ts +6 -4
  33. package/server/utils/pending-migrations.ts +0 -0
  34. package/server/utils/validate.ts +6 -1
  35. package/shared/types/supabase.ts +7 -0
@@ -6,8 +6,10 @@ definePageMeta({
6
6
  layout: 'setup',
7
7
  })
8
8
 
9
+ const { t, uiLocale } = usePlutoI18n()
10
+
9
11
  useHead({
10
- title: 'Setup Wizard',
12
+ title: () => t('supabase.setup.title'),
11
13
  })
12
14
 
13
15
  const config = useRuntimeConfig()
@@ -19,14 +21,14 @@ const toast = useToast()
19
21
 
20
22
  const isDev = import.meta.dev
21
23
 
22
- const items = ref<StepperItem[]>([
24
+ const items = computed<StepperItem[]>(() => [
23
25
  {
24
- title: 'Database Setup',
26
+ title: t('supabase.setup.steps.database'),
25
27
  icon: 'i-lucide-database',
26
28
  },
27
29
 
28
30
  {
29
- title: 'Complete Setup',
31
+ title: t('supabase.setup.steps.complete'),
30
32
  icon: 'i-lucide-check',
31
33
  },
32
34
  ])
@@ -66,10 +68,10 @@ const currentStepId = computed(() => {
66
68
 
67
69
  const currentStepSubmitLabel = computed(() => {
68
70
  if (currentStep.value === 0) {
69
- return 'Set up database'
71
+ return t('supabase.setup.submit')
70
72
  }
71
73
 
72
- return 'Submit'
74
+ return t('supabase.setup.next')
73
75
  })
74
76
 
75
77
  /**
@@ -91,8 +93,8 @@ async function waitForServerAndAdvance(): Promise<boolean> {
91
93
  await $fetch('/api/settings/first_setup')
92
94
 
93
95
  toast.add({
94
- title: 'Reconnected',
95
- description: 'The server is back. Your database has been set up.',
96
+ title: t('supabase.connection.reconnected'),
97
+ description: t('supabase.setup.reconnectedDescription'),
96
98
  icon: 'lucide:check-circle',
97
99
  color: 'success',
98
100
  })
@@ -126,8 +128,8 @@ async function completeDatabaseSetup() {
126
128
 
127
129
  if (data.success === false) {
128
130
  toast.add({
129
- title: 'Database setup failed',
130
- description: 'Please check your password and try again.',
131
+ title: t('supabase.setup.failed'),
132
+ description: t('supabase.setup.failedDescription'),
131
133
  icon: 'lucide:circle-x',
132
134
  color: 'error',
133
135
  })
@@ -141,8 +143,8 @@ async function completeDatabaseSetup() {
141
143
  }
142
144
 
143
145
  toast.add({
144
- title: 'Database setup complete',
145
- description: 'Your database has been set up successfully.',
146
+ title: t('supabase.setup.complete'),
147
+ description: t('supabase.setup.completeDescription'),
146
148
  icon: 'lucide:check-circle',
147
149
  color: 'success',
148
150
  })
@@ -164,8 +166,8 @@ async function completeDatabaseSetup() {
164
166
 
165
167
  if (import.meta.dev && !hasStatusCode) {
166
168
  toast.add({
167
- title: 'Dev server restarting',
168
- description: 'Setup is finishing. Reconnecting…',
169
+ title: t('supabase.connection.restarting'),
170
+ description: t('supabase.setup.restartingDescription'),
169
171
  icon: 'lucide:refresh-cw',
170
172
  color: 'info',
171
173
  })
@@ -177,9 +179,8 @@ async function completeDatabaseSetup() {
177
179
  }
178
180
 
179
181
  toast.add({
180
- title: 'Still reconnecting',
181
- description:
182
- 'The dev server is taking longer than expected. Reload this page in a moment.',
182
+ title: t('supabase.connection.stillReconnecting'),
183
+ description: t('supabase.connection.stillReconnectingDescription'),
183
184
  icon: 'lucide:triangle-alert',
184
185
  color: 'warning',
185
186
  })
@@ -192,8 +193,8 @@ async function completeDatabaseSetup() {
192
193
  }
193
194
 
194
195
  toast.add({
195
- title: 'Error setting up the database',
196
- description: 'Please check the console for more details.',
196
+ title: t('supabase.setup.error'),
197
+ description: t('supabase.connection.checkConsole'),
197
198
  icon: 'lucide:circle-x',
198
199
  color: 'error',
199
200
  })
@@ -210,7 +211,7 @@ function handleStepChange(step: number) {
210
211
  </script>
211
212
 
212
213
  <template>
213
- <UApp>
214
+ <UApp :locale="uiLocale">
214
215
  <div class="flex min-h-dvh items-center justify-center p-4">
215
216
  <UCard class="w-full max-w-125">
216
217
  <div class="flex flex-col gap-y-8">
@@ -223,7 +224,7 @@ function handleStepChange(step: number) {
223
224
  class="flex flex-col gap-y-4"
224
225
  @submit="handleStepChange(currentStep)"
225
226
  >
226
- <UFormField label="Supabase connection string" required>
227
+ <UFormField :label="t('supabase.connection.connectionString')" required>
227
228
  <UInput
228
229
  v-model="databaseForm.connectionString"
229
230
  :disabled="isSettingUpDatabase"
@@ -233,19 +234,24 @@ function handleStepChange(step: number) {
233
234
  />
234
235
 
235
236
  <template #help>
236
- You can find your connection string
237
- <ULink
238
- :to="`https://supabase.com/dashboard/project/${projectId}/database/settings?showConnect=true&method=transaction`"
239
- target="_blank"
240
- class="underline"
241
- external
237
+ <SupabaseMessageParts
238
+ :message="t('supabase.connection.connectionStringHelp')"
242
239
  >
243
- clicking here </ULink
244
- >.
240
+ <template #link>
241
+ <ULink
242
+ :to="`https://supabase.com/dashboard/project/${projectId}/database/settings?showConnect=true&method=transaction`"
243
+ target="_blank"
244
+ class="underline"
245
+ external
246
+ >
247
+ {{ t('supabase.connection.connectionStringHelpLink') }}
248
+ </ULink>
249
+ </template>
250
+ </SupabaseMessageParts>
245
251
  </template>
246
252
  </UFormField>
247
253
 
248
- <UFormField label="Database password" required>
254
+ <UFormField :label="t('supabase.connection.password')" required>
249
255
  <UInput
250
256
  v-model="databaseForm.password"
251
257
  :disabled="isSettingUpDatabase"
@@ -261,42 +267,50 @@ function handleStepChange(step: number) {
261
267
  variant="outline"
262
268
  >
263
269
  <template #description>
264
- Your password is created when you create your Supabase
265
- project. You can change it in the
266
- <ULink
267
- to="https://supabase.com/dashboard/project/_/database/settings"
268
- target="_blank"
270
+ <SupabaseMessageParts
271
+ :message="t('supabase.connection.passwordInfo')"
269
272
  >
270
- Supabase Database Settings </ULink
271
- >.
273
+ <template #link>
274
+ <ULink
275
+ to="https://supabase.com/dashboard/project/_/database/settings"
276
+ target="_blank"
277
+ >
278
+ {{ t('supabase.connection.passwordInfoLink') }}
279
+ </ULink>
280
+ </template>
281
+ </SupabaseMessageParts>
272
282
  </template>
273
283
  </UAlert>
274
284
 
275
285
  <UAlert
286
+ :title="t('supabase.connection.whyTitle')"
276
287
  color="warning"
277
288
  variant="outline"
278
- title="Why do I need to provide a database password?"
279
289
  icon="lucide:info"
280
290
  >
281
291
  <template #description>
282
292
  <div class="flex flex-col gap-y-4">
283
293
  <p>
284
- It will be used to create your database structure. It
285
- won't be stored anywhere. That's a
286
- <UTooltip
287
- text="Click on the 'Technical details' button for more info"
294
+ <SupabaseMessageParts
295
+ :message="t('supabase.setup.why')"
288
296
  >
289
- <span class="underline decoration-dotted cursor-help">
290
- promise</span
291
- ></UTooltip
292
- >! 😉
297
+ <template #promise>
298
+ <UTooltip :text="t('supabase.setup.promiseTooltip')">
299
+ <span
300
+ class="underline decoration-dotted cursor-help"
301
+ >
302
+ {{ t('supabase.setup.promise') }}</span
303
+ ></UTooltip
304
+ >
305
+ </template>
306
+ </SupabaseMessageParts>
293
307
  </p>
294
308
 
295
309
  <div>
296
310
  <UPopover :content="{ side: 'bottom' }" arrow>
297
311
  <UButton
312
+ :label="t('supabase.setup.technicalDetails')"
298
313
  icon="lucide:lightbulb"
299
- label="Technical details"
300
314
  color="info"
301
315
  variant="subtle"
302
316
  class="w-fit"
@@ -308,31 +322,32 @@ function handleStepChange(step: number) {
308
322
  class="flex flex-col gap-y-4 max-w-xs p-4 text-white text-sm"
309
323
  >
310
324
  <p>
311
- When you click "Next", a request to
312
- <ULink
313
- to="https://github.com/plutocms/supabase/blob/feat/setup-wizard/server/api/setup/create.post.ts"
314
- target="_blank"
315
- class="underline"
316
- >
317
- a server route</ULink
318
- >
319
- will be made with the database password you
320
- provided. This API will then use this password
321
- to make a direct PostgreSQL connection to your
322
- Supabase database and run the SQL queries from
323
- this
324
- <ULink
325
- to="https://github.com/plutocms/supabase/tree/main/db/migrations"
326
- target="_blank"
327
- class="underline"
325
+ <SupabaseMessageParts
326
+ :message="t('supabase.setup.technicalDetailsRequest')"
328
327
  >
329
- SQL files</ULink
330
- >.
328
+ <template #route>
329
+ <ULink
330
+ to="https://github.com/plutocms/supabase/blob/feat/setup-wizard/server/api/setup/create.post.ts"
331
+ target="_blank"
332
+ class="underline"
333
+ >
334
+ {{ t('supabase.setup.technicalDetailsRoute') }}</ULink
335
+ >
336
+ </template>
337
+ <template #files>
338
+ <ULink
339
+ to="https://github.com/plutocms/supabase/tree/main/db/migrations"
340
+ target="_blank"
341
+ class="underline"
342
+ >
343
+ {{ t('supabase.setup.technicalDetailsFiles') }}</ULink
344
+ >
345
+ </template>
346
+ </SupabaseMessageParts>
331
347
  </p>
332
348
 
333
349
  <p>
334
- After the setup is complete, the password will
335
- NOT be stored or used for any other purpose.
350
+ {{ t('supabase.setup.technicalDetailsStored') }}
336
351
  </p>
337
352
  </div>
338
353
  </template>
@@ -344,11 +359,11 @@ function handleStepChange(step: number) {
344
359
 
345
360
  <UAlert
346
361
  v-if="isDev"
362
+ :title="t('supabase.connection.restartTitle')"
363
+ :description="t('supabase.setup.restartDescription')"
347
364
  color="info"
348
365
  variant="outline"
349
- title="This restarts the dev server"
350
366
  icon="lucide:refresh-cw"
351
- description="Saving your connection string writes it to .env. In development, that restarts the server automatically. The page will briefly show a reconnecting message — this is expected, not an error."
352
367
  />
353
368
  </UForm>
354
369
  </section>
@@ -358,12 +373,11 @@ function handleStepChange(step: number) {
358
373
  <hgroup class="text-center">
359
374
  <span class="text-3xl">🎉</span>
360
375
 
361
- <h1 class="font-bold text-2xl">Setup Complete</h1>
376
+ <h1 class="font-bold text-2xl">{{ t('supabase.setup.doneTitle') }}</h1>
362
377
  </hgroup>
363
378
 
364
379
  <p class="text-balance text-center text-lg">
365
- Your database is ready. Create your admin account by signing
366
- up.
380
+ {{ t('supabase.setup.doneDescription') }}
367
381
  </p>
368
382
 
369
383
  <EnvPersistWarning
@@ -378,7 +392,7 @@ function handleStepChange(step: number) {
378
392
  color="primary"
379
393
  trailing
380
394
  >
381
- Go to sign up
395
+ {{ t('supabase.setup.goToSignup') }}
382
396
  </UButton>
383
397
  </p>
384
398
  </div>
@@ -5,6 +5,7 @@ definePageMeta({
5
5
  })
6
6
 
7
7
  const route = useRoute()
8
+ const { t } = usePlutoI18n()
8
9
  const { isLoggedIn, logout, allowedUnauthenticatedPaths } = await useAuth()
9
10
  const toast = useToast()
10
11
 
@@ -26,8 +27,8 @@ watch(visibility, async (current, previous) => {
26
27
  await logout({ redirectTo: route.path })
27
28
 
28
29
  toast.add({
29
- title: 'You are not logged in',
30
- description: 'Please log in to continue.',
30
+ title: t('supabase.auth.toasts.notLoggedIn'),
31
+ description: t('supabase.auth.toasts.notLoggedInDescription'),
31
32
  icon: 'lucide:circle-x',
32
33
  color: 'error',
33
34
  })
@@ -2,6 +2,10 @@ import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
2
2
  import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
3
3
 
4
4
  export default defineNuxtPlugin(() => {
5
+ // Read at setup, while the Nuxt context exists. The driver below calls it
6
+ // later, after an await, when the context is gone.
7
+ const errorText = usePlutoErrorText()
8
+
5
9
  definePlutoExtension({
6
10
  id: 'supabase',
7
11
  navbar: { id: 'admin-navbar', component: NavbarAdminProvider },
@@ -9,7 +13,7 @@ export default defineNuxtPlugin(() => {
9
13
  {
10
14
  id: 'general',
11
15
  order: 0,
12
- title: 'General',
16
+ title: 'supabase.settings.general.title',
13
17
  icon: 'lucide:globe',
14
18
  component: SettingsGeneralPanel,
15
19
  },
@@ -40,9 +44,26 @@ export default defineNuxtPlugin(() => {
40
44
  // gated by requireAdmin (public.is_admin() directly), never a named
41
45
  // capability — see server/utils/admin-guard.ts for why.
42
46
  capabilities: [
43
- { id: 'settings-manage', key: 'settings:manage', label: 'Manage site settings' },
44
- { id: 'users-read', key: 'users:read', label: 'View all user accounts' },
47
+ { id: 'settings-manage', key: 'settings:manage', label: 'supabase.capabilities.settingsManage' },
48
+ { id: 'users-read', key: 'users:read', label: 'supabase.capabilities.usersRead' },
45
49
  ],
50
+ // The admin language of the signed-in user, stored in profiles.locale
51
+ // (005_profile_locale.sql). A save error carries the translated server
52
+ // text, so the Settings language card can show it in a toast.
53
+ localeDriver: {
54
+ id: 'supabase',
55
+ load: async () => {
56
+ const response = await $fetch<{ locale: string | null }>('/api/profile/locale')
57
+ return response.locale
58
+ },
59
+ save: async (locale) => {
60
+ try {
61
+ await $fetch('/api/profile/locale', { method: 'POST', body: { locale } })
62
+ } catch (error) {
63
+ throw new Error(errorText(error))
64
+ }
65
+ },
66
+ },
46
67
  permissionsDriver: {
47
68
  id: 'supabase',
48
69
  load: async () => {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Loads the signed-in user's stored admin language whenever a session
3
+ * appears: after a login, and on a page load that already has a session.
4
+ * App-wide, like pluto-permissions-sync.ts, so any page gets the language,
5
+ * not only the pages under /admin.
6
+ *
7
+ * Client-only, unlike the permissions sync. A server `$fetch` does not forward
8
+ * the user's cookies, so a server run would read no locale and clear the cookie.
9
+ * The server already resolves the first locale from the cookie or the
10
+ * Accept-Language header. The stored value then replaces it on the client, and
11
+ * the cookie keeps it for the next server render.
12
+ *
13
+ * `useSupabaseSession()` is the same primitive the permissions sync uses.
14
+ * `syncUserLocale()` never throws. With no driver, or on an error, nothing
15
+ * changes.
16
+ */
17
+ export default defineNuxtPlugin(() => {
18
+ const session = useSupabaseSession()
19
+ const { syncUserLocale } = usePlutoI18n()
20
+
21
+ watch(
22
+ session,
23
+ (current) => {
24
+ if (current) {
25
+ void syncUserLocale()
26
+ }
27
+ },
28
+ { immediate: true }
29
+ )
30
+ })
@@ -0,0 +1,53 @@
1
+ // Turns a Supabase auth error into text for the admin. This file does not use
2
+ // Nuxt auto-imports, so plain Vitest can import it.
3
+
4
+ /** The translation key of each Supabase auth error code that the admin knows. */
5
+ export const AUTH_ERROR_KEYS: Record<string, string> = {
6
+ invalid_credentials: 'supabase.auth.errors.invalid_credentials',
7
+ email_not_confirmed: 'supabase.auth.errors.email_not_confirmed',
8
+ over_request_rate_limit: 'supabase.auth.errors.over_request_rate_limit',
9
+ over_email_send_rate_limit: 'supabase.auth.errors.over_email_send_rate_limit',
10
+ weak_password: 'supabase.auth.errors.weak_password',
11
+ same_password: 'supabase.auth.errors.same_password',
12
+ user_not_found: 'supabase.auth.errors.user_not_found',
13
+ email_exists: 'supabase.auth.errors.email_exists',
14
+ user_already_exists: 'supabase.auth.errors.user_already_exists',
15
+ signup_disabled: 'supabase.auth.errors.signup_disabled',
16
+ email_address_invalid: 'supabase.auth.errors.email_address_invalid',
17
+ }
18
+
19
+ /** The key for errors with no code, no message, or an unknown shape. */
20
+ export const AUTH_UNKNOWN_ERROR_KEY = 'supabase.auth.errors.unknown'
21
+
22
+ /** Returns the translation key of an auth error code, or `undefined`. */
23
+ export function authErrorKey(error: unknown): string | undefined {
24
+ const code = (error as { code?: unknown } | null | undefined)?.code
25
+
26
+ return typeof code === 'string' && Object.hasOwn(AUTH_ERROR_KEYS, code)
27
+ ? AUTH_ERROR_KEYS[code]
28
+ : undefined
29
+ }
30
+
31
+ /**
32
+ * Returns the text of an auth error. The order is:
33
+ *
34
+ * 1. The translated text of a known `error.code`.
35
+ * 2. The raw `error.message`.
36
+ * 3. The translated unknown-error text.
37
+ */
38
+ export function resolveAuthErrorText(
39
+ error: unknown,
40
+ i18n: { t: (key: string) => string, te: (key: string) => boolean }
41
+ ): string {
42
+ const key = authErrorKey(error)
43
+
44
+ if (key && i18n.te(key)) {
45
+ return i18n.t(key)
46
+ }
47
+
48
+ const message = (error as { message?: unknown } | null | undefined)?.message
49
+
50
+ return typeof message === 'string' && message.trim() !== ''
51
+ ? message
52
+ : i18n.t(AUTH_UNKNOWN_ERROR_KEY)
53
+ }
@@ -0,0 +1,44 @@
1
+ -- Profile language
2
+ --
3
+ -- Stores each user's admin language on their own profile row. NULL means
4
+ -- "Automatic": the user has not chosen one, so the admin uses the browser
5
+ -- language. The format check accepts any BCP 47 style code ('en', 'pt-BR'),
6
+ -- never a fixed list, so a new language needs no migration. The app decides
7
+ -- which codes it offers.
8
+ --
9
+ -- There is no UPDATE policy on public.profiles, on purpose: a user must never
10
+ -- write their own is_admin column. A SECURITY DEFINER function is the only
11
+ -- writer of this column. It touches the row of auth.uid() and no other row,
12
+ -- and it touches only the locale. This migration adds no RLS policy. The
13
+ -- existing select policy already lets a user read their own row.
14
+
15
+ alter table public.profiles add column if not exists locale text;
16
+
17
+ do $$
18
+ begin
19
+ if not exists (
20
+ select 1 from pg_constraint
21
+ where conname = 'profiles_locale_format'
22
+ and conrelid = 'public.profiles'::regclass
23
+ ) then
24
+ alter table public.profiles
25
+ add constraint profiles_locale_format
26
+ check (locale is null or locale ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$') not valid;
27
+ end if;
28
+ end
29
+ $$;
30
+
31
+ alter table public.profiles validate constraint profiles_locale_format;
32
+
33
+ -- Sets the language of the calling user. Pass NULL to choose Automatic.
34
+ create or replace function public.set_my_locale(new_locale text)
35
+ returns void
36
+ language sql security definer set search_path = ''
37
+ as $$
38
+ update public.profiles
39
+ set locale = new_locale, updated_at = now()
40
+ where id = auth.uid();
41
+ $$;
42
+
43
+ revoke execute on function public.set_my_locale(text) from public, anon;
44
+ grant execute on function public.set_my_locale(text) to authenticated;