@acms-subir/core 0.3.10 → 0.3.12
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 +24 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +11 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -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/theme-authoring.md +31 -0
- package/integration.mjs +39 -0
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +5 -5
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- 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 +40 -9
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +6 -3
- 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/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- 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/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/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 +1 -0
- package/src/lib/email/templates.ts +2 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/mobile/http.ts +4 -3
- package/src/lib/mobile/route.ts +41 -4
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +76 -0
- package/src/lib/repo/sql.ts +56 -24
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +12 -1
- package/src/lib/scheduler.ts +7 -0
- 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/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/settings.astro +31 -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/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/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/links.ts +1 -1
- 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/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +103 -16
- package/src/styles/email-settings.css +6 -0
- package/src/theme-sdk/calendar.ts +48 -0
- package/src/theme-sdk/index.ts +2 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
|
@@ -7,7 +7,10 @@ import { roleLabels } from '../../lib/roles';
|
|
|
7
7
|
// return address and the PKCE challenge, then returns to the app.
|
|
8
8
|
import { getRepo } from '../../lib/repo';
|
|
9
9
|
import { BRAND } from '../../lib/brand';
|
|
10
|
-
import {
|
|
10
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
11
|
+
import { AUTH } from '../../lib/auth/config';
|
|
12
|
+
import { getRequestAuth, requestSessionBinding } from '../../lib/auth/request';
|
|
13
|
+
import ClerkSession from '../../components/auth/ClerkSession.astro';
|
|
11
14
|
import { EDITOR } from '../../lib/editor/config';
|
|
12
15
|
import { checkAuthorize, checkConsentTicket, consentTicket, issueCode, redirectWith } from '../../lib/editor/auth';
|
|
13
16
|
import { bookingsEnabled } from '../../lib/calendar/config';
|
|
@@ -26,8 +29,10 @@ if (!EDITOR.enabled) return new Response(null, { status: 404 });
|
|
|
26
29
|
|
|
27
30
|
const leave = (to: string) => new Response(null, { status: 303, headers: { Location: to, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } });
|
|
28
31
|
const repo = await getRepo(Astro.locals);
|
|
29
|
-
const
|
|
30
|
-
const userId =
|
|
32
|
+
const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
|
|
33
|
+
const userId = auth.userId;
|
|
34
|
+
// consent tickets are bound to this browser session (built-in cookie or Clerk session)
|
|
35
|
+
const session = await requestSessionBinding(Astro.request, Astro.cookies, Astro.locals);
|
|
31
36
|
if (!userId) return Astro.redirect(`/admin/login?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
|
|
32
37
|
|
|
33
38
|
const check = checkAuthorize(Astro.url.searchParams);
|
|
@@ -41,7 +46,10 @@ const envOwner = userId === OWNER_ENV;
|
|
|
41
46
|
const user = envOwner ? null : await repo.getUser(userId);
|
|
42
47
|
const settings = await repo.getSettings();
|
|
43
48
|
const appName = EDITOR.name || params?.clientId || 'An app';
|
|
44
|
-
const
|
|
49
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
50
|
+
const switchUrl = `/admin/logout?next=${encodeURIComponent(here)}`;
|
|
51
|
+
// Clerk sign-out only accepts a same-origin POST; built-in sign-out is a link
|
|
52
|
+
const postLogout = AUTH.provider === 'clerk';
|
|
45
53
|
|
|
46
54
|
let error = '';
|
|
47
55
|
if (Astro.request.method === 'POST' && params && user) {
|
|
@@ -56,7 +64,7 @@ if (Astro.request.method === 'POST' && params && user) {
|
|
|
56
64
|
const version = await repo.email.sessionVersion(user.id);
|
|
57
65
|
if (version === null) error = 'Your account changed. Sign in again.';
|
|
58
66
|
else {
|
|
59
|
-
const code = await issueCode(repo, { id: user.id, sessionVersion: version }, params, String(form.get('device_name') ?? ''));
|
|
67
|
+
const code = await issueCode(repo, { id: user.id, sessionVersion: version }, params, String(form.get('device_name') ?? ''), Date.now(), auth.clerk ?? undefined);
|
|
60
68
|
return leave(redirectWith(params.redirectUri, { code, state: params.state, iss }));
|
|
61
69
|
}
|
|
62
70
|
}
|
|
@@ -85,8 +93,10 @@ const can = [
|
|
|
85
93
|
<title>Sign in to {appName} · {settings.siteTitle}</title>
|
|
86
94
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
87
95
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
96
|
+
<ClerkSession keepAlive signedOutUrl={`/admin/login?next=${encodeURIComponent(here)}`} />
|
|
88
97
|
</head>
|
|
89
98
|
<body>
|
|
99
|
+
{postLogout && <form id="switch-account" method="post" action={switchUrl} hidden></form>}
|
|
90
100
|
<main class="login-wrap consent-wrap">
|
|
91
101
|
<div class="card consent-card">
|
|
92
102
|
<div class="consent-site">
|
|
@@ -110,7 +120,9 @@ const can = [
|
|
|
110
120
|
<div class="consent-body">
|
|
111
121
|
<h1>Use your own account</h1>
|
|
112
122
|
<p class="consent-lead"><b>{appName}</b> signs in as a person, so each phone can be signed out on its own. You’re signed in with the owner password, which has no account to connect.</p>
|
|
113
|
-
|
|
123
|
+
{postLogout
|
|
124
|
+
? <button class="btn primary block" type="submit" form="switch-account">Sign in with an account</button>
|
|
125
|
+
: <a class="btn primary block" href={switchUrl}>Sign in with an account</a>}
|
|
114
126
|
<p class="hint consent-foot">Owners create accounts in Users.</p>
|
|
115
127
|
</div>
|
|
116
128
|
)}
|
|
@@ -150,7 +162,9 @@ const can = [
|
|
|
150
162
|
<div><dt>Returns to</dt><dd><code>{returnTo}</code></dd></div>
|
|
151
163
|
<div><dt>App ID</dt><dd><code>{params.clientId}</code></dd></div>
|
|
152
164
|
</dl>
|
|
153
|
-
<p class="hint consent-foot">Not you?
|
|
165
|
+
<p class="hint consent-foot">Not you? {postLogout
|
|
166
|
+
? <button class="linklike" type="submit" form="switch-account">Switch account</button>
|
|
167
|
+
: <a href={switchUrl}>Switch account</a>}</p>
|
|
154
168
|
</form>
|
|
155
169
|
)}
|
|
156
170
|
</div>
|
|
@@ -3,7 +3,7 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { env } from '../../lib/env';
|
|
5
5
|
import DesignManager from '../../components/DesignManager';
|
|
6
|
-
import { listThemes, FONT_PAIRINGS, getTheme } from '../../lib/theme';
|
|
6
|
+
import { listThemes, FONT_PAIRINGS, getTheme, isProjectTheme } from '../../lib/theme';
|
|
7
7
|
import { diffDesign } from '../../lib/designDiff';
|
|
8
8
|
import { mergeDesignDraft } from '../../lib/drafts';
|
|
9
9
|
import { postUrl } from '../../lib/content';
|
|
@@ -31,6 +31,7 @@ const themes = listThemes().map((t) => ({
|
|
|
31
31
|
version: t.version,
|
|
32
32
|
templateCount: Number(Boolean(t.HomeTemplate)) + Number(Boolean(t.EventTemplate)) + Object.keys(t.postTemplates).length + Object.keys(t.pageTemplates).length,
|
|
33
33
|
sectionCount: Object.keys(t.sections).length,
|
|
34
|
+
project: isProjectTheme(t.slug),
|
|
34
35
|
}));
|
|
35
36
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
36
37
|
---
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../layouts/Admin.astro';
|
|
3
3
|
import ImportExport from '../../components/import/ImportExport';
|
|
4
|
+
import { AUTH } from '../../lib/auth/config';
|
|
4
5
|
import { getRepo } from '../../lib/repo';
|
|
5
|
-
import {
|
|
6
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
6
8
|
|
|
7
9
|
// owner-only: an import can create accounts and change site settings
|
|
8
10
|
const repo = await getRepo(Astro.locals);
|
|
9
|
-
const sessionUserId = await
|
|
11
|
+
const sessionUserId = await validateRequestSession(Astro.request, Astro.cookies, Astro.locals);
|
|
10
12
|
const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
|
|
11
13
|
if (sessionUserId !== OWNER_ENV && sessionUser?.role !== 'owner') return Astro.redirect('/admin/settings');
|
|
12
14
|
const tab = Astro.url.searchParams.get('tab');
|
|
13
15
|
---
|
|
14
16
|
<Admin title="Import & export" section="settings" wide>
|
|
15
|
-
<ImportExport client:load initialTab={tab === 'export' || tab === 'history' ? tab : 'import'} />
|
|
17
|
+
<ImportExport client:load initialTab={tab === 'export' || tab === 'history' ? tab : 'import'} clerk={AUTH.provider === 'clerk'} />
|
|
16
18
|
</Admin>
|
|
@@ -3,13 +3,14 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { userConfig } from 'virtual:acms-config';
|
|
5
5
|
import { CONTENT } from '../../lib/content';
|
|
6
|
-
import {
|
|
6
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
7
8
|
import { getTraffic, umamiReadEnabled } from '../../lib/umami';
|
|
8
9
|
import { collectionUrl } from '../../lib/content';
|
|
9
10
|
|
|
10
11
|
const coreVersion = userConfig.coreVersion;
|
|
11
12
|
const repo = await getRepo(Astro.locals);
|
|
12
|
-
const sessionUserId = (await
|
|
13
|
+
const sessionUserId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
13
14
|
const [stats, pages, posts, links, settings, nav, users] = await Promise.all([
|
|
14
15
|
repo.getStats(),
|
|
15
16
|
repo.listPages(),
|
|
@@ -3,7 +3,8 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import Modal from '../../components/Modal.astro';
|
|
4
4
|
import { getRepo } from '../../lib/repo';
|
|
5
5
|
import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
|
|
6
|
-
import {
|
|
6
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
7
8
|
import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
|
|
8
9
|
import { issueApiKey } from '../../lib/api-keys';
|
|
9
10
|
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
@@ -15,7 +16,7 @@ import { PROVIDERS } from '../../lib/maps/providers';
|
|
|
15
16
|
// Site-wide integrations. Owners manage keys and webhook subscriptions;
|
|
16
17
|
// calendar-specific feeds and API help appear only when enabled.
|
|
17
18
|
const repo = await getRepo(Astro.locals);
|
|
18
|
-
const sessionId = (await
|
|
19
|
+
const sessionId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
19
20
|
const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
|
|
20
21
|
const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
|
|
21
22
|
const settings = await repo.getSettings();
|
|
@@ -1,19 +1,64 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeNext } from '../../lib/editor/next';
|
|
3
|
-
import { checkPassword, createSessionToken, verifyPassword,
|
|
3
|
+
import { checkPassword, createSessionToken, verifyPassword, SESSION_COOKIE } from '../../lib/auth';
|
|
4
|
+
import { getRequestAuth } from '../../lib/auth/request';
|
|
5
|
+
import { AUTH } from '../../lib/auth/config';
|
|
6
|
+
import { allowLogin } from '../../lib/auth/login-limit';
|
|
4
7
|
import { getRepo } from '../../lib/repo';
|
|
5
8
|
import { BRAND } from '../../lib/brand';
|
|
6
9
|
import ThemeScript from '../../components/ThemeScript.astro';
|
|
10
|
+
import ClerkSession from '../../components/auth/ClerkSession.astro';
|
|
7
11
|
import '../../styles/admin.css';
|
|
8
12
|
|
|
9
13
|
// ?next= returns to the editor-app sign-in page only (never another site)
|
|
10
14
|
const next = safeNext(Astro.url.searchParams.get('next'), Astro.url);
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
// ?return= brings an owner back to the admin page they left to sign in again
|
|
16
|
+
// (a fresh sign-in is the recent verification sensitive changes need)
|
|
17
|
+
function adminReturn(value: string | null): string | null {
|
|
18
|
+
if (!value || !value.startsWith('/admin') || value.includes('\\') || /[\u0000-\u001f]/.test(value)) return null;
|
|
19
|
+
let url: URL;
|
|
20
|
+
try { url = new URL(value, Astro.url.origin); } catch { return null; }
|
|
21
|
+
if (url.origin !== Astro.url.origin || !/^\/admin(\/|$)/.test(url.pathname)) return null;
|
|
22
|
+
if (/^\/admin\/(login|logout|app-auth)\b/.test(url.pathname)) return null;
|
|
23
|
+
return url.pathname + url.search + url.hash;
|
|
24
|
+
}
|
|
25
|
+
const back = next ? null : adminReturn(Astro.url.searchParams.get('return'));
|
|
26
|
+
const reverify = !!back && Astro.url.searchParams.get('reverify') === '1';
|
|
27
|
+
const clerkMode = AUTH.provider === 'clerk';
|
|
28
|
+
const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
|
|
29
|
+
if (auth.userId) {
|
|
30
|
+
return Astro.redirect(next ?? back ?? '/admin');
|
|
13
31
|
}
|
|
14
32
|
|
|
33
|
+
// Clerk mode: the provider's prebuilt components do every credential step on
|
|
34
|
+
// this page. Local passwords, the owner password and recovery links are off.
|
|
35
|
+
type ClerkView = 'sign-in' | 'sign-up' | 'denied' | 'configuration' | 'unavailable';
|
|
36
|
+
let clerkView: ClerkView = 'sign-in';
|
|
37
|
+
if (clerkMode) {
|
|
38
|
+
if (Astro.request.method === 'POST') {
|
|
39
|
+
return new Response('Password sign-in is turned off. Sign in with the form on this page.', { status: 403, headers: { 'Cache-Control': 'no-store' } });
|
|
40
|
+
}
|
|
41
|
+
Astro.response.headers.set('Cache-Control', 'no-store');
|
|
42
|
+
Astro.response.headers.set('Referrer-Policy', 'same-origin');
|
|
43
|
+
const params = Astro.url.searchParams;
|
|
44
|
+
// invitation links land here with ?signUp=1 (and Clerk's own ticket); an
|
|
45
|
+
// invitee who already has a Clerk account is sent to sign in instead
|
|
46
|
+
const invited = params.get('signUp') === '1' || params.has('__clerk_ticket');
|
|
47
|
+
clerkView = auth.error === 'configuration' ? 'configuration'
|
|
48
|
+
: auth.error === 'unavailable' ? 'unavailable'
|
|
49
|
+
: auth.error === 'access_denied' ? 'denied'
|
|
50
|
+
: invited && params.get('__clerk_status') !== 'sign_in' ? 'sign-up' : 'sign-in';
|
|
51
|
+
}
|
|
52
|
+
const signedOut = clerkMode && Astro.url.searchParams.get('signedOut') === '1';
|
|
53
|
+
// Clerk sign-out is POST-only (same-origin); these views render it as a form
|
|
54
|
+
const logoutAction = `/admin/logout${next ? `?next=${encodeURIComponent(next)}` : ''}`;
|
|
55
|
+
// the destination query every sign-in link on this page carries forward
|
|
56
|
+
const keep = next ? `next=${encodeURIComponent(next)}` : back ? `return=${encodeURIComponent(back)}` : '';
|
|
57
|
+
const retryHref = `/admin/login${keep ? `?${keep}` : ''}`;
|
|
58
|
+
const siteName = clerkMode ? (await (await getRepo(Astro.locals)).getSettings()).siteTitle || BRAND.name : BRAND.name;
|
|
59
|
+
|
|
15
60
|
let error = '';
|
|
16
|
-
if (Astro.request.method === 'POST') {
|
|
61
|
+
if (!clerkMode && Astro.request.method === 'POST') {
|
|
17
62
|
const form = await Astro.request.formData();
|
|
18
63
|
const email = String(form.get('email') ?? '').trim();
|
|
19
64
|
const password = String(form.get('password') ?? '');
|
|
@@ -22,10 +67,15 @@ if (Astro.request.method === 'POST') {
|
|
|
22
67
|
// a user account created in /admin/users.
|
|
23
68
|
let sessionUserId: string | null = null;
|
|
24
69
|
let sessionVersion = 0;
|
|
25
|
-
|
|
70
|
+
let address = 'unknown';
|
|
71
|
+
try { address = Astro.clientAddress || address; } catch { /* shared fallback bucket */ }
|
|
72
|
+
const repo = await getRepo(Astro.locals);
|
|
73
|
+
if (!await allowLogin(repo, address, email)) {
|
|
74
|
+
error = 'Too many sign-in attempts. Try again in a few minutes.';
|
|
75
|
+
Astro.response.status = 429;
|
|
76
|
+
} else if (!email && checkPassword(password)) {
|
|
26
77
|
sessionUserId = null; // default token = env owner
|
|
27
78
|
} else if (email) {
|
|
28
|
-
const repo = await getRepo(Astro.locals);
|
|
29
79
|
const auth = await repo.getUserAuth(email);
|
|
30
80
|
if (auth && (await verifyPassword(password, auth.passHash))) { sessionUserId = auth.user.id; sessionVersion = auth.sessionVersion; }
|
|
31
81
|
else {
|
|
@@ -43,7 +93,7 @@ if (Astro.request.method === 'POST') {
|
|
|
43
93
|
maxAge: 7 * 24 * 60 * 60,
|
|
44
94
|
secure: Astro.url.protocol === 'https:',
|
|
45
95
|
});
|
|
46
|
-
return Astro.redirect(next ?? '/admin');
|
|
96
|
+
return Astro.redirect(next ?? back ?? '/admin');
|
|
47
97
|
}
|
|
48
98
|
}
|
|
49
99
|
---
|
|
@@ -55,13 +105,14 @@ if (Astro.request.method === 'POST') {
|
|
|
55
105
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
56
106
|
<ThemeScript />
|
|
57
107
|
<meta name="robots" content="noindex" />
|
|
58
|
-
<title>Sign in · {BRAND.name} admin</title>
|
|
108
|
+
<title>{clerkView === 'sign-up' ? 'Accept invitation' : 'Sign in'} · {BRAND.name} admin</title>
|
|
59
109
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
60
110
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
111
|
+
<ClerkSession />
|
|
61
112
|
</head>
|
|
62
113
|
<body>
|
|
63
114
|
<div class="login-wrap">
|
|
64
|
-
<
|
|
115
|
+
<main class:list={['card card-pad login-card', { 'is-clerk': clerkMode }]}>
|
|
65
116
|
<div class="brand">
|
|
66
117
|
<svg width="26" height="26" viewBox="0 0 24 24" role="img" aria-label={BRAND.name}>
|
|
67
118
|
<rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="currentColor" opacity="0.85" />
|
|
@@ -70,22 +121,182 @@ if (Astro.request.method === 'POST') {
|
|
|
70
121
|
</svg>
|
|
71
122
|
<div class="brand-name">{BRAND.name}</div>
|
|
72
123
|
</div>
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
124
|
+
|
|
125
|
+
{!clerkMode && (
|
|
126
|
+
<>
|
|
127
|
+
{next && !error && <p class="hint login-for-app">Sign in to connect the app to your account.</p>}
|
|
128
|
+
{error && <div class="flash error">{error}</div>}
|
|
129
|
+
<form method="post">
|
|
130
|
+
<label class="field">
|
|
131
|
+
<span>Email</span>
|
|
132
|
+
<input type="email" name="email" autocomplete="username" placeholder="Leave empty for owner password" />
|
|
133
|
+
</label>
|
|
134
|
+
<label class="field">
|
|
135
|
+
<span>Password</span>
|
|
136
|
+
<input type="password" name="password" autofocus autocomplete="current-password" />
|
|
137
|
+
</label>
|
|
138
|
+
<button class="btn primary block" type="submit">Sign in</button>
|
|
139
|
+
</form>
|
|
140
|
+
<p class="hint"><a href="/account/forgot">Forgot your password?</a></p>
|
|
141
|
+
<p class="hint login-note">No account? The owner password from <code>ADMIN_PASSWORD</code> in <code>.env</code> always works with email left empty.</p>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{clerkMode && (
|
|
146
|
+
<div class="login-clerk" data-acms-login data-view={clerkView} data-next={next ?? back ?? '/admin'} data-keep={keep || undefined} data-logout={logoutAction} data-signed-out={signedOut ? '1' : undefined}>
|
|
147
|
+
{(clerkView === 'sign-in' || clerkView === 'sign-up') && (
|
|
148
|
+
<>
|
|
149
|
+
{next && <p class="hint login-for-app">Sign in to connect the app to your account.</p>}
|
|
150
|
+
{reverify
|
|
151
|
+
? <p class="flash" role="status">Sign in again to confirm it’s you. You’ll go straight back to the page you were on.</p>
|
|
152
|
+
: signedOut && <p class="flash" role="status">You’re signed out.</p>}
|
|
153
|
+
<div class="login-mount" data-clerk-mount aria-live="polite" aria-busy="true">
|
|
154
|
+
<p class="login-loading"><span class="login-spinner" aria-hidden="true"></span>{clerkView === 'sign-up' ? 'Loading your invitation…' : 'Loading sign-in…'}</p>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="flash error" role="alert" data-clerk-error hidden>
|
|
157
|
+
<span data-clerk-error-text>The sign-in service could not be loaded.</span>
|
|
158
|
+
<a class="btn sm" href={retryHref}>Try again</a>
|
|
159
|
+
</div>
|
|
160
|
+
<noscript><p class="flash error">Sign-in needs JavaScript. Turn it on and reload this page.</p></noscript>
|
|
161
|
+
{clerkView === 'sign-up'
|
|
162
|
+
? <p class="hint login-note">Create your sign-in with the email address your invitation was sent to. Access is only granted through an owner’s invitation.</p>
|
|
163
|
+
: <p class="hint login-note">Forgot your password? Enter your email, then choose “Forgot password?”. Verification and recovery are handled by your organization’s sign-in provider.</p>}
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{clerkView === 'denied' && (
|
|
168
|
+
<section class="login-state" aria-labelledby="login-state-h">
|
|
169
|
+
<span class="login-state-icon is-warn" aria-hidden="true">
|
|
170
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM12 8v4M12 15.5h.01" /></svg>
|
|
171
|
+
</span>
|
|
172
|
+
<h1 id="login-state-h">No access yet</h1>
|
|
173
|
+
<p>
|
|
174
|
+
You’re signed in<span data-clerk-who hidden> as <b data-clerk-email></b></span>, but this account hasn’t been given access to {siteName}.
|
|
175
|
+
</p>
|
|
176
|
+
<p class="hint">Ask an owner to invite you from <b>Users</b>, or to link your existing account. Signing up on your own doesn’t grant access.</p>
|
|
177
|
+
<form method="post" action={logoutAction}><button class="btn primary block" type="submit">Sign out and use another account</button></form>
|
|
178
|
+
</section>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{clerkView === 'unavailable' && (
|
|
182
|
+
<section class="login-state" aria-labelledby="login-state-h">
|
|
183
|
+
<span class="login-state-icon" aria-hidden="true">
|
|
184
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 0 1 13.7-5.6M20 12a8 8 0 0 1-13.7 5.6M18 3v4h-4M6 21v-4h4" /></svg>
|
|
185
|
+
</span>
|
|
186
|
+
<h1 id="login-state-h">Sign-in is unavailable</h1>
|
|
187
|
+
<p>We couldn’t confirm your session with the sign-in service. This is usually temporary.</p>
|
|
188
|
+
<div class="login-actions">
|
|
189
|
+
<a class="btn primary block" href={retryHref}>Try again</a>
|
|
190
|
+
<form method="post" action={logoutAction}><button class="btn ghost block" type="submit">Sign out</button></form>
|
|
191
|
+
</div>
|
|
192
|
+
</section>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{clerkView === 'configuration' && (
|
|
196
|
+
<section class="login-state" aria-labelledby="login-state-h">
|
|
197
|
+
<span class="login-state-icon is-error" aria-hidden="true">
|
|
198
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4M12 16.5h.01M10.3 3.9 2.4 17.6A2 2 0 0 0 4.1 20.6h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></svg>
|
|
199
|
+
</span>
|
|
200
|
+
<h1 id="login-state-h">Sign-in isn’t set up</h1>
|
|
201
|
+
<p>This site uses Clerk for staff sign-in, but its configuration is incomplete or doesn’t match this address.</p>
|
|
202
|
+
<p class="hint">Site operator: set <code>PUBLIC_CLERK_PUBLISHABLE_KEY</code>, <code>CLERK_SECRET_KEY</code> and <code>CLERK_AUTHORIZED_PARTIES</code> (including <code>{Astro.url.origin}</code>) in the deployment environment, then redeploy.</p>
|
|
203
|
+
<a class="btn block" href={retryHref}>Check again</a>
|
|
204
|
+
</section>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</main>
|
|
89
209
|
</div>
|
|
210
|
+
|
|
211
|
+
<script>
|
|
212
|
+
import { embedded, loadClerk } from '../../components/auth/clerk';
|
|
213
|
+
|
|
214
|
+
const root = document.querySelector<HTMLElement>('[data-acms-login]');
|
|
215
|
+
const SYNC_KEY = 'acms-clerk-sync';
|
|
216
|
+
|
|
217
|
+
function fail(message?: string) {
|
|
218
|
+
const box = root?.querySelector<HTMLElement>('[data-clerk-error]');
|
|
219
|
+
const mount = root?.querySelector<HTMLElement>('[data-clerk-mount]');
|
|
220
|
+
if (mount) { mount.hidden = true; mount.setAttribute('aria-busy', 'false'); }
|
|
221
|
+
if (!box) return;
|
|
222
|
+
if (message) box.querySelector('[data-clerk-error-text]')!.textContent = message;
|
|
223
|
+
box.hidden = false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function start(root: HTMLElement) {
|
|
227
|
+
const view = root.dataset.view;
|
|
228
|
+
const next = root.dataset.next || '/admin';
|
|
229
|
+
// configuration/unavailable: links only, nothing for the SDK to do
|
|
230
|
+
if (view !== 'sign-in' && view !== 'sign-up' && view !== 'denied') return;
|
|
231
|
+
let clerk;
|
|
232
|
+
try { clerk = await loadClerk(); }
|
|
233
|
+
catch (e) { if (view === 'sign-in' || view === 'sign-up') fail(e instanceof Error ? e.message : undefined); return; }
|
|
234
|
+
|
|
235
|
+
if (view === 'denied') {
|
|
236
|
+
const email = clerk.user?.primaryEmailAddress?.emailAddress;
|
|
237
|
+
if (email) {
|
|
238
|
+
root.querySelector('[data-clerk-email]')!.textContent = email;
|
|
239
|
+
root.querySelector<HTMLElement>('[data-clerk-who]')!.hidden = false;
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (view !== 'sign-in' && view !== 'sign-up') return;
|
|
244
|
+
|
|
245
|
+
// after /admin/logout revoked the session server-side, drop this
|
|
246
|
+
// browser's Clerk state too, then forget the flag (no loops on reload)
|
|
247
|
+
if (root.dataset.signedOut) {
|
|
248
|
+
const url = new URL(location.href);
|
|
249
|
+
url.searchParams.delete('signedOut');
|
|
250
|
+
const clean = url.pathname + url.search;
|
|
251
|
+
history.replaceState(null, '', clean);
|
|
252
|
+
try { sessionStorage.removeItem(SYNC_KEY); } catch {}
|
|
253
|
+
if (clerk.session) {
|
|
254
|
+
// Clerk navigates to the clean URL when done; that load mounts sign-in
|
|
255
|
+
try { await clerk.signOut({ redirectUrl: clean }); return; } catch { location.replace(clean); return; }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Clerk says signed in, the server didn't see it (an expired cookie
|
|
260
|
+
// or a just-finished handshake): refresh the cookie and continue once.
|
|
261
|
+
// A second miss shows an error instead of bouncing between pages.
|
|
262
|
+
if (clerk.isSignedIn && clerk.session) {
|
|
263
|
+
let last = 0;
|
|
264
|
+
try { last = Number(sessionStorage.getItem(SYNC_KEY)) || 0; } catch {}
|
|
265
|
+
if (Date.now() - last > 30_000) {
|
|
266
|
+
try { sessionStorage.setItem(SYNC_KEY, String(Date.now())); } catch {}
|
|
267
|
+
await clerk.session.getToken({ skipCache: true }).catch(() => null);
|
|
268
|
+
location.replace(next);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
fail('You’re signed in, but this site couldn’t confirm your session. Sign out and try again.');
|
|
272
|
+
const box = root.querySelector<HTMLElement>('[data-clerk-error]');
|
|
273
|
+
const out = document.createElement('form');
|
|
274
|
+
out.method = 'post';
|
|
275
|
+
out.action = root.dataset.logout || '/admin/logout';
|
|
276
|
+
out.innerHTML = '<button class="btn sm" type="submit">Sign out</button>';
|
|
277
|
+
box?.append(out);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const mount = root.querySelector<HTMLElement>('[data-clerk-mount]');
|
|
282
|
+
if (!mount) return;
|
|
283
|
+
mount.replaceChildren();
|
|
284
|
+
mount.setAttribute('aria-busy', 'false');
|
|
285
|
+
const keepNext = root.dataset.keep ?? '';
|
|
286
|
+
const props = {
|
|
287
|
+
// hash routing keeps every step (codes, recovery, MFA) on this page
|
|
288
|
+
routing: 'hash',
|
|
289
|
+
appearance: embedded,
|
|
290
|
+
forceRedirectUrl: next,
|
|
291
|
+
signUpForceRedirectUrl: next,
|
|
292
|
+
signInUrl: `/admin/login${keepNext ? `?${keepNext}` : ''}`,
|
|
293
|
+
signUpUrl: `/admin/login?signUp=1${keepNext ? `&${keepNext}` : ''}`,
|
|
294
|
+
};
|
|
295
|
+
if (view === 'sign-up') clerk.mountSignUp(mount, { ...props, signInForceRedirectUrl: next });
|
|
296
|
+
else clerk.mountSignIn(mount, props);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (root) start(root);
|
|
300
|
+
</script>
|
|
90
301
|
</body>
|
|
91
302
|
</html>
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { SESSION_COOKIE } from '../../lib/auth';
|
|
3
3
|
import { safeNext } from '../../lib/editor/next';
|
|
4
|
+
import { AUTH } from '../../lib/auth/config';
|
|
5
|
+
import { getRequestAuth, clerkClient, clerkOperation, invalidateClerkSession } from '../../lib/auth/request';
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
const logout: APIRoute = async ({ request, cookies, redirect, url, locals }) => {
|
|
8
|
+
if (AUTH.provider === 'clerk') {
|
|
9
|
+
if (request.method !== 'POST' || request.headers.get('origin') !== url.origin || request.headers.get('sec-fetch-site') === 'cross-site') return new Response('Open sign out from this site.', { status: 403 });
|
|
10
|
+
const auth = await getRequestAuth(request, cookies, locals);
|
|
11
|
+
if (auth.clerk) {
|
|
12
|
+
try { const client = await clerkClient(locals); await clerkOperation(() => client.sessions.revokeSession(auth.clerk!.sessionId)); invalidateClerkSession(auth.clerk); }
|
|
13
|
+
catch { return new Response('Unable to sign out. Please try again.', { status: 503, headers: { 'Cache-Control': 'no-store' } }); }
|
|
14
|
+
}
|
|
15
|
+
cookies.delete('__session', { path: '/' });
|
|
16
|
+
}
|
|
6
17
|
cookies.delete(SESSION_COOKIE, { path: '/' });
|
|
7
18
|
// "Switch account" on the editor-app sign-in page comes back to it
|
|
8
19
|
const next = safeNext(url.searchParams.get('next'), url);
|
|
9
|
-
return redirect(
|
|
20
|
+
return redirect(`/admin/login?${AUTH.provider === 'clerk' ? 'signedOut=1&' : ''}${next ? `next=${encodeURIComponent(next)}` : ''}`);
|
|
10
21
|
};
|
|
22
|
+
|
|
23
|
+
export const GET: APIRoute = context => AUTH.provider === 'clerk' ? new Response('Use the sign-out button on this site.', {status:405,headers:{Allow:'POST','Cache-Control':'no-store'}}) : logout(context);
|
|
24
|
+
export const POST: APIRoute = logout;
|
|
@@ -3,14 +3,16 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import AiSettings from '../../components/AiSettings';
|
|
4
4
|
import MonitoringSettings from '../../components/MonitoringSettings';
|
|
5
5
|
import { getRepo } from '../../lib/repo';
|
|
6
|
-
import {
|
|
6
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
8
|
+
import { AUTH } from '../../lib/auth/config';
|
|
7
9
|
import { invalidateRedirectCache } from '../../middleware';
|
|
8
10
|
import { CONTENT } from '../../lib/content';
|
|
9
11
|
import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
|
|
10
12
|
|
|
11
13
|
const repo = await getRepo(Astro.locals);
|
|
12
14
|
|
|
13
|
-
const sessionUserId = (await
|
|
15
|
+
const sessionUserId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
14
16
|
const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
|
|
15
17
|
const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
|
|
16
18
|
|
|
@@ -474,7 +476,7 @@ const groups = [
|
|
|
474
476
|
<section class="scard" id="email">
|
|
475
477
|
<header class="scard-head">
|
|
476
478
|
<a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
|
|
477
|
-
<div class="grow"><h2>Email</h2><p>Invitations, password resets, booking updates and reminders
|
|
479
|
+
<div class="grow"><h2>Email</h2><p>{AUTH.provider === 'clerk' ? 'Booking updates, reminders and staff notifications. Sign-in email comes from Clerk.' : 'Invitations, password resets, booking updates and reminders.'}</p></div>
|
|
478
480
|
<div class="scard-actions always"><a class="btn" href="/admin/email">Configure email</a></div>
|
|
479
481
|
</header>
|
|
480
482
|
<p class="hint">Choose Resend, Mailgun, Amazon SES or Cloudflare. Manage your sender, notifications and delivery activity.</p>
|
|
@@ -601,9 +603,12 @@ const groups = [
|
|
|
601
603
|
<div class="scard-actions always"><a class="btn" href="/api/backup" download>Download backup</a></div>
|
|
602
604
|
</header>
|
|
603
605
|
{isOwner && (
|
|
604
|
-
<div class="scard-sub first">
|
|
606
|
+
<div class="scard-sub first" data-rs-clerk={AUTH.provider === 'clerk' ? '1' : undefined}>
|
|
605
607
|
<h3>Restore from backup</h3>
|
|
606
608
|
<p class="hint">Pick a backup ZIP to see what's inside, then restore. <strong>Restoring replaces all current content and booking retry records. Use a fresh v5 backup for migration.</strong></p>
|
|
609
|
+
{AUTH.provider === 'clerk' && (
|
|
610
|
+
<p class="hint rs-clerk">With Clerk sign-in, restoring needs a sign-in from the last ten minutes and a backup that includes your current owner account (same account, email and owner role). Only your own Clerk link is kept; everyone else in the backup must be linked again in <a href="/admin/users">Users</a> before they can sign in. Sign-in settings stay as deployed.</p>
|
|
611
|
+
)}
|
|
607
612
|
<div class="rs-row">
|
|
608
613
|
<input type="file" id="rs-file" accept=".zip,application/zip" aria-label="Backup ZIP" />
|
|
609
614
|
<button class="btn" type="button" id="rs-analyze">Analyze</button>
|
|
@@ -655,6 +660,8 @@ const groups = [
|
|
|
655
660
|
</style>
|
|
656
661
|
|
|
657
662
|
<script>
|
|
663
|
+
import { checkReverified, signInAgain } from '../../components/auth/clerk';
|
|
664
|
+
|
|
658
665
|
// read-mode cards: Edit swaps the summary for the form; Cancel resets it
|
|
659
666
|
document.querySelectorAll<HTMLElement>('.scard[data-editable]').forEach((card) => {
|
|
660
667
|
const form = card.querySelector<HTMLFormElement>('form.scard-form');
|
|
@@ -687,16 +694,35 @@ const groups = [
|
|
|
687
694
|
const rsAnalyze = document.getElementById('rs-analyze');
|
|
688
695
|
const rsGo = document.getElementById('rs-go');
|
|
689
696
|
const rsReport = document.getElementById('rs-report');
|
|
697
|
+
// Clerk: restores need a recent sign-in; offering to sign in again (which
|
|
698
|
+
// leaves the page) instead of a doomed upload
|
|
699
|
+
const rsClerk = !!document.querySelector('[data-rs-clerk]');
|
|
700
|
+
function offerSignIn(text: string) {
|
|
701
|
+
if (!rsReport) return;
|
|
702
|
+
const again = document.createElement('button');
|
|
703
|
+
again.type = 'button'; again.className = 'btn sm'; again.textContent = 'Sign in again';
|
|
704
|
+
again.addEventListener('click', () => { again.disabled = true; void signInAgain(location.pathname + location.search + '#backup'); });
|
|
705
|
+
const p = document.createElement('span');
|
|
706
|
+
p.textContent = text + ' ';
|
|
707
|
+
rsReport.replaceChildren(p, again);
|
|
708
|
+
}
|
|
690
709
|
async function postBackup(dry: boolean) {
|
|
691
710
|
const f = rsFile?.files?.[0];
|
|
692
711
|
if (!f || !rsReport) return null;
|
|
712
|
+
const fresh = rsClerk ? await checkReverified() : 'unknown';
|
|
713
|
+
if (fresh === 'needed') {
|
|
714
|
+
offerSignIn('Restoring needs a sign-in from the last ten minutes. Sign in again, then choose the backup again.');
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
693
717
|
rsReport.textContent = dry ? 'Analyzing…' : 'Restoring…';
|
|
694
718
|
const form = new FormData();
|
|
695
719
|
form.set('file', f);
|
|
696
720
|
const res = await fetch(`/api/restore${dry ? '?dry=1' : ''}`, { method: 'POST', body: form });
|
|
697
721
|
const data = await res.json().catch(() => null);
|
|
698
722
|
if (!res.ok || !data) {
|
|
699
|
-
|
|
723
|
+
const error = `Error: ${data?.error ?? res.statusText}`;
|
|
724
|
+
// after a confirmed recent sign-in, a 409 means the backup doesn't match
|
|
725
|
+
if (rsClerk && res.status === 409 && fresh !== 'ok') offerSignIn(error); else rsReport.textContent = error;
|
|
700
726
|
return null;
|
|
701
727
|
}
|
|
702
728
|
return data;
|