@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
@@ -7,20 +7,21 @@ import { z } from 'astro:schema';
7
7
  import type { ActionAPIContext } from 'astro:actions';
8
8
  import { getRepo } from '../lib/repo';
9
9
  import { getMediaStore } from '../lib/media/store';
10
- import { validateSession, SESSION_COOKIE } from '../lib/auth';
10
+ import { validateRequestSession } from '../lib/auth/request';
11
11
  import { DocTranslations, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
12
12
  import { env } from '../lib/env';
13
13
  import { CONTENT, collectionUrl } from '../lib/content';
14
14
  import { invalidateRedirectCache } from '../middleware';
15
- import { isPrivateHost } from '../lib/net';
15
+ import { resolvesToPrivate } from '../lib/net';
16
16
  import { normalizeCalendarSrc } from '../lib/calendar/src';
17
+ import { saveCalendar } from '../lib/calendar/admin';
17
18
  import { getCalendarEvents } from '../lib/calendar/ics';
18
19
  import { siteLocale } from '../lib/dates';
19
20
  import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
20
21
  import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
21
22
  import { holdConflicts } from '../lib/booking/holds';
22
23
  import { setBookingStatus } from '../lib/booking/service';
23
- import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes, safeColor } from '../lib/calendar/events';
24
+ import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from '../lib/calendar/events';
24
25
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
25
26
  import { loadFeed } from '../lib/calendar/ics';
26
27
  import { NATIVE_ALL } from '../lib/calendar/render';
@@ -49,7 +50,7 @@ async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
49
50
  // All admin mutations live here. The middleware only guards /admin pages,
50
51
  // so every action re-checks the session itself.
