@plutocms/supabase 0.5.0 → 0.7.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 +14 -0
- package/FEATURES.md +2 -0
- package/app/components/settings/SettingsGeneralPanel.vue +30 -0
- package/app/pages/admin.vue +14 -0
- package/app/plugins/pluto-extension.ts +44 -0
- package/db/migrations/003_settings_generic_keys.sql +37 -0
- package/db/migrations/004_roles_and_capabilities.sql +298 -0
- package/nuxt.config.ts +0 -6
- package/package.json +3 -4
- package/server/api/migrations/run.post.ts +2 -2
- package/server/api/migrations/status.get.ts +2 -2
- package/server/api/permissions/me.get.ts +24 -0
- package/server/api/settings/index.get.ts +11 -12
- package/server/api/settings/update.post.ts +22 -21
- package/server/api/users/[id].get.ts +2 -2
- package/server/api/users/index.get.ts +2 -2
- package/server/utils/admin-guard.ts +11 -38
- package/server/utils/capability-guard.ts +52 -0
- package/shared/types/supabase.ts +87 -4
- package/app/pages/admin/settings.vue +0 -125
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0](https://github.com/plutocms/supabase/compare/v0.6.0...v0.7.0) (2026-09-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **permissions:** add role-based capabilities, replacing single-bit admin ([#53](https://github.com/plutocms/supabase/issues/53)) ([72f2eb0](https://github.com/plutocms/supabase/commit/72f2eb051efe15e261d3c7fcef95bdf867e11f5a))
|
|
9
|
+
|
|
10
|
+
## [0.6.0](https://github.com/plutocms/supabase/compare/v0.5.0...v0.6.0) (2026-09-12)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **settings:** generic, namespaced setting keys ([#51](https://github.com/plutocms/supabase/issues/51)) ([2410552](https://github.com/plutocms/supabase/commit/2410552ce6c880ecec1fac6f0b84852ef8612bea))
|
|
16
|
+
|
|
3
17
|
## [0.5.0](https://github.com/plutocms/supabase/compare/v0.4.1...v0.5.0) (2026-09-12)
|
|
4
18
|
|
|
5
19
|
|
package/FEATURES.md
CHANGED
|
@@ -8,3 +8,5 @@ policies shared by every project here.
|
|
|
8
8
|
|
|
9
9
|
- Layer migrations: apply pending, versioned, per-file database migrations from the admin UI,
|
|
10
10
|
after initial setup. @.claude/skills/layer-migrations/SKILL.md
|
|
11
|
+
- Permissions storage: role-based capabilities backing server route guards and RLS policies.
|
|
12
|
+
@.claude/skills/permissions-storage/SKILL.md
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { field } = usePlutoSettings()
|
|
3
|
+
|
|
4
|
+
const websiteTitle = field('website_title')
|
|
5
|
+
const websiteDescription = field('website_description')
|
|
6
|
+
const websiteUrl = field('website_url')
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="flex flex-col gap-y-6">
|
|
11
|
+
<UFormField label="Website Title" class="w-full lg:w-1/2">
|
|
12
|
+
<UInput v-model="websiteTitle" placeholder="e.g. My Website" />
|
|
13
|
+
</UFormField>
|
|
14
|
+
|
|
15
|
+
<UFormField label="Website Description" class="w-full lg:w-1/2">
|
|
16
|
+
<UInput
|
|
17
|
+
v-model="websiteDescription"
|
|
18
|
+
placeholder="e.g. My Website Description"
|
|
19
|
+
/>
|
|
20
|
+
</UFormField>
|
|
21
|
+
|
|
22
|
+
<UFormField label="Website URL" class="w-full lg:w-1/2">
|
|
23
|
+
<UInput
|
|
24
|
+
v-model="websiteUrl"
|
|
25
|
+
type="url"
|
|
26
|
+
placeholder="e.g. https://example.com"
|
|
27
|
+
/>
|
|
28
|
+
</UFormField>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
package/app/pages/admin.vue
CHANGED
|
@@ -8,6 +8,20 @@ const route = useRoute()
|
|
|
8
8
|
const { isLoggedIn, logout, allowedUnauthenticatedPaths } = await useAuth()
|
|
9
9
|
const toast = useToast()
|
|
10
10
|
|
|
11
|
+
const { load: loadPermissions, clear: clearPermissions } = usePlutoPermissions()
|
|
12
|
+
|
|
13
|
+
watch(
|
|
14
|
+
isLoggedIn,
|
|
15
|
+
(loggedIn) => {
|
|
16
|
+
if (loggedIn) {
|
|
17
|
+
loadPermissions()
|
|
18
|
+
} else {
|
|
19
|
+
clearPermissions()
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{ immediate: true }
|
|
23
|
+
)
|
|
24
|
+
|
|
11
25
|
const visibility = useDocumentVisibility()
|
|
12
26
|
|
|
13
27
|
watch(visibility, async (current, previous) => {
|
|
@@ -1,8 +1,52 @@
|
|
|
1
1
|
import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
|
|
2
|
+
import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
|
|
2
3
|
|
|
3
4
|
export default defineNuxtPlugin(() => {
|
|
4
5
|
definePlutoExtension({
|
|
5
6
|
id: 'supabase',
|
|
6
7
|
navbar: { id: 'admin-navbar', component: NavbarAdminProvider },
|
|
8
|
+
settingsPanels: [
|
|
9
|
+
{
|
|
10
|
+
id: 'general',
|
|
11
|
+
order: 0,
|
|
12
|
+
title: 'General',
|
|
13
|
+
icon: 'lucide:globe',
|
|
14
|
+
component: SettingsGeneralPanel,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
settingsDriver: {
|
|
18
|
+
id: 'supabase',
|
|
19
|
+
load: async () => {
|
|
20
|
+
const response = await $fetch<{ settings: Record<string, string> }>(
|
|
21
|
+
'/api/settings'
|
|
22
|
+
)
|
|
23
|
+
return response.settings
|
|
24
|
+
},
|
|
25
|
+
save: async (patch) => {
|
|
26
|
+
await $fetch('/api/settings/update', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
body: patch,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// NavbarAdmin.vue watches this to refresh its own /api/settings
|
|
32
|
+
// read (it shows the site's own URL) — the settings page saving
|
|
33
|
+
// through the new registry must still trigger it, exactly like
|
|
34
|
+
// the old page did.
|
|
35
|
+
const hasSettingsModified = useState<number>('has_settings_modified')
|
|
36
|
+
hasSettingsModified.value = Date.now()
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
capabilities: [
|
|
40
|
+
{ id: 'settings-manage', key: 'settings:manage', label: 'Manage site settings' },
|
|
41
|
+
{ id: 'users-read', key: 'users:read', label: 'View all user accounts' },
|
|
42
|
+
{ id: 'system-migrate', key: 'system:migrate', label: 'Apply pending layer migrations' },
|
|
43
|
+
],
|
|
44
|
+
permissionsDriver: {
|
|
45
|
+
id: 'supabase',
|
|
46
|
+
load: async () => {
|
|
47
|
+
const response = await $fetch<{ capabilities: string[] }>('/api/permissions/me')
|
|
48
|
+
return response.capabilities
|
|
49
|
+
},
|
|
50
|
+
},
|
|
7
51
|
})
|
|
8
52
|
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Generic settings keys
|
|
2
|
+
--
|
|
3
|
+
-- public.settings has been a fixed 3-value enum since 001_baseline.sql —
|
|
4
|
+
-- a layer other than @plutocms/supabase could never add its own setting
|
|
5
|
+
-- without altering a shared type. This adds a free-text, namespaced key
|
|
6
|
+
-- column alongside the enum column, without removing the enum column
|
|
7
|
+
-- yet. Dropping setting_name and the TSettings enum is left for a later
|
|
8
|
+
-- migration, once no shipped code writes it, to keep this one cheap to
|
|
9
|
+
-- reason about and its effects easy to undo.
|
|
10
|
+
--
|
|
11
|
+
-- Key format: lowercase segments separated by dots, e.g. 'website_title'
|
|
12
|
+
-- or 'blog.posts_per_page'. A layer other than core must namespace its
|
|
13
|
+
-- keys with its own name, so two layers can never collide.
|
|
14
|
+
|
|
15
|
+
alter table public.settings add column if not exists setting_key text;
|
|
16
|
+
|
|
17
|
+
update public.settings set setting_key = setting_name::text where setting_key is null;
|
|
18
|
+
|
|
19
|
+
alter table public.settings alter column setting_name drop not null;
|
|
20
|
+
|
|
21
|
+
create unique index if not exists settings_setting_key_key on public.settings (setting_key);
|
|
22
|
+
|
|
23
|
+
do $$
|
|
24
|
+
begin
|
|
25
|
+
if not exists (
|
|
26
|
+
select 1 from pg_constraint
|
|
27
|
+
where conname = 'settings_setting_key_format'
|
|
28
|
+
and conrelid = 'public.settings'::regclass
|
|
29
|
+
) then
|
|
30
|
+
alter table public.settings
|
|
31
|
+
add constraint settings_setting_key_format
|
|
32
|
+
check (setting_key ~ '^[a-z0-9_]+(?:\.[a-z0-9_]+)*$') not valid;
|
|
33
|
+
end if;
|
|
34
|
+
end
|
|
35
|
+
$$;
|
|
36
|
+
|
|
37
|
+
alter table public.settings validate constraint settings_setting_key_format;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
-- Roles and capabilities
|
|
2
|
+
--
|
|
3
|
+
-- Adds a role-based capability system. A role (public.roles) holds a set
|
|
4
|
+
-- of capabilities (public.role_capabilities); a user (public.user_roles)
|
|
5
|
+
-- holds a set of roles. public.has_capability(cap) answers "can the
|
|
6
|
+
-- calling user do this?" for any layer's server routes and RLS policies.
|
|
7
|
+
--
|
|
8
|
+
-- Standalone and self-sufficient: it does not depend on running as a
|
|
9
|
+
-- diff against another file, only on 001_baseline.sql's and
|
|
10
|
+
-- 002_admin_hardening.sql's objects (public.profiles, public.settings,
|
|
11
|
+
-- public.healthcheck, public.is_admin(), public.handle_new_user())
|
|
12
|
+
-- already existing.
|
|
13
|
+
|
|
14
|
+
-- Free text, never an enum. An enum would stop a layer, or an operator,
|
|
15
|
+
-- from adding a role or a capability — the exact problem
|
|
16
|
+
-- 003_settings_generic_keys.sql already fixed for settings.
|
|
17
|
+
create table if not exists public.roles (
|
|
18
|
+
key text primary key,
|
|
19
|
+
label text not null,
|
|
20
|
+
description text,
|
|
21
|
+
is_builtin boolean not null default false,
|
|
22
|
+
created_at timestamptz not null default now(),
|
|
23
|
+
constraint roles_key_format check (key ~ '^[a-z0-9_]+$')
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
create table if not exists public.role_capabilities (
|
|
27
|
+
role_key text not null references public.roles (key) on delete cascade,
|
|
28
|
+
capability text not null,
|
|
29
|
+
primary key (role_key, capability),
|
|
30
|
+
-- '<namespace>:<action>', or the literal '*' for every capability.
|
|
31
|
+
constraint role_capabilities_format
|
|
32
|
+
check (capability = '*' or capability ~ '^[a-z0-9_]+:[a-z0-9_]+$')
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
create table if not exists public.user_roles (
|
|
36
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
37
|
+
role_key text not null references public.roles (key) on delete cascade,
|
|
38
|
+
granted_at timestamptz not null default now(),
|
|
39
|
+
primary key (user_id, role_key)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
create index if not exists user_roles_user_id_idx on public.user_roles (user_id);
|
|
43
|
+
|
|
44
|
+
-- Seed the built-in admin role, with the wildcard capability.
|
|
45
|
+
insert into public.roles (key, label, description, is_builtin) values
|
|
46
|
+
('admin', 'Administrator', 'Full access to every feature.', true)
|
|
47
|
+
on conflict (key) do nothing;
|
|
48
|
+
|
|
49
|
+
insert into public.role_capabilities (role_key, capability) values ('admin', '*')
|
|
50
|
+
on conflict do nothing;
|
|
51
|
+
|
|
52
|
+
-- Every existing admin gets the admin role with no manual step.
|
|
53
|
+
insert into public.user_roles (user_id, role_key)
|
|
54
|
+
select p.id, 'admin' from public.profiles p where p.is_admin = true
|
|
55
|
+
on conflict do nothing;
|
|
56
|
+
|
|
57
|
+
-- Returns true when the calling user holds the given capability, either
|
|
58
|
+
-- directly through a role, or through public.is_admin(). SECURITY
|
|
59
|
+
-- DEFINER for the same reason public.is_admin() is: the lookup must not
|
|
60
|
+
-- be subject to the RLS policies it is being used to decide.
|
|
61
|
+
create or replace function public.has_capability(cap text)
|
|
62
|
+
returns boolean
|
|
63
|
+
language sql security definer set search_path = '' stable
|
|
64
|
+
as $$
|
|
65
|
+
select public.is_admin() or exists (
|
|
66
|
+
select 1
|
|
67
|
+
from public.user_roles ur
|
|
68
|
+
join public.role_capabilities rc on rc.role_key = ur.role_key
|
|
69
|
+
where ur.user_id = auth.uid()
|
|
70
|
+
and (rc.capability = cap or rc.capability = '*')
|
|
71
|
+
);
|
|
72
|
+
$$;
|
|
73
|
+
|
|
74
|
+
-- The flat capability list for the calling user. Backs /api/permissions/me.
|
|
75
|
+
create or replace function public.my_capabilities()
|
|
76
|
+
returns setof text
|
|
77
|
+
language sql security definer set search_path = '' stable
|
|
78
|
+
as $$
|
|
79
|
+
select '*'::text where public.is_admin()
|
|
80
|
+
union
|
|
81
|
+
select rc.capability
|
|
82
|
+
from public.user_roles ur
|
|
83
|
+
join public.role_capabilities rc on rc.role_key = ur.role_key
|
|
84
|
+
where ur.user_id = auth.uid();
|
|
85
|
+
$$;
|
|
86
|
+
|
|
87
|
+
grant execute on function public.has_capability(text) to authenticated;
|
|
88
|
+
grant execute on function public.my_capabilities() to authenticated;
|
|
89
|
+
|
|
90
|
+
-- Reimplements public.is_admin() to also recognize the 'admin' role, so
|
|
91
|
+
-- every existing admin passes every new capability check automatically,
|
|
92
|
+
-- with zero data migration. Kept as create or replace, not a new
|
|
93
|
+
-- function: its name and signature (returns boolean, no arguments,
|
|
94
|
+
-- language sql security definer set search_path = '', stable) never
|
|
95
|
+
-- change. 30 RLS policies across 4 repos call it by name.
|
|
96
|
+
create or replace function public.is_admin()
|
|
97
|
+
returns boolean
|
|
98
|
+
language sql
|
|
99
|
+
security definer
|
|
100
|
+
set search_path = ''
|
|
101
|
+
stable
|
|
102
|
+
as $$
|
|
103
|
+
select coalesce(
|
|
104
|
+
(select p.is_admin from public.profiles p where p.id = auth.uid()),
|
|
105
|
+
false
|
|
106
|
+
) or exists (
|
|
107
|
+
select 1 from public.user_roles ur
|
|
108
|
+
where ur.user_id = auth.uid() and ur.role_key = 'admin'
|
|
109
|
+
);
|
|
110
|
+
$$;
|
|
111
|
+
|
|
112
|
+
-- Also grant the 'admin' role to a newly-created first-admin account, so
|
|
113
|
+
-- has_capability() sees it the same way is_admin() does. Keeps the exact
|
|
114
|
+
-- existing signature; on_auth_user_created (from 001_baseline.sql)
|
|
115
|
+
-- already points at this function by name, and create or replace is
|
|
116
|
+
-- enough to update its behavior.
|
|
117
|
+
create or replace function public.handle_new_user()
|
|
118
|
+
returns trigger
|
|
119
|
+
language plpgsql
|
|
120
|
+
security definer set search_path = ''
|
|
121
|
+
as $$
|
|
122
|
+
declare
|
|
123
|
+
profile_count int;
|
|
124
|
+
should_be_admin boolean;
|
|
125
|
+
begin
|
|
126
|
+
select count(*) into profile_count from public.profiles;
|
|
127
|
+
|
|
128
|
+
-- Only the very first account is auto-promoted. Never trust
|
|
129
|
+
-- raw_user_meta_data for this: it is fully client-controlled — anyone can
|
|
130
|
+
-- pass an arbitrary `options.data` straight to Supabase's own
|
|
131
|
+
-- /auth/v1/signup endpoint, bypassing this project's own /api/auth/signup
|
|
132
|
+
-- route entirely — so honoring an `is_admin` key there let any new
|
|
133
|
+
-- signup grant itself admin.
|
|
134
|
+
should_be_admin := profile_count = 0;
|
|
135
|
+
|
|
136
|
+
insert into public.profiles (id, email, username, display_name, is_admin)
|
|
137
|
+
values (
|
|
138
|
+
new.id,
|
|
139
|
+
new.email,
|
|
140
|
+
new.raw_user_meta_data ->> 'username',
|
|
141
|
+
new.raw_user_meta_data ->> 'display_name',
|
|
142
|
+
should_be_admin
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if should_be_admin then
|
|
146
|
+
insert into public.user_roles (user_id, role_key) values (new.id, 'admin')
|
|
147
|
+
on conflict do nothing;
|
|
148
|
+
end if;
|
|
149
|
+
|
|
150
|
+
return new;
|
|
151
|
+
end;
|
|
152
|
+
$$;
|
|
153
|
+
|
|
154
|
+
-- RLS on the three new tables.
|
|
155
|
+
alter table public.roles enable row level security;
|
|
156
|
+
alter table public.role_capabilities enable row level security;
|
|
157
|
+
alter table public.user_roles enable row level security;
|
|
158
|
+
|
|
159
|
+
drop policy if exists "Enable read access for authenticated users on roles" on public.roles;
|
|
160
|
+
|
|
161
|
+
create policy "Enable read access for authenticated users on roles"
|
|
162
|
+
on public.roles
|
|
163
|
+
for select
|
|
164
|
+
to authenticated
|
|
165
|
+
using (true);
|
|
166
|
+
|
|
167
|
+
drop policy if exists "Enable insert for admins on roles" on public.roles;
|
|
168
|
+
|
|
169
|
+
create policy "Enable insert for admins on roles"
|
|
170
|
+
on public.roles
|
|
171
|
+
for insert
|
|
172
|
+
to authenticated
|
|
173
|
+
with check (public.is_admin());
|
|
174
|
+
|
|
175
|
+
drop policy if exists "Enable update for admins on roles" on public.roles;
|
|
176
|
+
|
|
177
|
+
create policy "Enable update for admins on roles"
|
|
178
|
+
on public.roles
|
|
179
|
+
for update
|
|
180
|
+
to authenticated
|
|
181
|
+
using (public.is_admin())
|
|
182
|
+
with check (public.is_admin());
|
|
183
|
+
|
|
184
|
+
drop policy if exists "Enable delete for admins on roles" on public.roles;
|
|
185
|
+
|
|
186
|
+
create policy "Enable delete for admins on roles"
|
|
187
|
+
on public.roles
|
|
188
|
+
for delete
|
|
189
|
+
to authenticated
|
|
190
|
+
using (public.is_admin());
|
|
191
|
+
|
|
192
|
+
drop policy if exists "Enable read access for authenticated users on role_capabilities" on public.role_capabilities;
|
|
193
|
+
|
|
194
|
+
create policy "Enable read access for authenticated users on role_capabilities"
|
|
195
|
+
on public.role_capabilities
|
|
196
|
+
for select
|
|
197
|
+
to authenticated
|
|
198
|
+
using (true);
|
|
199
|
+
|
|
200
|
+
drop policy if exists "Enable insert for admins on role_capabilities" on public.role_capabilities;
|
|
201
|
+
|
|
202
|
+
create policy "Enable insert for admins on role_capabilities"
|
|
203
|
+
on public.role_capabilities
|
|
204
|
+
for insert
|
|
205
|
+
to authenticated
|
|
206
|
+
with check (public.is_admin());
|
|
207
|
+
|
|
208
|
+
drop policy if exists "Enable update for admins on role_capabilities" on public.role_capabilities;
|
|
209
|
+
|
|
210
|
+
create policy "Enable update for admins on role_capabilities"
|
|
211
|
+
on public.role_capabilities
|
|
212
|
+
for update
|
|
213
|
+
to authenticated
|
|
214
|
+
using (public.is_admin())
|
|
215
|
+
with check (public.is_admin());
|
|
216
|
+
|
|
217
|
+
drop policy if exists "Enable delete for admins on role_capabilities" on public.role_capabilities;
|
|
218
|
+
|
|
219
|
+
create policy "Enable delete for admins on role_capabilities"
|
|
220
|
+
on public.role_capabilities
|
|
221
|
+
for delete
|
|
222
|
+
to authenticated
|
|
223
|
+
using (public.is_admin());
|
|
224
|
+
|
|
225
|
+
-- A user may always read their own role grants; only an admin reads
|
|
226
|
+
-- everyone's. Every write is admin-only, with no exception: with check
|
|
227
|
+
-- (true) here would let any authenticated user grant themselves the
|
|
228
|
+
-- 'admin' role, a privilege-escalation hole identical in shape to the
|
|
229
|
+
-- one closed in 002_admin_hardening.sql.
|
|
230
|
+
drop policy if exists "Enable read access for own or admin on user_roles" on public.user_roles;
|
|
231
|
+
|
|
232
|
+
create policy "Enable read access for own or admin on user_roles"
|
|
233
|
+
on public.user_roles
|
|
234
|
+
for select
|
|
235
|
+
to authenticated
|
|
236
|
+
using (user_id = auth.uid() or public.is_admin());
|
|
237
|
+
|
|
238
|
+
drop policy if exists "Enable insert for admins on user_roles" on public.user_roles;
|
|
239
|
+
|
|
240
|
+
create policy "Enable insert for admins on user_roles"
|
|
241
|
+
on public.user_roles
|
|
242
|
+
for insert
|
|
243
|
+
to authenticated
|
|
244
|
+
with check (public.is_admin());
|
|
245
|
+
|
|
246
|
+
drop policy if exists "Enable update for admins on user_roles" on public.user_roles;
|
|
247
|
+
|
|
248
|
+
create policy "Enable update for admins on user_roles"
|
|
249
|
+
on public.user_roles
|
|
250
|
+
for update
|
|
251
|
+
to authenticated
|
|
252
|
+
using (public.is_admin())
|
|
253
|
+
with check (public.is_admin());
|
|
254
|
+
|
|
255
|
+
drop policy if exists "Enable delete for admins on user_roles" on public.user_roles;
|
|
256
|
+
|
|
257
|
+
create policy "Enable delete for admins on user_roles"
|
|
258
|
+
on public.user_roles
|
|
259
|
+
for delete
|
|
260
|
+
to authenticated
|
|
261
|
+
using (public.is_admin());
|
|
262
|
+
|
|
263
|
+
-- Rewrite this repo's own three policies to named capabilities. Since
|
|
264
|
+
-- has_capability() folds in is_admin() through the 'admin' role check,
|
|
265
|
+
-- every existing admin is unaffected by this change.
|
|
266
|
+
drop policy if exists "Enable read access for authenticated users" on public.profiles;
|
|
267
|
+
|
|
268
|
+
create policy "Enable read access for authenticated users"
|
|
269
|
+
on public.profiles
|
|
270
|
+
for select
|
|
271
|
+
to authenticated, dashboard_user
|
|
272
|
+
using (auth.uid() = id or public.has_capability('users:read'));
|
|
273
|
+
|
|
274
|
+
drop policy if exists "Enable insert for authenticated users only" on public.settings;
|
|
275
|
+
|
|
276
|
+
create policy "Enable insert for authenticated users only"
|
|
277
|
+
on public.settings
|
|
278
|
+
for insert
|
|
279
|
+
to authenticated, dashboard_user
|
|
280
|
+
with check (public.has_capability('settings:manage'));
|
|
281
|
+
|
|
282
|
+
drop policy if exists "Enable update for authenticated users on settings" on public.settings;
|
|
283
|
+
|
|
284
|
+
create policy "Enable update for authenticated users on settings"
|
|
285
|
+
on public.settings
|
|
286
|
+
for update
|
|
287
|
+
to authenticated, dashboard_user
|
|
288
|
+
using (public.has_capability('settings:manage'))
|
|
289
|
+
with check (public.has_capability('settings:manage'));
|
|
290
|
+
|
|
291
|
+
drop policy if exists "Enable update for authenticated users on healthcheck" on public.healthcheck;
|
|
292
|
+
|
|
293
|
+
create policy "Enable update for authenticated users on healthcheck"
|
|
294
|
+
on public.healthcheck
|
|
295
|
+
for update
|
|
296
|
+
to authenticated, dashboard_user
|
|
297
|
+
using (public.has_capability('settings:manage'))
|
|
298
|
+
with check (public.has_capability('settings:manage'));
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -39,11 +39,10 @@
|
|
|
39
39
|
"@nuxt/eslint": "^1.16.0",
|
|
40
40
|
"@nuxt/ui": "^4.9.0",
|
|
41
41
|
"@nuxtjs/supabase": "^2.0.9",
|
|
42
|
-
"@plutocms/pluto": "^0.
|
|
42
|
+
"@plutocms/pluto": "^0.5.0",
|
|
43
43
|
"@plutocms/utils": "^0.2.1",
|
|
44
44
|
"postgres": "^3.4.9",
|
|
45
|
-
"supabase": "^2.109.1"
|
|
46
|
-
"yup": "1.7.1"
|
|
45
|
+
"supabase": "^2.109.1"
|
|
47
46
|
},
|
|
48
47
|
"devDependencies": {
|
|
49
48
|
"@antfu/eslint-config": "^6.2.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PlutoMigrationFile } from '../../../shared/types/migrations'
|
|
2
2
|
import type { MigrationFileResult } from '../../utils/migrations'
|
|
3
|
-
import {
|
|
3
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
4
4
|
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
5
5
|
import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
|
|
6
6
|
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
@@ -11,7 +11,7 @@ interface Payload {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export default defineEventHandler(async (event) => {
|
|
14
|
-
await
|
|
14
|
+
await requireCapability(event, 'system:migrate')
|
|
15
15
|
|
|
16
16
|
const body = await readBody<Payload | undefined>(event)
|
|
17
17
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
2
2
|
import { getMigrationStatus } from '../../utils/pending-migrations'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
-
await
|
|
5
|
+
await requireCapability(event, 'system:migrate')
|
|
6
6
|
|
|
7
7
|
const status = await getMigrationStatus(event)
|
|
8
8
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The calling user's own capability list. No admin gate — every signed-in
|
|
5
|
+
* user needs this to know what their own UI should show. A signed-out
|
|
6
|
+
* caller gets an empty list, not a 401, so the client composable never
|
|
7
|
+
* has to special-case this endpoint's error path.
|
|
8
|
+
*/
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
10
|
+
const user = await serverSupabaseUser(event)
|
|
11
|
+
|
|
12
|
+
if (!user) {
|
|
13
|
+
return { capabilities: [] as string[] }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
17
|
+
const { data, error } = await client.rpc('my_capabilities')
|
|
18
|
+
|
|
19
|
+
if (error) {
|
|
20
|
+
throw createError({ statusCode: 500, statusMessage: error.message })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { capabilities: data ?? [] }
|
|
24
|
+
})
|
|
@@ -3,24 +3,23 @@ import { serverSupabaseClient } from '#supabase/server'
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
const client = await serverSupabaseClient<Database>(event)
|
|
5
5
|
|
|
6
|
-
const { data } = await client
|
|
6
|
+
const { data } = await client
|
|
7
|
+
.from('settings')
|
|
8
|
+
.select('setting_key, setting_name, setting_value')
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
Database['public']['Tables']['settings']['Row']['setting_name']
|
|
10
|
-
|
|
11
|
-
// @ts-expect-error: Object.fromEntries may not infer the correct type for settings
|
|
12
|
-
const settings: Record<SettingsKey, string> = {
|
|
13
|
-
...Object.fromEntries(
|
|
14
|
-
data?.map((item) => [item.setting_name, item.setting_value]) || []
|
|
15
|
-
),
|
|
16
|
-
}
|
|
10
|
+
const settings: Record<string, string> = {}
|
|
17
11
|
|
|
18
12
|
data?.forEach((item) => {
|
|
19
|
-
|
|
13
|
+
// setting_key is null only for a row written before
|
|
14
|
+
// db/migrations/003_settings_generic_keys.sql backfilled it — fall
|
|
15
|
+
// back to the enum column so a mid-migration read never drops a row.
|
|
16
|
+
const key = item.setting_key ?? item.setting_name
|
|
17
|
+
|
|
18
|
+
if (!key || !item.setting_value) {
|
|
20
19
|
return
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
settings[
|
|
22
|
+
settings[key] = item.setting_value
|
|
24
23
|
})
|
|
25
24
|
|
|
26
25
|
return { settings }
|
|
@@ -1,36 +1,30 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import {
|
|
2
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
// A key is lowercase segments separated by dots, e.g. 'website_title' or
|
|
5
|
+
// 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
|
|
6
|
+
// constraint in db/migrations/003_settings_generic_keys.sql — this turns
|
|
7
|
+
// a violation into a clean 400 instead of a raw Postgres error. A layer
|
|
8
|
+
// other than core must namespace its own keys so two layers can never
|
|
9
|
+
// collide.
|
|
10
|
+
const SETTING_KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/
|
|
11
|
+
const MAX_SETTING_VALUE_LENGTH = 10_000
|
|
12
12
|
|
|
13
13
|
export default defineEventHandler(async (event) => {
|
|
14
|
-
await
|
|
15
|
-
|
|
16
|
-
type SettingName =
|
|
17
|
-
Database['public']['Tables']['settings']['Insert']['setting_name']
|
|
18
|
-
type FormBody = Record<SettingName, string>
|
|
14
|
+
await requireCapability(event, 'settings:manage')
|
|
19
15
|
|
|
20
16
|
const client = await serverSupabaseClient<Database>(event)
|
|
21
|
-
const body = await readBody<
|
|
17
|
+
const body = await readBody<Record<string, unknown>>(event)
|
|
22
18
|
|
|
23
19
|
if (!body || typeof body !== 'object') {
|
|
24
20
|
throw createError({ statusCode: 400, statusMessage: 'No payload sent.' })
|
|
25
21
|
}
|
|
26
22
|
|
|
27
23
|
const transformed = Object.entries(body).map(([key, value]) => {
|
|
28
|
-
if (
|
|
29
|
-
!KNOWN_SETTING_KEYS.includes(key as (typeof KNOWN_SETTING_KEYS)[number])
|
|
30
|
-
) {
|
|
24
|
+
if (!SETTING_KEY_PATTERN.test(key)) {
|
|
31
25
|
throw createError({
|
|
32
26
|
statusCode: 400,
|
|
33
|
-
statusMessage: `
|
|
27
|
+
statusMessage: `Invalid setting key: ${key}`,
|
|
34
28
|
})
|
|
35
29
|
}
|
|
36
30
|
|
|
@@ -41,15 +35,22 @@ export default defineEventHandler(async (event) => {
|
|
|
41
35
|
})
|
|
42
36
|
}
|
|
43
37
|
|
|
38
|
+
if (value.length > MAX_SETTING_VALUE_LENGTH) {
|
|
39
|
+
throw createError({
|
|
40
|
+
statusCode: 400,
|
|
41
|
+
statusMessage: `Setting "${key}" is too long.`,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
44
45
|
return {
|
|
45
|
-
|
|
46
|
+
setting_key: key,
|
|
46
47
|
setting_value: value,
|
|
47
48
|
}
|
|
48
49
|
})
|
|
49
50
|
|
|
50
51
|
const { data, error } = await client
|
|
51
52
|
.from('settings')
|
|
52
|
-
.upsert(transformed, { onConflict: '
|
|
53
|
+
.upsert(transformed, { onConflict: 'setting_key' })
|
|
53
54
|
.select()
|
|
54
55
|
|
|
55
56
|
if (error) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import {
|
|
2
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
-
await
|
|
5
|
+
await requireCapability(event, 'users:read')
|
|
6
6
|
|
|
7
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
8
8
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import {
|
|
2
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
-
await
|
|
5
|
+
await requireCapability(event, 'users:read')
|
|
6
6
|
|
|
7
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
8
8
|
|
|
@@ -1,46 +1,19 @@
|
|
|
1
1
|
import type { H3Event } from 'h3'
|
|
2
|
-
import {
|
|
2
|
+
import { ALL_CAPABILITIES, requireCapability } from './capability-guard'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Guards a server route so only
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `User` row. The claims object has no `id` field — the user's id is the
|
|
10
|
-
* `sub` claim. Using `user.id` here silently queries `eq('id', undefined)`,
|
|
11
|
-
* which matches zero rows and looks exactly like "not an admin" even for a
|
|
12
|
-
* real admin. Always read `user.sub`, never `user.id`.
|
|
13
|
-
*
|
|
14
|
-
* Reads `is_admin` from `public.profiles`, never from `user_metadata`. The
|
|
15
|
-
* `handle_new_user` trigger never writes `is_admin` back to
|
|
16
|
-
* `auth.users.raw_user_meta_data`, so `user_metadata.is_admin` can be unset
|
|
17
|
-
* even for the first admin. `public.profiles.is_admin` is the source of
|
|
18
|
-
* truth.
|
|
5
|
+
* Guards a server route so only an admin (or a holder of every
|
|
6
|
+
* capability, which is what the built-in admin role grants) can call it.
|
|
7
|
+
* A thin alias over requireCapability, kept for the routes that ask for
|
|
8
|
+
* "admin, full stop" rather than one named capability.
|
|
19
9
|
*
|
|
20
10
|
* Throws a 401 if there is no logged-in user, or a 403 if the user is not
|
|
21
|
-
* an admin. Returns the claims on success.
|
|
11
|
+
* an admin. Returns the claims on success. See capability-guard.ts for
|
|
12
|
+
* the user.sub vs user.id note: this function forwards straight into
|
|
13
|
+
* requireCapability, which never reads either field.
|
|
22
14
|
*/
|
|
23
15
|
export async function requireAdmin(event: H3Event) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const client = await serverSupabaseClient<Database>(event)
|
|
31
|
-
|
|
32
|
-
const { data: profile, error } = await client
|
|
33
|
-
.from('profiles')
|
|
34
|
-
.select('is_admin')
|
|
35
|
-
.eq('id', user.sub)
|
|
36
|
-
.single()
|
|
37
|
-
|
|
38
|
-
if (error || !profile?.is_admin) {
|
|
39
|
-
throw createError({
|
|
40
|
-
statusCode: 403,
|
|
41
|
-
statusMessage: 'Your account is not an admin.',
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return user
|
|
16
|
+
return requireCapability(event, ALL_CAPABILITIES, {
|
|
17
|
+
message: 'Your account is not an admin.',
|
|
18
|
+
})
|
|
46
19
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
3
|
+
|
|
4
|
+
/** The wildcard capability. A holder passes every capability check. */
|
|
5
|
+
export const ALL_CAPABILITIES = '*'
|
|
6
|
+
|
|
7
|
+
interface RequireCapabilityOptions {
|
|
8
|
+
/** Overrides the 403 message. */
|
|
9
|
+
message?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Guards a server route on a named capability.
|
|
14
|
+
*
|
|
15
|
+
* `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
|
|
16
|
+
* `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
|
|
17
|
+
* `User` row — used here only to distinguish "not logged in" (401) from
|
|
18
|
+
* "logged in but missing the capability" (403). The capability check
|
|
19
|
+
* itself runs in the database, through public.has_capability(), a
|
|
20
|
+
* SECURITY DEFINER function that reads auth.uid() directly from the same
|
|
21
|
+
* session — user.sub/user.id is never passed to it.
|
|
22
|
+
*
|
|
23
|
+
* public.has_capability() folds in public.is_admin(), so every existing
|
|
24
|
+
* admin passes every capability check with no data migration.
|
|
25
|
+
*
|
|
26
|
+
* Throws a 401 if there is no logged-in user, or a 403 if the user is
|
|
27
|
+
* missing the capability. Returns the claims on success.
|
|
28
|
+
*/
|
|
29
|
+
export async function requireCapability(
|
|
30
|
+
event: H3Event,
|
|
31
|
+
capability: string,
|
|
32
|
+
options?: RequireCapabilityOptions
|
|
33
|
+
) {
|
|
34
|
+
const user = await serverSupabaseUser(event)
|
|
35
|
+
|
|
36
|
+
if (!user) {
|
|
37
|
+
throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
41
|
+
const { data, error } = await client.rpc('has_capability', { cap: capability })
|
|
42
|
+
|
|
43
|
+
if (error || data !== true) {
|
|
44
|
+
throw createError({
|
|
45
|
+
statusCode: 403,
|
|
46
|
+
statusMessage:
|
|
47
|
+
options?.message ?? `Your account is missing the "${capability}" permission.`,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return user
|
|
52
|
+
}
|
package/shared/types/supabase.ts
CHANGED
|
@@ -253,30 +253,113 @@ export type Database = {
|
|
|
253
253
|
}
|
|
254
254
|
Relationships: []
|
|
255
255
|
}
|
|
256
|
+
role_capabilities: {
|
|
257
|
+
Row: {
|
|
258
|
+
capability: string
|
|
259
|
+
role_key: string
|
|
260
|
+
}
|
|
261
|
+
Insert: {
|
|
262
|
+
capability: string
|
|
263
|
+
role_key: string
|
|
264
|
+
}
|
|
265
|
+
Update: {
|
|
266
|
+
capability?: string
|
|
267
|
+
role_key?: string
|
|
268
|
+
}
|
|
269
|
+
Relationships: [
|
|
270
|
+
{
|
|
271
|
+
foreignKeyName: "role_capabilities_role_key_fkey"
|
|
272
|
+
columns: ["role_key"]
|
|
273
|
+
isOneToOne: false
|
|
274
|
+
referencedRelation: "roles"
|
|
275
|
+
referencedColumns: ["key"]
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
roles: {
|
|
280
|
+
Row: {
|
|
281
|
+
created_at: string
|
|
282
|
+
description: string | null
|
|
283
|
+
is_builtin: boolean
|
|
284
|
+
key: string
|
|
285
|
+
label: string
|
|
286
|
+
}
|
|
287
|
+
Insert: {
|
|
288
|
+
created_at?: string
|
|
289
|
+
description?: string | null
|
|
290
|
+
is_builtin?: boolean
|
|
291
|
+
key: string
|
|
292
|
+
label: string
|
|
293
|
+
}
|
|
294
|
+
Update: {
|
|
295
|
+
created_at?: string
|
|
296
|
+
description?: string | null
|
|
297
|
+
is_builtin?: boolean
|
|
298
|
+
key?: string
|
|
299
|
+
label?: string
|
|
300
|
+
}
|
|
301
|
+
Relationships: []
|
|
302
|
+
}
|
|
256
303
|
settings: {
|
|
257
304
|
Row: {
|
|
258
305
|
id: number
|
|
259
|
-
|
|
306
|
+
setting_key: string | null
|
|
307
|
+
setting_name: Database["public"]["Enums"]["tsettings"] | null
|
|
260
308
|
setting_value: string
|
|
261
309
|
}
|
|
262
310
|
Insert: {
|
|
263
311
|
id?: number
|
|
264
|
-
|
|
312
|
+
setting_key?: string | null
|
|
313
|
+
setting_name?: Database["public"]["Enums"]["tsettings"] | null
|
|
265
314
|
setting_value: string
|
|
266
315
|
}
|
|
267
316
|
Update: {
|
|
268
317
|
id?: number
|
|
269
|
-
|
|
318
|
+
setting_key?: string | null
|
|
319
|
+
setting_name?: Database["public"]["Enums"]["tsettings"] | null
|
|
270
320
|
setting_value?: string
|
|
271
321
|
}
|
|
272
322
|
Relationships: []
|
|
273
323
|
}
|
|
324
|
+
user_roles: {
|
|
325
|
+
Row: {
|
|
326
|
+
granted_at: string
|
|
327
|
+
role_key: string
|
|
328
|
+
user_id: string
|
|
329
|
+
}
|
|
330
|
+
Insert: {
|
|
331
|
+
granted_at?: string
|
|
332
|
+
role_key: string
|
|
333
|
+
user_id: string
|
|
334
|
+
}
|
|
335
|
+
Update: {
|
|
336
|
+
granted_at?: string
|
|
337
|
+
role_key?: string
|
|
338
|
+
user_id?: string
|
|
339
|
+
}
|
|
340
|
+
Relationships: [
|
|
341
|
+
{
|
|
342
|
+
foreignKeyName: "user_roles_role_key_fkey"
|
|
343
|
+
columns: ["role_key"]
|
|
344
|
+
isOneToOne: false
|
|
345
|
+
referencedRelation: "roles"
|
|
346
|
+
referencedColumns: ["key"]
|
|
347
|
+
},
|
|
348
|
+
]
|
|
349
|
+
}
|
|
274
350
|
}
|
|
275
351
|
Views: {
|
|
276
352
|
[_ in never]: never
|
|
277
353
|
}
|
|
278
354
|
Functions: {
|
|
279
|
-
|
|
355
|
+
has_capability: {
|
|
356
|
+
Args: { cap: string }
|
|
357
|
+
Returns: boolean
|
|
358
|
+
}
|
|
359
|
+
my_capabilities: {
|
|
360
|
+
Args: Record<PropertyKey, never>
|
|
361
|
+
Returns: string[]
|
|
362
|
+
}
|
|
280
363
|
}
|
|
281
364
|
Enums: {
|
|
282
365
|
thealthcheck: "first_setup"
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { object, string } from 'yup'
|
|
3
|
-
|
|
4
|
-
type Form = Partial<
|
|
5
|
-
Record<
|
|
6
|
-
Database['public']['Tables']['settings']['Insert']['setting_name'],
|
|
7
|
-
string
|
|
8
|
-
>
|
|
9
|
-
>
|
|
10
|
-
|
|
11
|
-
useHead({
|
|
12
|
-
title: 'Settings',
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
const toast = useToast()
|
|
16
|
-
|
|
17
|
-
const has_settings_modified = useState('has_settings_modified', () => {
|
|
18
|
-
return Date.now()
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
const { data } = await useFetch('/api/settings')
|
|
22
|
-
|
|
23
|
-
const schema = object({
|
|
24
|
-
website_title: string().required().label('Website Title'),
|
|
25
|
-
website_description: string().required().label('Website Description'),
|
|
26
|
-
website_url: string().url().required().label('Website URL'),
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const form = ref<Form>({
|
|
30
|
-
website_title: data.value?.settings.website_title || '',
|
|
31
|
-
website_description: data.value?.settings.website_description || '',
|
|
32
|
-
website_url: data.value?.settings.website_url || '',
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
const isSubmitting = ref<boolean>(false)
|
|
36
|
-
|
|
37
|
-
async function submitForm() {
|
|
38
|
-
const payload = form.value
|
|
39
|
-
|
|
40
|
-
isSubmitting.value = true
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
await $fetch('/api/settings/update', {
|
|
44
|
-
method: 'POST',
|
|
45
|
-
body: payload,
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
toast.add({
|
|
49
|
-
title: 'Settings updated successfully',
|
|
50
|
-
color: 'success',
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
has_settings_modified.value = Date.now()
|
|
54
|
-
} catch (error) {
|
|
55
|
-
if (import.meta.dev) {
|
|
56
|
-
console.error('Error updating settings:', error)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
toast.add({
|
|
60
|
-
title: 'Failed to update settings.',
|
|
61
|
-
color: 'error',
|
|
62
|
-
})
|
|
63
|
-
} finally {
|
|
64
|
-
isSubmitting.value = false
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
</script>
|
|
68
|
-
|
|
69
|
-
<template>
|
|
70
|
-
<AdminView>
|
|
71
|
-
<h1 class="text-3xl font-bold lg:text-4xl">Settings</h1>
|
|
72
|
-
|
|
73
|
-
<UCard>
|
|
74
|
-
<UForm :schema="schema" :state="form" @submit="submitForm">
|
|
75
|
-
<div class="flex flex-col gap-y-6">
|
|
76
|
-
<UFormField
|
|
77
|
-
label="Website Title"
|
|
78
|
-
name="website_title"
|
|
79
|
-
class="w-full lg:w-1/2"
|
|
80
|
-
>
|
|
81
|
-
<UInput
|
|
82
|
-
v-model="form.website_title"
|
|
83
|
-
placeholder="e.g. My Website"
|
|
84
|
-
/>
|
|
85
|
-
</UFormField>
|
|
86
|
-
|
|
87
|
-
<UFormField
|
|
88
|
-
label="Website Description"
|
|
89
|
-
name="website_description"
|
|
90
|
-
class="w-full lg:w-1/2"
|
|
91
|
-
>
|
|
92
|
-
<UInput
|
|
93
|
-
v-model="form.website_description"
|
|
94
|
-
placeholder="e.g. My Website Description"
|
|
95
|
-
/>
|
|
96
|
-
</UFormField>
|
|
97
|
-
|
|
98
|
-
<UFormField
|
|
99
|
-
label="Website URL"
|
|
100
|
-
name="website_url"
|
|
101
|
-
class="w-full lg:w-1/2"
|
|
102
|
-
>
|
|
103
|
-
<UInput
|
|
104
|
-
v-model="form.website_url"
|
|
105
|
-
type="url"
|
|
106
|
-
placeholder="e.g. https://example.com"
|
|
107
|
-
/>
|
|
108
|
-
</UFormField>
|
|
109
|
-
|
|
110
|
-
<div class="flex">
|
|
111
|
-
<UButton
|
|
112
|
-
:loading="isSubmitting"
|
|
113
|
-
:disabled="isSubmitting"
|
|
114
|
-
type="submit"
|
|
115
|
-
icon="lucide:save"
|
|
116
|
-
class="w-full justify-center sm:w-auto"
|
|
117
|
-
>
|
|
118
|
-
Save
|
|
119
|
-
</UButton>
|
|
120
|
-
</div>
|
|
121
|
-
</div>
|
|
122
|
-
</UForm>
|
|
123
|
-
</UCard>
|
|
124
|
-
</AdminView>
|
|
125
|
-
</template>
|