@acms-subir/core 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +17 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +23 -2
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +74 -4
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +11 -31
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +42 -12
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +8 -4
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +9 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +8 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +6 -5
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +63 -4
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +104 -0
- package/src/lib/repo/sql.ts +122 -44
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +31 -3
- package/src/lib/scheduler.ts +14 -0
- package/src/lib/schema.ts +35 -1
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +43 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +169 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
|
2
|
+
import type { Media } from '../lib/schema';
|
|
3
|
+
import { openZip, type ZipIndex } from '../lib/import/zipread';
|
|
4
|
+
import { ThemeDesignFile, ThemeTransferManifest, type DesignImportReport } from '../lib/theme-transfer';
|
|
5
|
+
import type { DesignChange } from '../lib/designDiff';
|
|
6
|
+
|
|
7
|
+
// Design → Export / Import theme dialogs (acms-theme v1 ZIPs, see
|
|
8
|
+
// lib/theme-transfer). Export downloads the design as this screen shows it;
|
|
9
|
+
// import previews the file against this site, uploads its images, then
|
|
10
|
+
// stages it as a draft for Review. Nothing here publishes.
|
|
11
|
+
|
|
12
|
+
export interface ThemeTransferHandle { openExport(): void; openImport(): void }
|
|
13
|
+
interface Props {
|
|
14
|
+
theme: { slug: string; name: string; project: boolean };
|
|
15
|
+
/** flush pending autosaves so the export/import sees the latest draft */
|
|
16
|
+
flush: () => Promise<boolean>;
|
|
17
|
+
onCopyThemeJson: () => Promise<void>;
|
|
18
|
+
copied: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Loaded {
|
|
22
|
+
name: string;
|
|
23
|
+
manifest: ThemeTransferManifest;
|
|
24
|
+
design: ThemeDesignFile;
|
|
25
|
+
media: Media[];
|
|
26
|
+
zip: ZipIndex;
|
|
27
|
+
}
|
|
28
|
+
interface Preview { report: DesignImportReport; changes: DesignChange[] }
|
|
29
|
+
|
|
30
|
+
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
31
|
+
async function errorOf(res: Response, fallback: string) {
|
|
32
|
+
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
33
|
+
return data?.error ?? `${fallback} (${res.status})`;
|
|
34
|
+
}
|
|
35
|
+
async function revision(): Promise<number> {
|
|
36
|
+
const res = await fetch('/api/design', { headers: { accept: 'application/json' } });
|
|
37
|
+
if (!res.ok) throw new Error(await errorOf(res, 'Couldn’t load the design'));
|
|
38
|
+
return ((await res.json()) as { revision: number }).revision;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readThemeFile(file: File): Promise<Loaded> {
|
|
42
|
+
const buf = await file.arrayBuffer();
|
|
43
|
+
let zip: ZipIndex;
|
|
44
|
+
try { zip = openZip(buf); } catch { throw new Error('Choose a theme file (.acms-theme.zip) exported from Design.'); }
|
|
45
|
+
if (zip.has('bundle.json')) throw new Error('This is a content export. Import it from Import & export instead.');
|
|
46
|
+
if (!zip.has('manifest.json') || !zip.has('design.json')) throw new Error('This ZIP isn’t a theme export. Export one from Design → Export on the other site.');
|
|
47
|
+
const json = async (n: string) => JSON.parse(new TextDecoder().decode(await zip.read(n)));
|
|
48
|
+
const manifest = ThemeTransferManifest.safeParse(await json('manifest.json'));
|
|
49
|
+
if (!manifest.success) throw new Error('This theme file was made by a newer or unknown version of acms.');
|
|
50
|
+
const design = ThemeDesignFile.safeParse(await json('design.json'));
|
|
51
|
+
if (!design.success) throw new Error('The theme file’s design.json is damaged.');
|
|
52
|
+
const media = zip.has('media.json') ? ((await json('media.json')) as Media[]) : [];
|
|
53
|
+
return { name: file.name, manifest: manifest.data, design: design.data, media, zip };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default forwardRef<ThemeTransferHandle, Props>(function ThemeTransfer({ theme, flush, onCopyThemeJson, copied }, ref) {
|
|
57
|
+
const exportRef = useRef<HTMLDialogElement>(null);
|
|
58
|
+
const importRef = useRef<HTMLDialogElement>(null);
|
|
59
|
+
const [withMedia, setWithMedia] = useState(true);
|
|
60
|
+
const [withNav, setWithNav] = useState(false);
|
|
61
|
+
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
62
|
+
const [preview, setPreview] = useState<Preview | null>(null);
|
|
63
|
+
const [useImages, setUseImages] = useState(true);
|
|
64
|
+
const [useNav, setUseNav] = useState(false);
|
|
65
|
+
const [busy, setBusy] = useState('');
|
|
66
|
+
const [error, setError] = useState('');
|
|
67
|
+
|
|
68
|
+
useImperativeHandle(ref, () => ({
|
|
69
|
+
openExport: () => exportRef.current?.showModal(),
|
|
70
|
+
openImport: () => { setLoaded(null); setPreview(null); setError(''); setBusy(''); importRef.current?.showModal(); },
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const download = async () => {
|
|
74
|
+
if (!(await flush())) return;
|
|
75
|
+
location.href = `/api/design/export?media=${withMedia ? 1 : 0}&nav=${withNav ? 1 : 0}`;
|
|
76
|
+
exportRef.current?.close();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const check = async (l: Loaded, nav: boolean) => {
|
|
80
|
+
const res = await fetch('/api/design/import', {
|
|
81
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ revision: await revision(), design: l.design, nav, dryRun: true }),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) throw new Error(await errorOf(res, 'Couldn’t read the theme file'));
|
|
85
|
+
setPreview((await res.json()) as Preview);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const choose = async (file: File | undefined) => {
|
|
89
|
+
if (!file) return;
|
|
90
|
+
setError(''); setPreview(null); setBusy('Reading…');
|
|
91
|
+
try {
|
|
92
|
+
if (!(await flush())) throw new Error('Save your current changes first.');
|
|
93
|
+
const l = await readThemeFile(file);
|
|
94
|
+
setLoaded(l); setUseImages(l.media.length > 0); setUseNav(false);
|
|
95
|
+
await check(l, false);
|
|
96
|
+
} catch (e) { setLoaded(null); setError(e instanceof Error ? e.message : 'Couldn’t read the file.'); }
|
|
97
|
+
finally { setBusy(''); }
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const toggleNav = async (on: boolean) => {
|
|
101
|
+
setUseNav(on);
|
|
102
|
+
if (loaded) try { await check(loaded, on); } catch (e) { setError(e instanceof Error ? e.message : 'Check failed.'); }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const run = async () => {
|
|
106
|
+
if (!loaded) return;
|
|
107
|
+
setError('');
|
|
108
|
+
try {
|
|
109
|
+
if (useImages) {
|
|
110
|
+
let n = 0;
|
|
111
|
+
for (const m of loaded.media) {
|
|
112
|
+
setBusy(`Uploading images ${++n}/${loaded.media.length}…`);
|
|
113
|
+
const form = new FormData();
|
|
114
|
+
form.set('media', JSON.stringify(m));
|
|
115
|
+
for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
|
|
116
|
+
form.set(key, new File([await loaded.zip.read(`media/${key}`)], key.split('/').pop() ?? 'file', { type: key === m.key ? m.mime : 'image/webp' }));
|
|
117
|
+
}
|
|
118
|
+
const res = await fetch('/api/import/media', { method: 'POST', body: form });
|
|
119
|
+
if (!res.ok) throw new Error(`${m.filename}: ${await errorOf(res, 'Upload failed')}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
setBusy('Staging the draft…');
|
|
123
|
+
const res = await fetch('/api/design/import', {
|
|
124
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ revision: await revision(), design: loaded.design, nav: useNav }),
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) throw new Error(await errorOf(res, 'Import failed'));
|
|
128
|
+
location.hash = 'review';
|
|
129
|
+
location.reload();
|
|
130
|
+
} catch (e) { setError(e instanceof Error ? e.message : 'Import failed.'); setBusy(''); }
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const r = preview?.report;
|
|
134
|
+
const m = loaded?.manifest;
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
<dialog className="modal dm-dialog" ref={exportRef} aria-labelledby="dm-export-title" onClick={(e) => { if (e.target === e.currentTarget) exportRef.current?.close(); }}>
|
|
138
|
+
<div className="modal-head">
|
|
139
|
+
<span id="dm-export-title">Export theme</span>
|
|
140
|
+
<button className="modal-x" type="button" onClick={() => exportRef.current?.close()} aria-label="Close">✕</button>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="modal-body">
|
|
143
|
+
<p className="dm-dialog-sub">Downloads <b>{theme.name}</b> as you see it here (including unpublished changes) as one <code>.acms-theme.zip</code>. Import it on another site or machine from Design → Import.</p>
|
|
144
|
+
<label className="check-row"><input type="checkbox" checked disabled />Colors, fonts, layout options, logo and page sections</label>
|
|
145
|
+
<label className="check-row"><input type="checkbox" checked={withMedia} onChange={(e) => setWithMedia(e.target.checked)} />Images used by the design</label>
|
|
146
|
+
<label className="check-row"><input type="checkbox" checked={withNav} onChange={(e) => setWithNav(e.target.checked)} />Navigation menus<small className="tt-muted">· links usually differ per site</small></label>
|
|
147
|
+
{theme.project && (
|
|
148
|
+
<p className="tt-note">The theme’s code (<code>src/themes/{theme.slug}/</code>) is part of your project, not this file. Move it with git, or run <code>acms theme pack {theme.slug}</code> and <code>acms theme install</code> on the other machine.</p>
|
|
149
|
+
)}
|
|
150
|
+
<p className="tt-muted tt-small">Never included: posts, page text, API keys or other settings.</p>
|
|
151
|
+
<div className="modal-foot">
|
|
152
|
+
<button className="btn ghost" type="button" onClick={() => void onCopyThemeJson()}>{copied ? 'Copied ✓' : 'Copy as theme.json'}</button>
|
|
153
|
+
<button className="btn primary" type="button" onClick={() => void download()}>Download</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</dialog>
|
|
157
|
+
|
|
158
|
+
<dialog className="modal dm-dialog" ref={importRef} aria-labelledby="dm-import-title" onClick={(e) => { if (e.target === e.currentTarget && !busy) importRef.current?.close(); }}>
|
|
159
|
+
<div className="modal-head">
|
|
160
|
+
<span id="dm-import-title">Import theme</span>
|
|
161
|
+
<button className="modal-x" type="button" onClick={() => importRef.current?.close()} aria-label="Close" disabled={Boolean(busy) && Boolean(preview)}>✕</button>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="modal-body">
|
|
164
|
+
{!loaded && (
|
|
165
|
+
<>
|
|
166
|
+
<p className="dm-dialog-sub">Choose a <code>.acms-theme.zip</code> exported from Design on another site or machine. It’s staged as a draft. You review it, then publish.</p>
|
|
167
|
+
<label className="tt-drop">
|
|
168
|
+
<input type="file" accept=".zip,application/zip" onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; void choose(f); }} disabled={Boolean(busy)} />
|
|
169
|
+
<b>{busy || 'Choose theme file…'}</b>
|
|
170
|
+
</label>
|
|
171
|
+
</>
|
|
172
|
+
)}
|
|
173
|
+
{loaded && m && (
|
|
174
|
+
<div className="tt-pkg">
|
|
175
|
+
<b>{m.theme.name || m.theme.slug}</b>{m.theme.version && <span className="tt-muted"> v{m.theme.version}</span>}
|
|
176
|
+
<small>{loaded.name} · exported {new Date(m.exportedAt).toLocaleDateString()}{m.source.title ? ` from ${m.source.title}` : ''}</small>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
{r && preview && (
|
|
180
|
+
<>
|
|
181
|
+
<ul className="tt-list">
|
|
182
|
+
<li>
|
|
183
|
+
<b>Design settings</b> <span className="imp-mark ok">{plural(preview.changes.length, 'change')}</span>
|
|
184
|
+
<small>Colors, fonts, logo{r.layouts.matched.length ? ` and sections for ${r.layouts.matched.join(', ')}` : ''}.</small>
|
|
185
|
+
</li>
|
|
186
|
+
{loaded!.media.length > 0 && (
|
|
187
|
+
<li><label className="check-row"><input type="checkbox" checked={useImages} onChange={(e) => setUseImages(e.target.checked)} /><span><b>Images</b><small>{plural(loaded!.media.length, 'image')} added to your media library (skipped if already there)</small></span></label></li>
|
|
188
|
+
)}
|
|
189
|
+
{!!loaded!.design.navItems?.length && (
|
|
190
|
+
<li><label className="check-row"><input type="checkbox" checked={useNav} onChange={(e) => void toggleNav(e.target.checked)} /><span><b>Navigation</b><small>Replaces your menus with {plural(loaded!.design.navItems.length, 'link')}</small></span></label></li>
|
|
191
|
+
)}
|
|
192
|
+
</ul>
|
|
193
|
+
{!r.theme.installed && (
|
|
194
|
+
<p className="tt-note warn"><b>The “{m?.theme.name || r.theme.slug}” theme isn’t installed on this site.</b> Its code lives in <code>src/themes/{r.theme.slug}/</code> of the other project. Copy it over (git, or <code>acms theme pack</code> / <code>acms theme install</code>), then rebuild. Until then the draft keeps your current theme.</p>
|
|
195
|
+
)}
|
|
196
|
+
{r.layouts.skipped.length > 0 && <p className="tt-note">No page here for {r.layouts.skipped.join(', ')}. {r.layouts.skipped.length === 1 ? 'Its layout is' : 'Their layouts are'} skipped. Create the page with the same address and import again to bring {r.layouts.skipped.length === 1 ? 'it' : 'them'} over.</p>}
|
|
197
|
+
{r.links.dropped > 0 && <p className="tt-note">{plural(r.links.dropped, 'button link')} pointed at items this site doesn’t have; they keep their typed address.</p>}
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
{error && <p className="flash error">{error}</p>}
|
|
201
|
+
{loaded && (
|
|
202
|
+
<div className="modal-foot">
|
|
203
|
+
<button className="btn ghost" type="button" disabled={Boolean(busy)} onClick={() => { setLoaded(null); setPreview(null); setError(''); }}>Choose another</button>
|
|
204
|
+
<button className="btn primary" type="button" disabled={!preview || Boolean(busy)} onClick={() => void run()}>{busy || 'Import & review draft'}</button>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</dialog>
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Optional Clerk sign-in: exposes only the public configuration to the page
|
|
3
|
+
// and, with keepAlive, loads the SDK so the short-lived session cookie keeps
|
|
4
|
+
// refreshing while the page stays open (admin screens, app consent, staff
|
|
5
|
+
// previews). Renders nothing in built-in mode.
|
|
6
|
+
//
|
|
7
|
+
// signedOutUrl: where to go when the session ends elsewhere; '' stays put.
|
|
8
|
+
// freshPosts: refresh the cookie before plain same-origin form POSTs (admin
|
|
9
|
+
// screens); off on public pages, whose forms aren't staff actions.
|
|
10
|
+
import { AUTH, clerkPublicConfig } from '../../lib/auth/config';
|
|
11
|
+
|
|
12
|
+
interface Props { keepAlive?: boolean; signedOutUrl?: string; freshPosts?: boolean }
|
|
13
|
+
const { keepAlive = false, signedOutUrl = '/admin/login', freshPosts = true } = Astro.props;
|
|
14
|
+
let config: { publishableKey: string; frontendApi: string } | null = null;
|
|
15
|
+
if (AUTH.provider === 'clerk') {
|
|
16
|
+
try { config = clerkPublicConfig(Astro.locals); } catch { config = null; }
|
|
17
|
+
}
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
{config && (
|
|
21
|
+
<>
|
|
22
|
+
<meta name="acms-clerk" data-key={config.publishableKey} data-frontend-api={config.frontendApi} data-keep-alive={keepAlive ? '1' : undefined} data-signed-out-url={keepAlive ? signedOutUrl : undefined} data-fresh-posts={keepAlive && freshPosts ? '1' : undefined} />
|
|
23
|
+
<link rel="preconnect" href={`https://${config.frontendApi}`} crossorigin />
|
|
24
|
+
</>
|
|
25
|
+
)}
|
|
26
|
+
|
|
27
|
+
<script>
|
|
28
|
+
import { loadClerk, refreshSession } from './clerk';
|
|
29
|
+
|
|
30
|
+
const meta = document.querySelector<HTMLMetaElement>('meta[name="acms-clerk"][data-keep-alive]');
|
|
31
|
+
if (meta) {
|
|
32
|
+
const signedOutUrl = meta.dataset.signedOutUrl ?? '/admin/login';
|
|
33
|
+
const start = () => loadClerk().then((clerk) => {
|
|
34
|
+
// ended elsewhere (signed out in another tab, revoked by an owner):
|
|
35
|
+
// go to sign-in instead of failing on the next save
|
|
36
|
+
let gone = false;
|
|
37
|
+
clerk.addListener(({ session }) => {
|
|
38
|
+
if (clerk.loaded && session === null && !gone && signedOutUrl) { gone = true; location.assign(signedOutUrl); }
|
|
39
|
+
}, { skipInitialEmit: true });
|
|
40
|
+
}).catch(() => { /* the server still enforces sign-in; a later request redirects */ });
|
|
41
|
+
if ('requestIdleCallback' in window) requestIdleCallback(start, { timeout: 2000 }); else setTimeout(start, 300);
|
|
42
|
+
|
|
43
|
+
// Timers stall while a laptop sleeps or a phone locks, so the cookie can
|
|
44
|
+
// be expired when the page comes back. Refresh then, and again right
|
|
45
|
+
// before any plain same-origin form POST (the last listener to run, so
|
|
46
|
+
// forms handled by script are left alone).
|
|
47
|
+
const wake = () => { if (document.visibilityState === 'visible') void refreshSession(); };
|
|
48
|
+
document.addEventListener('visibilitychange', wake);
|
|
49
|
+
addEventListener('pageshow', (e) => { if (e.persisted) wake(); });
|
|
50
|
+
addEventListener('online', wake);
|
|
51
|
+
if (meta.dataset.freshPosts) addEventListener('submit', async (e) => {
|
|
52
|
+
const form = e.target;
|
|
53
|
+
if (e.defaultPrevented || !(form instanceof HTMLFormElement) || form.target && form.target !== '_self') return;
|
|
54
|
+
const submitter = (e as SubmitEvent).submitter as HTMLButtonElement | HTMLInputElement | null;
|
|
55
|
+
const method = (submitter?.getAttribute('formmethod') ?? form.getAttribute('method') ?? 'get').toLowerCase();
|
|
56
|
+
const action = new URL(submitter?.getAttribute('formaction') ?? form.getAttribute('action') ?? '', location.href);
|
|
57
|
+
if (method !== 'post' || action.origin !== location.origin) return;
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
await refreshSession();
|
|
60
|
+
// form.submit() skips the submitter's name/value; carry it over
|
|
61
|
+
if (submitter?.name) {
|
|
62
|
+
const carry = document.createElement('input');
|
|
63
|
+
carry.type = 'hidden'; carry.name = submitter.name; carry.value = submitter.value;
|
|
64
|
+
form.append(carry);
|
|
65
|
+
}
|
|
66
|
+
const prev = form.getAttribute('action');
|
|
67
|
+
if (submitter?.hasAttribute('formaction')) form.setAttribute('action', action.href);
|
|
68
|
+
HTMLFormElement.prototype.submit.call(form);
|
|
69
|
+
if (prev === null) form.removeAttribute('action'); else form.setAttribute('action', prev);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
document.querySelectorAll<HTMLElement>('[data-clerk-profile]').forEach((el) => {
|
|
73
|
+
el.hidden = false;
|
|
74
|
+
el.addEventListener('click', async (e) => {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
try { (await loadClerk()).openUserProfile?.(); } catch { alert('The sign-in service is unavailable. Try again in a moment.'); }
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Clerk mode: forms marked data-reverify check for a recent verification (the
|
|
3
|
+
// "strict" policy, last ten minutes) before they submit. Without one they stay
|
|
4
|
+
// put, edits intact, and a dialog offers to sign in again (a fresh sign-in
|
|
5
|
+
// counts), returning here afterwards. Buttons marked data-acms-signin-again do
|
|
6
|
+
// the same; without script their form signs out (POST) instead. The server enforces the policy either
|
|
7
|
+
// way. Needs ClerkSession on the page.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<dialog class="modal reverify-dialog" aria-labelledby="reverify-h" data-reverify-dialog>
|
|
11
|
+
<div class="modal-head"><span id="reverify-h">Confirm it’s you</span></div>
|
|
12
|
+
<div class="modal-body">
|
|
13
|
+
<p class="reverify-text">Account and security changes need a sign-in from the last ten minutes.</p>
|
|
14
|
+
<p class="hint">Signing in again leaves this page and brings you back here, but anything you’ve typed and not saved is cleared. Choose <b>Not now</b> to keep your edits.</p>
|
|
15
|
+
<div class="modal-foot">
|
|
16
|
+
<button class="btn ghost" type="button" data-reverify-close>Not now</button>
|
|
17
|
+
<button class="btn primary" type="button" data-reverify-go>Sign in again</button>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</dialog>
|
|
21
|
+
|
|
22
|
+
<script>
|
|
23
|
+
import { checkReverified, clerkSettings, signInAgain } from './clerk';
|
|
24
|
+
|
|
25
|
+
const dialog = document.querySelector<HTMLDialogElement>('[data-reverify-dialog]');
|
|
26
|
+
const go = (b: HTMLButtonElement) => {
|
|
27
|
+
b.disabled = true;
|
|
28
|
+
b.textContent = 'Signing out…';
|
|
29
|
+
void signInAgain();
|
|
30
|
+
};
|
|
31
|
+
dialog?.querySelector('[data-reverify-close]')?.addEventListener('click', () => dialog.close());
|
|
32
|
+
dialog?.querySelector<HTMLButtonElement>('[data-reverify-go]')?.addEventListener('click', (e) => go(e.currentTarget as HTMLButtonElement));
|
|
33
|
+
|
|
34
|
+
if (clerkSettings()) {
|
|
35
|
+
document.addEventListener('submit', async (e) => {
|
|
36
|
+
const form = e.target;
|
|
37
|
+
if (!(form instanceof HTMLFormElement) || !form.matches('form[data-reverify]') || e.defaultPrevented) return;
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
const submitter = (e as SubmitEvent).submitter as HTMLButtonElement | null;
|
|
40
|
+
const buttons = [...form.querySelectorAll<HTMLButtonElement>('button')];
|
|
41
|
+
buttons.forEach((b) => (b.disabled = true));
|
|
42
|
+
const result = await checkReverified();
|
|
43
|
+
buttons.forEach((b) => (b.disabled = false));
|
|
44
|
+
if (result === 'needed') {
|
|
45
|
+
if (dialog && !dialog.open) dialog.showModal();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// form.submit() skips the submitter's name/value; carry it over
|
|
49
|
+
if (submitter?.name) {
|
|
50
|
+
const carry = document.createElement('input');
|
|
51
|
+
carry.type = 'hidden'; carry.name = submitter.name; carry.value = submitter.value;
|
|
52
|
+
form.append(carry);
|
|
53
|
+
}
|
|
54
|
+
HTMLFormElement.prototype.submit.call(form);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
document.querySelectorAll<HTMLButtonElement>('button[data-acms-signin-again]').forEach((b) => {
|
|
58
|
+
b.addEventListener('click', (e) => { e.preventDefault(); go(b); });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Browser side of optional Clerk staff sign-in. Pages render the public
|
|
2
|
+
// configuration (publishable key + Frontend API host) through
|
|
3
|
+
// ClerkSession.astro; nothing secret ever reaches the page. The SDK and its
|
|
4
|
+
// prebuilt UI come from this instance's own Frontend API, pinned to the major
|
|
5
|
+
// versions Clerk documents for script-tag installs, so no npm bundle is needed
|
|
6
|
+
// and the key can change per deployment at runtime.
|
|
7
|
+
|
|
8
|
+
const CLERK_JS = '@clerk/clerk-js@6/dist/clerk.browser.js';
|
|
9
|
+
const CLERK_UI = '@clerk/ui@1/dist/ui.browser.js';
|
|
10
|
+
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i;
|
|
11
|
+
|
|
12
|
+
type Listener = (e: { session?: ClerkSession | null }) => void;
|
|
13
|
+
export interface ClerkSession {
|
|
14
|
+
id: string;
|
|
15
|
+
getToken(opts?: { skipCache?: boolean }): Promise<string | null>;
|
|
16
|
+
checkAuthorization?(params: { reverification: 'strict' }): boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ClerkInstance {
|
|
19
|
+
loaded: boolean;
|
|
20
|
+
isSignedIn: boolean;
|
|
21
|
+
session: ClerkSession | null | undefined;
|
|
22
|
+
user: { primaryEmailAddress?: { emailAddress: string } | null; fullName?: string | null } | null | undefined;
|
|
23
|
+
load(opts: Record<string, unknown>): Promise<void>;
|
|
24
|
+
signOut(opts?: { redirectUrl?: string }): Promise<void>;
|
|
25
|
+
mountSignIn(el: HTMLElement, props?: Record<string, unknown>): void;
|
|
26
|
+
mountSignUp(el: HTMLElement, props?: Record<string, unknown>): void;
|
|
27
|
+
openUserProfile?(props?: Record<string, unknown>): void;
|
|
28
|
+
addListener(cb: Listener, opts?: { skipInitialEmit?: boolean }): () => void;
|
|
29
|
+
}
|
|
30
|
+
declare global {
|
|
31
|
+
// __internal_ClerkUICtor is the name Clerk's script-tag install docs pass to load()
|
|
32
|
+
interface Window { Clerk?: ClerkInstance; __internal_ClerkUICtor?: unknown }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Public configuration rendered by ClerkSession.astro, or null in built-in mode. */
|
|
36
|
+
export function clerkSettings(): { publishableKey: string; frontendApi: string } | null {
|
|
37
|
+
const meta = document.querySelector<HTMLMetaElement>('meta[name="acms-clerk"]');
|
|
38
|
+
const publishableKey = meta?.dataset.key ?? '', frontendApi = meta?.dataset.frontendApi ?? '';
|
|
39
|
+
return /^pk_(test|live)_/.test(publishableKey) && HOST.test(frontendApi) ? { publishableKey, frontendApi } : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Clerk components read the admin tokens, so Light/Night follow the theme.
|
|
43
|
+
export const appearance = {
|
|
44
|
+
variables: {
|
|
45
|
+
colorPrimary: 'var(--accent)',
|
|
46
|
+
colorPrimaryForeground: 'var(--on-accent)',
|
|
47
|
+
colorBackground: 'var(--panel)',
|
|
48
|
+
colorForeground: 'var(--text)',
|
|
49
|
+
colorMutedForeground: 'var(--muted)',
|
|
50
|
+
colorMuted: 'var(--panel-2)',
|
|
51
|
+
colorInput: 'var(--panel)',
|
|
52
|
+
colorInputForeground: 'var(--text)',
|
|
53
|
+
colorBorder: 'var(--line-strong)',
|
|
54
|
+
colorRing: 'var(--accent-ring)',
|
|
55
|
+
colorDanger: 'var(--red)',
|
|
56
|
+
colorSuccess: 'var(--green)',
|
|
57
|
+
colorWarning: 'var(--amber)',
|
|
58
|
+
colorNeutral: 'var(--text)',
|
|
59
|
+
colorShadow: 'rgb(60 52 40 / 12%)',
|
|
60
|
+
colorModalBackdrop: 'var(--dim)',
|
|
61
|
+
fontFamily: 'inherit',
|
|
62
|
+
fontFamilyButtons: 'inherit',
|
|
63
|
+
borderRadius: 'var(--r-md)',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
// Mounted sign-in/sign-up sit inside the login card, which already is the paper surface.
|
|
67
|
+
export const embedded = {
|
|
68
|
+
elements: {
|
|
69
|
+
rootBox: { width: '100%' },
|
|
70
|
+
cardBox: { width: '100%', boxShadow: 'none', border: '0', borderRadius: '0' },
|
|
71
|
+
card: { boxShadow: 'none', padding: '0', background: 'transparent', border: '0' },
|
|
72
|
+
footer: { background: 'transparent' },
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function addScript(src: string, attrs: Record<string, string> = {}): Promise<void> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const el = document.createElement('script');
|
|
79
|
+
el.src = src; el.async = true; el.crossOrigin = 'anonymous';
|
|
80
|
+
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
|
81
|
+
el.onload = () => resolve();
|
|
82
|
+
el.onerror = () => reject(new Error('The sign-in service could not be loaded.'));
|
|
83
|
+
document.head.appendChild(el);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let loading: Promise<ClerkInstance> | null = null;
|
|
88
|
+
/** Loads and initialises Clerk once per page. Rejects when not configured or unreachable. */
|
|
89
|
+
export function loadClerk(): Promise<ClerkInstance> {
|
|
90
|
+
if (loading) return loading;
|
|
91
|
+
const attempt = (async () => {
|
|
92
|
+
const settings = clerkSettings();
|
|
93
|
+
if (!settings) throw new Error('Clerk sign-in is not configured.');
|
|
94
|
+
const base = `https://${settings.frontendApi}/npm/`;
|
|
95
|
+
// the UI bundle must be present before clerk-js initialises its components
|
|
96
|
+
await addScript(base + CLERK_UI);
|
|
97
|
+
await addScript(base + CLERK_JS, { 'data-clerk-publishable-key': settings.publishableKey });
|
|
98
|
+
const clerk = window.Clerk;
|
|
99
|
+
if (!clerk) throw new Error('The sign-in service could not be loaded.');
|
|
100
|
+
if (!clerk.loaded) {
|
|
101
|
+
await clerk.load({
|
|
102
|
+
ui: { ClerkUI: window.__internal_ClerkUICtor },
|
|
103
|
+
appearance,
|
|
104
|
+
signInUrl: '/admin/login',
|
|
105
|
+
signUpUrl: '/admin/login?signUp=1',
|
|
106
|
+
afterSignOutUrl: '/admin/login',
|
|
107
|
+
allowedRedirectOrigins: [location.origin],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (!clerk.loaded) throw new Error('The sign-in service could not be loaded.');
|
|
111
|
+
return clerk;
|
|
112
|
+
})();
|
|
113
|
+
// a blocked or unreachable Frontend API must not leave pages waiting forever
|
|
114
|
+
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('The sign-in service is taking too long to respond.')), 20_000));
|
|
115
|
+
loading = Promise.race([attempt, timeout]);
|
|
116
|
+
loading.catch(() => { loading = null; });
|
|
117
|
+
return loading;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Re-mint the short-lived session cookie (supported getToken, no new
|
|
122
|
+
* verification). Never rejects and gives up after a few seconds, so callers
|
|
123
|
+
* can await it before a request without risking a stuck page.
|
|
124
|
+
*/
|
|
125
|
+
export function refreshSession(): Promise<void> {
|
|
126
|
+
const refresh = loadClerk().then((clerk) => clerk.session?.getToken({ skipCache: true })).then(() => undefined, () => undefined);
|
|
127
|
+
return Promise.race([refresh, new Promise<void>((resolve) => setTimeout(resolve, 4000))]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type Reverify = 'ok' | 'needed' | 'unknown';
|
|
131
|
+
/**
|
|
132
|
+
* Whether the session carries a recent ("strict": last ten minutes)
|
|
133
|
+
* verification, as sensitive changes require. The server checks the same
|
|
134
|
+
* policy on every such request; this only spares the owner a rejected
|
|
135
|
+
* attempt. clerk-js has no supported prompt outside React, so 'needed' is
|
|
136
|
+
* answered with signInAgain(). 'unknown' (SDK unavailable) means submit
|
|
137
|
+
* anyway and let the server decide.
|
|
138
|
+
*/
|
|
139
|
+
export async function checkReverified(): Promise<Reverify> {
|
|
140
|
+
let clerk: ClerkInstance;
|
|
141
|
+
try { clerk = await loadClerk(); } catch { return 'unknown'; }
|
|
142
|
+
const session = clerk.session;
|
|
143
|
+
if (!session || typeof session.checkAuthorization !== 'function') return 'unknown';
|
|
144
|
+
// a fresh token first: it carries the verification age this check reads,
|
|
145
|
+
// and it's the cookie the server will see
|
|
146
|
+
await refreshSession();
|
|
147
|
+
return session.checkAuthorization({ reverification: 'strict' }) ? 'ok' : 'needed';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A fresh sign-in counts as a recent verification. Signs out of Clerk and
|
|
152
|
+
* returns to `back` (this page by default) once signed in again. Leaves the
|
|
153
|
+
* page, so call it only when the owner chooses to.
|
|
154
|
+
*/
|
|
155
|
+
export async function signInAgain(back = location.pathname + location.search): Promise<void> {
|
|
156
|
+
const login = `/admin/login?reverify=1&return=${encodeURIComponent(back)}`;
|
|
157
|
+
try { await (await loadClerk()).signOut({ redirectUrl: login }); }
|
|
158
|
+
catch { postLogout(); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Server sign-out: Clerk mode only accepts a same-origin POST. */
|
|
162
|
+
export function postLogout(next?: string): void {
|
|
163
|
+
const form = document.createElement('form');
|
|
164
|
+
form.method = 'post';
|
|
165
|
+
form.action = '/admin/logout' + (next ? `?next=${encodeURIComponent(next)}` : '');
|
|
166
|
+
form.hidden = true;
|
|
167
|
+
document.body.append(form);
|
|
168
|
+
form.submit();
|
|
169
|
+
}
|
|
@@ -39,7 +39,7 @@ const errText = (e: unknown) => (e instanceof Error && e.message ? e.message : '
|
|
|
39
39
|
const date = (ms: number) => new Date(ms).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
40
40
|
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
41
41
|
|
|
42
|
-
export default function ImportExport({ initialTab = 'import' }: { initialTab?: Tab }) {
|
|
42
|
+
export default function ImportExport({ initialTab = 'import', clerk = false }: { initialTab?: Tab; clerk?: boolean }) {
|
|
43
43
|
const [tab, setTab] = useState<Tab>(initialTab);
|
|
44
44
|
const [runs, setRuns] = useState<Run[] | null>(null);
|
|
45
45
|
|
|
@@ -66,8 +66,8 @@ export default function ImportExport({ initialTab = 'import' }: { initialTab?: T
|
|
|
66
66
|
))}
|
|
67
67
|
<a href="/admin/settings#backup">Backup & restore</a>
|
|
68
68
|
</nav>
|
|
69
|
-
{tab === 'import' && <ImportTab onFinished={loadRuns} openHistory={() => setTab('history')} />}
|
|
70
|
-
{tab === 'export' && <ExportTab />}
|
|
69
|
+
{tab === 'import' && <ImportTab onFinished={loadRuns} openHistory={() => setTab('history')} clerk={clerk} />}
|
|
70
|
+
{tab === 'export' && <ExportTab clerk={clerk} />}
|
|
71
71
|
{tab === 'history' && <HistoryTab runs={runs} reload={loadRuns} />}
|
|
72
72
|
</div>
|
|
73
73
|
);
|
|
@@ -75,7 +75,7 @@ export default function ImportExport({ initialTab = 'import' }: { initialTab?: T
|
|
|
75
75
|
|
|
76
76
|
// ---------------------------------------------------------------------------
|
|
77
77
|
|
|
78
|
-
function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHistory: () => void }) {
|
|
78
|
+
function ImportTab({ onFinished, openHistory, clerk }: { onFinished: () => void; openHistory: () => void; clerk: boolean }) {
|
|
79
79
|
const [file, setFile] = useState<File | null>(null);
|
|
80
80
|
const [siteUrl, setSiteUrl] = useState('');
|
|
81
81
|
const [source, setSource] = useState<Source | null>(null);
|
|
@@ -225,7 +225,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
|
|
|
225
225
|
{plan && plan.authors.length > 0 && (
|
|
226
226
|
<div className="imp-authors">
|
|
227
227
|
<h3>Authors</h3>
|
|
228
|
-
<p className="hint">New authors get a byline and an author page, but can’t sign in until you set a password in Users
|
|
228
|
+
<p className="hint">{clerk ? 'New authors get a byline and an author page, but can’t sign in until you link their Clerk account in Users.' : 'New authors get a byline and an author page, but can’t sign in until you set a password in Users.'}</p>
|
|
229
229
|
{plan.authors.map((a) => (
|
|
230
230
|
<div key={a.sourceId} className="imp-author">
|
|
231
231
|
<div><b>{a.name}</b><small>{a.email}</small></div>
|
|
@@ -362,15 +362,22 @@ function Done({ report, reset, openHistory }: { report: Report; reset: () => voi
|
|
|
362
362
|
|
|
363
363
|
// ---------------------------------------------------------------------------
|
|
364
364
|
|
|
365
|
-
function ExportTab() {
|
|
365
|
+
function ExportTab({ clerk }: { clerk: boolean }) {
|
|
366
366
|
return (
|
|
367
367
|
<div className="imp-grid">
|
|
368
|
+
<div className="imp-stack">
|
|
368
369
|
<section className="imp-card">
|
|
369
370
|
<h3>Export content</h3>
|
|
370
371
|
<p>A ZIP with every post, page, tag, author byline, navigation item and redirect, plus your uploaded media. Import it into any acms site; it merges with what’s there instead of replacing it.</p>
|
|
371
372
|
<p className="hint">Not included: bookings, users’ passwords, API keys, email and provider settings. For a complete copy of this site, use a backup.</p>
|
|
372
373
|
<div className="imp-actions"><a className="btn primary" href="/api/export" download>Download export</a></div>
|
|
373
374
|
</section>
|
|
375
|
+
<section className="imp-card">
|
|
376
|
+
<h3>Themes</h3>
|
|
377
|
+
<p>Moving your theme to another site or machine? Export it from Design with its colors, fonts, logo, page sections and images, then import it there from the same screen.</p>
|
|
378
|
+
<div className="imp-actions"><a className="btn" href="/admin/design">Go to Design → Export</a></div>
|
|
379
|
+
</section>
|
|
380
|
+
</div>
|
|
374
381
|
<aside className="imp-side">
|
|
375
382
|
<div className="imp-card imp-help">
|
|
376
383
|
<h3>Export or backup?</h3>
|
|
@@ -380,8 +387,10 @@ function ExportTab() {
|
|
|
380
387
|
<tr><td>Use for</td><td>Moving content</td><td>Recovery</td></tr>
|
|
381
388
|
<tr><td>Import</td><td>Merges</td><td>Replaces all</td></tr>
|
|
382
389
|
<tr><td>Bookings</td><td>No</td><td>Yes</td></tr>
|
|
390
|
+
{clerk && <tr><td>Sign-in</td><td>Bylines only</td><td>Owner kept</td></tr>}
|
|
383
391
|
</tbody>
|
|
384
392
|
</table>
|
|
393
|
+
{clerk && <p className="hint">Restoring a backup needs a sign-in from the last ten minutes and a backup that includes your current owner account. Only your own Clerk link is kept; every other restored account has to be linked again in Users before it can sign in. Sign-in settings stay as deployed.</p>}
|
|
385
394
|
</div>
|
|
386
395
|
</aside>
|
|
387
396
|
</div>
|
|
@@ -459,6 +468,7 @@ const CSS = `
|
|
|
459
468
|
.imp-tabs button:hover,.imp-tabs a:hover{color:var(--text)}
|
|
460
469
|
.imp-tabs a{margin-left:auto;font-weight:500}
|
|
461
470
|
.imp-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:16px;align-items:start}
|
|
471
|
+
.imp-stack{display:grid;gap:16px;min-width:0}
|
|
462
472
|
.imp-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow-card);padding:20px 22px}
|
|
463
473
|
.imp-card h3{font-size:14px;margin:0 0 8px;letter-spacing:-.01em}
|
|
464
474
|
.imp-card p{margin:0 0 10px;font-size:13.5px}
|
|
@@ -49,6 +49,9 @@ export async function readSource(file: File, siteUrl = ''): Promise<Source> {
|
|
|
49
49
|
if (zip.has('content.json') && !zip.has('bundle.json')) {
|
|
50
50
|
throw new Error('This is an acms backup. Restore it from Settings → Backup instead; it replaces the whole site.');
|
|
51
51
|
}
|
|
52
|
+
if (zip.has('manifest.json') && zip.has('design.json')) {
|
|
53
|
+
throw new Error('This is a theme file. Import it from Design → Import instead.');
|
|
54
|
+
}
|
|
52
55
|
if (zip.has('bundle.json')) {
|
|
53
56
|
const bundle = ImportBundle.parse(JSON.parse(new TextDecoder().decode(await zip.read('bundle.json'))));
|
|
54
57
|
return { kind: bundle.source.kind, label: bundle.source.title || file.name, bundle, zip, hash };
|