@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.
Files changed (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +17 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/mobile-doctor.mjs +9 -0
  8. package/cli/monitor.mjs +189 -0
  9. package/cli/preview.mjs +1 -1
  10. package/deploy/Dockerfile +5 -1
  11. package/deploy/compose.yml +21 -0
  12. package/docs/live-video.md +1 -1
  13. package/docs/mobile.md +23 -2
  14. package/docs/theme-authoring.md +6 -0
  15. package/integration.mjs +74 -4
  16. package/migrate.mjs +5 -0
  17. package/package.json +9 -11
  18. package/src/actions/index.ts +11 -31
  19. package/src/components/AppAccess.tsx +117 -0
  20. package/src/components/DesignManager.tsx +29 -10
  21. package/src/components/EmailSettings.tsx +53 -11
  22. package/src/components/MonitoringSettings.tsx +112 -0
  23. package/src/components/ThemeTransfer.tsx +211 -0
  24. package/src/components/auth/ClerkSession.astro +80 -0
  25. package/src/components/auth/ReverifyForms.astro +61 -0
  26. package/src/components/auth/clerk.ts +169 -0
  27. package/src/components/import/ImportExport.tsx +16 -6
  28. package/src/components/import/runner.ts +3 -0
  29. package/src/layouts/Admin.astro +42 -12
  30. package/src/layouts/Site.astro +6 -1
  31. package/src/lib/api.ts +8 -4
  32. package/src/lib/auth/accounts.ts +94 -0
  33. package/src/lib/auth/config.ts +22 -0
  34. package/src/lib/auth/login-limit.ts +15 -0
  35. package/src/lib/auth/request.ts +161 -0
  36. package/src/lib/auth/store.ts +72 -0
  37. package/src/lib/auth.ts +3 -0
  38. package/src/lib/backup/archive.ts +26 -0
  39. package/src/lib/backup/content.ts +1 -1
  40. package/src/lib/backup/offsite.ts +173 -0
  41. package/src/lib/backup/store.ts +28 -0
  42. package/src/lib/booking/availability.ts +15 -2
  43. package/src/lib/booking/service.ts +53 -1
  44. package/src/lib/cache/config.ts +7 -0
  45. package/src/lib/cache/memory.ts +39 -0
  46. package/src/lib/cache/policy.ts +89 -0
  47. package/src/lib/cache/purge.ts +89 -0
  48. package/src/lib/calendar/admin.ts +41 -0
  49. package/src/lib/designPreview.ts +2 -2
  50. package/src/lib/dynamicBlocks.ts +1 -1
  51. package/src/lib/editor/auth.ts +35 -5
  52. package/src/lib/editor/http.ts +4 -1
  53. package/src/lib/editor/me.ts +4 -0
  54. package/src/lib/editor/openapi.ts +49 -1
  55. package/src/lib/editor/roles.ts +20 -0
  56. package/src/lib/editor/schedule.ts +340 -0
  57. package/src/lib/editor/schemas.ts +36 -1
  58. package/src/lib/editor/site-data.ts +110 -0
  59. package/src/lib/editor/store.ts +13 -7
  60. package/src/lib/editor/token-route.ts +2 -1
  61. package/src/lib/email/accounts.ts +4 -0
  62. package/src/lib/email/service.ts +9 -1
  63. package/src/lib/email/store.ts +10 -0
  64. package/src/lib/email/templates.ts +8 -1
  65. package/src/lib/email/types.ts +1 -0
  66. package/src/lib/forms/store.ts +5 -0
  67. package/src/lib/mobile/access-admin.ts +34 -0
  68. package/src/lib/mobile/access.ts +28 -0
  69. package/src/lib/mobile/calendar.ts +26 -13
  70. package/src/lib/mobile/content.ts +21 -1
  71. package/src/lib/mobile/forms.ts +77 -0
  72. package/src/lib/mobile/guest-store.ts +62 -0
  73. package/src/lib/mobile/guest.ts +160 -0
  74. package/src/lib/mobile/http.ts +6 -5
  75. package/src/lib/mobile/links.ts +69 -0
  76. package/src/lib/mobile/manage.ts +114 -0
  77. package/src/lib/mobile/openapi.ts +40 -4
  78. package/src/lib/mobile/route.ts +63 -4
  79. package/src/lib/mobile/schemas.ts +52 -1
  80. package/src/lib/mobile/search.ts +95 -0
  81. package/src/lib/mobile/site.ts +8 -2
  82. package/src/lib/monitoring.ts +171 -0
  83. package/src/lib/net.ts +82 -14
  84. package/src/lib/repo/migrate-runner.ts +96 -0
  85. package/src/lib/repo/migrations.ts +104 -0
  86. package/src/lib/repo/sql.ts +122 -44
  87. package/src/lib/repo/sqlite.ts +14 -1
  88. package/src/lib/repo/types.ts +31 -3
  89. package/src/lib/scheduler.ts +14 -0
  90. package/src/lib/schema.ts +35 -1
  91. package/src/lib/theme-transfer.ts +163 -0
  92. package/src/lib/theme.ts +5 -0
  93. package/src/lib/webhooks.ts +7 -2
  94. package/src/middleware.ts +41 -11
  95. package/src/pages/[...slug].astro +3 -2
  96. package/src/pages/account/[action].astro +6 -0
  97. package/src/pages/admin/app-auth.astro +21 -7
  98. package/src/pages/admin/calendar/resources/link.astro +2 -0
  99. package/src/pages/admin/design.astro +2 -1
  100. package/src/pages/admin/import.astro +5 -3
  101. package/src/pages/admin/index.astro +3 -2
  102. package/src/pages/admin/integrations.astro +3 -2
  103. package/src/pages/admin/login.astro +236 -25
  104. package/src/pages/admin/logout.ts +16 -2
  105. package/src/pages/admin/mobile.astro +6 -2
  106. package/src/pages/admin/settings.astro +43 -5
  107. package/src/pages/admin/users.astro +350 -59
  108. package/src/pages/api/backup.ts +10 -26
  109. package/src/pages/api/design/export.ts +72 -0
  110. package/src/pages/api/design/import.ts +41 -0
  111. package/src/pages/api/email/settings.ts +9 -5
  112. package/src/pages/api/health.ts +17 -0
  113. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  114. package/src/pages/api/live/settings.ts +2 -2
  115. package/src/pages/api/media/upload.ts +2 -2
  116. package/src/pages/api/mobile/access.ts +22 -0
  117. package/src/pages/api/monitoring.ts +22 -0
  118. package/src/pages/api/ops/status.ts +2 -1
  119. package/src/pages/api/push/admin.ts +3 -2
  120. package/src/pages/api/restore.ts +6 -3
  121. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  122. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  123. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  124. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  125. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  126. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  127. package/src/pages/api/v1/admin/links.ts +13 -0
  128. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  129. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  130. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  131. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  132. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  133. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  134. package/src/pages/api/v1/admin/schedule.ts +7 -0
  135. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  136. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  137. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  138. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  139. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  140. package/src/pages/api/v1/calendars.ts +1 -1
  141. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  142. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  143. package/src/pages/api/v1/guest/bookings.ts +6 -0
  144. package/src/pages/api/v1/guest/code.ts +6 -0
  145. package/src/pages/api/v1/guest/revoke.ts +6 -0
  146. package/src/pages/api/v1/guest/token.ts +6 -0
  147. package/src/pages/api/v1/links.ts +7 -0
  148. package/src/pages/api/v1/openapi.json.ts +1 -1
  149. package/src/pages/api/v1/push/topics.ts +1 -1
  150. package/src/pages/api/v1/resources.ts +1 -1
  151. package/src/pages/api/v1/search.ts +7 -0
  152. package/src/pages/api/v1/theme.ts +1 -1
  153. package/src/pages/blog/[slug].astro +3 -2
  154. package/src/pages/book/[resource]/[link].astro +3 -3
  155. package/src/pages/book/manage/[token].astro +11 -7
  156. package/src/pages/events/[slug].astro +3 -2
  157. package/src/pages/preview/[type]/[id].astro +3 -3
  158. package/src/styles/admin.css +169 -16
  159. package/src/styles/email-settings.css +6 -0
  160. package/src/virtual.d.ts +1 -0
  161. package/src/worker.ts +7 -1
@@ -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;
@@ -1,8 +1,10 @@
1
1
  ---
2
2
  import Admin from '../../layouts/Admin.astro';
3
+ import AppAccess from '../../components/AppAccess';
3
4
  import MobileApp from '../../components/MobileApp';
4
5
  import { getRepo } from '../../lib/repo';
5
6
  import { requireSession } from '../../lib/api';
7
+ import { PUSH } from '../../lib/push/config';
6
8
  const repo = await getRepo(Astro.locals);
7
9
  try { await requireSession(Astro.request, Astro.cookies, repo, true); }
8
10
  catch { return new Response('Owner access is required.', { status: 403 }); }
@@ -11,8 +13,10 @@ catch { return new Response('Owner access is required.', { status: 403 }); }
11
13
  <div class="page-head">
12
14
  <div>
13
15
  <h1>Mobile app</h1>
14
- <p class="sub">Notifications for people who installed your app.</p>
16
+ <p class="sub">What your app can do on this site{PUSH.enabled ? ', and notifications for people who installed it' : ''}.</p>
15
17
  </div>
16
18
  </div>
17
- <MobileApp client:only="react" />
19
+ <AppAccess client:only="react" />
20
+ {PUSH.enabled && <h2 class="section-title">Notifications</h2>}
21
+ {PUSH.enabled && <MobileApp client:only="react" />}
18
22
  </Admin>
@@ -1,15 +1,18 @@
1
1
  ---
2
2
  import Admin from '../../layouts/Admin.astro';
3
3
  import AiSettings from '../../components/AiSettings';
4
+ import MonitoringSettings from '../../components/MonitoringSettings';
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';
8
+ import { AUTH } from '../../lib/auth/config';
6
9
  import { invalidateRedirectCache } from '../../middleware';
7
10
  import { CONTENT } from '../../lib/content';
8
11
  import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
9
12
 
10
13
  const repo = await getRepo(Astro.locals);
11
14
 
12
- const sessionUserId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
15
+ const sessionUserId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
13
16
  const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
14
17
  const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
15
18
 
@@ -227,6 +230,7 @@ const groups = [
227
230
  { id: 'ai-agents', label: 'AI & agents', terms: 'ai agents openai custom kimi deepseek cli proxy cpamc tailscale mcp' },
228
231
  { id: 'analytics', label: 'Analytics', terms: 'analytics umami replay meta pixel tiktok tracking' },
229
232
  { id: 'code', label: 'Code injection', terms: 'code injection head footer scripts' },
233
+ { id: 'monitoring', label: 'Monitoring', terms: 'monitoring uptime kuma health status down alerts heartbeat scheduler' },
230
234
  ] }] : []),
