@acms-subir/core 0.3.10 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +11 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/theme-authoring.md +31 -0
- package/integration.mjs +39 -0
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +5 -5
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +40 -9
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +6 -3
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +1 -0
- package/src/lib/email/templates.ts +2 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/mobile/http.ts +4 -3
- package/src/lib/mobile/route.ts +41 -4
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +76 -0
- package/src/lib/repo/sql.ts +56 -24
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +12 -1
- package/src/lib/scheduler.ts +7 -0
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/settings.astro +31 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/links.ts +1 -1
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +103 -16
- package/src/styles/email-settings.css +6 -0
- package/src/theme-sdk/calendar.ts +48 -0
- package/src/theme-sdk/index.ts +2 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
package/cli/preview.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export async function prepareFixture(p,dir,slug){
|
|
|
17
17
|
await writeFile(join(root,'package.json'),JSON.stringify({name:'acms-isolated-preview',type:'module',private:true}));
|
|
18
18
|
await writeFile(join(root,'astro.config.mjs'),`import {defineConfig,passthroughImageService} from 'astro/config';\nimport react from '@astrojs/react';\nimport node from '@astrojs/node';\nimport cloudflare from '@astrojs/cloudflare';\nimport acms from '@acms-subir/core';\nexport default defineConfig({output:'server',vite:{resolve:{preserveSymlinks:true}},integrations:[react(),acms({seed:{siteTitle:'Theme preview — synthetic content'}})],adapter:process.env.DEPLOY_TARGET==='cloudflare'?cloudflare({platformProxy:{enabled:false},imageService:'passthrough'}):node({mode:'standalone'}),image:{service:passthroughImageService()}});\n`);
|
|
19
19
|
await writeFile(join(root,'src/pages/acms-theme-preview/setup.astro'),`---
|
|
20
|
-
import {getRepo} from '
|
|
20
|
+
import {getRepo} from ${JSON.stringify(join(CORE,'src/lib/repo/index.ts'))};
|
|
21
21
|
const repo=await getRepo(Astro.locals);
|
|
22
22
|
let home=await repo.getPageBySlug('preview-home');
|
|
23
23
|
if(!home){
|
package/deploy/Dockerfile
CHANGED
|
@@ -25,10 +25,14 @@ ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
|
|
|
25
25
|
RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
|
|
26
26
|
COPY --from=build /app/dist ./dist
|
|
27
27
|
COPY --from=build /app/node_modules/@acms-subir/core/email-runner.mjs ./email-runner.mjs
|
|
28
|
+
# acms migrate: applies pending migrations under the database lock before
|
|
29
|
+
# the server starts (the app's own first-request migration is the fallback)
|
|
30
|
+
COPY --from=build /app/node_modules/@acms-subir/core/migrate.mjs ./acms/migrate.mjs
|
|
31
|
+
COPY --from=build /app/node_modules/@acms-subir/core/cli/migrate.mjs /app/node_modules/@acms-subir/core/cli/migrations.generated.mjs ./acms/cli/
|
|
28
32
|
# ./data holds the SQLite database and uploaded media — mount a volume here.
|
|
29
33
|
# Only data/ needs to be writable (a chown -R of /app would copy-on-write
|
|
30
34
|
# duplicate every node_modules file into a fresh layer)
|
|
31
35
|
RUN mkdir -p data && chown node:node data
|
|
32
36
|
USER node
|
|
33
37
|
EXPOSE 4321
|
|
34
|
-
CMD ["
|
|
38
|
+
CMD ["sh", "-c", "node ./acms/migrate.mjs && exec node ./dist/server/entry.mjs"]
|
package/deploy/compose.yml
CHANGED
|
@@ -14,8 +14,29 @@ services:
|
|
|
14
14
|
- PORT=4321
|
|
15
15
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
|
16
16
|
- SESSION_SECRET=${SESSION_SECRET:?set SESSION_SECRET in .env}
|
|
17
|
+
# Optional Clerk staff authentication (provider selected in astro.config).
|
|
18
|
+
- PUBLIC_CLERK_PUBLISHABLE_KEY=${PUBLIC_CLERK_PUBLISHABLE_KEY:-}
|
|
19
|
+
- CLERK_SECRET_KEY=${CLERK_SECRET_KEY:-}
|
|
20
|
+
- CLERK_AUTHORIZED_PARTIES=${CLERK_AUTHORIZED_PARTIES:-}
|
|
21
|
+
- CLERK_JWT_KEY=${CLERK_JWT_KEY:-}
|
|
22
|
+
- CLERK_OWNER_USER_ID=${CLERK_OWNER_USER_ID:-}
|
|
23
|
+
- CLERK_OWNER_LOCAL_USER_ID=${CLERK_OWNER_LOCAL_USER_ID:-}
|
|
17
24
|
- EMAIL_RUNNER_SECRET=${EMAIL_RUNNER_SECRET:?set EMAIL_RUNNER_SECRET in .env}
|
|
18
25
|
- EMAIL_ENCRYPTION_KEY=${EMAIL_ENCRYPTION_KEY:-}
|
|
26
|
+
# Optional: behind the Cloudflare proxy, clear its cache on every content
|
|
27
|
+
# change (token needs Zone → Cache Purge). See docs/caching.md.
|
|
28
|
+
- CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID:-}
|
|
29
|
+
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:-}
|
|
30
|
+
# Optional scheduled off-site backups to S3-compatible storage (R2,
|
|
31
|
+
# Oracle Object Storage, AWS S3). See docs/production-deployment.md.
|
|
32
|
+
- BACKUP_S3_ENDPOINT=${BACKUP_S3_ENDPOINT:-}
|
|
33
|
+
- BACKUP_S3_BUCKET=${BACKUP_S3_BUCKET:-}
|
|
34
|
+
- BACKUP_S3_REGION=${BACKUP_S3_REGION:-}
|
|
35
|
+
- BACKUP_S3_ACCESS_KEY_ID=${BACKUP_S3_ACCESS_KEY_ID:-}
|
|
36
|
+
- BACKUP_S3_SECRET_ACCESS_KEY=${BACKUP_S3_SECRET_ACCESS_KEY:-}
|
|
37
|
+
- BACKUP_PREFIX=${BACKUP_PREFIX:-}
|
|
38
|
+
- BACKUP_KEEP=${BACKUP_KEEP:-}
|
|
39
|
+
- BACKUP_EVERY_HOURS=${BACKUP_EVERY_HOURS:-}
|
|
19
40
|
- SQLITE_URL=file:./data/acms.db
|
|
20
41
|
healthcheck:
|
|
21
42
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/api/v1/content?limit=1').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
package/docs/live-video.md
CHANGED
|
@@ -45,7 +45,7 @@ Rate-limit/quota errors back off. The UI reports estimated workload and provider
|
|
|
45
45
|
- **Design → Pages:** add a **Live video** section; choose `card` or `player`, preview and publish using the existing design flow.
|
|
46
46
|
- **Links:** add a **Live video** block and save. This is a compact card referencing the same site-wide status.
|
|
47
47
|
- **Older widget pages:** add a **Live video** widget and choose its display mode. Rich-text-only/custom coded homepages must opt into a Design section or explicitly render the component.
|
|
48
|
-
- **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
|
|
48
|
+
- **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/theme/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
|
|
49
49
|
|
|
50
50
|
### How it looks
|
|
51
51
|
|
package/docs/theme-authoring.md
CHANGED
|
@@ -34,6 +34,31 @@ const { page, posts } = Astro.props;
|
|
|
34
34
|
|
|
35
35
|
Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
|
|
36
36
|
|
|
37
|
+
### Upcoming events in coded templates
|
|
38
|
+
|
|
39
|
+
Use the server-side calendar helpers from the public theme entry point. Replace imports from `@acms-subir/core/lib/repo/*` and `@acms-subir/core/lib/calendar/*`; internal folders are not package exports.
|
|
40
|
+
|
|
41
|
+
```astro
|
|
42
|
+
---
|
|
43
|
+
import { upcomingEvents, calendarConfig, eventUrl } from '@acms-subir/core/theme';
|
|
44
|
+
const calendar = calendarConfig();
|
|
45
|
+
const upcoming = await upcomingEvents(Astro, { limit: 4, horizonDays: 90 });
|
|
46
|
+
const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: Astro.props.settings.timezone || 'UTC' });
|
|
47
|
+
---
|
|
48
|
+
{calendar.enabled && <section>
|
|
49
|
+
<a href={calendar.base}>Calendar</a>
|
|
50
|
+
{upcoming.map(({ event, start }) => (
|
|
51
|
+
<a href={eventUrl(event.slug)}>
|
|
52
|
+
{event.title} <time datetime={new Date(start).toISOString()}>{date.format(start)}</time>
|
|
53
|
+
</a>
|
|
54
|
+
))}
|
|
55
|
+
</section>}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
|
|
59
|
+
|
|
60
|
+
Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-negative safe integer and the horizon a positive finite number. Each `UpcomingEvent` contains `{ event, start, end, allDay, days }`, with start/end in Unix milliseconds. Format occurrence timestamps using the site's timezone rather than the authored `event.start` when displaying recurring events. `UpcomingEvent`, `UpcomingEventsOptions`, and `ThemeCalendarConfig` are exported types.
|
|
61
|
+
|
|
37
62
|
Template filenames: `home.astro`, `event.astro`, `header.astro`, `footer.astro`, `post-<name>.astro`, `page-<name>.astro`.
|
|
38
63
|
|
|
39
64
|
`header.astro` and `footer.astro` replace the site header and footer on every public page. They receive `ChromeProps` — one `site` prop (`SiteChrome`) with everything already resolved for the request: `title`, `tagline`, `logo`, `logoDark`, `homeUrl`, `links` (header nav, each `{ label, url, current, external }`, already localized), `footerLinks`, `social` (with inline SVG `icon`), `readLinks`, `footerHtml`, `languages` (`{ code, label, url, current }`, where `label` is the language's own name, e.g. "Español"), `search` (`{ url, label }` or null), `schemeToggle`, `liveHeader`, `headerStyle`, `footerStyle`. Core behaviors attach through data hooks, so keep them on your own elements: `data-search-open` (opens search), `data-scheme-toggle` (light/dark), `data-menu-open` (phone menu sheet, built from `site.links`) and a hidden `data-live-header` link (shown during a live stream).
|
|
@@ -91,6 +116,12 @@ The theme API version covers documented template/section contracts and public co
|
|
|
91
116
|
|
|
92
117
|
`theme diff` compares against the creation snapshot; `--baseline <folder>` selects another version. Runtime settings/navigation differences are available in `design pull`.
|
|
93
118
|
|
|
119
|
+
## Moving a theme between sites or machines
|
|
120
|
+
|
|
121
|
+
Design → **Export** downloads the design as the Design screen shows it (draft over live) as one `<slug>-<date>.acms-theme.zip`: `manifest.json` (format `acms-theme` v1, theme slug/name/version, core version), `design.json` (token overrides, logo/dark logo/favicon/cover, section layouts keyed by page slug, link targets by URL, and navigation only when ticked) and `media/<key>` with `media.json` for the images it uses. It never contains posts, page text, keys or other settings. Design → **Import** previews the file against the receiving site, uploads its images (kept by id, so `/media/` URLs stay valid), and stages everything as a draft; publish it from Review as usual. Layouts land on the page with the same slug (the exported home page falls back to this site's home); pages with no match are reported and skipped. Section links follow the item at the same URL, otherwise keep their typed address. Owners only; editors cannot export or import.
|
|
122
|
+
|
|
123
|
+
Theme **code** is not in the file: it is compiled into the build. When the file names a theme this site lacks, the import keeps the current theme and says so. Move `src/themes/<slug>/` with git, or `acms theme pack` / `acms theme install`, then rebuild. The same endpoints are scriptable with a scoped key: `GET /api/design/export` (`design:read`, plus `media:read` unless `?media=0`; `?nav=1` adds menus) and `POST /api/design/import` with `{ revision, design, nav?, dryRun? }` (`design:write-draft`).
|
|
124
|
+
|
|
94
125
|
## Sharing, versions and rollback
|
|
95
126
|
|
|
96
127
|
`theme pack` produces a deterministic theme-only tgz. `theme install` validates the archive before writing, rejects traversal/links/unexpected files/incompatible core versions, and never runs install scripts. Use `--force` for intentional replacement: the previous directory is retained in `.acms/theme-backups/`. `.acms/themes.lock.json` records the pinned version, digest and backup path. Copy a retained backup to a new theme identity or re-pack/install it to roll code back; rebuild and redeploy. No automatic download of theme dependencies occurs.
|
package/integration.mjs
CHANGED
|
@@ -88,6 +88,8 @@ const ROUTES = [
|
|
|
88
88
|
['/api/design/keys', 'api/design/keys.ts'],
|
|
89
89
|
['/api/design/capabilities', 'api/design/capabilities.ts'],
|
|
90
90
|
['/api/design/media', 'api/design/media.ts'],
|
|
91
|
+
['/api/design/export', 'api/design/export.ts'],
|
|
92
|
+
['/api/design/import', 'api/design/import.ts'],
|
|
91
93
|
['/api/design/ai', 'api/design/ai.ts'],
|
|
92
94
|
['/api/design/translate', 'api/design/translate.ts'],
|
|
93
95
|
['/api/agent', 'api/agent/index.ts'],
|
|
@@ -235,6 +237,28 @@ function resolveI18n(i18n, contentBases) {
|
|
|
235
237
|
// Defaults + validation for acms({ search }). Search ships on; disabling it
|
|
236
238
|
// removes the /search page(s), /api/search and /api/v1/search from the route table, and the
|
|
237
239
|
// layout skips the header trigger + modal (lib/search-config.ts).
|
|
240
|
+
// acms({ cache }): CDN caching of public pages and the public app API.
|
|
241
|
+
// `false` turns it off (every response is private, no-store); `memory` is the
|
|
242
|
+
// Node-only in-process cache of public API data, keyed by content version.
|
|
243
|
+
function resolveCache(cache) {
|
|
244
|
+
const defaults = { enabled: true, maxAge: 60, staleWhileRevalidate: 86400, memory: true };
|
|
245
|
+
if (cache === undefined || cache === true) return defaults;
|
|
246
|
+
if (cache === false) return { ...defaults, enabled: false, memory: false };
|
|
247
|
+
if (!cache || typeof cache !== 'object' || Array.isArray(cache)) throw new Error('acms: cache must be false or { maxAge?, staleWhileRevalidate?, memory? }');
|
|
248
|
+
const unknown = Object.keys(cache).filter((k) => !['maxAge', 'staleWhileRevalidate', 'memory'].includes(k));
|
|
249
|
+
if (unknown.length > 0) {
|
|
250
|
+
throw new Error(`acms: unknown cache key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — valid keys are maxAge, staleWhileRevalidate and memory`);
|
|
251
|
+
}
|
|
252
|
+
const out = { ...defaults, ...cache };
|
|
253
|
+
for (const k of ['maxAge', 'staleWhileRevalidate']) {
|
|
254
|
+
if (!Number.isInteger(out[k]) || out[k] < 0 || out[k] > 31_536_000) throw new Error(`acms: cache.${k} must be a whole number of seconds (0–31536000)`);
|
|
255
|
+
}
|
|
256
|
+
if (typeof out.memory !== 'boolean') throw new Error('acms: cache.memory must be true or false');
|
|
257
|
+
// maxAge 0 means "don't let the CDN store anything" — same as cache: false
|
|
258
|
+
if (out.maxAge === 0) return { ...out, enabled: false };
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
238
262
|
function resolveSearch(search) {
|
|
239
263
|
if (search === undefined) return { enabled: true };
|
|
240
264
|
const unknown = Object.keys(search).filter((k) => k !== 'enabled');
|
|
@@ -493,6 +517,15 @@ function acmsVitePlugin(userConfig) {
|
|
|
493
517
|
* pasted map links still work.
|
|
494
518
|
* @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
|
|
495
519
|
* Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
|
|
520
|
+
* @param {{provider?: 'builtin' | 'clerk'}} [options.auth] Staff authentication provider. Defaults to builtin.
|
|
521
|
+
* @param {false | {maxAge?: number, staleWhileRevalidate?: number, memory?: boolean}} [options.cache]
|
|
522
|
+
* Shared caching of public pages and the public app API (`/api/v1/*`):
|
|
523
|
+
* `Cache-Control: public, s-maxage=<maxAge>, stale-while-revalidate=<staleWhileRevalidate>`
|
|
524
|
+
* (defaults 60 and 86400). Admin, previews, signed-in visitors, bookings,
|
|
525
|
+
* forms and every non-GET request stay `private, no-store`. Every content
|
|
526
|
+
* change clears the cache (Workers Cache, or the Cloudflare zone when
|
|
527
|
+
* CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN are set). `memory: false` turns
|
|
528
|
+
* off the Node in-process API cache. `cache: false` turns all of it off.
|
|
496
529
|
* @returns {import('astro').AstroIntegration}
|
|
497
530
|
*/
|
|
498
531
|
export default function acms(options = {}) {
|
|
@@ -503,6 +536,7 @@ export default function acms(options = {}) {
|
|
|
503
536
|
]);
|
|
504
537
|
const i18n = resolveI18n(options.i18n, contentBases);
|
|
505
538
|
const search = resolveSearch(options.search);
|
|
539
|
+
const cache = resolveCache(options.cache);
|
|
506
540
|
const mobile = resolveMobile(options.mobile);
|
|
507
541
|
const calendar = resolveCalendar(options.calendar, contentBases, i18n.locales);
|
|
508
542
|
const maps = resolveMaps(options.maps);
|
|
@@ -510,13 +544,18 @@ export default function acms(options = {}) {
|
|
|
510
544
|
if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
|
|
511
545
|
if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
|
|
512
546
|
if (legal.updatedAt && (!/^\d{4}-\d{2}-\d{2}$/.test(legal.updatedAt) || !Number.isFinite(Date.parse(legal.updatedAt)) || new Date(legal.updatedAt).toISOString().slice(0,10) !== legal.updatedAt)) throw new Error('acms: legal.updatedAt must be a valid YYYY-MM-DD date');
|
|
547
|
+
const auth = { provider: options.auth?.provider === undefined ? 'builtin' : options.auth.provider };
|
|
548
|
+
if (options.auth !== undefined && (!options.auth || typeof options.auth !== 'object' || Array.isArray(options.auth) || Object.keys(options.auth).some(k => k !== 'provider'))) throw new Error('acms: auth accepts only provider');
|
|
549
|
+
if (!['builtin', 'clerk'].includes(auth.provider)) throw new Error('acms: auth.provider must be builtin or clerk');
|
|
513
550
|
const userConfig = {
|
|
551
|
+
auth,
|
|
514
552
|
coreVersion: CORE_VERSION,
|
|
515
553
|
brand: options.brand ?? {},
|
|
516
554
|
seed: options.seed ?? {},
|
|
517
555
|
content,
|
|
518
556
|
i18n,
|
|
519
557
|
search,
|
|
558
|
+
cache,
|
|
520
559
|
mobile,
|
|
521
560
|
calendar,
|
|
522
561
|
maps,
|
package/migrate.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -24,22 +24,16 @@
|
|
|
24
24
|
"bin": {
|
|
25
25
|
"acms": "cli.mjs",
|
|
26
26
|
"acms-email-runner": "email-runner.mjs",
|
|
27
|
-
"acms-check-deployment": "ops-check.mjs"
|
|
27
|
+
"acms-check-deployment": "ops-check.mjs",
|
|
28
|
+
"acms-migrate": "migrate.mjs"
|
|
28
29
|
},
|
|
29
30
|
"exports": {
|
|
30
31
|
".": "./integration.mjs",
|
|
31
32
|
"./package.json": "./package.json",
|
|
32
33
|
"./actions": "./src/actions/index.ts",
|
|
33
|
-
"./middleware": "./src/middleware.ts",
|
|
34
|
-
"./worker": "./src/worker.ts",
|
|
35
|
-
"./lib/*": "./src/lib/*",
|
|
36
|
-
"./pages/*": "./src/pages/*",
|
|
37
|
-
"./components/*": "./src/components/*",
|
|
38
|
-
"./layouts/*": "./src/layouts/*",
|
|
39
|
-
"./styles/*": "./src/styles/*",
|
|
40
|
-
"./themes/*": "./src/themes/*",
|
|
41
34
|
"./theme": "./src/theme-sdk/index.ts",
|
|
42
35
|
"./theme/schema": "./src/theme-sdk/contract.mjs",
|
|
36
|
+
"./theme/components/LiveCard.astro": "./src/components/LiveCard.astro",
|
|
43
37
|
"./theme/components/*": "./src/components/site/*"
|
|
44
38
|
},
|
|
45
39
|
"files": [
|
|
@@ -50,12 +44,14 @@
|
|
|
50
44
|
"email-runner.mjs",
|
|
51
45
|
"deploy",
|
|
52
46
|
"ops-check.mjs",
|
|
47
|
+
"migrate.mjs",
|
|
53
48
|
"cli",
|
|
54
49
|
"docs",
|
|
55
50
|
"section-library",
|
|
56
51
|
"mobile-template"
|
|
57
52
|
],
|
|
58
53
|
"dependencies": {
|
|
54
|
+
"@clerk/backend": "^3.22.0",
|
|
59
55
|
"@cloudflare/workers-types": "^5.20260927.1",
|
|
60
56
|
"@dnd-kit/core": "^6.3.1",
|
|
61
57
|
"@dnd-kit/sortable": "^10.0.0",
|
|
@@ -109,10 +105,12 @@
|
|
|
109
105
|
"@types/react-dom": "^19.2.4",
|
|
110
106
|
"@types/sanitize-html": "^2.16.1",
|
|
111
107
|
"@types/turndown": "^5.0.6",
|
|
108
|
+
"esbuild": "^0.28.1",
|
|
112
109
|
"miniflare": "^5.20260801.0-alpha",
|
|
113
110
|
"vitest": "^3.2.0"
|
|
114
111
|
},
|
|
115
112
|
"scripts": {
|
|
116
|
-
"test": "vitest run"
|
|
113
|
+
"test": "vitest run",
|
|
114
|
+
"gen:migrations": "node scripts/gen-migrations.mjs"
|
|
117
115
|
}
|
|
118
116
|
}
|
package/src/actions/index.ts
CHANGED
|
@@ -7,12 +7,12 @@ 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
17
|
import { saveCalendar } from '../lib/calendar/admin';
|
|
18
18
|
import { getCalendarEvents } from '../lib/calendar/ics';
|
|
@@ -50,7 +50,7 @@ async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
|
|
|
50
50
|
// All admin mutations live here. The middleware only guards /admin pages,
|
|
51
51
|
// so every action re-checks the session itself.
|
|
52
52
|
async function requireAdmin(ctx: ActionAPIContext) {
|
|
53
|
-
if (!(await
|
|
53
|
+
if (!(await validateRequestSession(ctx.request, ctx.cookies, ctx.locals))) {
|
|
54
54
|
throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
|
|
55
55
|
}
|
|
56
56
|
}
|
|
@@ -537,7 +537,7 @@ export const server = {
|
|
|
537
537
|
applyDesign: defineAction({
|
|
538
538
|
input: z.object({}),
|
|
539
539
|
handler: async (_input, ctx) => {
|
|
540
|
-
const userId = await
|
|
540
|
+
const userId = await validateRequestSession(ctx.request, ctx.cookies, ctx.locals);
|
|
541
541
|
if (!userId) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
|
|
542
542
|
const repo = await getRepo(ctx.locals);
|
|
543
543
|
const changes = await designChanges(repo);
|
|
@@ -776,7 +776,7 @@ export const server = {
|
|
|
776
776
|
let res: Response | null = null;
|
|
777
777
|
for (let hop = 0; hop < 5; hop++) {
|
|
778
778
|
const u = new URL(current);
|
|
779
|
-
if (!/^https?:$/.test(u.protocol) ||
|
|
779
|
+
if (!/^https?:$/.test(u.protocol) || await resolvesToPrivate(u.hostname)) {
|
|
780
780
|
throw new ActionError({ code: 'BAD_REQUEST', message: 'That URL can’t be bookmarked.' });
|
|
781
781
|
}
|
|
782
782
|
res = await fetch(u.href, {
|
|
@@ -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');
|