@plutocms/supabase 0.9.1 → 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.
Files changed (41) hide show
  1. package/.prettierignore +2 -0
  2. package/CHANGELOG.md +19 -0
  3. package/FEATURES.md +9 -0
  4. package/README.md +10 -1
  5. package/app/components/EnvPersistWarning.vue +30 -13
  6. package/app/components/SupabaseMessageParts.vue +29 -0
  7. package/app/components/auth/AuthSignIn.vue +56 -0
  8. package/app/components/migrations/MigrationsBanner.vue +28 -15
  9. package/app/components/settings/SettingsGeneralPanel.vue +21 -6
  10. package/app/composables/auth.ts +38 -101
  11. package/app/pages/admin/(auth)/confirm.vue +20 -23
  12. package/app/pages/admin/(auth)/forgot-password.vue +60 -63
  13. package/app/pages/admin/(auth)/signup.vue +115 -110
  14. package/app/pages/admin/(auth)/update-password.vue +78 -82
  15. package/app/pages/admin/migrations.vue +87 -51
  16. package/app/pages/admin/setup.vue +88 -75
  17. package/app/plugins/pluto-extension.ts +143 -10
  18. package/app/utils/auth-driver.ts +110 -0
  19. package/app/utils/auth-error.ts +53 -0
  20. package/db/migrations/005_profile_locale.sql +44 -0
  21. package/i18n/locales/en.json +193 -0
  22. package/i18n/locales/pt-BR.json +193 -0
  23. package/package.json +3 -3
  24. package/server/api/profile/locale.get.ts +39 -0
  25. package/server/api/profile/locale.post.ts +71 -0
  26. package/server/api/setup/create.post.ts +6 -4
  27. package/server/api/users/[id].get.ts +4 -2
  28. package/server/api/users/index.get.ts +4 -2
  29. package/server/utils/admin-guard.ts +10 -2
  30. package/server/utils/capability-guard.ts +18 -4
  31. package/server/utils/content-adapter.ts +6 -4
  32. package/server/utils/pending-migrations.ts +0 -0
  33. package/server/utils/validate.ts +6 -1
  34. package/shared/types/supabase.ts +7 -0
  35. package/app/components/navbar/NavbarAdmin.vue +0 -213
  36. package/app/components/navbar/NavbarAdminProvider.vue +0 -18
  37. package/app/middleware/auth.ts +0 -34
  38. package/app/middleware/setup-check.ts +0 -44
  39. package/app/pages/admin/(auth)/login.vue +0 -93
  40. package/app/pages/admin.vue +0 -56
  41. package/app/plugins/pluto-permissions-sync.ts +0 -38