231
235
  { label: 'Advanced', items: [
232
236
  ...(isOwner ? [{ id: 'import', label: 'Import & export', terms: 'import export ghost wordpress migrate migration move content' }] : []),
@@ -472,7 +476,7 @@ const groups = [
472
476
  <section class="scard" id="email">
473
477
  <header class="scard-head">
474
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>
475
- <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>
476
480
  <div class="scard-actions always"><a class="btn" href="/admin/email">Configure email</a></div>
477
481
  </header>
478
482
  <p class="hint">Choose Resend, Mailgun, Amazon SES or Cloudflare. Manage your sender, notifications and delivery activity.</p>
@@ -570,6 +574,16 @@ const groups = [
570
574
 
571
575
  {isOwner && <section class="scard" id="ai-agents"><header class="scard-head"><a class="scard-back" href="#" aria-label="All settings">←</a><div class="grow"><h2>AI &amp; agents</h2></div><a class="btn" href="/admin/ai">Import document</a></header><AiSettings client:load /></section>}
572
576
 
577
+ {isOwner && (
578
+ <section class="scard" id="monitoring">
579
+ <header class="scard-head">
580
+ <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>
581
+ <div class="grow"><h2>Monitoring</h2><p>Uptime Kuma watches the website, the app APIs and the scheduler, and alerts you when one stops.</p></div>
582
+ </header>
583
+ <MonitoringSettings client:visible />
584
+ </section>
585
+ )}
586
+
573
587
  <div class="set-group-title">Advanced</div>
574
588
 
575
589
  {isOwner && (
@@ -589,9 +603,12 @@ const groups = [
589
603
  <div class="scard-actions always"><a class="btn" href="/api/backup" download>Download backup</a></div>
590
604
  </header>
591
605
  {isOwner && (
592
- <div class="scard-sub first">
606
+ <div class="scard-sub first" data-rs-clerk={AUTH.provider === 'clerk' ? '1' : undefined}>
593
607
  <h3>Restore from backup</h3>
594
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
+ )}
595
612
  <div class="rs-row">
596
613
  <input type="file" id="rs-file" accept=".zip,application/zip" aria-label="Backup ZIP" />
597
614
  <button class="btn" type="button" id="rs-analyze">Analyze</button>
@@ -643,6 +660,8 @@ const groups = [
643
660
  </style>
644
661
 
645
662
  <script>
663
+ import { checkReverified, signInAgain } from '../../components/auth/clerk';
664
+
646
665
  // read-mode cards: Edit swaps the summary for the form; Cancel resets it
647
666
  document.querySelectorAll<HTMLElement>('.scard[data-editable]').forEach((card) => {
648
667
  const form = card.querySelector<HTMLFormElement>('form.scard-form');
@@ -675,16 +694,35 @@ const groups = [
675
694
  const rsAnalyze = document.getElementById('rs-analyze');
676
695
  const rsGo = document.getElementById('rs-go');
677
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
+ }
678
709
  async function postBackup(dry: boolean) {
679
710
  const f = rsFile?.files?.[0];
680
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
+ }
681
717
  rsReport.textContent = dry ? 'Analyzing…' : 'Restoring…';
682
718
  const form = new FormData();
683
719
  form.set('file', f);
684
720
  const res = await fetch(`/api/restore${dry ? '?dry=1' : ''}`, { method: 'POST', body: form });
685
721
  const data = await res.json().catch(() => null);
686
722
  if (!res.ok || !data) {
687
- 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;
688
726
  return null;
689
727
  }
690
728
  return data;