@acms-subir/core 0.3.10 → 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.
Files changed (95) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +11 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/preview.mjs +1 -1
  8. package/deploy/Dockerfile +5 -1
  9. package/deploy/compose.yml +21 -0
  10. package/docs/live-video.md +1 -1
  11. package/docs/theme-authoring.md +6 -0
  12. package/integration.mjs +39 -0
  13. package/migrate.mjs +5 -0
  14. package/package.json +9 -11
  15. package/src/actions/index.ts +5 -5
  16. package/src/components/DesignManager.tsx +29 -10
  17. package/src/components/EmailSettings.tsx +53 -11
  18. package/src/components/ThemeTransfer.tsx +211 -0
  19. package/src/components/auth/ClerkSession.astro +80 -0
  20. package/src/components/auth/ReverifyForms.astro +61 -0
  21. package/src/components/auth/clerk.ts +169 -0
  22. package/src/components/import/ImportExport.tsx +16 -6
  23. package/src/components/import/runner.ts +3 -0
  24. package/src/layouts/Admin.astro +40 -9
  25. package/src/layouts/Site.astro +6 -1
  26. package/src/lib/api.ts +6 -3
  27. package/src/lib/auth/accounts.ts +94 -0
  28. package/src/lib/auth/config.ts +22 -0
  29. package/src/lib/auth/login-limit.ts +15 -0
  30. package/src/lib/auth/request.ts +161 -0
  31. package/src/lib/auth/store.ts +72 -0
  32. package/src/lib/auth.ts +3 -0
  33. package/src/lib/backup/archive.ts +26 -0
  34. package/src/lib/backup/offsite.ts +173 -0
  35. package/src/lib/backup/store.ts +28 -0
  36. package/src/lib/cache/config.ts +7 -0
  37. package/src/lib/cache/memory.ts +39 -0
  38. package/src/lib/cache/policy.ts +89 -0
  39. package/src/lib/cache/purge.ts +89 -0
  40. package/src/lib/designPreview.ts +2 -2
  41. package/src/lib/dynamicBlocks.ts +1 -1
  42. package/src/lib/editor/auth.ts +35 -5
  43. package/src/lib/editor/http.ts +4 -1
  44. package/src/lib/editor/store.ts +13 -7
  45. package/src/lib/editor/token-route.ts +2 -1
  46. package/src/lib/email/accounts.ts +4 -0
  47. package/src/lib/email/service.ts +1 -0
  48. package/src/lib/email/templates.ts +2 -1
  49. package/src/lib/email/types.ts +1 -0
  50. package/src/lib/mobile/http.ts +4 -3
  51. package/src/lib/mobile/route.ts +41 -4
  52. package/src/lib/net.ts +82 -14
  53. package/src/lib/repo/migrate-runner.ts +96 -0
  54. package/src/lib/repo/migrations.ts +76 -0
  55. package/src/lib/repo/sql.ts +56 -24
  56. package/src/lib/repo/sqlite.ts +14 -1
  57. package/src/lib/repo/types.ts +12 -1
  58. package/src/lib/scheduler.ts +7 -0
  59. package/src/lib/theme-transfer.ts +163 -0
  60. package/src/lib/theme.ts +5 -0
  61. package/src/lib/webhooks.ts +7 -2
  62. package/src/middleware.ts +41 -11
  63. package/src/pages/[...slug].astro +3 -2
  64. package/src/pages/account/[action].astro +6 -0
  65. package/src/pages/admin/app-auth.astro +21 -7
  66. package/src/pages/admin/design.astro +2 -1
  67. package/src/pages/admin/import.astro +5 -3
  68. package/src/pages/admin/index.astro +3 -2
  69. package/src/pages/admin/integrations.astro +3 -2
  70. package/src/pages/admin/login.astro +236 -25
  71. package/src/pages/admin/logout.ts +16 -2
  72. package/src/pages/admin/settings.astro +31 -5
  73. package/src/pages/admin/users.astro +350 -59
  74. package/src/pages/api/backup.ts +10 -26
  75. package/src/pages/api/design/export.ts +72 -0
  76. package/src/pages/api/design/import.ts +41 -0
  77. package/src/pages/api/email/settings.ts +9 -5
  78. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  79. package/src/pages/api/live/settings.ts +2 -2
  80. package/src/pages/api/media/upload.ts +2 -2
  81. package/src/pages/api/push/admin.ts +3 -2
  82. package/src/pages/api/restore.ts +6 -3
  83. package/src/pages/api/v1/calendars.ts +1 -1
  84. package/src/pages/api/v1/links.ts +1 -1
  85. package/src/pages/api/v1/openapi.json.ts +1 -1
  86. package/src/pages/api/v1/push/topics.ts +1 -1
  87. package/src/pages/api/v1/resources.ts +1 -1
  88. package/src/pages/api/v1/theme.ts +1 -1
  89. package/src/pages/blog/[slug].astro +3 -2
  90. package/src/pages/events/[slug].astro +3 -2
  91. package/src/pages/preview/[type]/[id].astro +3 -3
  92. package/src/styles/admin.css +103 -16
  93. package/src/styles/email-settings.css +6 -0
  94. package/src/virtual.d.ts +1 -0
  95. 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 { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
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 session = Astro.cookies.get(SESSION_COOKIE)?.value ?? '';
30
- const userId = await validateSession(session, Astro.locals);
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 switchUrl = `/admin/logout?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`;
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
- <a class="btn primary block" href={switchUrl}>Sign in with an account</a>
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? <a href={switchUrl}>Switch account</a></p>
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 { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
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 validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals);
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 { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../lib/auth';
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 validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
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 { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../lib/auth';
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 validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
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, validateSession, SESSION_COOKIE } from '../../lib/auth';
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
- if ((await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals))) {
12
- return Astro.redirect(next ?? '/admin');
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
- if (!email && checkPassword(password)) {
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
- <div class="card card-pad login-card">
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
- {next && !error && <p class="hint login-for-app">Sign in to connect the app to your account.</p>}
74
- {error && <div class="flash error">{error}</div>}
75
- <form method="post">
76
- <label class="field">
77
- <span>Email</span>
78
- <input type="email" name="email" autocomplete="username" placeholder="Leave empty for owner password" />
79
- </label>
80
- <label class="field">
81
- <span>Password</span>
82
- <input type="password" name="password" autofocus autocomplete="current-password" />
83
- </label>
84
- <button class="btn primary block" type="submit">Sign in</button>
85
- </form>
86
- <p class="hint"><a href="/account/forgot">Forgot your password?</a></p>
87
- <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>
88
- </div>
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
- export const GET: APIRoute = ({ cookies, redirect, url }) => {
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(next ? `/admin/login?next=${encodeURIComponent(next)}` : '/admin/login');
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 { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
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 validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
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.</p></div>
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
- rsReport.textContent = `Error: ${data?.error ?? res.statusText}`;
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;