@glw907/cairn-cms 0.1.0 → 0.2.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/AdminList.svelte +8 -3
- package/dist/components/AdminList.svelte.d.ts.map +1 -1
- package/dist/components/ManageAdmins.svelte +87 -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 +26 -0
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +77 -1
- package/package.json +1 -1
- package/src/lib/auth.ts +60 -5
- package/src/lib/components/AdminList.svelte +8 -3
- package/src/lib/components/ManageAdmins.svelte +87 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/sveltekit/index.ts +100 -0
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
|
}
|
|
@@ -12,9 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
<div class="flex items-center justify-between">
|
|
14
14
|
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
<div class="flex items-center gap-2">
|
|
16
|
+
{#if data.editor?.role === 'owner'}
|
|
17
|
+
<a href="/admin/admins" class="btn btn-ghost btn-sm">Editors</a>
|
|
18
|
+
{/if}
|
|
19
|
+
<form method="POST" action="/admin/auth/logout">
|
|
20
|
+
<button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
|
|
21
|
+
</form>
|
|
22
|
+
</div>
|
|
18
23
|
</div>
|
|
19
24
|
|
|
20
25
|
<p class="mt-2 text-sm opacity-70">
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AdminList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminList.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGtD,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,WAAW,EAAE,mBAAmB,EAAE,CAAA;KAAE,CAAC;CACvF;
|
|
1
|
+
{"version":3,"file":"AdminList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminList.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGtD,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,WAAW,EAAE,mBAAmB,EAAE,CAAA;KAAE,CAAC;CACvF;AA+CH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,87 @@
|
|
|
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 class="flex items-center justify-between gap-4">
|
|
19
|
+
<div>
|
|
20
|
+
<a href="/admin" class="text-sm opacity-70 hover:underline">← Back</a>
|
|
21
|
+
<h1 class="mt-1 text-2xl font-bold">Editors</h1>
|
|
22
|
+
<p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
{#if data.saved}
|
|
27
|
+
<div class="alert alert-success mt-6"><span>Allowlist updated.</span></div>
|
|
28
|
+
{:else if data.error}
|
|
29
|
+
<div class="alert alert-error mt-6"><span>{data.error}</span></div>
|
|
30
|
+
{/if}
|
|
31
|
+
|
|
32
|
+
<div class="mt-6 overflow-x-auto rounded-box border border-base-300 bg-base-100">
|
|
33
|
+
<table class="table">
|
|
34
|
+
<thead>
|
|
35
|
+
<tr><th>Name</th><th>Email</th><th>Role</th><th class="text-right">Actions</th></tr>
|
|
36
|
+
</thead>
|
|
37
|
+
<tbody>
|
|
38
|
+
{#each data.admins as admin (admin.email)}
|
|
39
|
+
{@const isSelf = admin.email === data.self}
|
|
40
|
+
<tr>
|
|
41
|
+
<td class="font-medium">{admin.name}</td>
|
|
42
|
+
<td class="opacity-70">{admin.email}{#if isSelf}<span class="ml-1 opacity-50">(you)</span>{/if}</td>
|
|
43
|
+
<td>
|
|
44
|
+
<span class="badge {admin.role === 'owner' ? 'badge-primary' : 'badge-ghost'}">{admin.role}</span>
|
|
45
|
+
</td>
|
|
46
|
+
<td>
|
|
47
|
+
<div class="flex justify-end gap-2">
|
|
48
|
+
<!-- Flip role. Disabled for yourself so you can't demote the last owner out. -->
|
|
49
|
+
<form method="POST" action="?/setRole">
|
|
50
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
51
|
+
<input type="hidden" name="role" value={admin.role === 'owner' ? 'editor' : 'owner'} />
|
|
52
|
+
<button type="submit" class="btn btn-ghost btn-xs" disabled={isSelf}>
|
|
53
|
+
Make {admin.role === 'owner' ? 'editor' : 'owner'}
|
|
54
|
+
</button>
|
|
55
|
+
</form>
|
|
56
|
+
<form method="POST" action="?/remove">
|
|
57
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
58
|
+
<button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf}>Remove</button>
|
|
59
|
+
</form>
|
|
60
|
+
</div>
|
|
61
|
+
</td>
|
|
62
|
+
</tr>
|
|
63
|
+
{/each}
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<form method="POST" action="?/add"
|
|
69
|
+
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">
|
|
70
|
+
<label class="flex flex-col gap-1">
|
|
71
|
+
<span class="text-sm font-medium">Email</span>
|
|
72
|
+
<input type="email" name="email" required autocomplete="off" placeholder="you@example.com"
|
|
73
|
+
class="input input-bordered w-full" />
|
|
74
|
+
</label>
|
|
75
|
+
<label class="flex flex-col gap-1">
|
|
76
|
+
<span class="text-sm font-medium">Name</span>
|
|
77
|
+
<input type="text" name="name" required placeholder="Display name" class="input input-bordered w-full" />
|
|
78
|
+
</label>
|
|
79
|
+
<label class="flex flex-col gap-1">
|
|
80
|
+
<span class="text-sm font-medium">Role</span>
|
|
81
|
+
<select name="role" class="select select-bordered">
|
|
82
|
+
<option value="editor">editor</option>
|
|
83
|
+
<option value="owner">owner</option>
|
|
84
|
+
</select>
|
|
85
|
+
</label>
|
|
86
|
+
<button type="submit" class="btn btn-primary">Add editor</button>
|
|
87
|
+
</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;AAsFH,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';
|
|
@@ -87,5 +87,31 @@ export declare function saveCommit(event: PlatformEvent & {
|
|
|
87
87
|
editor: Editor | null;
|
|
88
88
|
};
|
|
89
89
|
}, adapter: CairnAdapter): Promise<never>;
|
|
90
|
+
export interface AdminsData {
|
|
91
|
+
admins: Editor[];
|
|
92
|
+
/** Acting owner's email, so the UI can disable self-targeted remove/demote. */
|
|
93
|
+
self: string;
|
|
94
|
+
saved: boolean;
|
|
95
|
+
error: string | null;
|
|
96
|
+
}
|
|
97
|
+
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
98
|
+
export declare function adminsLoad(event: PlatformEvent & {
|
|
99
|
+
locals: {
|
|
100
|
+
editor: Editor | null;
|
|
101
|
+
};
|
|
102
|
+
url: URL;
|
|
103
|
+
}): Promise<AdminsData>;
|
|
104
|
+
type AdminsActionEvent = PlatformEvent & {
|
|
105
|
+
request: Request;
|
|
106
|
+
locals: {
|
|
107
|
+
editor: Editor | null;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
/** Add (or update) an allowlist entry. Owner-only. */
|
|
111
|
+
export declare function addAdmin(event: AdminsActionEvent): Promise<never>;
|
|
112
|
+
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
113
|
+
export declare function removeAdmin(event: AdminsActionEvent): Promise<never>;
|
|
114
|
+
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
115
|
+
export declare function setAdminRole(event: AdminsActionEvent): Promise<never>;
|
|
90
116
|
export {};
|
|
91
117
|
//# 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":"AASA,OAAO,EAAmB,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAE7D,OAAO,EAUL,KAAK,MAAM,EAEZ,MAAM,SAAS,CAAC;AACjB,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAC3D,OAAO,EAAwD,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEhG,OAAO,EAAuC,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAErG,4FAA4F;AAC5F,MAAM,WAAW,QAAQ;IACvB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,kFAAkF;IAClF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAED,UAAU,aAAa;IACrB,QAAQ,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,QAAQ,CAAA;KAAE,CAAC;CAC/B;AAMD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE;IAAE,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAAE,EAC5C,OAAO,EAAE,YAAY,GACpB,eAAe,CAEjB;AAID,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,4FAA4F;AAC5F,wBAAsB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC;IAAE,WAAW,EAAE,mBAAmB,EAAE,CAAA;CAAE,CAAC,CAY1G;AAID,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE;IAAE,GAAG,EAAE,GAAG,CAAA;CAAE,GAAG,SAAS,CAKxD;AAID,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,wBAAsB,QAAQ,CAC5B,KAAK,EAAE;IAAE,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,EACzD,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,QAAQ,CAAC,CAyBnB;AAID,wBAAsB,WAAW,CAC/B,KAAK,EAAE,aAAa,GAAG;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,EACrD,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,KAAK,CAAC,CA8BhB;AAID,wBAAsB,YAAY,CAChC,KAAK,EAAE,aAAa,GAAG;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACpD,OAAO,CAAC,KAAK,CAAC,CA4BhB;AAID,wBAAgB,MAAM,CAAC,KAAK,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,KAAK,CAGzD;AAID,wBAAsB,UAAU,CAC9B,KAAK,EAAE,aAAa,GAAG;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAAE,EAC9E,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,KAAK,CAAC,CA0ChB;AAsBD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,iEAAiE;AACjE,wBAAsB,UAAU,CAC9B,KAAK,EAAE,aAAa,GAAG;IAAE,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,GACrE,OAAO,CAAC,UAAU,CAAC,CASrB;AAED,KAAK,iBAAiB,GAAG,aAAa,GAAG;IACvC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACnC,CAAC;AAMF,sDAAsD;AACtD,wBAAsB,QAAQ,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,KAAK,CAAC,CAWvE;AAED,4FAA4F;AAC5F,wBAAsB,WAAW,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,KAAK,CAAC,CAU1E;AAED,0FAA0F;AAC1F,wBAAsB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,KAAK,CAAC,CAe3E"}
|
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';
|
|
@@ -161,3 +161,79 @@ export async function saveCommit(event, adapter) {
|
|
|
161
161
|
await commitFile(adapter.backend, `${collection.dir}/${id}.md`, markdown, { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: editor.name, email: editor.email } }, token);
|
|
162
162
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
163
163
|
}
|
|
164
|
+
// ── /admin/admins (owner-gated editor management) ────────────────────────────
|
|
165
|
+
/**
|
|
166
|
+
* The privilege-escalation gate for the manage-admins surface: only `owner`s may load it or
|
|
167
|
+
* run its actions. Returns the acting owner (so callers can guard self-targeted mutations).
|
|
168
|
+
*/
|
|
169
|
+
function requireOwner(event) {
|
|
170
|
+
const editor = event.locals.editor;
|
|
171
|
+
if (!editor)
|
|
172
|
+
throw error(401, 'Not signed in');
|
|
173
|
+
if (editor.role !== 'owner')
|
|
174
|
+
throw error(403, 'Owner access required');
|
|
175
|
+
return editor;
|
|
176
|
+
}
|
|
177
|
+
/** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
|
|
178
|
+
function ownerKv(event) {
|
|
179
|
+
const kv = event.platform?.env?.AUTH_KV;
|
|
180
|
+
if (!kv)
|
|
181
|
+
throw error(500, 'Editor allowlist is not configured');
|
|
182
|
+
return kv;
|
|
183
|
+
}
|
|
184
|
+
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
185
|
+
export async function adminsLoad(event) {
|
|
186
|
+
const owner = requireOwner(event);
|
|
187
|
+
const admins = await listEditors(ownerKv(event));
|
|
188
|
+
return {
|
|
189
|
+
admins,
|
|
190
|
+
self: owner.email,
|
|
191
|
+
saved: event.url.searchParams.get('saved') === '1',
|
|
192
|
+
error: event.url.searchParams.get('error'),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function parseRole(value) {
|
|
196
|
+
return value === 'owner' ? 'owner' : 'editor';
|
|
197
|
+
}
|
|
198
|
+
/** Add (or update) an allowlist entry. Owner-only. */
|
|
199
|
+
export async function addAdmin(event) {
|
|
200
|
+
requireOwner(event);
|
|
201
|
+
const kv = ownerKv(event);
|
|
202
|
+
const form = await event.request.formData();
|
|
203
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
204
|
+
const name = String(form.get('name') ?? '').trim();
|
|
205
|
+
if (!EMAIL_RE.test(email) || !name) {
|
|
206
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
|
|
207
|
+
}
|
|
208
|
+
await setEditor(email, name, parseRole(form.get('role')), kv);
|
|
209
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
210
|
+
}
|
|
211
|
+
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
212
|
+
export async function removeAdmin(event) {
|
|
213
|
+
const owner = requireOwner(event);
|
|
214
|
+
const kv = ownerKv(event);
|
|
215
|
+
const form = await event.request.formData();
|
|
216
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
217
|
+
if (email === owner.email) {
|
|
218
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
|
|
219
|
+
}
|
|
220
|
+
await removeEditor(email, kv);
|
|
221
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
222
|
+
}
|
|
223
|
+
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
224
|
+
export async function setAdminRole(event) {
|
|
225
|
+
const owner = requireOwner(event);
|
|
226
|
+
const kv = ownerKv(event);
|
|
227
|
+
const form = await event.request.formData();
|
|
228
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
229
|
+
const role = parseRole(form.get('role'));
|
|
230
|
+
if (email === owner.email && role !== 'owner') {
|
|
231
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
|
|
232
|
+
}
|
|
233
|
+
const existing = await lookupEditor(email, kv);
|
|
234
|
+
if (!existing) {
|
|
235
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
|
|
236
|
+
}
|
|
237
|
+
await setEditor(email, existing.name, role, kv);
|
|
238
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
239
|
+
}
|
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
|
}
|
|
@@ -12,9 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
<div class="flex items-center justify-between">
|
|
14
14
|
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
<div class="flex items-center gap-2">
|
|
16
|
+
{#if data.editor?.role === 'owner'}
|
|
17
|
+
<a href="/admin/admins" class="btn btn-ghost btn-sm">Editors</a>
|
|
18
|
+
{/if}
|
|
19
|
+
<form method="POST" action="/admin/auth/logout">
|
|
20
|
+
<button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
|
|
21
|
+
</form>
|
|
22
|
+
</div>
|
|
18
23
|
</div>
|
|
19
24
|
|
|
20
25
|
<p class="mt-2 text-sm opacity-70">
|
|
@@ -0,0 +1,87 @@
|
|
|
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 class="flex items-center justify-between gap-4">
|
|
19
|
+
<div>
|
|
20
|
+
<a href="/admin" class="text-sm opacity-70 hover:underline">← Back</a>
|
|
21
|
+
<h1 class="mt-1 text-2xl font-bold">Editors</h1>
|
|
22
|
+
<p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
{#if data.saved}
|
|
27
|
+
<div class="alert alert-success mt-6"><span>Allowlist updated.</span></div>
|
|
28
|
+
{:else if data.error}
|
|
29
|
+
<div class="alert alert-error mt-6"><span>{data.error}</span></div>
|
|
30
|
+
{/if}
|
|
31
|
+
|
|
32
|
+
<div class="mt-6 overflow-x-auto rounded-box border border-base-300 bg-base-100">
|
|
33
|
+
<table class="table">
|
|
34
|
+
<thead>
|
|
35
|
+
<tr><th>Name</th><th>Email</th><th>Role</th><th class="text-right">Actions</th></tr>
|
|
36
|
+
</thead>
|
|
37
|
+
<tbody>
|
|
38
|
+
{#each data.admins as admin (admin.email)}
|
|
39
|
+
{@const isSelf = admin.email === data.self}
|
|
40
|
+
<tr>
|
|
41
|
+
<td class="font-medium">{admin.name}</td>
|
|
42
|
+
<td class="opacity-70">{admin.email}{#if isSelf}<span class="ml-1 opacity-50">(you)</span>{/if}</td>
|
|
43
|
+
<td>
|
|
44
|
+
<span class="badge {admin.role === 'owner' ? 'badge-primary' : 'badge-ghost'}">{admin.role}</span>
|
|
45
|
+
</td>
|
|
46
|
+
<td>
|
|
47
|
+
<div class="flex justify-end gap-2">
|
|
48
|
+
<!-- Flip role. Disabled for yourself so you can't demote the last owner out. -->
|
|
49
|
+
<form method="POST" action="?/setRole">
|
|
50
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
51
|
+
<input type="hidden" name="role" value={admin.role === 'owner' ? 'editor' : 'owner'} />
|
|
52
|
+
<button type="submit" class="btn btn-ghost btn-xs" disabled={isSelf}>
|
|
53
|
+
Make {admin.role === 'owner' ? 'editor' : 'owner'}
|
|
54
|
+
</button>
|
|
55
|
+
</form>
|
|
56
|
+
<form method="POST" action="?/remove">
|
|
57
|
+
<input type="hidden" name="email" value={admin.email} />
|
|
58
|
+
<button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf}>Remove</button>
|
|
59
|
+
</form>
|
|
60
|
+
</div>
|
|
61
|
+
</td>
|
|
62
|
+
</tr>
|
|
63
|
+
{/each}
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<form method="POST" action="?/add"
|
|
69
|
+
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">
|
|
70
|
+
<label class="flex flex-col gap-1">
|
|
71
|
+
<span class="text-sm font-medium">Email</span>
|
|
72
|
+
<input type="email" name="email" required autocomplete="off" placeholder="you@example.com"
|
|
73
|
+
class="input input-bordered w-full" />
|
|
74
|
+
</label>
|
|
75
|
+
<label class="flex flex-col gap-1">
|
|
76
|
+
<span class="text-sm font-medium">Name</span>
|
|
77
|
+
<input type="text" name="name" required placeholder="Display name" class="input input-bordered w-full" />
|
|
78
|
+
</label>
|
|
79
|
+
<label class="flex flex-col gap-1">
|
|
80
|
+
<span class="text-sm font-medium">Role</span>
|
|
81
|
+
<select name="role" class="select select-bordered">
|
|
82
|
+
<option value="editor">editor</option>
|
|
83
|
+
<option value="owner">owner</option>
|
|
84
|
+
</select>
|
|
85
|
+
</label>
|
|
86
|
+
<button type="submit" class="btn btn-primary">Add editor</button>
|
|
87
|
+
</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';
|
|
@@ -270,3 +274,99 @@ export async function saveCommit(
|
|
|
270
274
|
|
|
271
275
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
272
276
|
}
|
|
277
|
+
|
|
278
|
+
// ── /admin/admins (owner-gated editor management) ────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The privilege-escalation gate for the manage-admins surface: only `owner`s may load it or
|
|
282
|
+
* run its actions. Returns the acting owner (so callers can guard self-targeted mutations).
|
|
283
|
+
*/
|
|
284
|
+
function requireOwner(event: { locals: { editor: Editor | null } }): Editor {
|
|
285
|
+
const editor = event.locals.editor;
|
|
286
|
+
if (!editor) throw error(401, 'Not signed in');
|
|
287
|
+
if (editor.role !== 'owner') throw error(403, 'Owner access required');
|
|
288
|
+
return editor;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
|
|
292
|
+
function ownerKv(event: PlatformEvent): KVNamespace {
|
|
293
|
+
const kv = event.platform?.env?.AUTH_KV;
|
|
294
|
+
if (!kv) throw error(500, 'Editor allowlist is not configured');
|
|
295
|
+
return kv;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface AdminsData {
|
|
299
|
+
admins: Editor[];
|
|
300
|
+
/** Acting owner's email, so the UI can disable self-targeted remove/demote. */
|
|
301
|
+
self: string;
|
|
302
|
+
saved: boolean;
|
|
303
|
+
error: string | null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
307
|
+
export async function adminsLoad(
|
|
308
|
+
event: PlatformEvent & { locals: { editor: Editor | null }; url: URL },
|
|
309
|
+
): Promise<AdminsData> {
|
|
310
|
+
const owner = requireOwner(event);
|
|
311
|
+
const admins = await listEditors(ownerKv(event));
|
|
312
|
+
return {
|
|
313
|
+
admins,
|
|
314
|
+
self: owner.email,
|
|
315
|
+
saved: event.url.searchParams.get('saved') === '1',
|
|
316
|
+
error: event.url.searchParams.get('error'),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
type AdminsActionEvent = PlatformEvent & {
|
|
321
|
+
request: Request;
|
|
322
|
+
locals: { editor: Editor | null };
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
function parseRole(value: unknown): Role {
|
|
326
|
+
return value === 'owner' ? 'owner' : 'editor';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Add (or update) an allowlist entry. Owner-only. */
|
|
330
|
+
export async function addAdmin(event: AdminsActionEvent): Promise<never> {
|
|
331
|
+
requireOwner(event);
|
|
332
|
+
const kv = ownerKv(event);
|
|
333
|
+
const form = await event.request.formData();
|
|
334
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
335
|
+
const name = String(form.get('name') ?? '').trim();
|
|
336
|
+
if (!EMAIL_RE.test(email) || !name) {
|
|
337
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
|
|
338
|
+
}
|
|
339
|
+
await setEditor(email, name, parseRole(form.get('role')), kv);
|
|
340
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
|
|
344
|
+
export async function removeAdmin(event: AdminsActionEvent): Promise<never> {
|
|
345
|
+
const owner = requireOwner(event);
|
|
346
|
+
const kv = ownerKv(event);
|
|
347
|
+
const form = await event.request.formData();
|
|
348
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
349
|
+
if (email === owner.email) {
|
|
350
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
|
|
351
|
+
}
|
|
352
|
+
await removeEditor(email, kv);
|
|
353
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
357
|
+
export async function setAdminRole(event: AdminsActionEvent): Promise<never> {
|
|
358
|
+
const owner = requireOwner(event);
|
|
359
|
+
const kv = ownerKv(event);
|
|
360
|
+
const form = await event.request.formData();
|
|
361
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
362
|
+
const role = parseRole(form.get('role'));
|
|
363
|
+
if (email === owner.email && role !== 'owner') {
|
|
364
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
|
|
365
|
+
}
|
|
366
|
+
const existing = await lookupEditor(email, kv);
|
|
367
|
+
if (!existing) {
|
|
368
|
+
throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
|
|
369
|
+
}
|
|
370
|
+
await setEditor(email, existing.name, role, kv);
|
|
371
|
+
throw redirect(303, '/admin/admins?saved=1');
|
|
372
|
+
}
|