@acms-subir/core 0.3.30 → 0.3.32

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.
@@ -10,6 +10,7 @@ import { manageAvailabilityQuery, rescheduleInput } from './manage';
10
10
  import { formResponseInput } from './forms';
11
11
  import { codeInput, tokenInput, myBookingsQuery } from './guest';
12
12
  import { deviceInput, deviceUpdate } from '../push/api';
13
+ import { localeOnlyQuery } from './locale';
13
14
  import { pageQuery, queryParams } from './http';
14
15
  import * as schemas from './schemas';
15
16
  import { editorPaths, editorSchemas, editorSecuritySchemes } from '../editor/openapi';
@@ -37,7 +38,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
37
38
  empty.parse(queryParams(url));
38
39
  const paths: Record<string, unknown> = {};
39
40
  const reads: [string, string, z.AnyZodObject, unknown, boolean?][] = [
40
- ['/site', 'Site identity, navigation, languages and features', empty, ref('Site')],
41
+ ['/site', 'Site identity, navigation, languages, features and legal links (locale: links in that language)', localeOnlyQuery, ref('Site')],
41
42
  ['/theme', 'Active Design tokens including overrides', empty, ref('Theme')],
42
43
  ['/pages/home', 'Published home page and resolved sections', pagesQuery, ref('Page')],
43
44
  ['/pages/{slug}', 'Published page and resolved sections (slug may contain slashes)', pagesQuery, ref('Page')],
@@ -48,7 +49,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
48
49
  ['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
49
50
  ['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
50
51
  ['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
51
- ['/push/topics', 'Topics an app can offer as notification toggles; requires push', empty, ref('PushTopics')],
52
+ ['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
52
53
  ['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
53
54
  ['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
54
55
  ['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
@@ -8,7 +8,8 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
8
8
  export const Site = z.object({
9
9
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool }), apiVersion: z.literal(1),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool }),
12
+ legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
12
13
  });
13
14
  export const Theme = z.object({
14
15
  id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
@@ -104,7 +105,7 @@ export const Device = z.object({
104
105
  });
105
106
  export const DeviceRegistration = z.object({ created: bool, device: Device });
106
107
  export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
107
- export const PushTopics = z.object({ topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar']) })) });
108
+ export const PushTopics = z.object({ locale, topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar', 'site']), description: str.optional() })) });
108
109
  // times are Unix milliseconds (as on the website); compare them with serverNow, not the device clock
109
110
  const ms = z.number().int();
110
111
  export const LiveSnapshot = z.object({
@@ -9,11 +9,16 @@ import { resolveDesign } from '../theme';
9
9
  import { darkPalette, liveColor } from '../themeTokens';
10
10
  import { safeUrl } from './content';
11
11
  import { queryParams } from './http';
12
+ import { localeOnlyQuery, localizedUrl, mobileLocale } from './locale';
12
13
  import { guestSignInReady } from './guest';
14
+ import { LEGAL } from '../legal/config';
13
15
 
14
16
  export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
15
- z.object({}).strict().parse(queryParams(url));
17
+ // locale: the legal links point at that language's pages (/es/privacy)
18
+ const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
16
19
  const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
20
+ // the privacy notice and terms: the standard pages, or published pages at those slugs
21
+ const legalUrl = (slug: 'privacy' | 'terms') => (LEGAL.enabled || pages.some(p => p.status === 'published' && p.slug === slug) ? localizedUrl(`${siteOrigin(settings, url)}/${slug}`, siteOrigin(settings, url), locale) : null);
17
22
  const origin = siteOrigin(settings, url);
18
23
  const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
19
24
  const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
@@ -38,6 +43,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
38
43
  // Integrations → Live video; GET /api/v1/live answers 404 while it's off
39
44
  live: settings.live.mode !== 'off',
40
45
  },
46
+ // in the requested language (?locale=es → /es/privacy)
47
+ legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
41
48
  apiVersion: 1,
42
49
  };
43
50
  }
@@ -8,6 +8,7 @@ import { CONTENT, collectionUrl } from '../content';
8
8
  import { CALENDAR, eventUrl } from '../calendar/config';
9
9
  import { eventIsListed } from '../calendar/events';
10
10
  import { queryParams } from '../mobile/http';
11
+ import { localeOnlyQuery, mobileLocale } from '../mobile/locale';
11
12
  import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
12
13
  import { expoOptions } from './expo';
13
14
  import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
@@ -72,8 +73,8 @@ export async function unregisterDevice(repo: ContentRepository, rawToken: string
72
73
  }
73
74
  export async function listPushTopics(repo: ContentRepository, url: URL) {
74
75
  requirePush();
75
- z.object({}).strict().parse(queryParams(url));
76
- return { topics: await pushTopics(repo) };
76
+ const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
77
+ return { locale, topics: await pushTopics(repo, locale) };
77
78
  }
78
79
 
79
80
  // ---- admin -------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  // Push notifications config, set per project via the integration:
2
- // acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270 } }
2
+ // acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270, topics: [...] } }
3
3
  // Off by default. When off, the device/topic endpoints and the admin Mobile
4
4
  // app page are not registered at all (like other disabled modules), and the
5
5
  // scheduler just marks any queued automatic notifications as skipped.
@@ -10,6 +10,8 @@ import { ApiError } from '../api';
10
10
  export const PUSH = {
11
11
  enabled: userConfig.mobile?.push?.enabled === true,
12
12
  retentionDays: userConfig.mobile?.push?.retentionDays ?? 270,
13
+ /** topics the site adds (config mobile.push.topics); labels per language, '*' = every language */
14
+ topics: [...(userConfig.mobile?.push?.topics ?? [])] as Array<{ id: string; label: Record<string, string>; description: Record<string, string> }>,
13
15
  };
14
16
 
15
17
  export function requirePush() {
@@ -22,30 +22,51 @@ export const RECEIPT_DELAY = 15 * MINUTE;
22
22
 
23
23
  // ---- topics ------------------------------------------------------------------
24
24
 
25
+ // names of the built-in topics; other languages show English
26
+ const TOPIC_WORDS: Record<string, { news: string; events: string; live: string }> = {
27
+ en: { news: 'News', events: 'Events', live: 'Live video' },
28
+ es: { news: 'Noticias', events: 'Eventos', live: 'Video en vivo' },
29
+ fr: { news: 'Actualités', events: 'Événements', live: 'Vidéo en direct' },
30
+ de: { news: 'Neuigkeiten', events: 'Veranstaltungen', live: 'Live-Video' },
31
+ pt: { news: 'Notícias', events: 'Eventos', live: 'Vídeo ao vivo' },
32
+ };
33
+ /** A site topic's words in a language: exact code, its base ("es-MX" → "es"), every language ('*'), the site's main language, then any. */
34
+ export function topicWords(words: Record<string, string>, locale: string): string {
35
+ const base = locale.toLowerCase().split('-')[0];
36
+ return words[locale] ?? words[base] ?? words['*'] ?? (I18N.defaultLocale ? words[I18N.defaultLocale] : undefined) ?? Object.values(words)[0] ?? '';
37
+ }
38
+
25
39
  /**
26
- * Topics an app can offer as toggles. `news` = every new post, `events` = every
27
- * new event, `live` = "we're live" (while live video isn't off). Per-collection
28
- * and per-calendar topics appear once there is more than one of them (a single
29
- * one would just repeat news/events). Devices only get the topics they (or the
30
- * app, at their first registration) choose: a new topic never reaches existing
31
- * devices on its own.
40
+ * Topics an app can offer as toggles, named in `locale` (default: the site's
41
+ * main language). `news` = every new post, `events` = every new event, `live` =
42
+ * "we're live" (while live video isn't off), then the site's own topics
43
+ * (acms({ mobile: { push: { topics } } })). Per-collection and per-calendar
44
+ * topics appear once there is more than one of them (a single one would just
45
+ * repeat news/events). Devices only get the topics they (or the app, at their
46
+ * first registration) choose: a new topic never reaches existing devices on its own.
32
47
  */
33
- export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]> {
48
+ export async function pushTopics(repo: ContentRepository, locale?: string | null): Promise<PushTopic[]> {
49
+ const lang = locale || I18N.defaultLocale || (await repo.getSettings()).locale || 'en';
50
+ const w = TOPIC_WORDS[lang.toLowerCase().split('-')[0]] ?? TOPIC_WORDS.en;
34
51
  const collections = Object.entries(CONTENT.collections);
35
- const out: PushTopic[] = [{ id: 'news', label: 'News', kind: 'general' }];
52
+ const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
36
53
  if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
37
54
  if (CALENDAR.enabled) {
38
- out.push({ id: 'events', label: 'Events', kind: 'general' });
55
+ out.push({ id: 'events', label: w.events, kind: 'general' });
39
56
  const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
40
57
  if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
41
58
  }
42
- if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: 'Live video', kind: 'general' });
59
+ if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
60
+ for (const t of PUSH.topics) {
61
+ const description = topicWords(t.description, lang);
62
+ out.push({ id: t.id, label: topicWords(t.label, lang), kind: 'site', ...(description ? { description } : {}) });
63
+ }
43
64
  return out;
44
65
  }
45
66
 
46
67
  /** Every topic a device may subscribe to (listed or not). `live` stays valid while live video is off, so a saved choice survives. */
47
68
  export async function knownTopics(repo: ContentRepository): Promise<Set<string>> {
48
- const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
69
+ const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`), ...PUSH.topics.map((t) => t.id)]);
49
70
  if (CALENDAR.enabled) {
50
71
  set.add('events');
51
72
  for (const c of await repo.listCalendars()) if (c.visibility === 'public') set.add(`calendar:${c.slug}`);
@@ -252,7 +273,8 @@ export async function sendStartingSoon(repo: ContentRepository, opts: PushRunOpt
252
273
  if (!chosen.length) return 0;
253
274
  const lead = rule.minutes * MINUTE;
254
275
  const occurrences = expandEvents(chosen, settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + lead + 1 })
255
- .filter((o) => !o.allDay && o.start > now && o.start - lead <= now);
276
+ // a single cancelled date of a repeating event gets no reminder
277
+ .filter((o) => !o.allDay && !o.cancelled && o.start > now && o.start - lead <= now);
256
278
  const run = { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) };
257
279
  let sent = 0;
258
280
  for (const o of occurrences) {
@@ -34,7 +34,7 @@ export interface PushNotification {
34
34
  error: string; author: string; createdAt: number; finishedAt: number | null;
35
35
  }
36
36
  export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live'; docId: string; occurrence: number | null; status: string; createdAt: number }
37
- export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' }
37
+ export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' | 'site'; description?: string }
38
38
  export interface PushStats {
39
39
  devices: number;
40
40
  platforms: Record<PushPlatform, number>;
@@ -668,12 +668,20 @@ export const MIGRATIONS: Migration[] = [
668
668
  `ALTER TABLE events ADD COLUMN host_ids TEXT NOT NULL DEFAULT '[]'`,
669
669
  `ALTER TABLE events ADD COLUMN request TEXT`,
670
670
  ] },
671
+ // Settings → Legal (Settings.legal): shown on /privacy and /terms, so it
672
+ // bumps the content version; the automatic "last updated" stamps don't
673
+ { version: 57, statements: [
674
+ `ALTER TABLE settings ADD COLUMN legal TEXT NOT NULL DEFAULT '{}'`,
675
+ `ALTER TABLE settings ADD COLUMN legal_stamps TEXT NOT NULL DEFAULT '{}'`,
676
+ `DROP TRIGGER content_version_settings_update`,
677
+ ...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal']),
678
+ ] },
671
679
  ];
672
680
 
673
681
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
674
682
  export const CURRENT_CONTENT_VERSION_COLUMNS = {
675
683
  ...CONTENT_VERSION_COLUMNS,
676
- settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale'],
684
+ settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
677
685
  events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
678
686
  };
679
687
 
@@ -939,6 +939,17 @@ export class SqlRepository implements ContentRepository {
939
939
  await this.ready;
940
940
  await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
941
941
  }
942
+ async cancelOccurrenceRsvps(id: string, start: number) {
943
+ await this.ready;
944
+ // the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
945
+ const mutation = crypto.randomUUID();
946
+ const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
947
+ await this.driver.batch([
948
+ {sql:"UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=? WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ('pending','confirmed')",args:[mutation,Date.now(),id,start]},
949
+ ...notifications,
950
+ ]);
951
+ return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
952
+ }
942
953
  async discardEventDraft(id: string) {
943
954
  await this.ready;
944
955
  await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
@@ -2032,6 +2043,8 @@ export class SqlRepository implements ContentRepository {
2032
2043
  push: JSON.parse(String(r?.push ?? '{}')),
2033
2044
  calendar: JSON.parse(String(r?.calendar ?? '{}')),
2034
2045
  mobileAccess: JSON.parse(String(r?.mobile_access ?? '{}')),
2046
+ legal: JSON.parse(String(r?.legal ?? '{}')),
2047
+ legalStamps: JSON.parse(String(r?.legal_stamps ?? '{}')),
2035
2048
  monitoring: JSON.parse(String(r?.monitoring ?? '{}')),
2036
2049
  agent: JSON.parse(String(r?.agent ?? '{}')),
2037
2050
  draftsApiKey: r?.drafts_api_key ?? '',
@@ -2063,9 +2076,9 @@ export class SqlRepository implements ContentRepository {
2063
2076
  linksAvatarShape:'links_avatar_shape', linksAvatarFit:'links_avatar_fit', linksAvatarScale:'links_avatar_scale',
2064
2077
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
2065
2078
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
2066
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar', mobileAccess:'mobile_access', monitoring:'monitoring',
2079
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar', mobileAccess:'mobile_access', monitoring:'monitoring', legal:'legal', legalStamps:'legal_stamps',
2067
2080
  };
2068
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar','mobileAccess','monitoring']);
2081
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar','mobileAccess','monitoring','legal','legalStamps']);
2069
2082
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
2070
2083
  if (!entries.length) return;
2071
2084
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -149,6 +149,8 @@ export interface ContentRepository {
149
149
  discardEventDraft(id: string): Promise<void>;
150
150
  /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
151
151
  setEventCancelledDates(id: string, dates: string[]): Promise<void>;
152
+ /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
153
+ cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
152
154
  deleteEvent(id: string): Promise<void>;
153
155
  // saved-place sync (lib/maps/places.ts): rewrite just the location of the
154
156
  // live row and/or the staged draft, leaving every other field — and the
package/src/lib/schema.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { safeEventImageUrl } from './calendar/images';
2
2
  import { LiveConfig } from './live/types';
3
3
  import { PushSettings } from './push/types';
4
+ import { LegalSettings, LegalStamps } from './legal/types';
4
5
  import { SectionPages } from '../theme-sdk/contract.mjs';
5
6
  import { AiConfig, AgentConfig } from './ai-schema';
6
7
  import { z } from 'zod';
@@ -762,6 +763,9 @@ export const Settings = z.object({
762
763
  monitoring: Monitoring.default({}),
763
764
  // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
764
765
  push: PushSettings.default({}),
766
+ // Settings → Legal (lib/legal): operator details and their own sections
767
+ legal: LegalSettings.default({}),
768
+ legalStamps: LegalStamps,
765
769
  siteTitle: z.string().default('My Site'),
766
770
  tagline: z.string().default(''),
767
771
  // site-wide footer, markdown allowed; empty falls back to "title — tagline"
@@ -2,6 +2,8 @@
2
2
  import Admin from '../../layouts/Admin.astro';
3
3
  import AiSettings from '../../components/AiSettings';
4
4
  import MonitoringSettings from '../../components/MonitoringSettings';
5
+ import LegalSettings from '../../components/LegalSettings';
6
+ import { LEGAL } from '../../lib/legal/config';
5
7
  import { getRepo } from '../../lib/repo';
6
8
  import { accessFor } from '../../lib/permissions/access';
7
9
  import type { Permission } from '../../lib/permissions/catalog';
@@ -233,6 +235,7 @@ const groups = [
233
235
  { id: 'site', label: 'Title & description', terms: 'site title tagline url footer seo' },
234
236
  { id: 'localization', label: 'Timezone & language', terms: 'timezone locale language date' },
235
237
  { id: 'announcement', label: 'Announcement bar', terms: 'announcement banner bar' },
238
+ ...(can('site.settings') ? [{ id: 'legal', label: 'Legal', terms: 'legal privacy notice policy terms of service aviso de privacidad arco gdpr app store privacy data safety address' }] : []),
236
239
  ] },
237
240
  { label: 'Publishing', items: [
238
241
  ...(can('integrations.manage') ? [{ id: 'drafts-api', label: 'External drafts API', terms: 'api key drafts cors origins' }] : []),
@@ -381,6 +384,16 @@ const groups = [
381
384
  </form>
382
385
  </section>
383
386
 
387
+ {can('site.settings') && (
388
+ <section class="scard" id="legal">
389
+ <header class="scard-head">
390
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
391
+ <div class="grow"><h2>Legal</h2><p>{LEGAL.enabled ? 'Your privacy notice and terms (/privacy, /terms): who you are, your own sections, and the answers for the app stores.' : 'The standard privacy and terms pages are off for this site.'}</p></div>
392
+ </header>
393
+ <LegalSettings client:visible />
394
+ </section>
395
+ )}
396
+
384
397
  <div class="set-group-title">Publishing</div>
385
398
 
386
399
  {can('integrations.manage') && (
@@ -0,0 +1,23 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+ import { ApiError, apiError, json, readJson } from '../../lib/api';
4
+ import { legalAdminState, saveLegal } from '../../lib/legal/admin';
5
+ import { requirePermission } from '../../lib/permissions/access';
6
+
7
+ // Settings → Legal (General settings permission, this site's admin only).
8
+ export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
9
+ try {
10
+ const repo = await getRepo(locals);
11
+ await requirePermission(request, cookies, repo, 'site.settings');
12
+ return json(await legalAdminState(repo, url, locals));
13
+ } catch (e) { return apiError(e); }
14
+ };
15
+ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
16
+ try {
17
+ const repo = await getRepo(locals);
18
+ await requirePermission(request, cookies, repo, 'site.settings');
19
+ if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
20
+ await saveLegal(repo, await readJson(request, 200_000));
21
+ return json(await legalAdminState(repo, url, locals));
22
+ } catch (e) { return apiError(e); }
23
+ };
@@ -0,0 +1,23 @@
1
+ /* Settings → Legal */
2
+ .lg { display: grid; gap: 20px; }
3
+ .lg-block { display: grid; gap: 10px; }
4
+ .lg-block h3 { margin: 0; font-size: 14px; }
5
+ .lg-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px 16px; }
6
+ .lg-wide { grid-column: 1 / -1; }
7
+ .lg-warn { margin: 0; padding: 10px 12px 10px 28px; border: 1px solid var(--line); border-radius: 8px; background: var(--soft); font-size: 13px; display: grid; gap: 4px; }
8
+ .lg-links { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline; margin: 0; }
9
+ .lg-section { display: grid; gap: 8px; margin: 0; padding: 12px; border: 1px solid var(--line); border-radius: 8px; }
10
+ .lg-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
11
+ .lg-title { flex: 1 1 220px; }
12
+ .lg-section textarea, .lg-tr input, .lg-tr textarea { width: 100%; }
13
+ .lg-tr { display: grid; gap: 6px; }
14
+ .lg-tr summary { cursor: pointer; font-size: 13px; color: var(--muted); }
15
+ .lg-tr[open] { gap: 6px; }
16
+ .lg-tr[open] > * + * { margin-top: 6px; }
17
+ .lg-table { display: grid; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; font-size: 13px; }
18
+ .lg-table > div { display: grid; grid-template-columns: minmax(140px, 1fr) 2fr 2fr; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
19
+ .lg-table > div:first-child { border-top: 0; }
20
+ .lg-table small { display: block; margin-top: 2px; }
21
+ .lg-th { background: var(--soft); font-weight: 600; }
22
+ .lg-notes { margin: 0; padding-left: 18px; display: grid; gap: 4px; font-size: 13px; }
23
+ @media (max-width: 720px) { .lg-table > div { grid-template-columns: 1fr; gap: 4px; } }
package/src/virtual.d.ts CHANGED
@@ -14,11 +14,11 @@ declare module 'virtual:acms-config' {
14
14
  };
15
15
  // resolved by integration.mjs; empty locales = i18n not configured
16
16
  i18n: { locales: string[]; defaultLocale: string };
17
- mobile?: { ios?: { teamId: string; bundleId: string; paths: string[] }; android?: { package: string; sha256CertFingerprints: string[] }; push?: { enabled: boolean; retentionDays: number }; editor?: { enabled: boolean; name: string; redirectUris: string[]; accessTokenMinutes: number; refreshTokenDays: number } };
17
+ mobile?: { ios?: { teamId: string; bundleId: string; paths: string[] }; android?: { package: string; sha256CertFingerprints: string[] }; push?: { enabled: boolean; retentionDays: number; topics?: Array<{ id: string; label: Record<string, string>; description: Record<string, string> }> }; editor?: { enabled: boolean; name: string; redirectUris: string[]; accessTokenMinutes: number; refreshTokenDays: number } };
18
18
  search?: { enabled: boolean };
19
19
  // resolved by integration.mjs; '' = pick from the environment (lib/maps/config.ts)
20
20
  maps?: { provider: '' | 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias' };
21
- legal?: { enabled: boolean; operatorName: string; contactEmail: string; updatedAt: string };
21
+ legal?: { enabled: boolean; operatorName: string; contactEmail: string; address: string; country: string; updatedAt: string };
22
22
  // resolved by integration.mjs (lib/roles.ts and lib/permissions/catalog.ts read them)
23
23
  roles?: { key: string; label: string; summary: string; admin: boolean; home: string }[];
24
24
  permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];