@glw907/cairn-cms 0.1.0 → 0.3.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/dist/auth.d.ts +10 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +44 -5
- package/dist/components/AdminLayout.svelte +120 -8
- package/dist/components/AdminLayout.svelte.d.ts +6 -0
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/AdminList.svelte +5 -13
- package/dist/components/AdminList.svelte.d.ts +0 -3
- package/dist/components/AdminList.svelte.d.ts.map +1 -1
- package/dist/components/EditPage.svelte +4 -4
- package/dist/components/LoginPage.svelte +1 -1
- package/dist/components/ManageAdmins.svelte +84 -0
- package/dist/components/ManageAdmins.svelte.d.ts +10 -0
- package/dist/components/ManageAdmins.svelte.d.ts.map +1 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/sveltekit/index.d.ts +31 -0
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +81 -2
- package/package.json +1 -1
- package/src/lib/auth.ts +60 -5
- package/src/lib/components/AdminLayout.svelte +120 -8
- package/src/lib/components/AdminList.svelte +5 -13
- package/src/lib/components/EditPage.svelte +4 -4
- package/src/lib/components/LoginPage.svelte +1 -1
- package/src/lib/components/ManageAdmins.svelte +84 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/sveltekit/index.ts +106 -2
package/dist/auth.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { KVNamespace } from '@cloudflare/workers-types';
|
|
2
|
+
/** Two-tier, per-site role. `owner`s manage the editor allowlist; `editor`s only edit content. */
|
|
3
|
+
export type Role = 'owner' | 'editor';
|
|
2
4
|
export interface Editor {
|
|
3
5
|
email: string;
|
|
4
6
|
name: string;
|
|
7
|
+
role: Role;
|
|
5
8
|
}
|
|
6
9
|
export declare const SESSION_COOKIE = "cairn_session";
|
|
7
10
|
export declare const SESSION_MAX_AGE: number;
|
|
@@ -11,6 +14,12 @@ export declare function createMagicLink(email: string, secret: string, kv: KVNam
|
|
|
11
14
|
export declare function redeemMagicToken(token: string, secret: string, kv: KVNamespace): Promise<string | null>;
|
|
12
15
|
export declare function createSession(editor: Editor, secret: string): Promise<string>;
|
|
13
16
|
export declare function verifySession(token: string, secret: string): Promise<Editor | null>;
|
|
14
|
-
/** Look up an editor in the KV allowlist (`editor:<email>` →
|
|
17
|
+
/** Look up an editor in the KV allowlist (`editor:<email>` → `{name, role}`). */
|
|
15
18
|
export declare function lookupEditor(email: string, kv: KVNamespace): Promise<Editor | null>;
|
|
19
|
+
/** Every allowlisted editor, sorted by email — the manage-admins list. */
|
|
20
|
+
export declare function listEditors(kv: KVNamespace): Promise<Editor[]>;
|
|
21
|
+
/** Add or update an allowlist entry (JSON value). Email is normalized. */
|
|
22
|
+
export declare function setEditor(email: string, name: string, role: Role, kv: KVNamespace): Promise<void>;
|
|
23
|
+
/** Remove an allowlist entry. */
|
|
24
|
+
export declare function removeEditor(email: string, kv: KVNamespace): Promise<void>;
|
|
16
25
|
//# sourceMappingURL=auth.d.ts.map
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/lib/auth.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/lib/auth.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,kGAAkG;AAClG,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtC,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,eAAO,MAAM,cAAc,kBAAkB,CAAC;AAK9C,eAAO,MAAM,eAAe,QAAsB,CAAC;AA8DnD,mFAAmF;AACnF,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED,+FAA+F;AAC/F,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxB;AAMD,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGnF;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKzF;AAyBD,iFAAiF;AACjF,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKzF;AAED,0EAA0E;AAC1E,wBAAsB,WAAW,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpE;AAED,0EAA0E;AAC1E,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,IAAI,EACV,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,iCAAiC;AACjC,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhF"}
|
package/dist/auth.js
CHANGED
|
@@ -81,13 +81,52 @@ export async function verifySession(token, secret) {
|
|
|
81
81
|
const payload = await verifyToken(token, secret);
|
|
82
82
|
if (!payload || Date.now() > payload.exp)
|
|
83
83
|
return null;
|
|
84
|
-
|
|
84
|
+
// Sessions signed before roles existed carry no `role` — treat them as plain editors.
|
|
85
|
+
return { email: payload.email, name: payload.name, role: payload.role ?? 'editor' };
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
+
const KEY_PREFIX = 'editor:';
|
|
88
|
+
/**
|
|
89
|
+
* Decode a stored allowlist value into name + role. Current entries are JSON
|
|
90
|
+
* (`{"name","role"}`); legacy entries are a bare display-name string, read as `editor`
|
|
91
|
+
* so the allowlist migrates lazily — re-saving an entry upgrades it to the JSON shape.
|
|
92
|
+
*/
|
|
93
|
+
function parseEditorValue(raw) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
if (parsed && typeof parsed.name === 'string') {
|
|
97
|
+
return { name: parsed.name, role: parsed.role === 'owner' ? 'owner' : 'editor' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Not JSON — legacy bare display-name; treat as editor.
|
|
102
|
+
}
|
|
103
|
+
return { name: raw, role: 'editor' };
|
|
104
|
+
}
|
|
105
|
+
function serializeEditorValue(name, role) {
|
|
106
|
+
return JSON.stringify({ name, role });
|
|
107
|
+
}
|
|
108
|
+
/** Look up an editor in the KV allowlist (`editor:<email>` → `{name, role}`). */
|
|
87
109
|
export async function lookupEditor(email, kv) {
|
|
88
110
|
const normalized = email.trim().toLowerCase();
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
111
|
+
const raw = await kv.get(`${KEY_PREFIX}${normalized}`);
|
|
112
|
+
if (raw === null)
|
|
91
113
|
return null;
|
|
92
|
-
return { email: normalized,
|
|
114
|
+
return { email: normalized, ...parseEditorValue(raw) };
|
|
115
|
+
}
|
|
116
|
+
/** Every allowlisted editor, sorted by email — the manage-admins list. */
|
|
117
|
+
export async function listEditors(kv) {
|
|
118
|
+
const { keys } = await kv.list({ prefix: KEY_PREFIX });
|
|
119
|
+
const editors = await Promise.all(keys.map(async ({ name: key }) => {
|
|
120
|
+
const raw = (await kv.get(key)) ?? '';
|
|
121
|
+
return { email: key.slice(KEY_PREFIX.length), ...parseEditorValue(raw) };
|
|
122
|
+
}));
|
|
123
|
+
return editors.sort((a, b) => a.email.localeCompare(b.email));
|
|
124
|
+
}
|
|
125
|
+
/** Add or update an allowlist entry (JSON value). Email is normalized. */
|
|
126
|
+
export async function setEditor(email, name, role, kv) {
|
|
127
|
+
await kv.put(`${KEY_PREFIX}${email.trim().toLowerCase()}`, serializeEditorValue(name, role));
|
|
128
|
+
}
|
|
129
|
+
/** Remove an allowlist entry. */
|
|
130
|
+
export async function removeEditor(email, kv) {
|
|
131
|
+
await kv.delete(`${KEY_PREFIX}${email.trim().toLowerCase()}`);
|
|
93
132
|
}
|
|
@@ -1,18 +1,130 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// Neutral admin chrome
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Neutral admin chrome, shared across sites so the tool looks identical everywhere (only the
|
|
3
|
+
// adapter's siteName varies). When signed in it's a responsive DaisyUI drawer+navbar shell
|
|
4
|
+
// (`drawer lg:drawer-open` — sidebar pinned on desktop, slide-over + hamburger on mobile),
|
|
5
|
+
// patterned on scosman/CMSaasStarter's `(admin)/(menu)` layout. The nav is data-driven and
|
|
6
|
+
// role-gated, so a new surface is one entry in `nav` (plus its route + component). Signed out
|
|
7
|
+
// (the login page lives under this layout) it falls back to a minimal centered shell.
|
|
8
|
+
// Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
|
|
5
9
|
import type { Snippet } from 'svelte';
|
|
10
|
+
import type { Editor } from '../auth';
|
|
6
11
|
|
|
7
|
-
let {
|
|
12
|
+
let {
|
|
13
|
+
data,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
data: { siteName: string; editor: Editor | null; pathname: string };
|
|
17
|
+
children: Snippet;
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
interface NavItem {
|
|
21
|
+
href: string;
|
|
22
|
+
label: string;
|
|
23
|
+
icon: Snippet;
|
|
24
|
+
active: boolean;
|
|
25
|
+
/** Owner-only surface — hidden from regular editors. */
|
|
26
|
+
owner?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const nav = $derived<NavItem[]>([
|
|
30
|
+
{
|
|
31
|
+
href: '/admin',
|
|
32
|
+
label: 'Content',
|
|
33
|
+
icon: contentIcon,
|
|
34
|
+
active: data.pathname === '/admin' || data.pathname.startsWith('/admin/edit'),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
href: '/admin/admins',
|
|
38
|
+
label: 'Editors',
|
|
39
|
+
icon: editorsIcon,
|
|
40
|
+
owner: true,
|
|
41
|
+
active: data.pathname.startsWith('/admin/admins'),
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
const visibleNav = $derived(nav.filter((item) => !item.owner || data.editor?.role === 'owner'));
|
|
45
|
+
|
|
46
|
+
// Close the slide-over after a nav tap on mobile (no-op on desktop where it's pinned open).
|
|
47
|
+
function closeDrawer(): void {
|
|
48
|
+
const toggle = document.getElementById('admin-drawer');
|
|
49
|
+
if (toggle instanceof HTMLInputElement) toggle.checked = false;
|
|
50
|
+
}
|
|
8
51
|
</script>
|
|
9
52
|
|
|
53
|
+
{#snippet contentIcon()}
|
|
54
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
55
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
56
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
57
|
+
</svg>
|
|
58
|
+
{/snippet}
|
|
59
|
+
|
|
60
|
+
{#snippet editorsIcon()}
|
|
61
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
62
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
63
|
+
d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-1.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3.5-2.1" />
|
|
64
|
+
</svg>
|
|
65
|
+
{/snippet}
|
|
66
|
+
|
|
10
67
|
<svelte:head>
|
|
11
68
|
<meta name="robots" content="noindex, nofollow" />
|
|
12
69
|
</svelte:head>
|
|
13
70
|
|
|
14
|
-
|
|
15
|
-
<div class="
|
|
16
|
-
|
|
71
|
+
{#if data.editor}
|
|
72
|
+
<div class="drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
|
|
73
|
+
<input id="admin-drawer" type="checkbox" class="drawer-toggle" />
|
|
74
|
+
|
|
75
|
+
<div class="drawer-content">
|
|
76
|
+
<!-- Mobile top bar — the desktop sidebar replaces this at lg. -->
|
|
77
|
+
<div class="navbar bg-base-100 lg:hidden">
|
|
78
|
+
<div class="flex-1">
|
|
79
|
+
<span class="px-2 text-xl font-bold">{data.siteName} CMS</span>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="flex-none">
|
|
82
|
+
<label for="admin-drawer" class="btn btn-square btn-ghost" aria-label="Open menu">
|
|
83
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
84
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
|
85
|
+
</svg>
|
|
86
|
+
</label>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<main class="container px-4 py-6 lg:px-8">
|
|
91
|
+
{@render children()}
|
|
92
|
+
</main>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="drawer-side z-10">
|
|
96
|
+
<label for="admin-drawer" class="drawer-overlay" aria-label="Close menu"></label>
|
|
97
|
+
<div class="flex min-h-full w-80 flex-col bg-base-100 lg:border-r lg:border-base-300">
|
|
98
|
+
<ul class="menu menu-lg grow p-4">
|
|
99
|
+
<li class="menu-title flex flex-row items-center text-xl font-bold text-base-content">
|
|
100
|
+
<span class="grow">{data.siteName} CMS</span>
|
|
101
|
+
<label for="admin-drawer" class="ml-3 cursor-pointer lg:hidden" aria-label="Close menu">✕</label>
|
|
102
|
+
</li>
|
|
103
|
+
{#each visibleNav as item (item.href)}
|
|
104
|
+
<li>
|
|
105
|
+
<a href={item.href} class={item.active ? 'active' : ''} onclick={closeDrawer}>
|
|
106
|
+
{@render item.icon()}
|
|
107
|
+
{item.label}
|
|
108
|
+
</a>
|
|
109
|
+
</li>
|
|
110
|
+
{/each}
|
|
111
|
+
</ul>
|
|
112
|
+
|
|
113
|
+
<div class="border-t border-base-300 p-4">
|
|
114
|
+
<p class="text-sm font-medium">{data.editor.name}</p>
|
|
115
|
+
<p class="text-xs opacity-60">{data.editor.email}</p>
|
|
116
|
+
<form method="POST" action="/admin/auth/logout" class="mt-3">
|
|
117
|
+
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
|
|
118
|
+
</form>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{:else}
|
|
124
|
+
<!-- Signed out (login page): no nav, just a centered surface. -->
|
|
125
|
+
<div class="min-h-screen bg-base-200" data-pagefind-ignore>
|
|
126
|
+
<div class="mx-auto max-w-3xl px-4 py-8">
|
|
127
|
+
{@render children()}
|
|
128
|
+
</div>
|
|
17
129
|
</div>
|
|
18
|
-
|
|
130
|
+
{/if}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Editor } from '../auth';
|
|
2
3
|
type $$ComponentProps = {
|
|
4
|
+
data: {
|
|
5
|
+
siteName: string;
|
|
6
|
+
editor: Editor | null;
|
|
7
|
+
pathname: string;
|
|
8
|
+
};
|
|
3
9
|
children: Snippet;
|
|
4
10
|
};
|
|
5
11
|
declare const AdminLayout: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AdminLayout.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminLayout.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"AdminLayout.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminLayout.svelte.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAWrC,KAAK,gBAAgB,GAAI;IACtB,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACpE,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAyHJ,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// The /admin content list: every collection's files, linking into the editor. Data comes
|
|
3
|
-
// from `adminListLoad` (collections) merged with `adminLayoutLoad` (
|
|
4
|
-
|
|
3
|
+
// from `adminListLoad` (collections) merged with `adminLayoutLoad` (siteName). The shell
|
|
4
|
+
// (AdminLayout) owns the chrome — site title, signed-in identity, nav, sign out — so this
|
|
5
|
+
// page renders only the content body.
|
|
5
6
|
import type { AdminCollectionList } from '../sveltekit';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
|
-
data: {
|
|
9
|
+
data: { collections: AdminCollectionList[] };
|
|
9
10
|
}
|
|
10
11
|
let { data }: Props = $props();
|
|
11
12
|
</script>
|
|
12
13
|
|
|
13
|
-
<
|
|
14
|
-
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
15
|
-
<form method="POST" action="/admin/auth/logout">
|
|
16
|
-
<button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
|
|
17
|
-
</form>
|
|
18
|
-
</div>
|
|
19
|
-
|
|
20
|
-
<p class="mt-2 text-sm opacity-70">
|
|
21
|
-
Signed in as {data.editor?.name} ({data.editor?.email})
|
|
22
|
-
</p>
|
|
14
|
+
<h1 class="text-2xl font-bold">Content</h1>
|
|
23
15
|
|
|
24
16
|
{#each data.collections as collection (collection.type)}
|
|
25
17
|
<section class="mt-8">
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AdminList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminList.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"AdminList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminList.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGtD,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,WAAW,EAAE,mBAAmB,EAAE,CAAA;KAAE,CAAC;CAC9C;AAkCH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -73,14 +73,14 @@
|
|
|
73
73
|
name={field.name}
|
|
74
74
|
required={field.required}
|
|
75
75
|
value={fmString(field.name)}
|
|
76
|
-
class="input
|
|
76
|
+
class="input w-full"
|
|
77
77
|
/>
|
|
78
78
|
</label>
|
|
79
79
|
{:else if field.type === 'textarea'}
|
|
80
80
|
<label class="flex flex-col gap-1">
|
|
81
81
|
<span class="text-sm font-medium">{field.label}</span>
|
|
82
82
|
<textarea name={field.name} required={field.required} rows={field.rows ?? 4}
|
|
83
|
-
class="textarea
|
|
83
|
+
class="textarea w-full">{fmString(field.name)}</textarea>
|
|
84
84
|
</label>
|
|
85
85
|
{:else if field.type === 'tags'}
|
|
86
86
|
<div class="flex flex-col gap-1">
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
<label class="flex flex-col gap-1">
|
|
100
100
|
<span class="text-sm font-medium">{field.label}</span>
|
|
101
101
|
<input type="text" name={field.name} value={fmFreeTags(field.name)}
|
|
102
|
-
placeholder={field.placeholder ?? 'comma, separated'} class="input
|
|
102
|
+
placeholder={field.placeholder ?? 'comma, separated'} class="input w-full" />
|
|
103
103
|
</label>
|
|
104
104
|
{:else if field.type === 'boolean'}
|
|
105
105
|
<label class="flex items-center gap-2 text-sm font-medium">
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
{#if mounted}
|
|
116
116
|
<MarkdownEditor {carta} bind:value={body} mode="tabs" />
|
|
117
117
|
{:else}
|
|
118
|
-
<textarea bind:value={body} rows="20" class="textarea
|
|
118
|
+
<textarea bind:value={body} rows="20" class="textarea w-full font-mono"></textarea>
|
|
119
119
|
{/if}
|
|
120
120
|
</div>
|
|
121
121
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Owner-gated editor management: list the allowlist, change roles, remove editors, add new
|
|
3
|
+
// ones. Reuses the same neutral DaisyUI chrome as the rest of the admin (panels, alerts,
|
|
4
|
+
// table, buttons). Data comes from `adminsLoad` merged with `adminLayoutLoad` (siteName);
|
|
5
|
+
// mutations post to the page's named form actions (`?/add`, `?/remove`, `?/setRole`).
|
|
6
|
+
import type { AdminsData } from '../sveltekit';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
data: AdminsData & { siteName: string };
|
|
10
|
+
}
|
|
11
|
+
let { data }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<svelte:head>
|
|
15
|
+
<title>Editors · {data.siteName} CMS</title>
|
|
16
|
+
</svelte:head>
|
|
17
|
+
|
|
18
|
+
<div>
|
|
19
|
+
<h1 class="text-2xl font-bold">Editors</h1>
|
|
20
|
+
<p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
{#if data.saved}
|
|
24
|
+
<div class="alert alert-success mt-6"><span>Allowlist updated.</span></div>
|
|
25
|
+
{:else if data.error}
|
|
26
|
+
<div class="alert alert-error mt-6"><span>{data.error}</span></div>
|
|
27
|
+
{/if}
|
|
28
|
+
|
|
29
|
+
<div class="mt-6 overflow-x-auto rounded-box border border-base-300 bg-base-100">
|
|
30
|
+
<table class="table">
|
|
31
|
+
<thead>
|
|
32
|
+
<tr><th>Name</th><th>Email</th><th>Role</th><th class="text-right">Actions</th></tr>
|
|
33
|
+
</thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
{#each data.admins as admin (admin.email)}
|
|
36
|
+
{@const isSelf = admin.email === data.self}
|
|
37
|
+
<tr>
|
|
38
|
+
<td class="font-medium">{admin.name}</td>
|
|
39
|
+
<td class="opacity-70">{admin.email}{#if isSelf}<span class="ml-1 opacity-50">(you)</span>{/if}</td>
|
|
40
|
+
<td>
|
|
41
|
+
<span class="badge {admin.role === 'owner' ? 'badge-primary' : 'badge-ghost'}">{admin.role}</span>
|
|
42
|
+
</td>
|
|
43
|
+
<td>
|
|
44
|
+
<div class="flex justify-end gap-2">
|
|
45
|
+
<!-- Flip role. Disabled for yourself so you can't demote the last owner out. -->
|
|
46
|
+
<form method="POST" action="?/setRole">
|
|
47
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
48
|
+
<input type="hidden" name="role" value={admin.role === 'owner' ? 'editor' : 'owner'} />
|
|
49
|
+
<button type="submit" class="btn btn-ghost btn-xs" disabled={isSelf}>
|
|
50
|
+
Make {admin.role === 'owner' ? 'editor' : 'owner'}
|
|
51
|
+
</button>
|
|
52
|
+
</form>
|
|
53
|
+
<form method="POST" action="?/remove">
|
|
54
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
55
|
+
<button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf}>Remove</button>
|
|
56
|
+
</form>
|
|
57
|
+
</div>
|
|
58
|
+
</td>
|
|
59
|
+
</tr>
|
|
60
|
+
{/each}
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<form method="POST" action="?/add"
|
|
66
|
+
class="mt-8 grid gap-4 rounded-box border border-base-300 bg-base-100 p-6 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
|
|
67
|
+
<label class="flex flex-col gap-1">
|
|
68
|
+
<span class="text-sm font-medium">Email</span>
|
|
69
|
+
<input type="email" name="email" required autocomplete="off" placeholder="you@example.com"
|
|
70
|
+
class="input w-full" />
|
|
71
|
+
</label>
|
|
72
|
+
<label class="flex flex-col gap-1">
|
|
73
|
+
<span class="text-sm font-medium">Name</span>
|
|
74
|
+
<input type="text" name="name" required placeholder="Display name" class="input w-full" />
|
|
75
|
+
</label>
|
|
76
|
+
<label class="flex flex-col gap-1">
|
|
77
|
+
<span class="text-sm font-medium">Role</span>
|
|
78
|
+
<select name="role" class="select">
|
|
79
|
+
<option value="editor">editor</option>
|
|
80
|
+
<option value="owner">owner</option>
|
|
81
|
+
</select>
|
|
82
|
+
</label>
|
|
83
|
+
<button type="submit" class="btn btn-primary">Add editor</button>
|
|
84
|
+
</form>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AdminsData } from '../sveltekit';
|
|
2
|
+
interface Props {
|
|
3
|
+
data: AdminsData & {
|
|
4
|
+
siteName: string;
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
declare const ManageAdmins: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type ManageAdmins = ReturnType<typeof ManageAdmins>;
|
|
9
|
+
export default ManageAdmins;
|
|
10
|
+
//# sourceMappingURL=ManageAdmins.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ManageAdmins.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ManageAdmins.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG7C,UAAU,KAAK;IACb,IAAI,EAAE,UAAU,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACzC;AAmFH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -2,4 +2,5 @@ export { default as AdminLayout } from './AdminLayout.svelte';
|
|
|
2
2
|
export { default as AdminList } from './AdminList.svelte';
|
|
3
3
|
export { default as LoginPage } from './LoginPage.svelte';
|
|
4
4
|
export { default as EditPage } from './EditPage.svelte';
|
|
5
|
+
export { default as ManageAdmins } from './ManageAdmins.svelte';
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/components/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/components/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,uBAAuB,CAAC"}
|
package/dist/components/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { default as AdminLayout } from './AdminLayout.svelte';
|
|
|
4
4
|
export { default as AdminList } from './AdminList.svelte';
|
|
5
5
|
export { default as LoginPage } from './LoginPage.svelte';
|
|
6
6
|
export { default as EditPage } from './EditPage.svelte';
|
|
7
|
+
export { default as ManageAdmins } from './ManageAdmins.svelte';
|
|
@@ -24,15 +24,20 @@ interface PlatformEvent {
|
|
|
24
24
|
export interface AdminLayoutData {
|
|
25
25
|
editor: Editor | null;
|
|
26
26
|
siteName: string;
|
|
27
|
+
pathname: string;
|
|
27
28
|
}
|
|
28
29
|
/**
|
|
29
30
|
* Branding + session for every admin page. `siteName` flows from the adapter without pulling
|
|
30
31
|
* its plugin graph into client bundles — the import stays server-side in the layout load.
|
|
32
|
+
* `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
|
|
33
|
+
* (those kit virtual modules have no types outside a kit app, so they can't live in the
|
|
34
|
+
* package); reading `event.url` here also opts the layout load into rerunning on navigation.
|
|
31
35
|
*/
|
|
32
36
|
export declare function adminLayoutLoad(event: {
|
|
33
37
|
locals: {
|
|
34
38
|
editor: Editor | null;
|
|
35
39
|
};
|
|
40
|
+
url: URL;
|
|
36
41
|
}, adapter: CairnAdapter): AdminLayoutData;
|
|
37
42
|
export interface AdminCollectionList {
|
|
38
43
|
type: string;
|
|
@@ -87,5 +92,31 @@ export declare function saveCommit(event: PlatformEvent & {
|
|
|
87
92
|
editor: Editor | null;
|
|
88
93
|
};
|
|
89
94
|
}, adapter: CairnAdapter): Promise<never>;
|
|
95
|
+
export interface AdminsData {
|
|
96
|
+
admins: Editor[];
|
|
97
|
+
/** Acting owner's email, so the UI can disable self-targeted remove/demote. */
|
|
98
|
+
self: string;
|
|
99
|
+
saved: boolean;
|
|
100
|
+
error: string | null;
|
|
101
|
+
}
|
|
102
|
+
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
103
|
+
export declare function adminsLoad(event: PlatformEvent & {
|
|
104
|
+
locals: {
|
|
105
|
+
editor: Editor | null;
|
|
106
|
+
};
|
|
107
|
+
url: URL;
|
|
108
|
+
}): Promise<AdminsData>;
|
|
109
|
+
type AdminsActionEvent = PlatformEvent & {
|
|
110
|
+
request: Request;
|
|
111
|
+
locals: {
|
|
112
|
+
editor: Editor | null;
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
/** Add (or update) an allowlist entry. Owner-only. */
|
|
116
|
+
export declare function addAdmin(event: AdminsActionEvent): Promise<never>;
|
|
117
|
+
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
118
|
+
export declare function removeAdmin(event: AdminsActionEvent): Promise<never>;
|
|
119
|
+
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
120
|
+
export declare function setAdminRole(event: AdminsActionEvent): Promise<never>;
|
|
90
121
|
export {};
|
|
91
122
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAmB,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAE7D,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/index.ts"],"names":[],"mappings":"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"}
|
package/dist/sveltekit/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// thrown objects share class identity with the host's runtime (else the redirect 500s).
|
|
10
10
|
import { redirect, error } from '@sveltejs/kit';
|
|
11
11
|
import matter from 'gray-matter';
|
|
12
|
-
import { createMagicLink, redeemMagicToken, createSession, lookupEditor, SESSION_COOKIE, SESSION_MAX_AGE, } from '../auth';
|
|
12
|
+
import { createMagicLink, redeemMagicToken, createSession, lookupEditor, listEditors, setEditor, removeEditor, SESSION_COOKIE, SESSION_MAX_AGE, } from '../auth';
|
|
13
13
|
import { sendMagicLink } from '../email';
|
|
14
14
|
import { listMarkdown, readRaw, commitFile, installationToken } from '../github';
|
|
15
15
|
import { serializeMarkdown } from '../content';
|
|
@@ -18,9 +18,12 @@ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
|
18
18
|
/**
|
|
19
19
|
* Branding + session for every admin page. `siteName` flows from the adapter without pulling
|
|
20
20
|
* its plugin graph into client bundles — the import stays server-side in the layout load.
|
|
21
|
+
* `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
|
|
22
|
+
* (those kit virtual modules have no types outside a kit app, so they can't live in the
|
|
23
|
+
* package); reading `event.url` here also opts the layout load into rerunning on navigation.
|
|
21
24
|
*/
|
|
22
25
|
export function adminLayoutLoad(event, adapter) {
|
|
23
|
-
return { editor: event.locals.editor, siteName: adapter.siteName };
|
|
26
|
+
return { editor: event.locals.editor, siteName: adapter.siteName, pathname: event.url.pathname };
|
|
24
27
|
}
|
|
25
28
|
/** List every collection's markdown files. A failed listing degrades to an inline error. */
|
|
26
29
|
export async function adminListLoad(adapter) {
|
|
@@ -161,3 +164,79 @@ export async function saveCommit(event, adapter) {
|
|
|
161
164
|
await commitFile(adapter.backend, `${collection.dir}/${id}.md`, markdown, { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: editor.name, email: editor.email } }, token);
|
|
162
165
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
163
166
|
}
|
|
167
|
+
// ── /admin/admins (owner-gated editor management) ────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* The privilege-escalation gate for the manage-admins surface: only `owner`s may load it or
|
|
170
|
+
* run its actions. Returns the acting owner (so callers can guard self-targeted mutations).
|
|
171
|
+
*/
|
|
172
|
+
function requireOwner(event) {
|
|
173
|
+
const editor = event.locals.editor;
|
|
174
|
+
if (!editor)
|
|
175
|
+
throw error(401, 'Not signed in');
|
|
176
|
+
if (editor.role !== 'owner')
|
|
177
|
+
throw error(403, 'Owner access required');
|
|
178
|
+
return editor;
|
|
179
|
+
}
|
|
180
|
+
/** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
|
|
181
|
+
function ownerKv(event) {
|
|
182
|
+
const kv = event.platform?.env?.AUTH_KV;
|
|
183
|
+
if (!kv)
|
|
184
|
+
throw error(500, 'Editor allowlist is not configured');
|
|
185
|
+
return kv;
|
|
186
|
+
}
|
|
187
|
+
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
188
|
+
export async function adminsLoad(event) {
|
|
189
|
+
const owner = requireOwner(event);
|
|
190
|
+
const admins = await listEditors(ownerKv(event));
|
|
191
|
+
return {
|
|
192
|
+
admins,
|
|
193
|
+
self: owner.email,
|
|
194
|
+
saved: event.url.searchParams.get('saved') === '1',
|
|
195
|
+
error: event.url.searchParams.get('error'),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function parseRole(value) {
|
|
199
|
+
return value === 'owner' ? 'owner' : 'editor';
|
|
200
|
+
}
|
|
201
|
+
/** Add (or update) an allowlist entry. Owner-only. */
|
|
202
|
+
export async function addAdmin(event) {
|
|
203
|
+
requireOwner(event);
|
|
204
|
+
const kv = ownerKv(event);
|
|
205
|
+
const form = await event.request.formData();
|
|
206
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
207
|
+
const name = String(form.get('name') ?? '').trim();
|
|
208
|
+
if (!EMAIL_RE.test(email) || !name) {
|
|
209
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
|
|
210
|
+
}
|
|
211
|
+
await setEditor(email, name, parseRole(form.get('role')), kv);
|
|
212
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
213
|
+
}
|
|
214
|
+
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
215
|
+
export async function removeAdmin(event) {
|
|
216
|
+
const owner = requireOwner(event);
|
|
217
|
+
const kv = ownerKv(event);
|
|
218
|
+
const form = await event.request.formData();
|
|
219
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
220
|
+
if (email === owner.email) {
|
|
221
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
|
|
222
|
+
}
|
|
223
|
+
await removeEditor(email, kv);
|
|
224
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
225
|
+
}
|
|
226
|
+
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
227
|
+
export async function setAdminRole(event) {
|
|
228
|
+
const owner = requireOwner(event);
|
|
229
|
+
const kv = ownerKv(event);
|
|
230
|
+
const form = await event.request.formData();
|
|
231
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
232
|
+
const role = parseRole(form.get('role'));
|
|
233
|
+
if (email === owner.email && role !== 'owner') {
|
|
234
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
|
|
235
|
+
}
|
|
236
|
+
const existing = await lookupEditor(email, kv);
|
|
237
|
+
if (!existing) {
|
|
238
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
|
|
239
|
+
}
|
|
240
|
+
await setEditor(email, existing.name, role, kv);
|
|
241
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
242
|
+
}
|
package/package.json
CHANGED
package/src/lib/auth.ts
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
import type { KVNamespace } from '@cloudflare/workers-types';
|
|
8
8
|
import { bytesToB64url } from './utils';
|
|
9
9
|
|
|
10
|
+
/** Two-tier, per-site role. `owner`s manage the editor allowlist; `editor`s only edit content. */
|
|
11
|
+
export type Role = 'owner' | 'editor';
|
|
12
|
+
|
|
10
13
|
export interface Editor {
|
|
11
14
|
email: string;
|
|
12
15
|
name: string;
|
|
16
|
+
role: Role;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export const SESSION_COOKIE = 'cairn_session';
|
|
@@ -118,13 +122,64 @@ export async function createSession(editor: Editor, secret: string): Promise<str
|
|
|
118
122
|
export async function verifySession(token: string, secret: string): Promise<Editor | null> {
|
|
119
123
|
const payload = await verifyToken<SessionPayload>(token, secret);
|
|
120
124
|
if (!payload || Date.now() > payload.exp) return null;
|
|
121
|
-
|
|
125
|
+
// Sessions signed before roles existed carry no `role` — treat them as plain editors.
|
|
126
|
+
return { email: payload.email, name: payload.name, role: payload.role ?? 'editor' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const KEY_PREFIX = 'editor:';
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decode a stored allowlist value into name + role. Current entries are JSON
|
|
133
|
+
* (`{"name","role"}`); legacy entries are a bare display-name string, read as `editor`
|
|
134
|
+
* so the allowlist migrates lazily — re-saving an entry upgrades it to the JSON shape.
|
|
135
|
+
*/
|
|
136
|
+
function parseEditorValue(raw: string): { name: string; role: Role } {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(raw) as { name?: unknown; role?: unknown };
|
|
139
|
+
if (parsed && typeof parsed.name === 'string') {
|
|
140
|
+
return { name: parsed.name, role: parsed.role === 'owner' ? 'owner' : 'editor' };
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Not JSON — legacy bare display-name; treat as editor.
|
|
144
|
+
}
|
|
145
|
+
return { name: raw, role: 'editor' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function serializeEditorValue(name: string, role: Role): string {
|
|
149
|
+
return JSON.stringify({ name, role });
|
|
122
150
|
}
|
|
123
151
|
|
|
124
|
-
/** Look up an editor in the KV allowlist (`editor:<email>` →
|
|
152
|
+
/** Look up an editor in the KV allowlist (`editor:<email>` → `{name, role}`). */
|
|
125
153
|
export async function lookupEditor(email: string, kv: KVNamespace): Promise<Editor | null> {
|
|
126
154
|
const normalized = email.trim().toLowerCase();
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
return { email: normalized,
|
|
155
|
+
const raw = await kv.get(`${KEY_PREFIX}${normalized}`);
|
|
156
|
+
if (raw === null) return null;
|
|
157
|
+
return { email: normalized, ...parseEditorValue(raw) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every allowlisted editor, sorted by email — the manage-admins list. */
|
|
161
|
+
export async function listEditors(kv: KVNamespace): Promise<Editor[]> {
|
|
162
|
+
const { keys } = await kv.list({ prefix: KEY_PREFIX });
|
|
163
|
+
const editors = await Promise.all(
|
|
164
|
+
keys.map(async ({ name: key }): Promise<Editor> => {
|
|
165
|
+
const raw = (await kv.get(key)) ?? '';
|
|
166
|
+
return { email: key.slice(KEY_PREFIX.length), ...parseEditorValue(raw) };
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
return editors.sort((a, b) => a.email.localeCompare(b.email));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Add or update an allowlist entry (JSON value). Email is normalized. */
|
|
173
|
+
export async function setEditor(
|
|
174
|
+
email: string,
|
|
175
|
+
name: string,
|
|
176
|
+
role: Role,
|
|
177
|
+
kv: KVNamespace,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
await kv.put(`${KEY_PREFIX}${email.trim().toLowerCase()}`, serializeEditorValue(name, role));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Remove an allowlist entry. */
|
|
183
|
+
export async function removeEditor(email: string, kv: KVNamespace): Promise<void> {
|
|
184
|
+
await kv.delete(`${KEY_PREFIX}${email.trim().toLowerCase()}`);
|
|
130
185
|
}
|
|
@@ -1,18 +1,130 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// Neutral admin chrome
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Neutral admin chrome, shared across sites so the tool looks identical everywhere (only the
|
|
3
|
+
// adapter's siteName varies). When signed in it's a responsive DaisyUI drawer+navbar shell
|
|
4
|
+
// (`drawer lg:drawer-open` — sidebar pinned on desktop, slide-over + hamburger on mobile),
|
|
5
|
+
// patterned on scosman/CMSaasStarter's `(admin)/(menu)` layout. The nav is data-driven and
|
|
6
|
+
// role-gated, so a new surface is one entry in `nav` (plus its route + component). Signed out
|
|
7
|
+
// (the login page lives under this layout) it falls back to a minimal centered shell.
|
|
8
|
+
// Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
|
|
5
9
|
import type { Snippet } from 'svelte';
|
|
10
|
+
import type { Editor } from '../auth';
|
|
6
11
|
|
|
7
|
-
let {
|
|
12
|
+
let {
|
|
13
|
+
data,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
data: { siteName: string; editor: Editor | null; pathname: string };
|
|
17
|
+
children: Snippet;
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
interface NavItem {
|
|
21
|
+
href: string;
|
|
22
|
+
label: string;
|
|
23
|
+
icon: Snippet;
|
|
24
|
+
active: boolean;
|
|
25
|
+
/** Owner-only surface — hidden from regular editors. */
|
|
26
|
+
owner?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const nav = $derived<NavItem[]>([
|
|
30
|
+
{
|
|
31
|
+
href: '/admin',
|
|
32
|
+
label: 'Content',
|
|
33
|
+
icon: contentIcon,
|
|
34
|
+
active: data.pathname === '/admin' || data.pathname.startsWith('/admin/edit'),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
href: '/admin/admins',
|
|
38
|
+
label: 'Editors',
|
|
39
|
+
icon: editorsIcon,
|
|
40
|
+
owner: true,
|
|
41
|
+
active: data.pathname.startsWith('/admin/admins'),
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
const visibleNav = $derived(nav.filter((item) => !item.owner || data.editor?.role === 'owner'));
|
|
45
|
+
|
|
46
|
+
// Close the slide-over after a nav tap on mobile (no-op on desktop where it's pinned open).
|
|
47
|
+
function closeDrawer(): void {
|
|
48
|
+
const toggle = document.getElementById('admin-drawer');
|
|
49
|
+
if (toggle instanceof HTMLInputElement) toggle.checked = false;
|
|
50
|
+
}
|
|
8
51
|
</script>
|
|
9
52
|
|
|
53
|
+
{#snippet contentIcon()}
|
|
54
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
55
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
56
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
57
|
+
</svg>
|
|
58
|
+
{/snippet}
|
|
59
|
+
|
|
60
|
+
{#snippet editorsIcon()}
|
|
61
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
62
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
63
|
+
d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-1.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3.5-2.1" />
|
|
64
|
+
</svg>
|
|
65
|
+
{/snippet}
|
|
66
|
+
|
|
10
67
|
<svelte:head>
|
|
11
68
|
<meta name="robots" content="noindex, nofollow" />
|
|
12
69
|
</svelte:head>
|
|
13
70
|
|
|
14
|
-
|
|
15
|
-
<div class="
|
|
16
|
-
|
|
71
|
+
{#if data.editor}
|
|
72
|
+
<div class="drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
|
|
73
|
+
<input id="admin-drawer" type="checkbox" class="drawer-toggle" />
|
|
74
|
+
|
|
75
|
+
<div class="drawer-content">
|
|
76
|
+
<!-- Mobile top bar — the desktop sidebar replaces this at lg. -->
|
|
77
|
+
<div class="navbar bg-base-100 lg:hidden">
|
|
78
|
+
<div class="flex-1">
|
|
79
|
+
<span class="px-2 text-xl font-bold">{data.siteName} CMS</span>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="flex-none">
|
|
82
|
+
<label for="admin-drawer" class="btn btn-square btn-ghost" aria-label="Open menu">
|
|
83
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
84
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
|
85
|
+
</svg>
|
|
86
|
+
</label>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<main class="container px-4 py-6 lg:px-8">
|
|
91
|
+
{@render children()}
|
|
92
|
+
</main>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="drawer-side z-10">
|
|
96
|
+
<label for="admin-drawer" class="drawer-overlay" aria-label="Close menu"></label>
|
|
97
|
+
<div class="flex min-h-full w-80 flex-col bg-base-100 lg:border-r lg:border-base-300">
|
|
98
|
+
<ul class="menu menu-lg grow p-4">
|
|
99
|
+
<li class="menu-title flex flex-row items-center text-xl font-bold text-base-content">
|
|
100
|
+
<span class="grow">{data.siteName} CMS</span>
|
|
101
|
+
<label for="admin-drawer" class="ml-3 cursor-pointer lg:hidden" aria-label="Close menu">✕</label>
|
|
102
|
+
</li>
|
|
103
|
+
{#each visibleNav as item (item.href)}
|
|
104
|
+
<li>
|
|
105
|
+
<a href={item.href} class={item.active ? 'active' : ''} onclick={closeDrawer}>
|
|
106
|
+
{@render item.icon()}
|
|
107
|
+
{item.label}
|
|
108
|
+
</a>
|
|
109
|
+
</li>
|
|
110
|
+
{/each}
|
|
111
|
+
</ul>
|
|
112
|
+
|
|
113
|
+
<div class="border-t border-base-300 p-4">
|
|
114
|
+
<p class="text-sm font-medium">{data.editor.name}</p>
|
|
115
|
+
<p class="text-xs opacity-60">{data.editor.email}</p>
|
|
116
|
+
<form method="POST" action="/admin/auth/logout" class="mt-3">
|
|
117
|
+
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
|
|
118
|
+
</form>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{:else}
|
|
124
|
+
<!-- Signed out (login page): no nav, just a centered surface. -->
|
|
125
|
+
<div class="min-h-screen bg-base-200" data-pagefind-ignore>
|
|
126
|
+
<div class="mx-auto max-w-3xl px-4 py-8">
|
|
127
|
+
{@render children()}
|
|
128
|
+
</div>
|
|
17
129
|
</div>
|
|
18
|
-
|
|
130
|
+
{/if}
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// The /admin content list: every collection's files, linking into the editor. Data comes
|
|
3
|
-
// from `adminListLoad` (collections) merged with `adminLayoutLoad` (
|
|
4
|
-
|
|
3
|
+
// from `adminListLoad` (collections) merged with `adminLayoutLoad` (siteName). The shell
|
|
4
|
+
// (AdminLayout) owns the chrome — site title, signed-in identity, nav, sign out — so this
|
|
5
|
+
// page renders only the content body.
|
|
5
6
|
import type { AdminCollectionList } from '../sveltekit';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
|
-
data: {
|
|
9
|
+
data: { collections: AdminCollectionList[] };
|
|
9
10
|
}
|
|
10
11
|
let { data }: Props = $props();
|
|
11
12
|
</script>
|
|
12
13
|
|
|
13
|
-
<
|
|
14
|
-
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
15
|
-
<form method="POST" action="/admin/auth/logout">
|
|
16
|
-
<button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
|
|
17
|
-
</form>
|
|
18
|
-
</div>
|
|
19
|
-
|
|
20
|
-
<p class="mt-2 text-sm opacity-70">
|
|
21
|
-
Signed in as {data.editor?.name} ({data.editor?.email})
|
|
22
|
-
</p>
|
|
14
|
+
<h1 class="text-2xl font-bold">Content</h1>
|
|
23
15
|
|
|
24
16
|
{#each data.collections as collection (collection.type)}
|
|
25
17
|
<section class="mt-8">
|
|
@@ -73,14 +73,14 @@
|
|
|
73
73
|
name={field.name}
|
|
74
74
|
required={field.required}
|
|
75
75
|
value={fmString(field.name)}
|
|
76
|
-
class="input
|
|
76
|
+
class="input w-full"
|
|
77
77
|
/>
|
|
78
78
|
</label>
|
|
79
79
|
{:else if field.type === 'textarea'}
|
|
80
80
|
<label class="flex flex-col gap-1">
|
|
81
81
|
<span class="text-sm font-medium">{field.label}</span>
|
|
82
82
|
<textarea name={field.name} required={field.required} rows={field.rows ?? 4}
|
|
83
|
-
class="textarea
|
|
83
|
+
class="textarea w-full">{fmString(field.name)}</textarea>
|
|
84
84
|
</label>
|
|
85
85
|
{:else if field.type === 'tags'}
|
|
86
86
|
<div class="flex flex-col gap-1">
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
<label class="flex flex-col gap-1">
|
|
100
100
|
<span class="text-sm font-medium">{field.label}</span>
|
|
101
101
|
<input type="text" name={field.name} value={fmFreeTags(field.name)}
|
|
102
|
-
placeholder={field.placeholder ?? 'comma, separated'} class="input
|
|
102
|
+
placeholder={field.placeholder ?? 'comma, separated'} class="input w-full" />
|
|
103
103
|
</label>
|
|
104
104
|
{:else if field.type === 'boolean'}
|
|
105
105
|
<label class="flex items-center gap-2 text-sm font-medium">
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
{#if mounted}
|
|
116
116
|
<MarkdownEditor {carta} bind:value={body} mode="tabs" />
|
|
117
117
|
{:else}
|
|
118
|
-
<textarea bind:value={body} rows="20" class="textarea
|
|
118
|
+
<textarea bind:value={body} rows="20" class="textarea w-full font-mono"></textarea>
|
|
119
119
|
{/if}
|
|
120
120
|
</div>
|
|
121
121
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Owner-gated editor management: list the allowlist, change roles, remove editors, add new
|
|
3
|
+
// ones. Reuses the same neutral DaisyUI chrome as the rest of the admin (panels, alerts,
|
|
4
|
+
// table, buttons). Data comes from `adminsLoad` merged with `adminLayoutLoad` (siteName);
|
|
5
|
+
// mutations post to the page's named form actions (`?/add`, `?/remove`, `?/setRole`).
|
|
6
|
+
import type { AdminsData } from '../sveltekit';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
data: AdminsData & { siteName: string };
|
|
10
|
+
}
|
|
11
|
+
let { data }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<svelte:head>
|
|
15
|
+
<title>Editors · {data.siteName} CMS</title>
|
|
16
|
+
</svelte:head>
|
|
17
|
+
|
|
18
|
+
<div>
|
|
19
|
+
<h1 class="text-2xl font-bold">Editors</h1>
|
|
20
|
+
<p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
{#if data.saved}
|
|
24
|
+
<div class="alert alert-success mt-6"><span>Allowlist updated.</span></div>
|
|
25
|
+
{:else if data.error}
|
|
26
|
+
<div class="alert alert-error mt-6"><span>{data.error}</span></div>
|
|
27
|
+
{/if}
|
|
28
|
+
|
|
29
|
+
<div class="mt-6 overflow-x-auto rounded-box border border-base-300 bg-base-100">
|
|
30
|
+
<table class="table">
|
|
31
|
+
<thead>
|
|
32
|
+
<tr><th>Name</th><th>Email</th><th>Role</th><th class="text-right">Actions</th></tr>
|
|
33
|
+
</thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
{#each data.admins as admin (admin.email)}
|
|
36
|
+
{@const isSelf = admin.email === data.self}
|
|
37
|
+
<tr>
|
|
38
|
+
<td class="font-medium">{admin.name}</td>
|
|
39
|
+
<td class="opacity-70">{admin.email}{#if isSelf}<span class="ml-1 opacity-50">(you)</span>{/if}</td>
|
|
40
|
+
<td>
|
|
41
|
+
<span class="badge {admin.role === 'owner' ? 'badge-primary' : 'badge-ghost'}">{admin.role}</span>
|
|
42
|
+
</td>
|
|
43
|
+
<td>
|
|
44
|
+
<div class="flex justify-end gap-2">
|
|
45
|
+
<!-- Flip role. Disabled for yourself so you can't demote the last owner out. -->
|
|
46
|
+
<form method="POST" action="?/setRole">
|
|
47
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
48
|
+
<input type="hidden" name="role" value={admin.role === 'owner' ? 'editor' : 'owner'} />
|
|
49
|
+
<button type="submit" class="btn btn-ghost btn-xs" disabled={isSelf}>
|
|
50
|
+
Make {admin.role === 'owner' ? 'editor' : 'owner'}
|
|
51
|
+
</button>
|
|
52
|
+
</form>
|
|
53
|
+
<form method="POST" action="?/remove">
|
|
54
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
55
|
+
<button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf}>Remove</button>
|
|
56
|
+
</form>
|
|
57
|
+
</div>
|
|
58
|
+
</td>
|
|
59
|
+
</tr>
|
|
60
|
+
{/each}
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<form method="POST" action="?/add"
|
|
66
|
+
class="mt-8 grid gap-4 rounded-box border border-base-300 bg-base-100 p-6 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
|
|
67
|
+
<label class="flex flex-col gap-1">
|
|
68
|
+
<span class="text-sm font-medium">Email</span>
|
|
69
|
+
<input type="email" name="email" required autocomplete="off" placeholder="you@example.com"
|
|
70
|
+
class="input w-full" />
|
|
71
|
+
</label>
|
|
72
|
+
<label class="flex flex-col gap-1">
|
|
73
|
+
<span class="text-sm font-medium">Name</span>
|
|
74
|
+
<input type="text" name="name" required placeholder="Display name" class="input w-full" />
|
|
75
|
+
</label>
|
|
76
|
+
<label class="flex flex-col gap-1">
|
|
77
|
+
<span class="text-sm font-medium">Role</span>
|
|
78
|
+
<select name="role" class="select">
|
|
79
|
+
<option value="editor">editor</option>
|
|
80
|
+
<option value="owner">owner</option>
|
|
81
|
+
</select>
|
|
82
|
+
</label>
|
|
83
|
+
<button type="submit" class="btn btn-primary">Add editor</button>
|
|
84
|
+
</form>
|
|
@@ -4,3 +4,4 @@ export { default as AdminLayout } from './AdminLayout.svelte';
|
|
|
4
4
|
export { default as AdminList } from './AdminList.svelte';
|
|
5
5
|
export { default as LoginPage } from './LoginPage.svelte';
|
|
6
6
|
export { default as EditPage } from './EditPage.svelte';
|
|
7
|
+
export { default as ManageAdmins } from './ManageAdmins.svelte';
|
|
@@ -15,9 +15,13 @@ import {
|
|
|
15
15
|
redeemMagicToken,
|
|
16
16
|
createSession,
|
|
17
17
|
lookupEditor,
|
|
18
|
+
listEditors,
|
|
19
|
+
setEditor,
|
|
20
|
+
removeEditor,
|
|
18
21
|
SESSION_COOKIE,
|
|
19
22
|
SESSION_MAX_AGE,
|
|
20
23
|
type Editor,
|
|
24
|
+
type Role,
|
|
21
25
|
} from '../auth';
|
|
22
26
|
import { sendMagicLink, type EmailSender } from '../email';
|
|
23
27
|
import { listMarkdown, readRaw, commitFile, installationToken, type RepoFile } from '../github';
|
|
@@ -48,17 +52,21 @@ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
|
48
52
|
export interface AdminLayoutData {
|
|
49
53
|
editor: Editor | null;
|
|
50
54
|
siteName: string;
|
|
55
|
+
pathname: string;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/**
|
|
54
59
|
* Branding + session for every admin page. `siteName` flows from the adapter without pulling
|
|
55
60
|
* its plugin graph into client bundles — the import stays server-side in the layout load.
|
|
61
|
+
* `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
|
|
62
|
+
* (those kit virtual modules have no types outside a kit app, so they can't live in the
|
|
63
|
+
* package); reading `event.url` here also opts the layout load into rerunning on navigation.
|
|
56
64
|
*/
|
|
57
65
|
export function adminLayoutLoad(
|
|
58
|
-
event: { locals: { editor: Editor | null } },
|
|
66
|
+
event: { locals: { editor: Editor | null }; url: URL },
|
|
59
67
|
adapter: CairnAdapter,
|
|
60
68
|
): AdminLayoutData {
|
|
61
|
-
return { editor: event.locals.editor, siteName: adapter.siteName };
|
|
69
|
+
return { editor: event.locals.editor, siteName: adapter.siteName, pathname: event.url.pathname };
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// ── /admin (content list) ────────────────────────────────────────────────────
|
|
@@ -270,3 +278,99 @@ export async function saveCommit(
|
|
|
270
278
|
|
|
271
279
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
272
280
|
}
|
|
281
|
+
|
|
282
|
+
// ── /admin/admins (owner-gated editor management) ────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The privilege-escalation gate for the manage-admins surface: only `owner`s may load it or
|
|
286
|
+
* run its actions. Returns the acting owner (so callers can guard self-targeted mutations).
|
|
287
|
+
*/
|
|
288
|
+
function requireOwner(event: { locals: { editor: Editor | null } }): Editor {
|
|
289
|
+
const editor = event.locals.editor;
|
|
290
|
+
if (!editor) throw error(401, 'Not signed in');
|
|
291
|
+
if (editor.role !== 'owner') throw error(403, 'Owner access required');
|
|
292
|
+
return editor;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
|
|
296
|
+
function ownerKv(event: PlatformEvent): KVNamespace {
|
|
297
|
+
const kv = event.platform?.env?.AUTH_KV;
|
|
298
|
+
if (!kv) throw error(500, 'Editor allowlist is not configured');
|
|
299
|
+
return kv;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface AdminsData {
|
|
303
|
+
admins: Editor[];
|
|
304
|
+
/** Acting owner's email, so the UI can disable self-targeted remove/demote. */
|
|
305
|
+
self: string;
|
|
306
|
+
saved: boolean;
|
|
307
|
+
error: string | null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
311
|
+
export async function adminsLoad(
|
|
312
|
+
event: PlatformEvent & { locals: { editor: Editor | null }; url: URL },
|
|
313
|
+
): Promise<AdminsData> {
|
|
314
|
+
const owner = requireOwner(event);
|
|
315
|
+
const admins = await listEditors(ownerKv(event));
|
|
316
|
+
return {
|
|
317
|
+
admins,
|
|
318
|
+
self: owner.email,
|
|
319
|
+
saved: event.url.searchParams.get('saved') === '1',
|
|
320
|
+
error: event.url.searchParams.get('error'),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type AdminsActionEvent = PlatformEvent & {
|
|
325
|
+
request: Request;
|
|
326
|
+
locals: { editor: Editor | null };
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
function parseRole(value: unknown): Role {
|
|
330
|
+
return value === 'owner' ? 'owner' : 'editor';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Add (or update) an allowlist entry. Owner-only. */
|
|
334
|
+
export async function addAdmin(event: AdminsActionEvent): Promise<never> {
|
|
335
|
+
requireOwner(event);
|
|
336
|
+
const kv = ownerKv(event);
|
|
337
|
+
const form = await event.request.formData();
|
|
338
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
339
|
+
const name = String(form.get('name') ?? '').trim();
|
|
340
|
+
if (!EMAIL_RE.test(email) || !name) {
|
|
341
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
|
|
342
|
+
}
|
|
343
|
+
await setEditor(email, name, parseRole(form.get('role')), kv);
|
|
344
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
348
|
+
export async function removeAdmin(event: AdminsActionEvent): Promise<never> {
|
|
349
|
+
const owner = requireOwner(event);
|
|
350
|
+
const kv = ownerKv(event);
|
|
351
|
+
const form = await event.request.formData();
|
|
352
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
353
|
+
if (email === owner.email) {
|
|
354
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
|
|
355
|
+
}
|
|
356
|
+
await removeEditor(email, kv);
|
|
357
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
361
|
+
export async function setAdminRole(event: AdminsActionEvent): Promise<never> {
|
|
362
|
+
const owner = requireOwner(event);
|
|
363
|
+
const kv = ownerKv(event);
|
|
364
|
+
const form = await event.request.formData();
|
|
365
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
366
|
+
const role = parseRole(form.get('role'));
|
|
367
|
+
if (email === owner.email && role !== 'owner') {
|
|
368
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
|
|
369
|
+
}
|
|
370
|
+
const existing = await lookupEditor(email, kv);
|
|
371
|
+
if (!existing) {
|
|
372
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
|
|
373
|
+
}
|
|
374
|
+
await setEditor(email, existing.name, role, kv);
|
|
375
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
376
|
+
}
|