@acms-subir/core 0.3.29 → 0.3.31
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 +33 -0
- package/cli/migrations.generated.mjs +9 -1
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +10 -5
- package/integration.mjs +6 -3
- package/package.json +1 -1
- package/src/components/LegalPage.astro +18 -10
- package/src/components/LegalSettings.tsx +152 -0
- package/src/components/LiveManager.tsx +15 -3
- package/src/lib/booking/service.ts +16 -1
- package/src/lib/calendar/schedule.ts +5 -1
- package/src/lib/legal/admin.ts +60 -0
- package/src/lib/legal/app-privacy.ts +49 -0
- package/src/lib/legal/config.ts +4 -0
- package/src/lib/legal/facts.ts +91 -0
- package/src/lib/legal/index.ts +34 -23
- package/src/lib/legal/text-en.ts +128 -0
- package/src/lib/legal/text-es.ts +129 -0
- package/src/lib/legal/text.ts +69 -0
- package/src/lib/legal/types.ts +28 -0
- package/src/lib/live/service.ts +22 -5
- package/src/lib/live/store.ts +13 -3
- package/src/lib/mobile/schemas.ts +2 -1
- package/src/lib/mobile/site.ts +5 -0
- package/src/lib/push/service.ts +2 -1
- package/src/lib/repo/migrations.ts +9 -1
- package/src/lib/repo/sql.ts +15 -2
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/schema.ts +4 -0
- package/src/pages/admin/settings.astro +13 -0
- package/src/pages/api/legal.ts +23 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +8 -11
- package/src/pages/api/live/settings.ts +4 -1
- package/src/styles/legal-settings.css +23 -0
- package/src/virtual.d.ts +1 -1
package/src/lib/live/store.ts
CHANGED
|
@@ -40,9 +40,19 @@ export class LiveStore {
|
|
|
40
40
|
async state(hash: string, sessionHash: string, revision: number, encrypted: string, expiresAt: number) {
|
|
41
41
|
await (await this.driver()).batch([{sql:'DELETE FROM live_oauth_states WHERE expires_at<? OR session_hash=?',args:[Date.now(),sessionHash]}, {sql:'INSERT INTO live_oauth_states (hash,session_hash,revision,encrypted,expires_at) VALUES (?,?,?,?,?)',args:[hash,sessionHash,revision,encrypted,expiresAt]}]);
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Google's redirect finds its pending state by the state value alone (it often arrives without a
|
|
44
|
+
// usable sign-in); only the signed-in admin who began it can finish it (consumeSessionStates).
|
|
45
|
+
async pendingState(hash: string, now: number) {
|
|
46
|
+
const rows = await (await this.driver()).all('SELECT encrypted,expires_at FROM live_oauth_states WHERE hash=? AND expires_at>?', [hash,now]);
|
|
47
|
+
return rows[0] ? {encrypted:String(rows[0].encrypted),expiresAt:Number(rows[0].expires_at)} : null;
|
|
48
|
+
}
|
|
49
|
+
/** Swaps in the payload with Google's code. Compare-and-swap on the old ciphertext: only the first callback counts. */
|
|
50
|
+
async attachCode(hash: string, before: string, after: string, expiresAt: number, now: number) {
|
|
51
|
+
return (await (await this.driver()).all('UPDATE live_oauth_states SET encrypted=?,expires_at=? WHERE hash=? AND encrypted=? AND expires_at>? RETURNING hash', [after,expiresAt,hash,before,now])).length > 0;
|
|
52
|
+
}
|
|
53
|
+
async consumeSessionStates(sessionHash: string, now: number) {
|
|
54
|
+
const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE session_hash=? AND expires_at>? RETURNING revision,encrypted', [sessionHash,now]);
|
|
55
|
+
return rows.map(r => ({revision:Number(r.revision),encrypted:String(r.encrypted)}));
|
|
46
56
|
}
|
|
47
57
|
async heartbeat(now: number) { await (await this.driver()).run('UPDATE live_health SET last_at=? WHERE id=1',[now]); }
|
|
48
58
|
async lastHeartbeat() { return Number((await (await this.driver()).all('SELECT last_at FROM live_health WHERE id=1'))[0]?.last_at ?? 0); }
|
|
@@ -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 }),
|
|
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 }),
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -10,10 +10,13 @@ import { darkPalette, liveColor } from '../themeTokens';
|
|
|
10
10
|
import { safeUrl } from './content';
|
|
11
11
|
import { queryParams } from './http';
|
|
12
12
|
import { guestSignInReady } from './guest';
|
|
13
|
+
import { LEGAL } from '../legal/config';
|
|
13
14
|
|
|
14
15
|
export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
|
|
15
16
|
z.object({}).strict().parse(queryParams(url));
|
|
16
17
|
const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
|
|
18
|
+
// the privacy notice and terms: the standard pages, or published pages at those slugs
|
|
19
|
+
const legalUrl = (slug: 'privacy' | 'terms') => (LEGAL.enabled || pages.some(p => p.status === 'published' && p.slug === slug) ? `${siteOrigin(settings, url)}/${slug}` : null);
|
|
17
20
|
const origin = siteOrigin(settings, url);
|
|
18
21
|
const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
|
|
19
22
|
const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
|
|
@@ -38,6 +41,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
38
41
|
// Integrations → Live video; GET /api/v1/live answers 404 while it's off
|
|
39
42
|
live: settings.live.mode !== 'off',
|
|
40
43
|
},
|
|
44
|
+
// in another site language, add /<code> after the origin, like every other page
|
|
45
|
+
legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
|
|
41
46
|
apiVersion: 1,
|
|
42
47
|
};
|
|
43
48
|
}
|
package/src/lib/push/service.ts
CHANGED
|
@@ -252,7 +252,8 @@ export async function sendStartingSoon(repo: ContentRepository, opts: PushRunOpt
|
|
|
252
252
|
if (!chosen.length) return 0;
|
|
253
253
|
const lead = rule.minutes * MINUTE;
|
|
254
254
|
const occurrences = expandEvents(chosen, settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + lead + 1 })
|
|
255
|
-
|
|
255
|
+
// a single cancelled date of a repeating event gets no reminder
|
|
256
|
+
.filter((o) => !o.allDay && !o.cancelled && o.start > now && o.start - lead <= now);
|
|
256
257
|
const run = { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) };
|
|
257
258
|
let sent = 0;
|
|
258
259
|
for (const o of occurrences) {
|
|
@@ -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
|
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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));
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getRepo } from '../../../../../lib/repo';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { receiveOAuthCallback } from '../../../../../lib/live/service';
|
|
4
|
+
// Google's redirect back is a cross-site GET that may carry no usable sign-in (Clerk development
|
|
5
|
+
// instances, a session token that expired on Google's screens), so it doesn't check one: it parks
|
|
6
|
+
// the code on its one-use pending state, and the signed-in admin page finishes with a same-origin
|
|
7
|
+
// POST (finishAuthorize) bound to the session that began it. The code never enters a URL again.
|
|
8
|
+
export const GET:APIRoute=async({locals,url})=>{
|
|
8
9
|
const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer'};
|
|
9
10
|
try{
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const access=await accessFor(request,cookies,repo);
|
|
13
|
-
if(!session||!access?.can('services.manage'))throw new ApiError(403,'Sign in as someone who manages connected services and start authorization again.');
|
|
14
|
-
await finishOAuth(repo,session,url,locals);
|
|
15
|
-
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=connected'}});
|
|
11
|
+
await receiveOAuthCallback(await getRepo(locals),url,locals);
|
|
12
|
+
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=finish'}});
|
|
16
13
|
}catch{
|
|
17
14
|
// Never reflect callback codes, tokens or provider error bodies into an HTML page or URL.
|
|
18
15
|
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=error'}});
|
|
@@ -4,7 +4,7 @@ import { getRepo } from '../../../lib/repo';
|
|
|
4
4
|
import { ApiError, apiError, json, readJson } from '../../../lib/api';
|
|
5
5
|
import { requestSessionBinding } from '../../../lib/auth/request';
|
|
6
6
|
import { Provider } from '../../../lib/live/types';
|
|
7
|
-
import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
7
|
+
import { adminState, beginOAuth, disconnect, finishOAuth, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
8
8
|
import { requirePermission } from '../../../lib/permissions/access';
|
|
9
9
|
import { sendManualLiveNotice } from '../../../lib/push/service';
|
|
10
10
|
const Input=z.discriminatedUnion('action',[
|
|
@@ -13,6 +13,8 @@ const Input=z.discriminatedUnion('action',[
|
|
|
13
13
|
z.object({action:z.literal('selectChannel'),provider:z.literal('youtube'),channelId:z.string().min(1).max(200)}).strict(),
|
|
14
14
|
z.object({action:z.literal('enable'),provider:Provider,enabled:z.boolean()}).strict(),
|
|
15
15
|
z.object({action:z.literal('authorize'),provider:z.literal('youtube')}).strict(),
|
|
16
|
+
// after Google's redirect (?live=finish): exchange the parked code for this signed-in session
|
|
17
|
+
z.object({action:z.literal('finishAuthorize'),provider:z.literal('youtube')}).strict(),
|
|
16
18
|
z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
|
|
17
19
|
// notify: "Notify app users" on a manual link (sends once per link; needs push)
|
|
18
20
|
z.object({action:z.literal('saveConfig'),config:z.unknown(),notify:z.boolean().default(false)}).strict(),
|
|
@@ -30,6 +32,7 @@ export const POST:APIRoute=async({request,cookies,locals,url})=>{
|
|
|
30
32
|
case 'saveConnection':await saveConnection(repo,input,locals);break;
|
|
31
33
|
case 'test':await syncConnection(repo,input.provider,locals,true);break;
|
|
32
34
|
case 'authorize':return json({authorizationUrl:await beginOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals)});
|
|
35
|
+
case 'finishAuthorize':await finishOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals);break;
|
|
33
36
|
case 'disconnect':await disconnect(repo,input.provider,locals);break;
|
|
34
37
|
case 'saveConfig':{
|
|
35
38
|
await repo.updateSettings({live:validateConfig(input.config)});
|
|
@@ -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
|
@@ -18,7 +18,7 @@ declare module 'virtual:acms-config' {
|
|
|
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 }[];
|