51
52
  async function requireAdmin(ctx: ActionAPIContext) {
52
- if (!(await validateSession(ctx.cookies.get(SESSION_COOKIE)?.value, ctx.locals))) {
53
+ if (!(await validateRequestSession(ctx.request, ctx.cookies, ctx.locals))) {
53
54
  throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
54
55
  }
55
56
  }
@@ -536,7 +537,7 @@ export const server = {
536
537
  applyDesign: defineAction({
537
538
  input: z.object({}),
538
539
  handler: async (_input, ctx) => {
539
- const userId = await validateSession(ctx.cookies.get(SESSION_COOKIE)?.value, ctx.locals);
540
+ const userId = await validateRequestSession(ctx.request, ctx.cookies, ctx.locals);
540
541
  if (!userId) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
541
542
  const repo = await getRepo(ctx.locals);
542
543
  const changes = await designChanges(repo);
@@ -775,7 +776,7 @@ export const server = {
775
776
  let res: Response | null = null;
776
777
  for (let hop = 0; hop < 5; hop++) {
777
778
  const u = new URL(current);
778
- if (!/^https?:$/.test(u.protocol) || isPrivateHost(u.hostname)) {
779
+ if (!/^https?:$/.test(u.protocol) || await resolvesToPrivate(u.hostname)) {
779
780
  throw new ActionError({ code: 'BAD_REQUEST', message: 'That URL can’t be bookmarked.' });
780
781
  }
781
782
  res = await fetch(u.href, {
@@ -1212,32 +1213,11 @@ export const server = {
1212
1213
  handler: async ({ id, ...data }, ctx) => {
1213
1214
  await requireAdmin(ctx);
1214
1215
  const repo = await getRepo(ctx.locals);
1215
- const existing = id ? await repo.getCalendar(id) : null;
1216
- if (id && !existing) throw new ActionError({ code: 'NOT_FOUND', message: 'Calendar not found.' });
1217
- const kind = existing?.kind ?? data.kind ?? 'native';
1218
- let feedUrl = existing?.feedUrl ?? '';
1219
- if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
1220
- const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
1221
- if (!normalized) throw new ActionError({ code: 'BAD_REQUEST', message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.' });
1222
- feedUrl = normalized;
1216
+ try { return { calendar: await saveCalendar(repo, id, data) }; }
1217
+ catch (e) {
1218
+ if (e instanceof ApiError) throw new ActionError({ code: e.status === 404 ? 'NOT_FOUND' : 'BAD_REQUEST', message: e.message });
1219
+ throw e;
1223
1220
  }
1224
- const base = slugify(data.slug || data.name);
1225
- let slug = base;
1226
- for (let n = 2; ; n++) {
1227
- const clash = await repo.getCalendarBySlug(slug);
1228
- if (!clash || clash.id === id) break;
1229
- slug = `${base}-${n}`;
1230
- }
1231
- const fields = {
1232
- name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
1233
- description: data.description ?? existing?.description ?? '', feedUrl,
1234
- visibility: data.visibility ?? existing?.visibility ?? 'public',
1235
- };
1236
- if (existing) {
1237
- await repo.updateCalendar(existing.id, fields);
1238
- return { calendar: (await repo.getCalendar(existing.id))! };
1239
- }
1240
- return { calendar: await repo.createCalendar({ ...fields, kind }) };
1241
1221
  },
1242
1222
  }),
1243
1223
  reorder: defineAction({
@@ -0,0 +1,117 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { AppAccessState } from '../lib/mobile/access-admin';
3
+ import type { MobileAccess } from '../lib/schema';
4
+
5
+ // Admin → Mobile app → App access: what visitor and staff apps may do on
6
+ // this site. Each change saves at once; switching something off makes its
7
+ // app routes answer "turned off" immediately.
8
+
9
+ const ENDPOINT = '/api/mobile/access';
10
+ async function api(body?: Record<string, unknown>): Promise<AppAccessState> {
11
+ const res = await fetch(ENDPOINT, {
12
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
13
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
14
+ body: body ? JSON.stringify(body) : undefined,
15
+ });
16
+ const data = await res.json().catch(() => null);
17
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
18
+ return data as AppAccessState;
19
+ }
20
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
21
+
22
+ const BLOCKER: Record<NonNullable<AppAccessState['guestSignIn']['blocker']>, string> = {
23
+ calendar: 'Turn on the calendar to use guest sign-in.',
24
+ email: 'Set up email sending in Settings → Email first; sign-in codes go out by email.',
25
+ encryption: 'Add EMAIL_ENCRYPTION_KEY to the server first; it protects codes waiting to be sent.',
26
+ };
27
+
28
+ export default function AppAccess() {
29
+ const [state, setState] = useState<AppAccessState | null>(null);
30
+ const [error, setError] = useState('');
31
+ const [saved, setSaved] = useState('');
32
+ // overlapping saves: only the newest reply may replace what's on screen
33
+ const latest = useRef(0);
34
+ useEffect(() => { api().then(setState, (e) => setError(errText(e))); }, []);
35
+ if (!state) return error ? <p className="flash error">{error}</p> : <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /></div>;
36
+ const a = state.access;
37
+
38
+ const save = async (patch: Partial<MobileAccess>, note = 'Saved.') => {
39
+ setError(''); setSaved('');
40
+ const n = ++latest.current;
41
+ // show the change at once; reload the server's copy if it is refused
42
+ setState((s) => s && { ...s, access: { ...s.access, ...patch } });
43
+ try { const next = await api({ action: 'save', access: patch }); if (n === latest.current) { setState(next); setSaved(note); } }
44
+ catch (e) { setError(errText(e)); if (n === latest.current) api().then(setState, () => {}); }
45
+ };
46
+ const signOutAll = async () => {
47
+ if (!confirm(`Sign out ${state.guestSignIn.devices === 1 ? 'the 1 device' : `all ${state.guestSignIn.devices} devices`}? People sign in again with a new code.`)) return;
48
+ try { setState(await api({ action: 'signOutGuests' })); setSaved('Everyone was signed out.'); } catch (e) { setError(errText(e)); }
49
+ };
50
+ const Switch = ({ on, label, onChange, disabled }: { on: boolean; label: string; onChange: (v: boolean) => void; disabled?: boolean }) => (
51
+ <button type="button" role="switch" aria-checked={on} aria-label={label} disabled={disabled} className={`acc-switch${on ? ' on' : ''}`} onClick={() => onChange(!on)}><i /></button>
52
+ );
53
+ const Num = ({ value, min, max, label, field }: { value: number; min: number; max: number; label: string; field: 'bookingsPerEmailDay' | 'responsesPerFormDay' }) => (
54
+ <input className="acc-num" type="number" inputMode="numeric" min={min} max={max} defaultValue={value} aria-label={label}
55
+ onBlur={(e) => { const n = Math.round(Number(e.currentTarget.value)); if (Number.isFinite(n) && n !== value && n >= min && n <= max) save({ [field]: n }); else e.currentTarget.value = String(value); }} />
56
+ );
57
+ const guest = state.guestSignIn;
58
+
59
+ return (
60
+ <div className="acc">
61
+ {(error || saved) && <p className={`flash ${error ? 'error' : 'ok'}`} role="status">{error || saved}</p>}
62
+ <section className="card card-pad">
63
+ <div className="card-head"><b>App access</b><span className="hint">What visitor apps can do on this site</span></div>
64
+ {state.modules.calendar && (
65
+ <div className="acc-row">
66
+ <div><b>Change bookings from the app</b><small>Visitors can view, cancel and move a booking with its private link.</small></div>
67
+ <Switch on={a.bookingChanges} label="Change bookings from the app" onChange={(v) => save({ bookingChanges: v })} />
68
+ </div>
69
+ )}
70
+ {state.modules.calendar && (
71
+ <div className="acc-row">
72
+ <div>
73
+ <b>Guest sign-in</b>
74
+ <small>“My bookings” with a code sent by email. {guest.devices > 0 ? `${guest.devices.toLocaleString()} ${guest.devices === 1 ? 'device' : 'devices'} signed in.` : 'No devices signed in.'}</small>
75
+ {guest.blocker && a.guestSignIn && <small className="acc-warn">{BLOCKER[guest.blocker]}</small>}
76
+ </div>
77
+ <div className="acc-actions">
78
+ {guest.devices > 0 && <button type="button" className="btn sm ghost danger" onClick={signOutAll}>Sign out all</button>}
79
+ <Switch on={a.guestSignIn} label="Guest sign-in" onChange={(v) => save({ guestSignIn: v })} />
80
+ </div>
81
+ </div>
82
+ )}
83
+ <div className="acc-row">
84
+ <div><b>Form responses from the app</b><small>Published forms can be filled in from the app.</small></div>
85
+ <Switch on={a.forms} label="Form responses from the app" onChange={(v) => save({ forms: v })} />
86
+ </div>
87
+ <div className="acc-row">
88
+ <div><b>Links page</b><small>The app can show your links page.</small></div>
89
+ <Switch on={a.links} label="Links page" onChange={(v) => save({ links: v })} />
90
+ </div>
91
+ </section>
92
+
93
+ <section className="card card-pad">
94
+ <div className="card-head"><b>Limits</b><span className="hint">0 means no limit</span></div>
95
+ {state.modules.calendar && (
96
+ <div className="acc-row">
97
+ <div><b>Bookings per email per day</b><small>New bookings and RSVPs from one address, on the website and in apps. Staff bookings don’t count.</small></div>
98
+ <Num value={a.bookingsPerEmailDay} min={0} max={1000} label="Bookings per email per day" field="bookingsPerEmailDay" />
99
+ </div>
100
+ )}
101
+ <div className="acc-row">
102
+ <div><b>Responses per form per day</b><small>From apps. Extra responses are refused until tomorrow.</small></div>
103
+ <Num value={a.responsesPerFormDay} min={0} max={100000} label="Responses per form per day" field="responsesPerFormDay" />
104
+ </div>
105
+ {state.modules.calendar && (
106
+ <div className="acc-row">
107
+ <div><b>Who changes rooms, hours and calendars</b><small>In the staff app. Bookings, events and content stay open to all staff.</small></div>
108
+ <div className="seg" role="group" aria-label="Who changes rooms, hours and calendars">
109
+ <button type="button" aria-pressed={a.scheduleRoles === 'admins'} onClick={() => a.scheduleRoles !== 'admins' && save({ scheduleRoles: 'admins' })}>Admins + owners</button>
110
+ <button type="button" aria-pressed={a.scheduleRoles === 'staff'} onClick={() => a.scheduleRoles !== 'staff' && save({ scheduleRoles: 'staff' })}>All staff</button>
111
+ </div>
112
+ </div>
113
+ )}
114
+ </section>
115
+ </div>
116
+ );
117
+ }
@@ -7,12 +7,13 @@ import ThemeSectionsEditor, { SectionsReview, useDesignApi, type SectionFocus }
7
7
  import { checkLayouts, type Check } from './sections/checks';
8
8
  import { EMPTY_TARGETS } from './sections/SectionFields';
9
9
  import DesignAccess from './DesignAccess';
10
+ import ThemeTransfer, { type ThemeTransferHandle } from './ThemeTransfer';
10
11
  import '../styles/theme-editor.css';
11
12
 
12
13
  // Design screen: pick a code-first theme (src/themes/<slug>/), layer sparse
13
14
  // token overrides on top (saved to settings.designOverrides — the theme files
14
- // are never written to), set the site logo, and export the effective design
15
- // as a committable theme.json.
15
+ // are never written to), set the site logo, and export/import the design as
16
+ // an .acms-theme.zip (ThemeTransfer) or the effective tokens as theme.json.
16
17
  //
17
18
  // Every change stages into settings.draftDesign — the preview iframe renders
18
19
  // the staged design (?designPreview=1, admin-only) while visitors keep the
@@ -30,6 +31,8 @@ interface ThemeInfo {
30
31
  version: string;
31
32
  templateCount: number;
32
33
  sectionCount: number;
34
+ /** from the project's src/themes/ (its code lives in the site repo) */
35
+ project: boolean;
33
36
  }
34
37
 
35
38
  // post-page toggles: designOverrides key + which theme default backs it
@@ -147,6 +150,8 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
147
150
  const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
148
151
  const [coverPickerOpen, setCoverPickerOpen] = useState(false);
149
152
  const [copied, setCopied] = useState(false);
153
+ const transferRef = useRef<ThemeTransferHandle>(null);
154
+ const [justPublished, setJustPublished] = useState(false);
150
155
  const [tick, setTick] = useState(0);
151
156
  const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
152
157
  // open on the device you're most likely designing for: desktop on a wide
@@ -301,6 +306,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
301
306
  setPending(false);
302
307
  setChanges([]);
303
308
  setVersions(null);
309
+ setJustPublished(true);
304
310
  setTick((t) => t + 1);
305
311
  void designApi.reload();
306
312
  };
@@ -348,6 +354,12 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
348
354
  location.reload();
349
355
  };
350
356
 
357
+ // export/import read the stored draft: land queued edits first
358
+ const flushAll = async () => {
359
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; if (!(await doSave())) return false; }
360
+ return flushSections.current();
361
+ };
362
+
351
363
  const discardDesign = async () => {
352
364
  if (!window.confirm('Discard the staged design changes and go back to the live design? This cannot be undone.')) return;
353
365
  if (timer.current) { clearTimeout(timer.current); timer.current = null; }
@@ -433,7 +445,11 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
433
445
  <span className={`lm-savestate ${saveState}`}>
434
446
  {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? (saveMessage || 'Save failed') : null}
435
447
  </span>
436
- {!editorOnly && <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>}
448
+ {!editorOnly && <>
449
+ <button className="btn sm ghost" type="button" onClick={() => transferRef.current?.openImport()} title="Bring in a theme exported from another site or machine">Import</button>
450
+ <button className="btn sm ghost" type="button" onClick={() => transferRef.current?.openExport()} title="Download this design as a theme file">Export</button>
451
+ <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>
452
+ </>}
437
453
  </div>
438
454
  </div>
439
455
 
@@ -442,7 +458,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
442
458
  {hasDraft ? (
443
459
  <><span className="dm-st is-draft">Draft</span><span className="dm-st-sub">· {changes.length > 0 ? `${changes.length} ${changes.length === 1 ? 'change' : 'changes'}` : 'page layouts'}, not live</span></>
444
460
  ) : (
445
- <><span className="dm-st is-live">Live</span><span className="dm-st-sub">· no unpublished changes</span></>
461
+ <><span className="dm-st is-live">Live</span><span className="dm-st-sub">· {justPublished && !editorOnly
462
+ ? <>published. Working on another machine? <button className="dm-resetlink" type="button" onClick={() => transferRef.current?.openExport()}>Export this theme</button></>
463
+ : 'no unpublished changes'}</span></>
446
464
  )}
447
465
  <span className="dm-status-actions">
448
466
  {overrideCount > 0 && !editorOnly && (
@@ -633,7 +651,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
633
651
  <span className="dm-theme-name">{t.name}</span>
634
652
  <span className="dm-theme-desc">{t.description.split(' — ')[0]}</span>
635
653
  <span className="dm-chip">v{t.version} · {t.sectionCount} sections</span>
636
- <span className="dm-theme-desc">{t.templateCount} custom templates · installed</span>
654
+ <span className="dm-theme-desc">{t.templateCount} custom templates · {t.project ? 'project theme' : 'built-in'}</span>
637
655
  </button>
638
656
  ))}
639
657
  </div>
@@ -789,13 +807,12 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
789
807
 
790
808
  <div className="dm-row">
791
809
  <div className="dm-lab">
792
- <span>Export</span>
793
- <small>Effective design (theme + your overrides) as a committable <code>theme.json</code></small>
810
+ <span>Move this theme</span>
811
+ <small>Export your design as one file and import it on another site or machine</small>
794
812
  </div>
795
813
  <div className="dm-ctl">
796
- <button className="btn sm" type="button" onClick={copyThemeJson}>
797
- {copied ? 'Copied ✓' : 'Copy as theme.json'}
798
- </button>
814
+ <button className="btn sm" type="button" onClick={() => transferRef.current?.openExport()}>Export…</button>
815
+ <button className="btn sm" type="button" onClick={() => transferRef.current?.openImport()}>Import…</button>
799
816
  </div>
800
817
  </div>
801
818
  </div>
@@ -1032,6 +1049,8 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
1032
1049
  </div>
1033
1050
  </dialog>
1034
1051
 
1052
+ <ThemeTransfer ref={transferRef} theme={theme} flush={flushAll} onCopyThemeJson={copyThemeJson} copied={copied} />
1053
+
1035
1054
  <ImagePicker
1036
1055
  open={logoPickerOpen}
1037
1056
  unsplashEnabled={unsplashEnabled}
@@ -1,11 +1,14 @@
1
1
  import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
2
2
  import type { Credentials, EmailConfig, PublicEmailSettings } from '../lib/email/types';
3
+ import { checkReverified, signInAgain } from './auth/clerk';
3
4
  import '../styles/email-settings.css';
4
5
 
5
6
  // Settings → Email: Setup, Notifications, Templates, Activity over /api/email/settings.
6
7
  // Secrets are write-only: inputs start empty, blank keeps the saved value, and a
7
8
  // successful save wipes password and secret fields. Nothing goes to browser
8
9
  // storage, nothing polls, and no email is sent unless the owner types an address.
10
+ // Saves are confirmed with the current password, or, when the site signs in
11
+ // with Clerk, by a sign-in from the last ten minutes.
9
12
 
10
13
  type Choice = EmailConfig['provider'];
11
14
  type Job = PublicEmailSettings['activity'][number];
@@ -72,11 +75,12 @@ async function api<T>(body?: unknown, signal?: AbortSignal): Promise<T> {
72
75
  body: body === undefined ? undefined : JSON.stringify(body),
73
76
  });
74
77
  const data = await res.json().catch(() => null);
75
- if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
78
+ if (!res.ok) throw Object.assign(new Error(data?.error || `${res.status} ${res.statusText || 'Request failed'}`), { status: res.status });
76
79
  if (!data) throw new Error('The server sent an empty response.');
77
80
  return data as T;
78
81
  }
79
82
  const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
83
+ const refused = (e: unknown) => (e as { status?: number } | null)?.status === 403;
80
84
  const validAddress = (s: string) => s.length <= 254 && /^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(s);
81
85
  // mirrors the server's parseFrom: `address` or `Name <address>`
82
86
  function validSender(raw: string) {
@@ -115,6 +119,8 @@ export default function EmailSettings() {
115
119
  const [notifyMsg, setNotifyMsg] = useState(NO_MSG);
116
120
  const [testMsg, setTestMsg] = useState(NO_MSG);
117
121
  const [actMsg, setActMsg] = useState(NO_MSG);
122
+ // Clerk: the sign-in is too old for a save; offer to sign in again
123
+ const [stale, setStale] = useState(false);
118
124
 
119
125
  const live = (c = ctl.current) => !!c && !c.signal.aborted;
120
126
 
@@ -175,6 +181,22 @@ export default function EmailSettings() {
175
181
  const hbStale = !data.heartbeat || Date.now() - data.heartbeat > 15 * 60000;
176
182
  const set = <K extends keyof EmailConfig>(k: K, v: EmailConfig[K]) => setForm((f) => f && { ...f, [k]: v });
177
183
  const savedOff = data.config.provider === 'none' || !data.config.enabled;
184
+ const clerk = data.authProvider === 'clerk';
185
+
186
+ // Clerk: check for a recent sign-in before saving, keeping the edits on the
187
+ // page when it's too old. If Clerk can't be reached the save still goes
188
+ // through and the server decides.
189
+ async function confirmIdentity(fail: (err: string) => void) {
190
+ if (!clerk) return true;
191
+ const r = await checkReverified();
192
+ if (!live()) return false;
193
+ if (r === 'needed') { setStale(true); fail('Saving needs a sign-in from the last ten minutes. Nothing was saved; your edits are still here.'); return false; }
194
+ setStale(false);
195
+ return true;
196
+ }
197
+ function reSignIn() {
198
+ if (confirm('Sign in again? You’ll come back to this page, but unsaved edits here are cleared.')) void signInAgain();
199
+ }
178
200
 
179
201
  function pickProvider(next: Choice) {
180
202
  setCreds({});
@@ -194,31 +216,33 @@ export default function EmailSettings() {
194
216
  if (f.from && !validSender(f.from)) return fail('Sender must be an address or Name <address>.');
195
217
  if (f.replyTo && !validSender(f.replyTo)) return fail('Reply-to must be an address or Name <address>.');
196
218
  if (f.provider === 'mailgun' && !usesEnv && !/^[a-z0-9.-]+$/i.test(f.domain)) return fail('Enter the Mailgun sending domain, like mg.example.com.');
197
- if (!setupPw) return fail('Enter your current password to save.');
219
+ if (!clerk && !setupPw) return fail('Enter your current password to save.');
198
220
  const credentials: Credentials = {};
199
221
  if (!usesEnv) for (const c of CREDS[f.provider]) { const v = creds[c.key]?.trim(); if (v) credentials[c.key] = v; }
200
222
  await run('save', async () => {
201
223
  try {
202
- const next = await api<PublicEmailSettings>({ action: 'save', config: f, credentials, reauthPassword: setupPw });
224
+ if (!await confirmIdentity(fail)) return;
225
+ const next = await api<PublicEmailSettings>({ action: 'save', config: f, credentials, reauthPassword: clerk ? '' : setupPw });
203
226
  if (!live()) return;
204
227
  adopt(next); setTested(false);
205
228
  setSetupMsg({
206
229
  ok: next.configured ? 'Saved. The configuration is valid; send a test to check the connection.' : `Saved, but email isn’t ready yet: ${next.error || 'no provider is configured.'}`,
207
230
  err: '',
208
231
  });
209
- } catch (e) { if (live()) { setSetupPw(''); fail(errText(e)); } }
232
+ } catch (e) { if (live()) { setSetupPw(''); fail(errText(e)); setStale(clerk && refused(e)); } }
210
233
  });
211
234
  }
212
235
 
213
236
  async function saveNotify() {
214
237
  setNotifyMsg(NO_MSG);
215
- if (!notify.pw) return setNotifyMsg({ ok: '', err: 'Enter your current password to save.' });
238
+ if (!clerk && !notify.pw) return setNotifyMsg({ ok: '', err: 'Enter your current password to save.' });
216
239
  const config = { ...data!.config, reminders: notify.reminders, staffNotifications: notify.staff };
217
240
  await run('notify', async () => {
218
241
  try {
219
- const next = await api<PublicEmailSettings>({ action: 'save', config, credentials: {}, reauthPassword: notify.pw });
242
+ if (!await confirmIdentity((err) => setNotifyMsg({ ok: '', err }))) return;
243
+ const next = await api<PublicEmailSettings>({ action: 'save', config, credentials: {}, reauthPassword: clerk ? '' : notify.pw });
220
244
  if (live()) { adopt(next, 'notify'); setNotifyMsg({ ok: 'Notification settings saved.', err: '' }); }
221
- } catch (e) { if (live()) { setNotify((n) => ({ ...n, pw: '' })); setNotifyMsg({ ok: '', err: errText(e) }); } }
245
+ } catch (e) { if (live()) { setNotify((n) => ({ ...n, pw: '' })); setNotifyMsg({ ok: '', err: errText(e) }); setStale(clerk && refused(e)); } }
222
246
  });
223
247
  }
224
248
 
@@ -262,7 +286,22 @@ export default function EmailSettings() {
262
286
  <div className="ems-status" role="alert">{m.err && <p className="flash error">{m.err}</p>}</div>
263
287
  </>
264
288
  );
265
- const pwField = (value: string, onChange: (v: string) => void, key: string) => (
289
+ const pwField = (value: string, onChange: (v: string) => void, key: string) => clerk ? (
290
+ <div className="ems-verify" id={id(key)}>
291
+ <span className="ems-verify-mark" aria-hidden="true">
292
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM9 12l2 2 4-4" /></svg>
293
+ </span>
294
+ <p>
295
+ <b>Confirmed with your sign-in.</b>{' '}
296
+ {stale ? 'Your sign-in is more than ten minutes old. Sign in again, then save; your edits stay here until you do.' : 'Saving needs a sign-in from the last ten minutes. If yours is older, you’ll be asked to sign in again.'}
297
+ </p>
298
+ {stale && (
299
+ <span className="ems-verify-actions">
300
+ <button type="button" className="btn sm" onClick={reSignIn} disabled={!!busy}>Sign in again</button>
301
+ </span>
302
+ )}
303
+ </div>
304
+ ) : (
266
305
  <label className="field ems-pw">
267
306
  <span>Your current password</span>
268
307
  <input type="password" autoComplete="current-password" value={value} onChange={(e) => onChange(e.target.value)} aria-describedby={id(key)} />
@@ -427,9 +466,10 @@ export default function EmailSettings() {
427
466
  <form className="ems-card" onSubmit={(e) => { e.preventDefault(); void saveNotify(); }} noValidate>
428
467
  <header className="ems-head"><div><h2>Always sent</h2><p>Essential messages go out whenever email is on and a provider is configured, unless the address is suppressed after a bounce or complaint.</p></div></header>
429
468
  <ul className="ems-list">
430
- <li>Account invitations, password resets and password-changed notices</li>
469
+ {!clerk && <li>Account invitations, password resets and password-changed notices</li>}
431
470
  <li>Booking received, confirmed, declined, cancelled and rescheduled</li>
432
471
  </ul>
472
+ {clerk && <p className="hint">Sign-in email (invitations, verification codes and password recovery) comes from Clerk, not from this connection.</p>}
433
473
  <h3 className="ems-sub">Optional</h3>
434
474
  <label className="ems-toggle">
435
475
  <input type="checkbox" checked={notify.reminders} onChange={(e) => setNotify((n) => ({ ...n, reminders: e.target.checked }))} />
@@ -452,7 +492,7 @@ export default function EmailSettings() {
452
492
  <header className="ems-head"><div><h2>Built-in templates</h2><p>Every message has HTML and plain-text versions. The logo, name and colors come from your site settings. This sample uses made-up example.com data; real account links are never previewed.</p></div></header>
453
493
  <div className="ems-row">
454
494
  <div className="seg" role="group" aria-label="Sample message">
455
- {(Object.keys(SAMPLES) as (keyof typeof SAMPLES)[]).map((k) => (
495
+ {(Object.keys(SAMPLES) as (keyof typeof SAMPLES)[]).filter((k) => !clerk || !ACCOUNT_SAMPLES.has(k)).map((k) => (
456
496
  <button key={k} type="button" aria-pressed={tpl === k} onClick={() => setTpl(k)}>{SAMPLES[k].label}</button>
457
497
  ))}
458
498
  </div>
@@ -461,7 +501,7 @@ export default function EmailSettings() {
461
501
  <button type="button" aria-pressed={plain} onClick={() => setPlain(true)}>Plain text</button>
462
502
  </div>
463
503
  </div>
464
- <Preview s={SAMPLES[tpl]} plain={plain} />
504
+ <Preview s={SAMPLES[clerk && ACCOUNT_SAMPLES.has(tpl) ? 'booking' : tpl]} plain={plain} />
465
505
  </div>
466
506
  </section>
467
507
 
@@ -506,6 +546,8 @@ const SAMPLES = {
506
546
  reset: { label: 'Password reset', subject: 'Reset your Example Studio password', heading: 'Reset your password', body: ['Someone asked to reset the password for sam@example.com.', 'The link works once and expires in 30 minutes.'], action: 'Choose a new password', foot: 'If this wasn’t you, ignore this email; your password stays the same.' },
507
547
  booking: { label: 'Booking confirmed', subject: 'Confirmed: Studio tour, Tue 14 Oct', heading: 'Your booking is confirmed', body: ['Studio tour with Example Studio', 'Tuesday 14 October 2026, 10:00–10:45 (America/New_York)', '123 Example Street, Springfield'], action: 'Manage booking', ics: true, foot: 'Need to change it? Use the link above or reply to this email.' },
508
548
  } satisfies Record<string, Sample>;
549
+ // sign-in email that Clerk sends instead when the site uses Clerk
550
+ const ACCOUNT_SAMPLES = new Set<keyof typeof SAMPLES>(['invite', 'reset']);
509
551
 
510
552
  function Preview({ s, plain }: { s: Sample; plain: boolean }) {
511
553
  const text = [s.heading, '', ...s.body, '', `${s.action}: [link omitted in preview]`, '', s.foot, '', '— Example Studio'].join('\n');
@@ -0,0 +1,112 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { MonitoringState, MonitorStatus } from '../lib/monitoring';
3
+
4
+ // Settings → Monitoring: this site's own health, its Uptime Kuma monitors
5
+ // (read from a public Kuma status page) and the scheduler's push URL.
6
+
7
+ const ENDPOINT = '/api/monitoring';
8
+ async function api(body?: Record<string, unknown>): Promise<MonitoringState> {
9
+ const res = await fetch(ENDPOINT, {
10
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
11
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
12
+ body: body ? JSON.stringify(body) : undefined,
13
+ });
14
+ const data = await res.json().catch(() => null);
15
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
16
+ return data as MonitoringState;
17
+ }
18
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
19
+
20
+ function ago(iso: string | null, now = Date.now()) {
21
+ if (!iso) return '';
22
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
23
+ if (s < 60) return `${s} s`;
24
+ if (s < 3600) return `${Math.round(s / 60)} min`;
25
+ if (s < 86_400) return `${Math.round(s / 3600)} h`;
26
+ return `${Math.round(s / 86_400)} d`;
27
+ }
28
+ // 99.98%, 50%, 100%: never rounds a bad day up to 100%
29
+ const pct = (n: number | null) => (n === null ? '' : `${String(Math.floor(n * 10_000) / 100)}%`);
30
+ const WORD: Record<MonitorStatus['status'], string> = { up: 'Up', down: 'Down', pending: 'Pending', maintenance: 'Maintenance', unknown: 'No data' };
31
+ const CHECK: Record<string, string> = { ok: 'OK', down: 'down', stalled: 'stopped', stuck: 'stuck', off: 'off' };
32
+
33
+ function Tile({ m }: { m: MonitorStatus }) {
34
+ // segments never break inside ("100%\u00a0(24\u00a0h)")
35
+ const sub = m.status === 'unknown' ? ['No checks yet']
36
+ : [`${WORD[m.status]}${m.since ? ` for ${ago(m.since)}` : ''}`, m.uptime24h !== null ? `${pct(m.uptime24h)} (24 h)` : ''].filter(Boolean);
37
+ return (
38
+ <div className={`mon-tile${m.status === 'down' ? ' is-down' : ''}`}>
39
+ <div className="mon-name"><span className={`mon-dot ${m.status}`} aria-hidden="true" />{m.name}</div>
40
+ <div className="mon-sub">{sub.map((s, i) => <span key={i}>{i > 0 && ' · '}<span className="nw">{s}</span></span>)}</div>
41
+ {m.status === 'down' && m.message && <div className="mon-msg">{m.message}</div>}
42
+ <div className="mon-beats" aria-hidden="true">{m.recent.map((b, i) => <i key={i} className={b} />)}</div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export default function MonitoringSettings() {
48
+ const [state, setState] = useState<MonitoringState | null>(null);
49
+ const [error, setError] = useState('');
50
+ const [note, setNote] = useState('');
51
+ const [busy, setBusy] = useState('');
52
+ const [push, setPush] = useState('');
53
+ const [page, setPage] = useState('');
54
+ useEffect(() => { api().then((s) => { setState(s); setPage(s.statusPage); }, (e) => setError(errText(e))); }, []);
55
+ if (!state) return error ? <p className="flash error">{error}</p> : <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /></div>;
56
+
57
+ const run = async (what: string, body: Record<string, unknown>, done: string) => {
58
+ setBusy(what); setError(''); setNote('');
59
+ try { const s = await api(body); setState(s); setPage(s.statusPage); setPush(''); setNote(done); }
60
+ catch (e) { setError(errText(e)); }
61
+ finally { setBusy(''); }
62
+ };
63
+ const save = () => run('save', { action: 'save', statusPage: page, ...(push.trim() ? { pushUrl: push } : {}) }, 'Saved.');
64
+ const h = state.health;
65
+ const changed = page.trim() !== state.statusPage || push.trim() !== '';
66
+
67
+ return (
68
+ <div className="mon">
69
+ {(error || note) && <p className={`flash ${error ? 'error' : ''}`} role="status">{error || note}</p>}
70
+
71
+ <div className="mon-self">
72
+ <div><span className={`mon-dot ${h.status === 'ok' ? 'up' : h.status === 'degraded' ? 'pending' : 'down'}`} aria-hidden="true" /><b>This site</b></div>
73
+ <span>Database {CHECK[h.checks.database]}</span>
74
+ <span>Scheduler {CHECK[h.checks.scheduler]}{state.lastRun ? `, last ran ${ago(state.lastRun)} ago` : ', never ran'}</span>
75
+ <span>Email {CHECK[h.checks.email]}</span>
76
+ <a href="/api/health" target="_blank" rel="noreferrer">/api/health</a>
77
+ </div>
78
+
79
+ {state.statusPage && state.monitors === null && <p className="flash error">Couldn’t read the status page. Check the address and that the page is published in Kuma.</p>}
80
+ {state.monitors && (state.monitors.length
81
+ ? <div className="mon-grid">{state.monitors.map((m) => <Tile key={m.id} m={m} />)}</div>
82
+ : <p className="hint">The status page has no monitors yet. Add them to it in Kuma.</p>)}
83
+ {!state.statusPage && (
84
+ <div className="mon-empty">
85
+ <b>Set up Uptime Kuma</b>
86
+ <p>From the site folder, run:</p>
87
+ <code className="mon-cmd">npx acms monitor kuma --url https://your-site --kuma https://your-kuma</code>
88
+ <p>It creates the website, app API, staff app API, health and scheduler monitors plus a status page in Kuma, and prints the two addresses to paste below.</p>
89
+ </div>
90
+ )}
91
+
92
+ <div className="mon-fields">
93
+ <label className="mon-field">
94
+ <span>Scheduler heartbeat <small>Kuma push monitor URL{state.pushFromEnv ? ' (set by UPTIME_PUSH_URL on the server)' : ''}</small></span>
95
+ <div className="mon-inline">
96
+ <input type="url" inputMode="url" placeholder={state.pushUrl || 'https://uptime.example.com/api/push/…'} value={push} onChange={(e) => setPush(e.currentTarget.value)} disabled={state.pushFromEnv} aria-label="Kuma push URL" />
97
+ <button type="button" className="btn" disabled={!state.pushUrl || !!busy} onClick={() => run('test', { action: 'test' }, 'Kuma received the test beat.')}>{busy === 'test' ? 'Sending…' : 'Send test'}</button>
98
+ </div>
99
+ {state.pushUrl && !state.pushFromEnv && <small>Saved as <code>{state.pushUrl}</code> · paste a new one to replace it</small>}
100
+ </label>
101
+ <label className="mon-field">
102
+ <span>Status page <small>Public Kuma status page with this site’s monitors</small></span>
103
+ <input type="url" inputMode="url" placeholder="https://uptime.example.com/status/my-site" value={page} onChange={(e) => setPage(e.currentTarget.value)} aria-label="Kuma status page" />
104
+ </label>
105
+ <div className="mon-actions">
106
+ {state.pushUrl && !state.pushFromEnv && <button type="button" className="btn ghost danger" disabled={!!busy} onClick={() => run('save', { action: 'save', pushUrl: '' }, 'Heartbeat removed.')}>Remove heartbeat</button>}
107
+ <button type="button" className="btn primary" disabled={!changed || !!busy} onClick={save}>{busy === 'save' ? 'Saving…' : 'Save'}</button>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ );
112
+ }