@acms-subir/core 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +17 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +23 -2
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +74 -4
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +11 -31
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +42 -12
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +8 -4
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +9 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +8 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +6 -5
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +63 -4
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +104 -0
- package/src/lib/repo/sql.ts +122 -44
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +31 -3
- package/src/lib/scheduler.ts +14 -0
- package/src/lib/schema.ts +35 -1
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +43 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +169 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
package/src/actions/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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) ||
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
|
15
|
-
//
|
|
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 &&
|
|
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">·
|
|
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 ·
|
|
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>
|
|
793
|
-
<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={
|
|
797
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|