@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { userConfig } from 'virtual:acms-config';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { getMediaStore } from '../../../lib/media/store';
|
|
5
|
+
import { apiError, ApiError } from '../../../lib/api';
|
|
6
|
+
import { designIdentity } from '../../../lib/design-service';
|
|
7
|
+
import { draftNavItems, mergeDesignDraft } from '../../../lib/drafts';
|
|
8
|
+
import { sectionTargets } from '../../../lib/section-targets';
|
|
9
|
+
import { getTheme, isProjectTheme } from '../../../lib/theme';
|
|
10
|
+
import { buildDesignExport, mediaKeysIn, themeFileName, THEME_TRANSFER_FORMAT, type ThemeTransferManifest } from '../../../lib/theme-transfer';
|
|
11
|
+
import { zipStream, type ZipEntry } from '../../../lib/backup/zip';
|
|
12
|
+
import type { Media } from '../../../lib/schema';
|
|
13
|
+
|
|
14
|
+
// Theme export (acms-theme v1): the design as the Design screen shows it
|
|
15
|
+
// (draft over live) plus the images it uses, as one ZIP for Design → Import
|
|
16
|
+
// theme on another site or machine. ?media=0 skips images, ?nav=1 adds menus.
|
|
17
|
+
export const GET: APIRoute = async ({ request, cookies, locals }) => {
|
|
18
|
+
try {
|
|
19
|
+
const repo = await getRepo(locals);
|
|
20
|
+
const url = new URL(request.url);
|
|
21
|
+
const includeMedia = url.searchParams.get('media') !== '0', includeNav = url.searchParams.get('nav') === '1';
|
|
22
|
+
const who = await designIdentity(request, cookies, repo, 'design:read');
|
|
23
|
+
if ('role' in who && who.role === 'editor') throw new ApiError(403, 'Only an owner can export the theme.');
|
|
24
|
+
if (includeMedia && !who.scopes.includes('media:read')) throw new ApiError(403, 'Required scope: media:read (or export with media=0).');
|
|
25
|
+
const live = await repo.getSettings();
|
|
26
|
+
const settings = mergeDesignDraft(live);
|
|
27
|
+
const [pages, liveNav, targets] = await Promise.all([repo.listPages(), repo.listNavItems(), sectionTargets(repo, locals)]);
|
|
28
|
+
const design = buildDesignExport({
|
|
29
|
+
settings, navItems: draftNavItems(live.draftDesign) ?? liveNav, pages, links: targets.links, origin: url.origin, includeNav,
|
|
30
|
+
});
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const theme = getTheme(settings.activeTheme);
|
|
33
|
+
const media = includeMedia
|
|
34
|
+
? (await Promise.all([...new Set(mediaKeysIn(design).map((k) => k.split('/')[0]))].map((id) => repo.getMedia(id)))).filter((m) => m !== null)
|
|
35
|
+
: [];
|
|
36
|
+
const manifest: ThemeTransferManifest = {
|
|
37
|
+
format: THEME_TRANSFER_FORMAT, version: 1, exportedAt: now,
|
|
38
|
+
coreVersion: String((userConfig as { coreVersion?: string }).coreVersion ?? ''),
|
|
39
|
+
source: { title: live.siteTitle, siteUrl: url.origin },
|
|
40
|
+
theme: { slug: theme.slug, name: theme.name, version: theme.version, project: isProjectTheme(theme.slug) },
|
|
41
|
+
includes: ['design', ...(media.length ? ['media' as const] : []), ...(includeNav ? ['nav' as const] : [])],
|
|
42
|
+
};
|
|
43
|
+
const store = getMediaStore(locals);
|
|
44
|
+
async function* entries(): AsyncGenerator<ZipEntry> {
|
|
45
|
+
const enc = new TextEncoder();
|
|
46
|
+
yield { name: 'manifest.json', data: enc.encode(JSON.stringify(manifest, null, 1)) };
|
|
47
|
+
yield { name: 'design.json', data: enc.encode(JSON.stringify(design, null, 1)) };
|
|
48
|
+
// a missing variant is skipped; the row still imports with what's left
|
|
49
|
+
const rows: Media[] = [];
|
|
50
|
+
for (const m of media) {
|
|
51
|
+
const variants: Media['variants'] = [];
|
|
52
|
+
let original = false;
|
|
53
|
+
for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
|
|
54
|
+
const obj = await store.get(key);
|
|
55
|
+
if (!obj) continue;
|
|
56
|
+
if (key === m.key) original = true; else variants.push(m.variants.find((v) => v.key === key)!);
|
|
57
|
+
yield { name: `media/${key}`, data: new Uint8Array(await new Response(obj.body).arrayBuffer()) };
|
|
58
|
+
}
|
|
59
|
+
if (original) rows.push({ ...m, variants });
|
|
60
|
+
}
|
|
61
|
+
yield { name: 'media.json', data: enc.encode(JSON.stringify(rows, null, 1)) };
|
|
62
|
+
}
|
|
63
|
+
return new Response(zipStream(entries(), now), {
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'application/zip',
|
|
66
|
+
'Cache-Control': 'no-store',
|
|
67
|
+
'X-Content-Type-Options': 'nosniff',
|
|
68
|
+
'Content-Disposition': `attachment; filename="${themeFileName(theme.slug, now)}"`,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
} catch (e) { return apiError(e); }
|
|
72
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { apiError, ApiError, json, readJson } from '../../../lib/api';
|
|
5
|
+
import { DesignPatch, designIdentity, runDesignOperation } from '../../../lib/design-service';
|
|
6
|
+
import { diffDesign } from '../../../lib/designDiff';
|
|
7
|
+
import { sectionTargets } from '../../../lib/section-targets';
|
|
8
|
+
import { getTheme, listThemes } from '../../../lib/theme';
|
|
9
|
+
import { planDesignImport, ThemeDesignFile } from '../../../lib/theme-transfer';
|
|
10
|
+
|
|
11
|
+
// Theme import: the design.json of an acms-theme ZIP becomes a staged draft
|
|
12
|
+
// (never live). dryRun returns what would change; the browser uploads the
|
|
13
|
+
// ZIP's images through /api/import/media before the real run.
|
|
14
|
+
const Body = z.object({
|
|
15
|
+
revision: z.number().int().nonnegative(),
|
|
16
|
+
design: ThemeDesignFile,
|
|
17
|
+
nav: z.boolean().default(false),
|
|
18
|
+
dryRun: z.boolean().default(false),
|
|
19
|
+
}).strict();
|
|
20
|
+
|
|
21
|
+
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|
22
|
+
try {
|
|
23
|
+
const repo = await getRepo(locals);
|
|
24
|
+
const input = Body.parse(await readJson(request, 2_000_000));
|
|
25
|
+
const who = await designIdentity(request, cookies, repo, 'design:write-draft');
|
|
26
|
+
if ('role' in who && who.role === 'editor') throw new ApiError(403, 'Only an owner can import a theme.');
|
|
27
|
+
const current = await repo.getSettings();
|
|
28
|
+
if (current.designRevision !== input.revision) throw new ApiError(409, 'Design changed. Reload and try again.');
|
|
29
|
+
const [pages, targets, liveNav] = await Promise.all([repo.listPages(), sectionTargets(repo, locals), repo.listNavItems()]);
|
|
30
|
+
const { patch, report } = planDesignImport({
|
|
31
|
+
file: input.design, pages, links: targets.links, installedThemes: listThemes().map((t) => t.slug),
|
|
32
|
+
stagedPages: (current.draftDesign?.sectionPages ?? current.sectionPages) as typeof current.sectionPages, includeNav: input.nav,
|
|
33
|
+
});
|
|
34
|
+
const parsed = DesignPatch.parse(patch);
|
|
35
|
+
const draft = { ...current.draftDesign, ...parsed };
|
|
36
|
+
const changes = diffDesign(current, liveNav, draft, (slug) => getTheme(slug).name);
|
|
37
|
+
if (input.dryRun) return json({ report, changes });
|
|
38
|
+
const result = await runDesignOperation(repo, who, { operation: 'stage', revision: input.revision, patch: parsed }, new URL(request.url).origin);
|
|
39
|
+
return json({ report, changes, revision: result.revision });
|
|
40
|
+
} catch (e) { return apiError(e); }
|
|
41
|
+
};
|
|
@@ -2,8 +2,10 @@ import type { APIRoute } from 'astro';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
|
|
5
|
-
import {
|
|
5
|
+
import { validateRequestSession } from '../../../lib/auth/request';
|
|
6
6
|
import { reauthenticate } from '../../../lib/email/accounts';
|
|
7
|
+
import { AUTH } from '../../../lib/auth/config';
|
|
8
|
+
import { getRequestAuth } from '../../../lib/auth/request';
|
|
7
9
|
import { decrypt, encrypt, encryptionAvailable, resolveConfig, runtimeValue } from '../../../lib/email/config';
|
|
8
10
|
import { dispatchEmail } from '../../../lib/email/service';
|
|
9
11
|
import { emailReadiness } from '../../../lib/email/readiness';
|
|
@@ -12,13 +14,13 @@ import type { Credentials, PublicEmailSettings } from '../../../lib/email/types'
|
|
|
12
14
|
const Config = z.object({provider:z.enum(['auto','none','resend','mailgun','ses','cloudflare']),source:z.enum(['env','encrypted']),from:z.string().trim().max(320),replyTo:z.string().trim().max(320),domain:z.string().trim().max(253),region:z.string().trim().max(40),configurationSet:z.string().trim().max(200),snsTopicArn:z.string().trim().max(300),enabled:z.boolean(),reminders:z.boolean(),staffNotifications:z.boolean()}).strict();
|
|
13
15
|
const Secrets = z.object({apiKey:z.string().max(4096).optional(),webhookSecret:z.string().max(4096).optional(),accessKeyId:z.string().max(256).optional(),secretAccessKey:z.string().max(4096).optional(),sessionToken:z.string().max(8192).optional()}).strict();
|
|
14
16
|
const Input = z.discriminatedUnion('action',[
|
|
15
|
-
z.object({action:z.literal('save'),config:Config,credentials:Secrets.default({}),reauthPassword:z.string().max(1024)}).strict(),
|
|
17
|
+
z.object({action:z.literal('save'),config:Config,credentials:Secrets.default({}),reauthPassword:z.string().max(1024).default('')}).strict(),
|
|
16
18
|
z.object({action:z.literal('test'),to:z.string().trim().email().max(254)}).strict(),
|
|
17
19
|
z.object({action:z.literal('retry'),id:z.string().max(100)}).strict(),
|
|
18
20
|
]);
|
|
19
21
|
export async function publicSettings(repo:ContentRepository,locals?:unknown):Promise<PublicEmailSettings> {
|
|
20
22
|
const record=await repo.email.config(),c=resolveConfig(record,locals);
|
|
21
|
-
return {version:c.version,config:c.config,provider:c.provider,...emailReadiness(c,(await repo.getSettings()).siteUrl,locals),environmentLocked:!!runtimeValue('EMAIL_PROVIDER',locals),credentialFields:Object.entries(c.credentials).filter(([,v])=>!!v).map(([k])=>k),activity:(await repo.email.list()).map(({payload,lease,fingerprint,rendered,...safe})=>safe),heartbeat:await repo.email.heartbeat()};
|
|
23
|
+
return {authProvider:AUTH.provider,version:c.version,config:c.config,provider:c.provider,...emailReadiness(c,(await repo.getSettings()).siteUrl,locals),environmentLocked:!!runtimeValue('EMAIL_PROVIDER',locals),credentialFields:Object.entries(c.credentials).filter(([,v])=>!!v).map(([k])=>k),activity:(await repo.email.list()).map(({payload,lease,fingerprint,rendered,...safe})=>safe),heartbeat:await repo.email.heartbeat()};
|
|
22
24
|
}
|
|
23
25
|
export const GET:APIRoute=async({request,cookies,locals})=>{
|
|
24
26
|
try{const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);return json(await publicSettings(repo,locals));}catch(e){return apiError(e);}
|
|
@@ -26,11 +28,13 @@ export const GET:APIRoute=async({request,cookies,locals})=>{
|
|
|
26
28
|
export const POST:APIRoute=async({request,cookies,locals})=>{
|
|
27
29
|
try{
|
|
28
30
|
const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);
|
|
29
|
-
const userId=(await
|
|
31
|
+
const userId=(await validateRequestSession(request, cookies, repo))!;
|
|
30
32
|
const input=Input.parse(await readJson(request,20000));
|
|
31
33
|
if(input.action==='save'){
|
|
32
34
|
if(!await repo.email.rateLimit(`reauth:${userId}`,10,10*60000))throw new ApiError(429,'Too many attempts. Try again later.');
|
|
33
|
-
if
|
|
35
|
+
if (AUTH.provider === 'clerk') {
|
|
36
|
+
if (!(await getRequestAuth(request,cookies,locals)).reverified) throw new ApiError(403,'Sign out and sign in again with Clerk before changing email settings. Recent verification is required.');
|
|
37
|
+
} else if(!await reauthenticate(repo,userId,input.reauthPassword))throw new ApiError(403,'Your current password is required to change email settings.');
|
|
34
38
|
const current=await repo.email.config();
|
|
35
39
|
if(runtimeValue('EMAIL_PROVIDER',locals)&&(input.config.provider!==runtimeValue('EMAIL_PROVIDER',locals)||input.config.source!=='env'))throw new ApiError(400,'Provider is managed by EMAIL_PROVIDER in the deployment environment.');
|
|
36
40
|
let encrypted='';
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getRepo } from '../../../../../lib/repo';
|
|
3
3
|
import { ApiError } from '../../../../../lib/api';
|
|
4
|
-
import {
|
|
4
|
+
import { OWNER_ENV } from '../../../../../lib/auth';
|
|
5
|
+
import { requestSessionBinding, validateRequestSession } from '../../../../../lib/auth/request';
|
|
5
6
|
import { finishOAuth } from '../../../../../lib/live/service';
|
|
6
|
-
export const GET:APIRoute=async({cookies,locals,url})=>{
|
|
7
|
+
export const GET:APIRoute=async({request,cookies,locals,url})=>{
|
|
7
8
|
const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer'};
|
|
8
9
|
try{
|
|
9
|
-
const repo=await getRepo(locals),session=cookies
|
|
10
|
+
const repo=await getRepo(locals),session=await requestSessionBinding(request,cookies,locals);
|
|
10
11
|
// OAuth is intentionally a cross-site GET. Session-bound, one-use state provides CSRF protection.
|
|
11
|
-
const user=await
|
|
12
|
+
const user=await validateRequestSession(request,cookies,repo);
|
|
12
13
|
if(!session||!user||user!==OWNER_ENV&&(await repo.getUser(user))?.role!=='owner')throw new ApiError(403,'Sign in as the site owner and start authorization again.');
|
|
13
14
|
await finishOAuth(repo,session,url,locals);
|
|
14
15
|
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=connected'}});
|
|
@@ -2,7 +2,7 @@ import type { APIRoute } from 'astro';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
|
|
5
|
-
import {
|
|
5
|
+
import { requestSessionBinding } from '../../../lib/auth/request';
|
|
6
6
|
import { Provider } from '../../../lib/live/types';
|
|
7
7
|
import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
8
8
|
const Input=z.discriminatedUnion('action',[
|
|
@@ -26,7 +26,7 @@ export const POST:APIRoute=async({request,cookies,locals,url})=>{
|
|
|
26
26
|
switch(input.action){
|
|
27
27
|
case 'saveConnection':await saveConnection(repo,input,locals);break;
|
|
28
28
|
case 'test':await syncConnection(repo,input.provider,locals,true);break;
|
|
29
|
-
case 'authorize':return json({authorizationUrl:await beginOAuth(repo,cookies
|
|
29
|
+
case 'authorize':return json({authorizationUrl:await beginOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals)});
|
|
30
30
|
case 'disconnect':await disconnect(repo,input.provider,locals);break;
|
|
31
31
|
case 'saveConfig':await repo.updateSettings({live:validateConfig(input.config)});break;
|
|
32
32
|
case 'selectChannel':{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { validateRequestSession } from '../../../lib/auth/request';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { ApiError } from '../../../lib/api';
|
|
5
5
|
import { storeUpload } from '../../../lib/media/upload';
|
|
@@ -7,7 +7,7 @@ import { storeUpload } from '../../../lib/media/upload';
|
|
|
7
7
|
// Multipart upload from the admin; the rules live in lib/media/upload.ts
|
|
8
8
|
// (shared with editor apps).
|
|
9
9
|
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|
10
|
-
if (!(await
|
|
10
|
+
if (!(await validateRequestSession(request, cookies, locals))) {
|
|
11
11
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
12
12
|
}
|
|
13
13
|
const form = await request.formData();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getRepo } from '../../../lib/repo';
|
|
3
3
|
import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
|
|
4
|
-
import { OWNER_ENV
|
|
4
|
+
import { OWNER_ENV } from '../../../lib/auth';
|
|
5
|
+
import { validateRequestSession } from '../../../lib/auth/request';
|
|
5
6
|
import { AdminPushInput, adminPushAction, adminPushState } from '../../../lib/push/api';
|
|
6
7
|
|
|
7
8
|
// Admin → Mobile app (owner only; registered only when push is enabled).
|
|
@@ -19,7 +20,7 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
19
20
|
// sending reaches every installed app: only from this site's admin
|
|
20
21
|
if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Send from this site’s admin.');
|
|
21
22
|
const input = AdminPushInput.parse(await readJson(request, 20_000));
|
|
22
|
-
const id = await
|
|
23
|
+
const id = await validateRequestSession(request, cookies, repo);
|
|
23
24
|
const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
|
|
24
25
|
const result = await adminPushAction(repo, input, { url, locals, author });
|
|
25
26
|
return json({ ...result, state: input.action === 'count' ? undefined : await adminPushState(repo, url, locals) });
|
package/src/pages/api/restore.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
3
|
+
import { getRequestAuth, validateRequestSession } from '../../lib/auth/request';
|
|
3
4
|
import { getRepo } from '../../lib/repo';
|
|
4
5
|
import { getMediaStore } from '../../lib/media/store';
|
|
5
6
|
import { unzip } from '../../lib/backup/unzip';
|
|
6
7
|
import { BackupContent } from '../../lib/backup/content';
|
|
7
8
|
|
|
8
9
|
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
9
|
-
const sessionUserId = (await
|
|
10
|
+
const sessionUserId = (await validateRequestSession(request, cookies, locals));
|
|
10
11
|
if (!sessionUserId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
11
12
|
const repo = await getRepo(locals);
|
|
12
13
|
const sessionUser = sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
|
|
@@ -45,6 +46,8 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
45
46
|
return Response.json({ error: 'content.json failed validation — is this from a newer acms?' }, { status: 400 });
|
|
46
47
|
}
|
|
47
48
|
const content = parsed.data;
|
|
49
|
+
const auth = await getRequestAuth(request, cookies, locals);
|
|
50
|
+
if (auth.clerk && (!auth.reverified || !content.users.some(u => u.id === sessionUserId && u.role === 'owner' && u.email === sessionUser?.email))) return Response.json({error:'A Clerk restore requires recent verification and a backup containing your current owner account. Sign in again and choose a matching backup.'}, {status:409});
|
|
48
51
|
delete content.settings.ai;
|
|
49
52
|
delete content.settings.agent;
|
|
50
53
|
|
|
@@ -86,7 +89,7 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
86
89
|
await store.put(key, buf, m.mime);
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
|
-
await repo.restoreAll(content);
|
|
92
|
+
await repo.restoreAll(content, auth.clerk ? { issuer: auth.clerk.issuer, subject: auth.clerk.subject, userId: sessionUserId } : undefined);
|
|
90
93
|
|
|
91
94
|
return Response.json({ ok: true, restored: { posts: content.posts.length, pages: content.pages.length, media: content.media.length, events: content.events.length, bookings: content.bookings.length, bookingRequests: content.bookingRequests?.length ?? 0 } }, { headers: { 'Cache-Control': 'no-store' } });
|
|
92
95
|
};
|
|
@@ -2,6 +2,6 @@ import { listCalendars } from '../../../lib/mobile/calendar';
|
|
|
2
2
|
import { readRoute } from '../../../lib/mobile/route';
|
|
3
3
|
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
4
|
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
-
export const GET = readRoute(listCalendars, true);
|
|
5
|
+
export const GET = readRoute(listCalendars, true, false, { timeless: true });
|
|
6
6
|
export const HEAD = GET;
|
|
7
7
|
export const ALL = () => methodNotAllowed();
|
|
@@ -2,6 +2,6 @@ import { getLinks } from '../../../lib/mobile/links';
|
|
|
2
2
|
import { readRoute } from '../../../lib/mobile/route';
|
|
3
3
|
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
4
|
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
-
export const GET = readRoute(getLinks, true);
|
|
5
|
+
export const GET = readRoute(getLinks, true, false, { timeless: true });
|
|
6
6
|
export const HEAD = GET;
|
|
7
7
|
export const ALL = () => methodNotAllowed();
|
|
@@ -2,6 +2,6 @@ import { getOpenApi } from '../../../lib/mobile/openapi';
|
|
|
2
2
|
import { readRoute } from '../../../lib/mobile/route';
|
|
3
3
|
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
4
|
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
-
export const GET = readRoute(getOpenApi, true, true);
|
|
5
|
+
export const GET = readRoute(getOpenApi, true, true, { timeless: true });
|
|
6
6
|
export const HEAD = GET;
|
|
7
7
|
export const ALL = () => methodNotAllowed();
|
|
@@ -2,6 +2,6 @@ import { listPushTopics } from '../../../../lib/push/api';
|
|
|
2
2
|
import { readRoute } from '../../../../lib/mobile/route';
|
|
3
3
|
import { methodNotAllowed } from '../../../../lib/mobile/http';
|
|
4
4
|
export { preflight as OPTIONS } from '../../../../lib/mobile/http';
|
|
5
|
-
export const GET = readRoute(listPushTopics, true);
|
|
5
|
+
export const GET = readRoute(listPushTopics, true, false, { timeless: true });
|
|
6
6
|
export const HEAD = GET;
|
|
7
7
|
export const ALL = () => methodNotAllowed();
|
|
@@ -2,6 +2,6 @@ import { listResources } from '../../../lib/mobile/calendar';
|
|
|
2
2
|
import { readRoute } from '../../../lib/mobile/route';
|
|
3
3
|
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
4
|
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
-
export const GET = readRoute(listResources, true);
|
|
5
|
+
export const GET = readRoute(listResources, true, false, { timeless: true });
|
|
6
6
|
export const HEAD = GET;
|
|
7
7
|
export const ALL = () => methodNotAllowed();
|
|
@@ -2,6 +2,6 @@ import { getThemeTokens } from '../../../lib/mobile/site';
|
|
|
2
2
|
import { readRoute } from '../../../lib/mobile/route';
|
|
3
3
|
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
4
|
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
-
export const GET = readRoute(getThemeTokens);
|
|
5
|
+
export const GET = readRoute(getThemeTokens, true, false, { timeless: true });
|
|
6
6
|
export const HEAD = GET;
|
|
7
7
|
export const ALL = () => methodNotAllowed();
|
|
@@ -8,7 +8,7 @@ import { expandDynamicBlocks, extractToc } from '../../lib/dynamicBlocks';
|
|
|
8
8
|
import TagLabel from '../../components/site/TagLabel.astro';
|
|
9
9
|
import PostCard from '../../components/site/PostCard.astro';
|
|
10
10
|
import { highlightCodeHtml } from '../../lib/highlightCode';
|
|
11
|
-
import {
|
|
11
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
12
12
|
import { slugify, authorSlug, publicTags } from '../../lib/schema';
|
|
13
13
|
import { resolveDesign } from '../../lib/theme';
|
|
14
14
|
import { effectiveSettings } from '../../lib/designPreview';
|
|
@@ -27,7 +27,7 @@ const locale = requestLocale(Astro.url, Astro.locals);
|
|
|
27
27
|
const rawPost = slug ? await repo.getPostBySlug(slug, col.key) : null;
|
|
28
28
|
const post = rawPost ? localizeDoc(rawPost, locale) : null;
|
|
29
29
|
|
|
30
|
-
const isAdmin = (await
|
|
30
|
+
const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
31
31
|
if (!post || (post.status !== 'published' && !isAdmin)) {
|
|
32
32
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
33
33
|
}
|
|
@@ -111,6 +111,7 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
111
111
|
---
|
|
112
112
|
|
|
113
113
|
<Site
|
|
114
|
+
staffPreview={!!isAdmin && post.status !== 'published'}
|
|
114
115
|
title={post.seoTitle || post.title}
|
|
115
116
|
exactTitle={Boolean(post.seoTitle)}
|
|
116
117
|
description={post.seoDesc || post.excerpt || undefined}
|
|
@@ -12,7 +12,7 @@ import EventPage from '../../components/EventPage.astro';
|
|
|
12
12
|
import { getRepo } from '../../lib/repo';
|
|
13
13
|
import { expandDynamicBlocks } from '../../lib/dynamicBlocks';
|
|
14
14
|
import { highlightCodeHtml } from '../../lib/highlightCode';
|
|
15
|
-
import {
|
|
15
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
16
16
|
import { effectiveSettings } from '../../lib/designPreview';
|
|
17
17
|
import { resolveDesign } from '../../lib/theme';
|
|
18
18
|
import { siteOrigin } from '../../lib/seo';
|
|
@@ -34,7 +34,7 @@ const rawEvent = await repo.getEventBySlug(Astro.params.slug ?? '');
|
|
|
34
34
|
const event = rawEvent ? localizeDoc(rawEvent, locale) : null;
|
|
35
35
|
const [rawSettings, allCalendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
36
36
|
const calMap = new Map(allCalendars.map((c) => [c.id, c]));
|
|
37
|
-
const isAdmin = (await
|
|
37
|
+
const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
38
38
|
if (!event || (!eventIsPublic(event, calMap) && !isAdmin)) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
39
39
|
|
|
40
40
|
const baseSettings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
|
|
@@ -81,6 +81,7 @@ const props = { event, occurrence, settings, calendars, html, upcoming, origin,
|
|
|
81
81
|
---
|
|
82
82
|
|
|
83
83
|
<Site
|
|
84
|
+
staffPreview={!!isAdmin && !eventIsPublic(event, calMap)}
|
|
84
85
|
title={event.seoTitle || event.title}
|
|
85
86
|
exactTitle={Boolean(event.seoTitle)}
|
|
86
87
|
description={event.seoDesc || event.excerpt || undefined}
|
|
@@ -6,7 +6,7 @@ import { renderMarkdown } from '../../../lib/markdown';
|
|
|
6
6
|
import { verifyPreviewToken } from '../../../lib/preview';
|
|
7
7
|
import { expandDynamicBlocks } from '../../../lib/dynamicBlocks';
|
|
8
8
|
import { highlightCodeHtml } from '../../../lib/highlightCode';
|
|
9
|
-
import {
|
|
9
|
+
import { validateRequestSession } from '../../../lib/auth/request';
|
|
10
10
|
import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../../lib/drafts';
|
|
11
11
|
import EventPage from '../../../components/EventPage.astro';
|
|
12
12
|
import { featuredOccurrence } from '../../../lib/calendar/events';
|
|
@@ -19,7 +19,7 @@ import { siteOrigin } from '../../../lib/seo';
|
|
|
19
19
|
const { type, id } = Astro.params;
|
|
20
20
|
if (type !== 'post' && type !== 'page' && type !== 'event') return new Response(null, { status: 404 });
|
|
21
21
|
|
|
22
|
-
const isAdmin = (await
|
|
22
|
+
const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
23
23
|
const token = Astro.url.searchParams.get('token') ?? undefined;
|
|
24
24
|
if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
|
|
25
25
|
return new Response(null, { status: 404 });
|
|
@@ -53,7 +53,7 @@ const fmt = (ts: number | null) =>
|
|
|
53
53
|
ts ? new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '';
|
|
54
54
|
---
|
|
55
55
|
|
|
56
|
-
<Site title={post?.title ?? event?.title ?? page?.seoTitle ?? page?.title}>
|
|
56
|
+
<Site title={post?.title ?? event?.title ?? page?.seoTitle ?? page?.title} staffPreview={!!isAdmin}>
|
|
57
57
|
{event && eventView?.occurrence && (
|
|
58
58
|
<EventPage event={event} occurrence={eventView.occurrence} settings={eventView.settings} calendars={eventView.calendars} html={eventView.html} upcoming={[]} origin={eventView.origin} preview />
|
|
59
59
|
)}
|
package/src/styles/admin.css
CHANGED
|
@@ -757,6 +757,25 @@ dialog.modal[open] { animation: modal-in 0.16s ease-out; }
|
|
|
757
757
|
.btn.block { width: 100%; justify-content: center; min-height: 38px; }
|
|
758
758
|
.login-note { margin-top: 14px; }
|
|
759
759
|
.login-for-app { text-align: center; margin: -8px 0 14px; color: var(--muted); }
|
|
760
|
+
.login-card.is-clerk { max-width: 420px; }
|
|
761
|
+
.login-mount { min-height: 180px; }
|
|
762
|
+
.login-mount[hidden] { display: none; }
|
|
763
|
+
.login-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 180px; margin: 0; color: var(--muted); font-size: 13px; }
|
|
764
|
+
.login-spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--line-strong); border-top-color: var(--accent); animation: login-spin 0.8s linear infinite; }
|
|
765
|
+
@keyframes login-spin { to { transform: rotate(360deg); } }
|
|
766
|
+
@media (prefers-reduced-motion: reduce) { .login-spinner { animation-duration: 2.4s; } }
|
|
767
|
+
.login-clerk [data-clerk-error] { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
|
768
|
+
.login-clerk [data-clerk-error][hidden] { display: none; }
|
|
769
|
+
.login-state { text-align: center; }
|
|
770
|
+
.login-state h1 { font-size: 19px; letter-spacing: -0.02em; margin: 10px 0 6px; }
|
|
771
|
+
.login-state p { color: var(--text-2); margin: 0 0 10px; overflow-wrap: anywhere; }
|
|
772
|
+
.login-state .hint { margin-bottom: 18px; line-height: 1.5; }
|
|
773
|
+
.login-state-icon { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: var(--r-md); background: var(--sel); color: var(--text-2); }
|
|
774
|
+
.login-state-icon svg { width: 20px; height: 20px; }
|
|
775
|
+
.login-state-icon.is-warn { background: var(--amber-soft); color: var(--amber); }
|
|
776
|
+
.login-state-icon.is-error { background: var(--red-soft); color: var(--red); }
|
|
777
|
+
.login-actions { display: grid; gap: 8px; }
|
|
778
|
+
[data-clerk-profile][hidden] { display: none; }
|
|
760
779
|
|
|
761
780
|
/* editor-app sign-in (consent) — same paper card as login, a little wider */
|
|
762
781
|
.consent-wrap { align-items: start; padding-top: max(8vh, 20px); }
|
|
@@ -1238,19 +1257,72 @@ input.mono { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12.5px;
|
|
|
1238
1257
|
.grow-text { flex: 1; min-width: 220px; }
|
|
1239
1258
|
.grow-text .hint { margin-left: 6px; }
|
|
1240
1259
|
.hint.tight { margin: 2px 0 0; }
|
|
1241
|
-
.user-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
1242
|
-
.user-form-row { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; align-items: center; }
|
|
1243
|
-
.user-form .w-name { width: 150px; }
|
|
1244
|
-
.user-form .w-role { width: 100px; }
|
|
1245
|
-
.user-form .w-pass { width: 180px; }
|
|
1246
|
-
.user-form .w-slug { width: 140px; }
|
|
1247
|
-
.user-form .w-grow { flex: 1; min-width: 220px; }
|
|
1248
|
-
.user-form .w-full { width: 100%; }
|
|
1249
|
-
.user-email { min-width: 180px; }
|
|
1250
1260
|
.td-actions { text-align: right; width: 80px; }
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
}
|
|
1261
|
+
/* Users: sign-in strip, tabs, member table, profile drawer */
|
|
1262
|
+
.auth-strip { display: flex; align-items: center; gap: 12px; padding: 12px 16px; margin-bottom: 18px; }
|
|
1263
|
+
.auth-mark { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--r-md); background: var(--sel); color: var(--text-2); }
|
|
1264
|
+
.auth-mark svg { width: 17px; height: 17px; }
|
|
1265
|
+
.auth-main { flex: 1; min-width: 0; }
|
|
1266
|
+
.auth-main h2 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13.5px; margin: 0 0 2px; }
|
|
1267
|
+
.auth-main .hint { margin: 0; line-height: 1.5; }
|
|
1268
|
+
.auth-where { flex: none; margin: 0; font-size: 12px; }
|
|
1269
|
+
.users-tabs { margin-bottom: 12px; }
|
|
1270
|
+
.users-tabs .count { font-size: 11px; font-weight: 600; color: var(--muted); background: var(--panel-2); border-radius: var(--r-full); padding: 0 6px; min-width: 18px; text-align: center; }
|
|
1271
|
+
.users-tabs [aria-selected='true'] .count { color: var(--text-2); background: var(--sel); }
|
|
1272
|
+
.users-panel[hidden] { display: none; }
|
|
1273
|
+
.users-panel.devices-card { margin-top: 0; }
|
|
1274
|
+
.users-panel-hint { margin: 0; padding: 14px 18px 4px; }
|
|
1275
|
+
.users-table tr[data-user-row] { cursor: pointer; }
|
|
1276
|
+
.users-table td { padding-top: 10px; padding-bottom: 10px; }
|
|
1277
|
+
.user-cell { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
|
1278
|
+
.avatar.user-av { width: 32px; height: 32px; font-size: 11px; }
|
|
1279
|
+
.user-who { display: flex; flex-direction: column; min-width: 0; line-height: 1.35; }
|
|
1280
|
+
.user-who .t-title, .user-who .t-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1281
|
+
.user-you { margin-left: 7px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
|
1282
|
+
dialog.modal.drawer {
|
|
1283
|
+
margin: 0 0 0 auto; width: min(440px, 100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw;
|
|
1284
|
+
border-radius: var(--r-lg) 0 0 var(--r-lg); border-left: 1px solid var(--line-strong);
|
|
1285
|
+
flex-direction: column; overflow: hidden;
|
|
1286
|
+
}
|
|
1287
|
+
dialog.modal.drawer[open] { display: flex; animation: drawer-in 0.2s ease-out; }
|
|
1288
|
+
@keyframes drawer-in { from { opacity: 0.4; transform: translateX(28px); } }
|
|
1289
|
+
.drawer-head { display: flex; align-items: center; gap: 12px; padding: 18px 20px 16px; border-bottom: 1px solid var(--line); }
|
|
1290
|
+
.avatar.drawer-av { width: 46px; height: 46px; font-size: 15px; }
|
|
1291
|
+
.drawer-title { flex: 1; min-width: 0; }
|
|
1292
|
+
.drawer-title h2 { font-size: 16px; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1293
|
+
.drawer-title a { font-size: 12px; color: var(--muted); text-decoration: none; }
|
|
1294
|
+
.drawer-title a:hover { color: var(--accent-deep); }
|
|
1295
|
+
.drawer-body { flex: 1; overflow-y: auto; padding: 4px 20px 8px; }
|
|
1296
|
+
.drawer-body > .flash { margin: 14px 0 0; }
|
|
1297
|
+
.drawer-sec { padding: 16px 0 6px; border-bottom: 1px solid var(--line); }
|
|
1298
|
+
.drawer-sec:last-child { border-bottom: 0; }
|
|
1299
|
+
.drawer-sec h3 { margin: 0 0 10px; font-size: 11px; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase; color: var(--faint); }
|
|
1300
|
+
.drawer-kv { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; font-size: 13px; }
|
|
1301
|
+
.drawer-kv .k { color: var(--muted); flex: none; }
|
|
1302
|
+
.drawer-kv .v { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
1303
|
+
.drawer-email { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1304
|
+
.drawer-link { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
1305
|
+
.drawer-link input { flex: 1; min-width: 0; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; }
|
|
1306
|
+
.drawer-link .hint { flex-basis: 100%; margin: 0; }
|
|
1307
|
+
label.field > .slug-wrap { display: flex; margin: 0; font-size: inherit; font-weight: inherit; color: var(--text); }
|
|
1308
|
+
.drawer-sec .slug-wrap input { flex: 1; min-width: 0; }
|
|
1309
|
+
.drawer-foot { display: flex; align-items: center; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--line); background: var(--panel-2); }
|
|
1310
|
+
.drawer-foot .spacer { flex: 1; }
|
|
1311
|
+
input.is-readonly { background: transparent; border-style: dashed; color: var(--text-2); }
|
|
1312
|
+
.callout { margin: 0 0 12px; padding: 10px 12px; border-radius: var(--r-md); background: var(--blue-soft); color: var(--blue); font-size: 12.5px; line-height: 1.5; }
|
|
1313
|
+
.callout.warn { background: var(--amber-soft); color: var(--amber); }
|
|
1314
|
+
@media (max-width: 640px) {
|
|
1315
|
+
dialog.modal.drawer { width: 100vw; border-radius: 0; border-left: 0; }
|
|
1316
|
+
.auth-strip { flex-wrap: wrap; }
|
|
1317
|
+
.auth-where { flex-basis: 100%; margin-left: 44px; }
|
|
1318
|
+
.users-table th:nth-child(3), .users-table td:nth-child(3) { display: none; }
|
|
1319
|
+
}
|
|
1320
|
+
/* Users: flash messages + Clerk re-verification */
|
|
1321
|
+
.users-flash { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
|
1322
|
+
.users-flash-actions { display: inline-flex; gap: 6px; flex-wrap: wrap; }
|
|
1323
|
+
.reverify-text { margin: 0 0 8px; font-size: 14px; line-height: 1.5; }
|
|
1324
|
+
.reverify-dialog .hint { margin: 0; }
|
|
1325
|
+
[data-acms-reverify][hidden] { display: none; }
|
|
1254
1326
|
.sheet:focus, .sheet:focus-visible { outline: none; }
|
|
1255
1327
|
|
|
1256
1328
|
/* ---- calendar module (events list, calendars) --------------------------- */
|
|
@@ -1511,10 +1583,6 @@ a.bk-row:hover, .bk-row.is-sel { background: var(--hover); }
|
|
|
1511
1583
|
.dm-status-actions { margin-left: 0; width: 100%; }
|
|
1512
1584
|
}
|
|
1513
1585
|
|
|
1514
|
-
/* role: marker + text (no badges) */
|
|
1515
|
-
.role-mark { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--text-2); }
|
|
1516
|
-
.role-mark i { width: 7px; height: 7px; border-radius: 2px; background: var(--blue); }
|
|
1517
|
-
.role-mark i.is-owner { background: var(--accent); }
|
|
1518
1586
|
|
|
1519
1587
|
/* Users → Signed-in apps */
|
|
1520
1588
|
.devices-card { margin-top: 20px; }
|
|
@@ -1656,3 +1724,22 @@ a.bk-row:hover, .bk-row.is-sel { background: var(--hover); }
|
|
|
1656
1724
|
.mon-tile { padding: 10px 12px; }
|
|
1657
1725
|
.mon-sub { font-size: 12px; }
|
|
1658
1726
|
}
|
|
1727
|
+
|
|
1728
|
+
/* Design → Export / Import theme (ThemeTransfer) */
|
|
1729
|
+
.tt-muted { color: var(--muted); }
|
|
1730
|
+
.tt-small { font-size: 12px; margin: 10px 0 0; }
|
|
1731
|
+
.check-row .tt-muted { font-size: 12px; margin-left: 2px; }
|
|
1732
|
+
.tt-note { font-size: 12.5px; color: var(--text-2); background: var(--panel-2); border-radius: var(--r-md); padding: 9px 11px; margin: 10px 0 0; }
|
|
1733
|
+
.tt-note.warn { background: var(--amber-soft); color: var(--amber); }
|
|
1734
|
+
.tt-drop { display: flex; align-items: center; justify-content: center; border: 1.5px dashed var(--line-strong); border-radius: var(--r-md); padding: 26px 12px; cursor: pointer; color: var(--text-2); }
|
|
1735
|
+
.tt-drop:hover { border-color: var(--accent); color: var(--text); }
|
|
1736
|
+
.tt-drop input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
|
1737
|
+
.tt-drop:focus-within { box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
1738
|
+
.tt-pkg { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--panel); padding: 10px 12px; margin-bottom: 12px; }
|
|
1739
|
+
.tt-pkg small { display: block; color: var(--muted); font-size: 12px; }
|
|
1740
|
+
.tt-list { list-style: none; margin: 0; padding: 0; }
|
|
1741
|
+
.tt-list li { padding: 9px 0; border-top: 1px solid var(--line); font-size: 13px; }
|
|
1742
|
+
.tt-list li:first-child { border-top: 0; }
|
|
1743
|
+
.tt-list small { display: block; color: var(--muted); font-size: 12px; }
|
|
1744
|
+
.tt-list .check-row { margin: 0; }
|
|
1745
|
+
.tt-list .imp-mark { font-size: 11px; font-weight: 600; color: var(--green); background: var(--green-soft); border-radius: 999px; padding: 1px 7px; margin-left: 4px; }
|
|
@@ -63,6 +63,12 @@
|
|
|
63
63
|
.ems-note { background: var(--amber-soft); color: var(--text-2); border-radius: var(--r-md); padding: 9px 13px; margin: 0 0 12px; font-size: 13px; }
|
|
64
64
|
.ems-pw { max-width: 340px; margin-top: 4px; }
|
|
65
65
|
.ems-pw .hint { display: block; margin-top: 4px; }
|
|
66
|
+
.ems-verify { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; padding: 10px 12px; background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-md); }
|
|
67
|
+
.ems-verify p { flex: 1; min-width: 200px; margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.45; }
|
|
68
|
+
.ems-verify p b { color: var(--text-2); font-weight: 600; }
|
|
69
|
+
.ems-verify-mark { flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: var(--r-sm); background: var(--sel); color: var(--text-2); }
|
|
70
|
+
.ems-verify-mark svg { width: 16px; height: 16px; }
|
|
71
|
+
.ems-verify-actions { display: inline-flex; gap: 6px; }
|
|
66
72
|
.ems-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
67
73
|
.ems-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
|
68
74
|
.ems-row .field { margin-bottom: 0; }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CALENDAR } from '../lib/calendar/config';
|
|
2
|
+
import type { Occurrence } from '../lib/calendar/events';
|
|
3
|
+
|
|
4
|
+
export { eventUrl } from '../lib/calendar/config';
|
|
5
|
+
|
|
6
|
+
export interface ThemeCalendarConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
base: string;
|
|
9
|
+
eventsBase: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Public calendar routes configured by the site's integration. */
|
|
13
|
+
export function calendarConfig(): ThemeCalendarConfig {
|
|
14
|
+
const { enabled, base, eventsBase } = CALENDAR;
|
|
15
|
+
return { enabled, base, eventsBase };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UpcomingEventsOptions {
|
|
19
|
+
/** Maximum distinct events; defaults to 6. Zero returns an empty list. */
|
|
20
|
+
limit?: number;
|
|
21
|
+
/** Look-ahead window in days; defaults to 400. */
|
|
22
|
+
horizonDays?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Occurrence start/end are Unix milliseconds; event retains its authored fields. */
|
|
26
|
+
export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>;
|
|
27
|
+
|
|
28
|
+
/** Server-side helper for Astro templates and endpoints. No repository access needed. */
|
|
29
|
+
export async function upcomingEvents(
|
|
30
|
+
context: { locals: unknown },
|
|
31
|
+
{ limit = 6, horizonDays = 400 }: UpcomingEventsOptions = {},
|
|
32
|
+
): Promise<UpcomingEvent[]> {
|
|
33
|
+
if (!Number.isSafeInteger(limit) || limit < 0) {
|
|
34
|
+
throw new RangeError('upcomingEvents limit must be a non-negative safe integer');
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isFinite(horizonDays) || horizonDays <= 0) {
|
|
37
|
+
throw new RangeError('upcomingEvents horizonDays must be a positive finite number');
|
|
38
|
+
}
|
|
39
|
+
if (!CALENDAR.enabled || limit === 0) return [];
|
|
40
|
+
|
|
41
|
+
const [{ getRepo }, { upcomingEvents: loadUpcomingEvents }] = await Promise.all([
|
|
42
|
+
import('../lib/repo'),
|
|
43
|
+
import('../lib/calendar/cards'),
|
|
44
|
+
]);
|
|
45
|
+
const repo = await getRepo(context.locals);
|
|
46
|
+
const settings = await repo.getSettings();
|
|
47
|
+
return loadUpcomingEvents(repo, settings, Date.now(), { limit, horizonDays });
|
|
48
|
+
}
|