@@ -1,213 +0,0 @@
1
- <script setup lang="ts">
2
- import { UDropdownMenu } from '#components'
3
- import { domainFromUrl } from '#imports'
4
-
5
- const host = useRequestURL().host
6
-
7
- const route = useRoute()
8
- const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
9
-
10
- function openSidebar() {
11
- isSidebarOpen.value = true
12
- }
13
-
14
- const { isLoggedIn, user, logout } = await useAuth()
15
-
16
- const displayName = computed(() => {
17
- return (
18
- user.value?.display_name ||
19
- user.value?.full_name ||
20
- user.value?.name ||
21
- user.value?.username ||
22
- 'User'
23
- )
24
- })
25
-
26
- const shortDisplayName = computed(() => displayName.value.split(' ')[0])
27
-
28
- const userMenuLabel = computed(() => {
29
- return user.value?.username
30
- ? `${displayName.value} (${user.value.username})`
31
- : displayName.value
32
- })
33
-
34
- const has_settings_modified = useState<boolean>('has_settings_modified')
35
-
36
- const { data: settingsData, status } = await useFetch('/api/settings', {
37
- watch: [has_settings_modified],
38
- })
39
-
40
- const maybeDevUrl = computed(() => {
41
- if (import.meta.dev) {
42
- return `http://${host}`
43
- }
44
-
45
- return settingsData.value?.settings.website_url || ''
46
- })
47
-
48
- type DropdownItems = NonNullable<
49
- Parameters<typeof UDropdownMenu>[0]['items']
50
- >
51
-
52
- const items = computed<DropdownItems>(() => [
53
- [
54
- {
55
- label: userMenuLabel.value,
56
- avatar: {
57
- icon: 'lucide:user',
58
- class: 'bg-primary',
59
- ui: {
60
- icon: 'text-inverted text-sm',
61
- },
62
- },
63
- type: 'label',
64
- },
65
- ],
66
-
67
- [
68
- {
69
- label: 'Settings',
70
- icon: 'i-lucide-cog',
71
- type: 'link',
72
- to: '/admin/settings',
73
- kbds: ['meta', ','],
74
- onSelect() {
75
- if (isLoggedIn.value) {
76
- navigateTo('/admin/settings')
77
- }
78
- },
79
- },
80
- ],
81
-
82
- [
83
- {
84
- label: 'Pluto on GitHub',
85
- icon: 'i-simple-icons-github',
86
- type: 'link',
87
- to: 'https://github.com/ojvribeiro/pluto',
88
- target: '_blank',
89
- external: true,
90
- },
91
- ],
92
-
93
- [
94
- {
95
- label: 'Logout',
96
- icon: 'i-lucide-log-out',
97
- kbds: ['meta', 'shift', 'q'],
98
- class: 'cursor-pointer',
99
- onSelect() {
100
- logout({ showToast: true })
101
- },
102
- },
103
- ],
104
- ])
105
-
106
- defineShortcuts(extractShortcuts(items.value))
107
- </script>
108
-
109
- <template>
110
- <PlutoNavbarAdmin>
111
- <div
112
- class="flex h-full min-w-0 items-stretch justify-between gap-1 px-2 lg:px-4"
113
- >
114
- <div class="flex min-w-0 items-center gap-x-1 lg:gap-x-3">
115
- <UButton
116
- class="lg:hidden"
117
- icon="lucide:menu"
118
- color="neutral"
119
- variant="ghost"
120
- aria-label="Open navigation"
121
- square
122
- @click="openSidebar"
123
- />
124
-
125
- <div class="h-full shrink-0 py-1">
126
- <NuxtLink
127
- title="Go to dashboard home"
128
- to="/admin/home"
129
- class="group block h-full"
130
- >
131
- <img
132
- src="/img/pluto.png"
133
- class="block h-full grayscale-100 group-hover:grayscale-0"
134
- />
135
- </NuxtLink>
136
- </div>
137
-
138
- <div class="min-w-0 h-full overflow-hidden">
139
- <ul class="flex h-full min-w-0 items-center text-sm">
140
- <NavbarAdminActionButton
141
- :show="route.path.startsWith('/admin')"
142
- :title="`Visit ${domainFromUrl(maybeDevUrl)}`"
143
- :icon="
144
- status === 'pending'
145
- ? 'lucide:loader-circle'
146
- : 'lucide:external-link'
147
- "
148
- :disabled="status === 'pending'"
149
- :to="maybeDevUrl"
150
- target="_blank"
151
- label="View site"
152
- />
153
-
154
- <ClientOnly>
155
- <template #fallback>
156
- <li class="h-full">
157
- <div
158
- class="text-dimmed flex h-full items-center gap-x-2 rounded-sm px-2"
159
- >
160
- <Icon name="svg-spinners:ring-resize" />
161
- </div>
162
- </li>
163
- </template>
164
-
165
- <PlutoNavbarAdminActions />
166
- </ClientOnly>
167
- </ul>
168
- </div>
169
- </div>
170
-
171
- <div class="flex shrink-0 items-center gap-1 lg:gap-3">
172
- <ColorModeButton />
173
-
174
- <div class="h-full">
175
- <UDropdownMenu
176
- :items="items"
177
- :ui="{
178
- content: 'w-64',
179
- }"
180
- :modal="false"
181
- >
182
- <button
183
- class="group h-full py-1"
184
- aria-label="Open user menu"
185
- type="button"
186
- >
187
- <div
188
- class="flex h-full items-center gap-x-2 rounded-sm rounded-l-xl pr-2 pl-0.5 group-hover:bg-elevated"
189
- >
190
- <UAvatar
191
- :ui="{
192
- root: 'rounded-xl',
193
- icon: 'text-inverted text-lg',
194
- }"
195
- :alt="displayName"
196
- size="sm"
197
- icon="lucide:user"
198
- class="bg-primary"
199
- />
200
-
201
- <span
202
- class="text-highlighted hidden text-sm font-semibold lg:inline"
203
- >
204
- {{ shortDisplayName }}
205
- </span>
206
- </div>
207
- </button>
208
- </UDropdownMenu>
209
- </div>
210
- </div>
211
- </div>
212
- </PlutoNavbarAdmin>
213
- </template>
@@ -1,18 +0,0 @@
1
- <script setup lang="ts">
2
- const route = useRoute()
3
- const session = useSupabaseSession()
4
-
5
- const shouldShowNavbar = computed(() => {
6
- if (allowedUnauthenticatedPaths.includes(route.path)) {
7
- return false
8
- }
9
-
10
- return route.path === '/admin'
11
- || route.path.startsWith('/admin/')
12
- || Boolean(session.value)
13
- })
14
- </script>
15
-
16
- <template>
17
- <NavbarAdmin v-if="shouldShowNavbar" />
18
- </template>
@@ -1,34 +0,0 @@
1
- import type { RouteLocationNormalizedGeneric } from 'vue-router'
2
-
3
- export default defineNuxtRouteMiddleware(async (to, from) => {
4
- const { isLoggedIn, logout, allowedUnauthenticatedPaths } = await useAuth({
5
- route: to as RouteLocationNormalizedGeneric,
6
- })
7
-
8
- if (
9
- isLoggedIn.value &&
10
- allowedUnauthenticatedPaths.includes(to.path) &&
11
- to.path !== '/admin/update-password'
12
- ) {
13
- return navigateTo(
14
- to.query.redirect
15
- ? decodeURIComponent(to.query.redirect as string)
16
- : '/admin/home'
17
- )
18
- }
19
-
20
- if (
21
- !isLoggedIn.value &&
22
- to.path.startsWith('/admin') &&
23
- !allowedUnauthenticatedPaths.includes(to.path)
24
- ) {
25
- if (from.query.redirect) {
26
- return logout({
27
- redirectTo: from.query.redirect as string,
28
- showToast: true,
29
- })
30
- } else {
31
- return logout()
32
- }
33
- }
34
- })
@@ -1,44 +0,0 @@
1
- export default defineNuxtRouteMiddleware(async (to, _from) => {
2
- if (!to.path.startsWith('/admin')) {
3
- return
4
- }
5
-
6
- try {
7
- const data = await $fetch('/api/settings/first_setup')
8
-
9
- if (!data.success) {
10
- // Cannot determine setup state — send to setup page
11
- if (to.path !== '/admin/setup') {
12
- return navigateTo('/admin/setup')
13
- }
14
- return
15
- }
16
-
17
- if (data.is_first_setup) {
18
- // Setup not done yet — only allow the setup page
19
- if (to.path !== '/admin/setup') {
20
- return navigateTo('/admin/setup')
21
- }
22
- } else {
23
- // Setup already done — block access to setup page.
24
- //
25
- // Keep this redirect even after layer migrations move to
26
- // /admin/migrations. The setup page prompts an unauthenticated
27
- // visitor for a raw database connection string, and it is safe only
28
- // because it is unreachable once setup is done. Reopening it for a
29
- // routine layer addition would let an anonymous visitor submit a
30
- // connection string to the server.
31
- if (to.path === '/admin/setup') {
32
- return navigateTo('/admin/login')
33
- }
34
- }
35
- } catch (error) {
36
- if (import.meta.dev) {
37
- console.error('Error checking first setup status:', error)
38
- }
39
-
40
- if (to.path !== '/admin/setup') {
41
- return navigateTo('/admin/setup')
42
- }
43
- }
44
- })
@@ -1,93 +0,0 @@
1
- <script setup lang="ts">
2
- interface Form {
3
- email: string
4
- password: string
5
- }
6
-
7
- definePageMeta({
8
- layout: 'auth',
9
- })
10
-
11
- useHead({
12
- title: 'Login',
13
- })
14
-
15
- const { isSubmitting, login } = await useAuth()
16
-
17
- const form = ref<Form>({
18
- email: '',
19
- password: '',
20
- })
21
-
22
- function submitForm() {
23
- login(form.value)
24
- }
25
- </script>
26
-
27
- <template>
28
- <div class="grid min-h-dvh place-items-center p-4">
29
- <UCard class="w-full max-w-100">
30
- <form @submit.prevent="submitForm">
31
- <div class="flex flex-col gap-y-6">
32
- <h1 class="text-3xl font-bold">Login</h1>
33
-
34
- <UFormField label="Email">
35
- <UInput
36
- v-model="form.email"
37
- placeholder="victor@example.com"
38
- class="w-full"
39
- autocomplete="email"
40
- />
41
- </UFormField>
42
-
43
- <UFormField label="Password">
44
- <UInput
45
- v-model="form.password"
46
- placeholder="••••"
47
- class="w-full"
48
- type="password"
49
- />
50
- </UFormField>
51
-
52
- <div
53
- class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-x-4"
54
- >
55
- <UFormField>
56
- <UButton
57
- :loading="isSubmitting"
58
- type="submit"
59
- icon="lucide:log-in"
60
- class="w-full justify-center sm:w-auto"
61
- >
62
- Login
63
- </UButton>
64
- </UFormField>
65
-
66
- <UFormField>
67
- <UButton variant="link" to="/admin/signup" class="px-0">
68
- Create Account
69
- </UButton>
70
- </UFormField>
71
- </div>
72
-
73
- <div
74
- class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
75
- >
76
- <UButton
77
- variant="link"
78
- icon="lucide:arrow-left"
79
- to="/"
80
- class="px-0"
81
- >
82
- Back to Home
83
- </UButton>
84
-
85
- <UButton variant="link" to="/admin/forgot-password" class="px-0">
86
- Forgot password?
87
- </UButton>
88
- </div>
89
- </div>
90
- </form>
91
- </UCard>
92
- </div>
93
- </template>
@@ -1,56 +0,0 @@
1
- <script setup lang="ts">
2
- definePageMeta({
3
- middleware: ['setup-check', 'auth'],
4
- layout: 'admin',
5
- })
6
-
7
- const route = useRoute()
8
- const { isLoggedIn, logout, allowedUnauthenticatedPaths } = await useAuth()
9
- const toast = useToast()
10
-
11
- // Loading/clearing permissions on login/logout lives in
12
- // app/plugins/pluto-permissions-sync.ts now, not here — that plugin runs
13
- // on every page, not just this one, so a signed-in admin browsing a public
14
- // page directly (no prior visit to /admin/** this session) still gets
15
- // their capabilities loaded.
16
-
17
- const visibility = useDocumentVisibility()
18
-
19
- watch(visibility, async (current, previous) => {
20
- if (current === 'visible' && previous === 'hidden') {
21
- if (
22
- !isLoggedIn.value &&
23
- !route.path.startsWith('/admin/setup') &&
24
- !allowedUnauthenticatedPaths.includes(route.path)
25
- ) {
26
- await logout({ redirectTo: route.path })
27
-
28
- toast.add({
29
- title: 'You are not logged in',
30
- description: 'Please log in to continue.',
31
- icon: 'lucide:circle-x',
32
- color: 'error',
33
- })
34
- }
35
- }
36
- })
37
- </script>
38
-
39
- <template>
40
- <div class="light:text-zinc-800 h-full dark:text-white">
41
- <ClientOnly>
42
- <Container
43
- v-if="
44
- isLoggedIn
45
- && !route.path.startsWith('/admin/setup')
46
- && !route.path.startsWith('/admin/migrations')
47
- "
48
- class="pt-4"
49
- >
50
- <MigrationsBanner />
51
- </Container>
52
- </ClientOnly>
53
-
54
- <NuxtPage />
55
- </div>
56
- </template>
@@ -1,38 +0,0 @@
1
- /**
2
- * Loads (or clears) the current user's capabilities whenever their session
3
- * appears or disappears — app-wide, not page-scoped.
4
- *
5
- * This used to live as a `watch` inside `app/pages/admin.vue`. That only
6
- * ran while `admin.vue` was the mounted page, so a signed-in admin loading
7
- * a public page directly (a fresh navigation or a page reload, with no
8
- * earlier visit to `/admin/**` in the same app instance) never triggered a
9
- * load at all — `usePlutoPermissions().granted` stayed empty, and every
10
- * `can()` check for that admin read as `false` on that page, even though
11
- * the same admin's capability-gated UI worked correctly inside `/admin/**`.
12
- * `NavbarAdminActions.vue`'s "Edit product" quick-link (shown while
13
- * viewing a live `/product/**` page) is exactly the kind of capability-
14
- * gated UI that lives outside `/admin/**` by design, so it needs this to
15
- * fire on every page, not just admin ones.
16
- *
17
- * `useSupabaseSession()` (not `useAuth()`) on purpose: it is the same
18
- * primitive `useAuth()`'s own `isLoggedIn` is built from
19
- * (`computed(() => !!supabaseSession.value)`), and it is genuinely
20
- * app-wide reactive state already (managed by `@nuxtjs/supabase`'s own
21
- * plugin), so reading it directly here needs no extra composable surface.
22
- */
23
- export default defineNuxtPlugin(() => {
24
- const session = useSupabaseSession()
25
- const { load, clear } = usePlutoPermissions()
26
-
27
- watch(
28
- session,
29
- (current) => {
30
- if (current) {
31
- load()
32
- } else {
33
- clear()
34
- }
35
- },
36
- { immediate: true }
37
- )
38
- })