@acms-subir/core 0.3.12 → 0.3.13
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 +6 -0
- package/cli/migrations.generated.mjs +16 -1
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/actions/index.ts +76 -59
- package/src/components/RolesManager.tsx +276 -0
- package/src/layouts/Admin.astro +8 -3
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/store.ts +4 -4
- package/src/lib/content-service.ts +14 -0
- package/src/lib/design-service.ts +11 -3
- package/src/lib/editor/http.ts +7 -0
- package/src/lib/editor/me.ts +12 -8
- package/src/lib/editor/roles.ts +14 -6
- package/src/lib/editor/schedule.ts +12 -12
- package/src/lib/editor/sections.ts +11 -4
- package/src/lib/editor/site-data.ts +4 -2
- package/src/lib/editor/translations.ts +2 -1
- package/src/lib/email/accounts.ts +2 -2
- package/src/lib/email/store.ts +3 -3
- package/src/lib/email/types.ts +1 -1
- package/src/lib/permissions/access.ts +73 -0
- package/src/lib/permissions/catalog.ts +147 -0
- package/src/lib/permissions/migration.ts +17 -0
- package/src/lib/permissions/service.ts +59 -0
- package/src/lib/permissions/store.ts +54 -0
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/roles.ts +9 -0
- package/src/pages/admin/c/[collection]/index.astro +6 -0
- package/src/pages/admin/calendar/bookings.astro +2 -0
- package/src/pages/admin/calendar/events/index.astro +11 -0
- package/src/pages/admin/calendar/resources/[id].astro +2 -0
- package/src/pages/admin/calendar/resources/index.astro +2 -0
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/calendar/settings.astro +2 -0
- package/src/pages/admin/email.astro +2 -2
- package/src/pages/admin/forms/[id]/share.astro +2 -0
- package/src/pages/admin/forms/[id]/submissions.astro +2 -0
- package/src/pages/admin/forms/index.astro +2 -0
- package/src/pages/admin/import.astro +3 -6
- package/src/pages/admin/integrations.astro +10 -12
- package/src/pages/admin/live.astro +3 -3
- package/src/pages/admin/mobile.astro +3 -3
- package/src/pages/admin/pages/index.astro +8 -1
- package/src/pages/admin/roles.astro +80 -0
- package/src/pages/admin/settings.astro +31 -28
- package/src/pages/admin/tags.astro +2 -0
- package/src/pages/admin/users.astro +87 -41
- package/src/pages/api/ai/clean.ts +3 -2
- package/src/pages/api/ai/draft.ts +3 -2
- package/src/pages/api/ai/settings.ts +4 -3
- package/src/pages/api/ai/test.ts +3 -2
- package/src/pages/api/design/export.ts +2 -1
- package/src/pages/api/design/import.ts +2 -1
- package/src/pages/api/design/keys.ts +3 -2
- package/src/pages/api/email/settings.ts +4 -3
- package/src/pages/api/export.ts +3 -2
- package/src/pages/api/import/asset.ts +3 -2
- package/src/pages/api/import/index.ts +4 -3
- package/src/pages/api/import/media.ts +3 -2
- package/src/pages/api/live/oauth/youtube/callback.ts +4 -4
- package/src/pages/api/live/settings.ts +4 -3
- package/src/pages/api/media/upload.ts +4 -0
- package/src/pages/api/mobile/access.ts +4 -3
- package/src/pages/api/monitoring.ts +4 -3
- package/src/pages/api/push/admin.ts +4 -3
- package/src/pages/api/roles.ts +36 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +2 -1
- package/src/pages/api/v1/admin/bookings/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/index.ts +2 -1
- package/src/pages/api/v1/admin/links.ts +2 -1
- package/src/pages/api/v1/admin/media/index.ts +2 -1
|
@@ -3,8 +3,8 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import AiSettings from '../../components/AiSettings';
|
|
4
4
|
import MonitoringSettings from '../../components/MonitoringSettings';
|
|
5
5
|
import { getRepo } from '../../lib/repo';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { accessFor } from '../../lib/permissions/access';
|
|
7
|
+
import type { Permission } from '../../lib/permissions/catalog';
|
|
8
8
|
import { AUTH } from '../../lib/auth/config';
|
|
9
9
|
import { invalidateRedirectCache } from '../../middleware';
|
|
10
10
|
import { CONTENT } from '../../lib/content';
|
|
@@ -12,9 +12,10 @@ import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl }
|
|
|
12
12
|
|
|
13
13
|
const repo = await getRepo(Astro.locals);
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const isOwner =
|
|
15
|
+
// each card follows its permission on Team → Roles
|
|
16
|
+
const access = await accessFor(Astro.request, Astro.cookies, repo);
|
|
17
|
+
const isOwner = access?.role === 'owner';
|
|
18
|
+
const can = (permission: Permission) => access?.can(permission) ?? false;
|
|
18
19
|
|
|
19
20
|
const genKey = () =>
|
|
20
21
|
'acms_sk_' + [...crypto.getRandomValues(new Uint8Array(24))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
@@ -41,7 +42,9 @@ if (Astro.request.method === 'POST') {
|
|
|
41
42
|
const form = await Astro.request.formData();
|
|
42
43
|
const action = String(form.get('action') || 'site');
|
|
43
44
|
postedAction = action;
|
|
44
|
-
if (action
|
|
45
|
+
if (['site', 'localization', 'announcement', 'addRedirect', 'removeRedirect'].includes(action) && !can('site.settings')) {
|
|
46
|
+
flashError = 'Your role doesn’t include “General settings”. Ask an owner if you need it.';
|
|
47
|
+
} else if (action === 'site') {
|
|
45
48
|
// site URL: normalize to a bare origin; empty is fine (falls back to the
|
|
46
49
|
// request origin until configured)
|
|
47
50
|
const rawUrl = String(form.get('siteUrl') ?? '').trim();
|
|
@@ -75,8 +78,8 @@ if (Astro.request.method === 'POST') {
|
|
|
75
78
|
flash = 'Timezone & language saved.';
|
|
76
79
|
}
|
|
77
80
|
} else if (action === 'codeInjection') {
|
|
78
|
-
if (!
|
|
79
|
-
flashError = '
|
|
81
|
+
if (!can('site.code')) {
|
|
82
|
+
flashError = 'Your role doesn’t include “Code injection and analytics”.';
|
|
80
83
|
} else {
|
|
81
84
|
await repo.updateSettings({
|
|
82
85
|
headCode: String(form.get('headCode') ?? ''),
|
|
@@ -85,8 +88,8 @@ if (Astro.request.method === 'POST') {
|
|
|
85
88
|
flash = 'Code injection saved.';
|
|
86
89
|
}
|
|
87
90
|
} else if (action === 'analytics') {
|
|
88
|
-
if (!
|
|
89
|
-
flashError = '
|
|
91
|
+
if (!can('site.code')) {
|
|
92
|
+
flashError = 'Your role doesn’t include “Code injection and analytics”.';
|
|
90
93
|
} else {
|
|
91
94
|
// replay sample rate is edited as a percent, stored as a fraction
|
|
92
95
|
const replayPct = Number(String(form.get('umamiReplaySampleRate') ?? '15').trim());
|
|
@@ -167,8 +170,8 @@ if (Astro.request.method === 'POST') {
|
|
|
167
170
|
await repo.deleteRedirect(String(form.get('id') ?? ''));
|
|
168
171
|
invalidateRedirectCache();
|
|
169
172
|
flash = 'Redirect removed.';
|
|
170
|
-
} else if (!
|
|
171
|
-
flashError = '
|
|
173
|
+
} else if (!can('integrations.manage')) {
|
|
174
|
+
flashError = 'Your role doesn’t include “API keys and webhooks”.';
|
|
172
175
|
} else {
|
|
173
176
|
const current = await repo.getSettings();
|
|
174
177
|
if (action === 'generateKey') {
|
|
@@ -222,18 +225,18 @@ const groups = [
|
|
|
222
225
|
{ id: 'announcement', label: 'Announcement bar', terms: 'announcement banner bar' },
|
|
223
226
|
] },
|
|
224
227
|
{ label: 'Publishing', items: [
|
|
225
|
-
...(
|
|
228
|
+
...(can('integrations.manage') ? [{ id: 'drafts-api', label: 'External drafts API', terms: 'api key drafts cors origins' }] : []),
|
|
226
229
|
{ id: 'redirects', label: 'Redirects', terms: 'redirects 301 302 urls' },
|
|
227
230
|
] },
|
|
228
|
-
...(
|
|
229
|
-
{ id: 'email', label: 'Email', terms: 'email transactional resend mailgun amazon ses aws cloudflare invitation invite password reset booking calendar notifications reminders' },
|
|
230
|
-
|
|
231
|
-
{ id: 'analytics', label: 'Analytics', terms: 'analytics umami replay meta pixel tiktok tracking' },
|
|
232
|
-
|
|
233
|
-
{ id: 'monitoring', label: 'Monitoring', terms: 'monitoring uptime kuma health status down alerts heartbeat scheduler' },
|
|
231
|
+
...(can('services.manage') || can('site.code') ? [{ label: 'Integrations', items: [
|
|
232
|
+
...(can('services.manage') ? [{ id: 'email', label: 'Email', terms: 'email transactional resend mailgun amazon ses aws cloudflare invitation invite password reset booking calendar notifications reminders' },
|
|
233
|
+
{ id: 'ai-agents', label: 'AI & agents', terms: 'ai agents openai custom kimi deepseek cli proxy cpamc tailscale mcp' }] : []),
|
|
234
|
+
...(can('site.code') ? [{ id: 'analytics', label: 'Analytics', terms: 'analytics umami replay meta pixel tiktok tracking' },
|
|
235
|
+
{ id: 'code', label: 'Code injection', terms: 'code injection head footer scripts' }] : []),
|
|
236
|
+
...(can('services.manage') ? [{ id: 'monitoring', label: 'Monitoring', terms: 'monitoring uptime kuma health status down alerts heartbeat scheduler' }] : []),
|
|
234
237
|
] }] : []),
|
|
235
238
|
{ label: 'Advanced', items: [
|
|
236
|
-
...(
|
|
239
|
+
...(can('data.import') ? [{ id: 'import', label: 'Import & export', terms: 'import export ghost wordpress migrate migration move content' }] : []),
|
|
237
240
|
{ id: 'backup', label: 'Backup', terms: 'backup restore export zip' },
|
|
238
241
|
{ id: 'backend', label: 'Backend', terms: 'backend database d1 sqlite' },
|
|
239
242
|
] },
|
|
@@ -370,7 +373,7 @@ const groups = [
|
|
|
370
373
|
|
|
371
374
|
<div class="set-group-title">Publishing</div>
|
|
372
375
|
|
|
373
|
-
{
|
|
376
|
+
{can('integrations.manage') && (
|
|
374
377
|
<section class="scard" id="drafts-api">
|
|
375
378
|
<header class="scard-head">
|
|
376
379
|
<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>
|
|
@@ -470,9 +473,9 @@ const groups = [
|
|
|
470
473
|
</form>
|
|
471
474
|
</section>
|
|
472
475
|
|
|
473
|
-
{
|
|
476
|
+
{(can('services.manage') || can('site.code')) && <div class="set-group-title">Integrations</div>}
|
|
474
477
|
|
|
475
|
-
{
|
|
478
|
+
{can('services.manage') && (
|
|
476
479
|
<section class="scard" id="email">
|
|
477
480
|
<header class="scard-head">
|
|
478
481
|
<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>
|
|
@@ -483,7 +486,7 @@ const groups = [
|
|
|
483
486
|
</section>
|
|
484
487
|
)}
|
|
485
488
|
|
|
486
|
-
{
|
|
489
|
+
{can('site.code') && (
|
|
487
490
|
<section class={`scard ${openCard === 'analytics' ? 'editing' : ''}`} id="analytics" data-editable>
|
|
488
491
|
<header class="scard-head">
|
|
489
492
|
<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>
|
|
@@ -545,7 +548,7 @@ const groups = [
|
|
|
545
548
|
</section>
|
|
546
549
|
)}
|
|
547
550
|
|
|
548
|
-
{
|
|
551
|
+
{can('site.code') && (
|
|
549
552
|
<section class={`scard ${openCard === 'codeInjection' ? 'editing' : ''}`} id="code" data-editable>
|
|
550
553
|
<header class="scard-head">
|
|
551
554
|
<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>
|
|
@@ -572,9 +575,9 @@ const groups = [
|
|
|
572
575
|
</section>
|
|
573
576
|
)}
|
|
574
577
|
|
|
575
|
-
{
|
|
578
|
+
{can('services.manage') && <section class="scard" id="ai-agents"><header class="scard-head"><a class="scard-back" href="#" aria-label="All settings">←</a><div class="grow"><h2>AI & agents</h2></div><a class="btn" href="/admin/ai">Import document</a></header><AiSettings client:load /></section>}
|
|
576
579
|
|
|
577
|
-
{
|
|
580
|
+
{can('services.manage') && (
|
|
578
581
|
<section class="scard" id="monitoring">
|
|
579
582
|
<header class="scard-head">
|
|
580
583
|
<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>
|
|
@@ -586,7 +589,7 @@ const groups = [
|
|
|
586
589
|
|
|
587
590
|
<div class="set-group-title">Advanced</div>
|
|
588
591
|
|
|
589
|
-
{
|
|
592
|
+
{can('data.import') && (
|
|
590
593
|
<section class="scard" id="import">
|
|
591
594
|
<header class="scard-head">
|
|
592
595
|
<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>
|
|
@@ -4,8 +4,10 @@ import Modal from '../../components/Modal.astro';
|
|
|
4
4
|
import { getRepo } from '../../lib/repo';
|
|
5
5
|
import { slugify } from '../../lib/schema';
|
|
6
6
|
import { CONTENT, tagUrl } from '../../lib/content';
|
|
7
|
+
import { forbiddenUnless } from '../../lib/permissions/access';
|
|
7
8
|
|
|
8
9
|
const repo = await getRepo(Astro.locals);
|
|
10
|
+
if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit'); if (denied) return denied; }
|
|
9
11
|
|
|
10
12
|
if (Astro.request.method === 'POST') {
|
|
11
13
|
const form = await Astro.request.formData();
|
|
@@ -13,12 +13,16 @@ import { kickEmail } from '../../lib/email/service';
|
|
|
13
13
|
import { waitUntilFrom } from '../../lib/webhooks';
|
|
14
14
|
import { requireSession } from '../../lib/api';
|
|
15
15
|
import { slugify, authorSlug, Role } from '../../lib/schema';
|
|
16
|
-
import { roleLabels } from '../../lib/roles';
|
|
16
|
+
import { roleLabels, roleSummaries } from '../../lib/roles';
|
|
17
|
+
import { accessFor } from '../../lib/permissions/access';
|
|
18
|
+
import { ROLE_RANK, assignableRoles } from '../../lib/permissions/catalog';
|
|
17
19
|
import { EDITOR } from '../../lib/editor/config';
|
|
18
20
|
|
|
19
21
|
const repo = await getRepo(Astro.locals);
|
|
20
22
|
|
|
21
|
-
//
|
|
23
|
+
// Owners manage everyone. Team → Roles can let other roles invite people
|
|
24
|
+
// (Invite people) or manage members (Manage members), always limited to roles
|
|
25
|
+
// below their own. The env-password session is always owner.
|
|
22
26
|
const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
|
|
23
27
|
const sessionUserId = auth.userId;
|
|
24
28
|
// Clerk mode: credentials live in Clerk; ACMS keeps accounts, roles and the
|
|
@@ -29,8 +33,14 @@ let clerkReady = !clerkMode;
|
|
|
29
33
|
if (clerkMode) {
|
|
30
34
|
try { issuer = clerkConfig(Astro.locals).issuer; clerkReady = true; } catch { clerkReady = false; }
|
|
31
35
|
}
|
|
32
|
-
const
|
|
33
|
-
const isOwner =
|
|
36
|
+
const access = await accessFor(Astro.request, Astro.cookies, repo);
|
|
37
|
+
const isOwner = access?.role === 'owner';
|
|
38
|
+
const canInvite = access?.can('team.invite') ?? false;
|
|
39
|
+
const canManage = access?.can('team.manage') ?? false;
|
|
40
|
+
// roles this person may give out (owners: all; everyone else: below their own)
|
|
41
|
+
const assignable = access ? assignableRoles(access.role) : [];
|
|
42
|
+
const below = (role: Role) => isOwner || (!!access && ROLE_RANK[role] < ROLE_RANK[access.role]);
|
|
43
|
+
const manages = (u: { id: string; role: Role }) => canManage && below(u.role) && (isOwner || u.id !== sessionUserId);
|
|
34
44
|
|
|
35
45
|
let error = '';
|
|
36
46
|
let failedOp = '';
|
|
@@ -67,14 +77,30 @@ if (Astro.url.searchParams.has('signedOut')) notice = 'Signed out. The app has t
|
|
|
67
77
|
if (Astro.url.searchParams.has('linked')) notice = 'Account linked. They can now sign in with Clerk; their apps were signed out.';
|
|
68
78
|
if (Astro.url.searchParams.has('emailChanged')) notice = clerkMode ? 'Email updated.' : 'Email updated. They were signed out everywhere and sign in with the new address.';
|
|
69
79
|
if (Astro.url.searchParams.has('invited')) notice = clerkMode ? 'Invitation sent. Clerk emails the sign-up link; it expires in seven days.' : 'Invitation queued.';
|
|
70
|
-
if (Astro.request.method === 'POST' &&
|
|
80
|
+
if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
|
|
71
81
|
const form = await Astro.request.formData();
|
|
72
82
|
const op = String(form.get('op'));
|
|
73
83
|
failedOp = op;
|
|
74
84
|
failedId = String(form.get('id') ?? '');
|
|
75
85
|
try {
|
|
76
|
-
await requireSession(Astro.request,Astro.cookies,repo
|
|
86
|
+
await requireSession(Astro.request,Astro.cookies,repo);
|
|
77
87
|
if(Astro.request.headers.get('origin')!==Astro.url.origin)throw new Error('Cross-origin requests are not allowed.');
|
|
88
|
+
// what this person may do here, checked again on the server for every change
|
|
89
|
+
const inviteOp = ['create', 'resend', 'revoke'].includes(op);
|
|
90
|
+
if (inviteOp && !canInvite) throw new Error('Your role doesn’t include “Invite people”. Ask an owner if you need it.');
|
|
91
|
+
if (!inviteOp && !canManage) throw new Error('Your role doesn’t include “Manage members”. Ask an owner if you need it.');
|
|
92
|
+
if (op === 'link' && !isOwner) throw new Error('Only owners can link sign-in accounts.');
|
|
93
|
+
if (op === 'create' && !assignable.includes(Role.parse(form.get('role')))) throw new Error('You can only invite people to roles below your own.');
|
|
94
|
+
if (op === 'resend' || op === 'revoke') {
|
|
95
|
+
const target = clerkMode ? await repo.auth.invitation(issuer, String(form.get('id'))) : await repo.email.token(String(form.get('id')));
|
|
96
|
+
if (target && !below(Role.parse(target.role))) throw new Error('Only an owner can change invitations for this role.');
|
|
97
|
+
}
|
|
98
|
+
if (['update', 'delete', 'email'].includes(op)) {
|
|
99
|
+
const target = await repo.getUser(String(form.get('id')));
|
|
100
|
+
if (!target) throw new Error('The account no longer exists.');
|
|
101
|
+
if (!manages(target)) throw new Error('You can only manage people in roles below your own.');
|
|
102
|
+
if (op === 'update' && !assignable.includes(Role.parse(form.get('role')))) throw new Error('You can only assign roles below your own.');
|
|
103
|
+
}
|
|
78
104
|
if (clerkMode) {
|
|
79
105
|
if (!clerkReady) throw new Error('Clerk sign-in isn’t fully configured, so accounts can’t be changed right now.');
|
|
80
106
|
if (SENSITIVE.has(op) && !auth.reverified) {
|
|
@@ -91,7 +117,7 @@ if (Astro.request.method === 'POST' && isOwner && !error) {
|
|
|
91
117
|
if (previous.state === 'pending' || previous.state === 'sending') await revokeClerkInvitation(repo, previous.id, Astro.locals);
|
|
92
118
|
data = { email: previous.email, name: previous.name, role: previous.role };
|
|
93
119
|
}
|
|
94
|
-
await inviteClerkUser(repo, data, Astro.locals);
|
|
120
|
+
await inviteClerkUser(repo, { ...data, invitedBy: sessionUserId }, Astro.locals);
|
|
95
121
|
return Astro.redirect('/admin/users?invited=1');
|
|
96
122
|
}
|
|
97
123
|
if (op === 'revoke') await revokeClerkInvitation(repo, String(form.get('id')), Astro.locals);
|
|
@@ -109,7 +135,7 @@ if (Astro.request.method === 'POST' && isOwner && !error) {
|
|
|
109
135
|
const invitedRole = Role.parse(previous ? previous.role : form.get('role'));
|
|
110
136
|
await issueAccountToken(repo,{purpose:'invite',email:previous?String(previous.email):String(form.get('email')||''),
|
|
111
137
|
name:previous?String(previous.name):String(form.get('name')||''),
|
|
112
|
-
role:invitedRole},Astro.locals);
|
|
138
|
+
role:invitedRole,invitedBy:sessionUserId},Astro.locals);
|
|
113
139
|
await kickEmail(repo,Astro.locals,waitUntilFrom(Astro.locals));
|
|
114
140
|
}
|
|
115
141
|
if(!clerkMode&&op==='revoke')await repo.email.revoke(String(form.get('id')));
|
|
@@ -148,8 +174,8 @@ if (Astro.request.method === 'GET' && isOwner && clerkMode && clerkReady) {
|
|
|
148
174
|
try { await syncClerkEmails(repo, Astro.locals); } catch { /* Clerk unavailable: keep stored emails */ }
|
|
149
175
|
}
|
|
150
176
|
const users = await repo.listUsers();
|
|
151
|
-
type Row = { id: string; email: string; role: AuthInvitation['role']; state: string; expiresAt: number; name?: string };
|
|
152
|
-
const invitations: Row[] = !
|
|
177
|
+
type Row = { id: string; email: string; role: AuthInvitation['role']; state: string; expiresAt: number; name?: string; invitedBy?: string; createdAt?: number };
|
|
178
|
+
const invitations: Row[] = !canInvite ? [] : clerkMode ? (clerkReady ? await repo.auth.invitations(issuer) : []) : await repo.email.invitations();
|
|
153
179
|
// Clerk identity linked to each local account (owners see who can sign in)
|
|
154
180
|
const linked = new Map<string, string>(clerkMode && clerkReady
|
|
155
181
|
? (await repo.auth.identities(issuer)).map((r) => [String(r.userId), String(r.subject)]) : []);
|
|
@@ -184,11 +210,13 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
184
210
|
<h1>Users</h1>
|
|
185
211
|
<p class="sub">{users.length} {users.length === 1 ? 'person' : 'people'}{clerkMode && isOwner && clerkReady && <> · {linked.size} connected to Clerk</>}</p>
|
|
186
212
|
</div>
|
|
187
|
-
{
|
|
213
|
+
{canInvite && clerkReady && <button class="btn primary" type="button" data-modal="invite-user">Invite people</button>}
|
|
188
214
|
</div>
|
|
189
215
|
|
|
190
|
-
{!isOwner && (
|
|
191
|
-
|
|
216
|
+
{!isOwner && !canManage && (
|
|
217
|
+
canInvite
|
|
218
|
+
? <div class="flash" role="status">You can invite people to roles below yours. Ask an owner to change existing accounts.</div>
|
|
219
|
+
: <div class="flash error">{clerkMode ? 'Only owners can manage users. Ask an owner to make changes.' : 'Only owners can manage users. Ask an owner (or sign in with the owner password) to make changes.'}</div>
|
|
192
220
|
)}
|
|
193
221
|
{error && (
|
|
194
222
|
<div class="flash error users-flash" role="alert">
|
|
@@ -215,7 +243,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
215
243
|
{clerkMode
|
|
216
244
|
? 'Clerk handles passwords, verification and recovery. ACMS roles decide what each person can do; only invited or linked accounts get in.'
|
|
217
245
|
: 'People sign in with their email and a password they choose from their invitation. Password resets use your email settings.'}
|
|
218
|
-
{clerkMode &&
|
|
246
|
+
{clerkMode && (canInvite || canManage) && clerkReady && ' Changes here ask you to verify it’s you.'}
|
|
219
247
|
</p>
|
|
220
248
|
{clerkMode && !clerkReady && (
|
|
221
249
|
<p class="hint">Set <code>PUBLIC_CLERK_PUBLISHABLE_KEY</code>, <code>CLERK_SECRET_KEY</code> and <code>CLERK_AUTHORIZED_PARTIES</code> in the deployment environment.</p>
|
|
@@ -224,26 +252,30 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
224
252
|
<p class="hint auth-where">Set in the deployment configuration</p>
|
|
225
253
|
</section>
|
|
226
254
|
|
|
227
|
-
{
|
|
228
|
-
<Modal id="invite-user" title="Invite
|
|
255
|
+
{canInvite && clerkReady && (
|
|
256
|
+
<Modal id="invite-user" title="Invite people" open={!!error && failedOp === 'create'}>
|
|
229
257
|
<form method="post" data-reverify>
|
|
230
258
|
<input type="hidden" name="op" value="create" />
|
|
231
|
-
<div class="field-row">
|
|
232
|
-
<label class="field">
|
|
233
|
-
<span>Name</span>
|
|
234
|
-
<input type="text" name="name" placeholder="Name" required autofocus />
|
|
235
|
-
</label>
|
|
236
|
-
<label class="field">
|
|
237
|
-
<span>Role</span>
|
|
238
|
-
<select name="role">
|
|
239
|
-
{Role.options.map(role => <option value={role} selected={role === 'editor'}>{roleLabels[role]}</option>)}
|
|
240
|
-
</select>
|
|
241
|
-
</label>
|
|
242
|
-
</div>
|
|
243
259
|
<label class="field">
|
|
244
260
|
<span>Email</span>
|
|
245
|
-
<input type="email" name="email" placeholder="email@example.com" required />
|
|
261
|
+
<input type="email" name="email" placeholder="email@example.com" required autofocus />
|
|
246
262
|
</label>
|
|
263
|
+
<label class="field">
|
|
264
|
+
<span>Name</span>
|
|
265
|
+
<input type="text" name="name" placeholder="Name" required />
|
|
266
|
+
</label>
|
|
267
|
+
<fieldset class="invite-roles">
|
|
268
|
+
<legend>Role</legend>
|
|
269
|
+
{Role.options.slice().reverse().map((role) => {
|
|
270
|
+
const allowed = assignable.includes(role);
|
|
271
|
+
return (
|
|
272
|
+
<label class:list={['invite-role', { 'is-off': !allowed }]}>
|
|
273
|
+
<input type="radio" name="role" value={role} checked={role === (assignable.includes('editor') ? 'editor' : assignable[assignable.length - 1])} disabled={!allowed} />
|
|
274
|
+
<span><b>{roleLabels[role]}</b>{!allowed && <span class="pill archived">Owner only</span>}<small>{roleSummaries[role]}</small></span>
|
|
275
|
+
</label>
|
|
276
|
+
);
|
|
277
|
+
})}
|
|
278
|
+
</fieldset>
|
|
247
279
|
<p class="hint">{clerkMode
|
|
248
280
|
? 'Clerk emails them a sign-up link that’s valid for seven days. Their account gets this role once they accept.'
|
|
249
281
|
: 'They’ll receive a single-use invitation valid for seven days and choose their own password.'}</p>
|
|
@@ -253,15 +285,15 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
253
285
|
</div>
|
|
254
286
|
<p class="hint modal-note">{clerkMode
|
|
255
287
|
? <>Already have an ACMS account? Open it in the list and link its Clerk user ID instead, so posts and roles stay attached.</>
|
|
256
|
-
: <>They sign in at <code>/admin/login</code> with email + password.
|
|
288
|
+
: <>They sign in at <code>/admin/login</code> with email + password. {isOwner ? <a href="/admin/roles">Roles</a> : 'Roles'} decide what each role can do.</>}</p>
|
|
257
289
|
</form>
|
|
258
290
|
</Modal>
|
|
259
291
|
)}
|
|
260
292
|
|
|
261
|
-
{(
|
|
293
|
+
{(canInvite && clerkReady || showDevices) && (
|
|
262
294
|
<div class="seg users-tabs" role="tablist" aria-label="Users sections">
|
|
263
295
|
<button type="button" role="tab" id="tab-members" aria-controls="panel-members" aria-selected={tab === 'members' ? 'true' : 'false'} data-users-tab="members">Members <span class="count">{users.length}</span></button>
|
|
264
|
-
{
|
|
296
|
+
{canInvite && clerkReady && <button type="button" role="tab" id="tab-invitations" aria-controls="panel-invitations" aria-selected={tab === 'invitations' ? 'true' : 'false'} data-users-tab="invitations">Invitations{pendingInvites > 0 && <span class="count">{pendingInvites}</span>}</button>}
|
|
265
297
|
{showDevices && <button type="button" role="tab" id="tab-devices" aria-controls="panel-devices" aria-selected={tab === 'devices' ? 'true' : 'false'} data-users-tab="devices">Signed-in apps{devices.length > 0 && <span class="count">{devices.length}</span>}</button>}
|
|
266
298
|
</div>
|
|
267
299
|
)}
|
|
@@ -271,7 +303,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
271
303
|
<thead><tr><th>Name</th><th>Role</th>{clerkMode && isOwner && clerkReady && <th>Sign-in</th>}<th><span class="sr-only">Actions</span></th></tr></thead>
|
|
272
304
|
<tbody>
|
|
273
305
|
{users.map((u) => (
|
|
274
|
-
<tr data-user-row={
|
|
306
|
+
<tr data-user-row={manages(u) ? `user-${u.id}` : undefined}>
|
|
275
307
|
<td>
|
|
276
308
|
<div class="user-cell">
|
|
277
309
|
<span class="avatar user-av">{u.avatar ? <img src={u.avatar} alt="" loading="lazy" /> : initials(u.name)}</span>
|
|
@@ -288,7 +320,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
288
320
|
: <span class="pill archived">Not linked</span>}</td>
|
|
289
321
|
)}
|
|
290
322
|
<td class="td-actions">
|
|
291
|
-
{
|
|
323
|
+
{manages(u) && <button class="btn sm ghost" type="button" data-modal={`user-${u.id}`} aria-label={`Edit ${u.name}`}>Edit</button>}
|
|
292
324
|
</td>
|
|
293
325
|
</tr>
|
|
294
326
|
))}
|
|
@@ -299,7 +331,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
299
331
|
</table>
|
|
300
332
|
</section>
|
|
301
333
|
|
|
302
|
-
{
|
|
334
|
+
{users.filter(manages).map((u) => {
|
|
303
335
|
const isLinked = linked.has(u.id);
|
|
304
336
|
const self = clerkMode && u.id === sessionUserId;
|
|
305
337
|
const formId = `upd-${u.id}`;
|
|
@@ -334,7 +366,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
334
366
|
<label class="field">
|
|
335
367
|
<span>Role</span>
|
|
336
368
|
<select name="role" form={formId}>
|
|
337
|
-
{Role.options.map(role => <option value={role} selected={u.role === role}>{roleLabels[role]}</option>)}
|
|
369
|
+
{Role.options.filter((role) => assignable.includes(role)).map(role => <option value={role} selected={u.role === role}>{roleLabels[role]}</option>)}
|
|
338
370
|
</select>
|
|
339
371
|
</label>
|
|
340
372
|
{!clerkMode && (
|
|
@@ -354,7 +386,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
354
386
|
? <span class="pill published" title={linked.get(u.id)}>Connected</span>
|
|
355
387
|
: <span class="pill archived">Not linked</span>}</span>
|
|
356
388
|
</div>
|
|
357
|
-
{!isLinked && (
|
|
389
|
+
{!isLinked && isOwner && (
|
|
358
390
|
<form method="post" class="drawer-link" data-reverify>
|
|
359
391
|
<input type="hidden" name="op" value="link" />
|
|
360
392
|
<input type="hidden" name="id" value={u.id} />
|
|
@@ -431,18 +463,19 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
431
463
|
);
|
|
432
464
|
})}
|
|
433
465
|
|
|
434
|
-
{
|
|
466
|
+
{canInvite && clerkReady && <section class="card users-panel" id="panel-invitations" role="tabpanel" aria-labelledby="tab-invitations" data-users-panel="invitations" hidden={tab !== 'invitations'}>
|
|
435
467
|
<p class="hint users-panel-hint">{clerkMode
|
|
436
468
|
? 'Clerk delivers these emails; your email settings aren’t needed for them. Resending cancels the previous link.'
|
|
437
469
|
: <>Manage delivery in <a href="/admin/email">Email settings</a>. Resending revokes the previous link.</>}</p>
|
|
438
470
|
<table class="list"><thead><tr><th>Email</th><th>Role</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
|
439
471
|
{invitations.map(i=>{
|
|
440
472
|
const [label, tone] = clerkMode ? (stateLabel[i.state] ?? [i.state, 'archived']) : [i.state, ''];
|
|
441
|
-
|
|
473
|
+
const inviter = i.invitedBy ? (userById.get(i.invitedBy)?.name ?? (i.invitedBy === OWNER_ENV ? 'Owner' : 'Removed user')) : '';
|
|
474
|
+
return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}{(inviter || i.createdAt) && <div class="t-sub">{inviter && <>Invited by {inviter}</>}{inviter && i.createdAt ? ' · ' : ''}{i.createdAt ? day(i.createdAt) : ''}</div>}</td><td>{roleLabels[i.role] ?? i.role}</td>
|
|
442
475
|
<td>{clerkMode ? <span class={`pill ${tone}`}>{label}</span> : i.state}{clerkMode && i.state === 'pending' && <div class="t-sub">Expires {day(i.expiresAt)}</div>}</td>
|
|
443
|
-
<td class="td-actions">{i.state!=='accepted'&&<form method="post" class="inline-form" data-reverify><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{(i.state==='pending'||(clerkMode&&i.state==='sending'))&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>;
|
|
476
|
+
<td class="td-actions">{i.state!=='accepted'&&!below(i.role)&&<span class="hint">Owner only</span>}{i.state!=='accepted'&&below(i.role)&&<form method="post" class="inline-form" data-reverify><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{(i.state==='pending'||(clerkMode&&i.state==='sending'))&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>;
|
|
444
477
|
})}
|
|
445
|
-
{!invitations.length&&<tr><td colspan="4" class="hint">No invitations yet. Use <b>Invite
|
|
478
|
+
{!invitations.length&&<tr><td colspan="4" class="hint">No invitations yet. Use <b>Invite people</b> to add someone.</td></tr>}
|
|
446
479
|
</tbody></table></section>}
|
|
447
480
|
|
|
448
481
|
{showDevices && (
|
|
@@ -543,4 +576,17 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
543
576
|
});
|
|
544
577
|
}
|
|
545
578
|
</script>
|
|
579
|
+
<style>
|
|
580
|
+
/* invite dialog: each role with what it's for; roles above yours stay visible but off */
|
|
581
|
+
.invite-roles { border: 0; padding: 0; margin: 4px 0 12px; display: grid; gap: 6px; }
|
|
582
|
+
.invite-roles legend { font-size: 13px; font-weight: 600; margin-bottom: 6px; }
|
|
583
|
+
.invite-role { display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; border: 1px solid var(--line-strong); border-radius: var(--r-md); cursor: pointer; }
|
|
584
|
+
.invite-role:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
|
585
|
+
.invite-role input { margin-top: 3px; accent-color: var(--accent); }
|
|
586
|
+
.invite-role > span { display: grid; gap: 1px; }
|
|
587
|
+
.invite-role b { font-size: 14px; }
|
|
588
|
+
.invite-role small { color: var(--muted); font-size: 12.5px; }
|
|
589
|
+
.invite-role .pill { margin-left: 6px; }
|
|
590
|
+
.invite-role.is-off { opacity: .55; cursor: not-allowed; }
|
|
591
|
+
</style>
|
|
546
592
|
</Admin>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getRepo } from '../../../lib/repo';
|
|
3
|
-
import { apiError, json, readJson
|
|
3
|
+
import { apiError, json, readJson } from '../../../lib/api';
|
|
4
4
|
import { cleanDocument } from '../../../lib/ai';
|
|
5
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
5
6
|
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|
6
7
|
try {
|
|
7
8
|
const repo = await getRepo(locals);
|
|
8
|
-
await
|
|
9
|
+
await requirePermission(request, cookies, repo, 'content.edit');
|
|
9
10
|
const input = await readJson(request);
|
|
10
11
|
const { ai } = await repo.getSettings();
|
|
11
12
|
return json(await cleanDocument(ai, input));
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import { apiError, json, readJson
|
|
4
|
+
import { apiError, json, readJson } from '../../../lib/api';
|
|
5
5
|
import { runAgentTool } from '../../../lib/agent';
|
|
6
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
6
7
|
const Input = z.object({ kind: z.enum(['posts', 'pages']), collection: z.string().optional(), title: z.string().trim().min(1).max(300), content: z.string().trim().min(1).max(100_000) }).strict();
|
|
7
8
|
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|
8
9
|
try {
|
|
9
10
|
const repo = await getRepo(locals);
|
|
10
|
-
await
|
|
11
|
+
await requirePermission(request, cookies, repo, 'content.edit');
|
|
11
12
|
const input = Input.parse(await readJson(request));
|
|
12
13
|
return json(await runAgentTool(repo, { tool: 'create_draft', arguments: input }, false), 201);
|
|
13
14
|
} catch (error) { return apiError(error); }
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import { ApiError, apiError, json, readJson
|
|
4
|
+
import { ApiError, apiError, json, readJson } from '../../../lib/api';
|
|
5
5
|
import { encryptApiKey, generateAgentKey, normalizeAiUrl, publicAiConfig } from '../../../lib/ai-config';
|
|
6
6
|
import { AiProvider } from '../../../lib/ai-schema';
|
|
7
7
|
import type { Settings } from '../../../lib/schema';
|
|
8
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
8
9
|
|
|
9
10
|
function publicSettings(settings: Settings, url: URL) {
|
|
10
11
|
const { keyHash, ...agent } = settings.agent;
|
|
@@ -20,14 +21,14 @@ const Input = z.discriminatedUnion('action', [
|
|
|
20
21
|
export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
21
22
|
try {
|
|
22
23
|
const repo = await getRepo(locals);
|
|
23
|
-
await
|
|
24
|
+
await requirePermission(request, cookies, repo, 'services.manage');
|
|
24
25
|
return json(publicSettings(await repo.getSettings(), url));
|
|
25
26
|
} catch (error) { return apiError(error); }
|
|
26
27
|
};
|
|
27
28
|
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
28
29
|
try {
|
|
29
30
|
const repo = await getRepo(locals);
|
|
30
|
-
await
|
|
31
|
+
await requirePermission(request, cookies, repo, 'services.manage');
|
|
31
32
|
const input = Input.parse(await readJson(request, 16_000));
|
|
32
33
|
const current = await repo.getSettings();
|
|
33
34
|
let token: string | undefined;
|
package/src/pages/api/ai/test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getRepo } from '../../../lib/repo';
|
|
3
|
-
import { apiError, json
|
|
3
|
+
import { apiError, json } from '../../../lib/api';
|
|
4
4
|
import { listAiModels } from '../../../lib/ai';
|
|
5
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
5
6
|
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|
6
7
|
try {
|
|
7
8
|
const repo = await getRepo(locals);
|
|
8
|
-
await
|
|
9
|
+
await requirePermission(request, cookies, repo, 'services.manage');
|
|
9
10
|
const { ai } = await repo.getSettings();
|
|
10
11
|
return json({ models: await listAiModels(ai), baseUrl: ai.baseUrl });
|
|
11
12
|
} catch (error) { return apiError(error); }
|
|
@@ -4,6 +4,7 @@ import { getRepo } from '../../../lib/repo';
|
|
|
4
4
|
import { getMediaStore } from '../../../lib/media/store';
|
|
5
5
|
import { apiError, ApiError } from '../../../lib/api';
|
|
6
6
|
import { designIdentity } from '../../../lib/design-service';
|
|
7
|
+
import { deniedMessage } from '../../../lib/permissions/catalog';
|
|
7
8
|
import { draftNavItems, mergeDesignDraft } from '../../../lib/drafts';
|
|
8
9
|
import { sectionTargets } from '../../../lib/section-targets';
|
|
9
10
|
import { getTheme, isProjectTheme } from '../../../lib/theme';
|
|
@@ -20,7 +21,7 @@ export const GET: APIRoute = async ({ request, cookies, locals }) => {
|
|
|
20
21
|
const url = new URL(request.url);
|
|
21
22
|
const includeMedia = url.searchParams.get('media') !== '0', includeNav = url.searchParams.get('nav') === '1';
|
|
22
23
|
const who = await designIdentity(request, cookies, repo, 'design:read');
|
|
23
|
-
if ('role' in who && who.role === 'editor') throw new ApiError(403, '
|
|
24
|
+
if ('role' in who && who.role === 'editor') throw new ApiError(403, deniedMessage('site.layout'));
|
|
24
25
|
if (includeMedia && !who.scopes.includes('media:read')) throw new ApiError(403, 'Required scope: media:read (or export with media=0).');
|
|
25
26
|
const live = await repo.getSettings();
|
|
26
27
|
const settings = mergeDesignDraft(live);
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { apiError, ApiError, json, readJson } from '../../../lib/api';
|
|
5
5
|
import { DesignPatch, designIdentity, runDesignOperation } from '../../../lib/design-service';
|
|
6
|
+
import { deniedMessage } from '../../../lib/permissions/catalog';
|
|
6
7
|
import { diffDesign } from '../../../lib/designDiff';
|
|
7
8
|
import { sectionTargets } from '../../../lib/section-targets';
|
|
8
9
|
import { getTheme, listThemes } from '../../../lib/theme';
|
|
@@ -23,7 +24,7 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|
|
23
24
|
const repo = await getRepo(locals);
|
|
24
25
|
const input = Body.parse(await readJson(request, 2_000_000));
|
|
25
26
|
const who = await designIdentity(request, cookies, repo, 'design:write-draft');
|
|
26
|
-
if ('role' in who && who.role === 'editor') throw new ApiError(403, '
|
|
27
|
+
if ('role' in who && who.role === 'editor') throw new ApiError(403, deniedMessage('site.layout'));
|
|
27
28
|
const current = await repo.getSettings();
|
|
28
29
|
if (current.designRevision !== input.revision) throw new ApiError(409, 'Design changed. Reload and try again.');
|
|
29
30
|
const [pages, targets, liveNav] = await Promise.all([repo.listPages(), sectionTargets(repo, locals), repo.listNavItems()]);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import { apiError, json, readJson,
|
|
4
|
+
import { apiError, json, readJson, ApiError } from '../../../lib/api';
|
|
5
5
|
import { DESIGN_SCOPES } from '../../../lib/design-service';
|
|
6
6
|
import { generateAgentKey } from '../../../lib/ai-config';
|
|
7
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
7
8
|
export const ALL:APIRoute=async({request,cookies,locals})=>{
|
|
8
9
|
try {
|
|
9
|
-
const repo=await getRepo(locals);await
|
|
10
|
+
const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'integrations.manage');
|
|
10
11
|
if(request.method==='GET')return json({keys:(await repo.listApiKeys()).filter(k=>k.scopes.some(s=>DESIGN_SCOPES.includes(s as typeof DESIGN_SCOPES[number])))});
|
|
11
12
|
if(request.method==='POST') {
|
|
12
13
|
const input=z.object({name:z.string().min(1).max(100),scopes:z.array(z.enum(DESIGN_SCOPES)).min(1)}).strict().parse(await readJson(request,10000));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import { ApiError, apiError, json, readJson
|
|
4
|
+
import { ApiError, apiError, json, readJson } from '../../../lib/api';
|
|
5
5
|
import { validateRequestSession } from '../../../lib/auth/request';
|
|
6
6
|
import { reauthenticate } from '../../../lib/email/accounts';
|
|
7
7
|
import { AUTH } from '../../../lib/auth/config';
|
|
@@ -11,6 +11,7 @@ import { dispatchEmail } from '../../../lib/email/service';
|
|
|
11
11
|
import { emailReadiness } from '../../../lib/email/readiness';
|
|
12
12
|
import type { ContentRepository } from '../../../lib/repo/types';
|
|
13
13
|
import type { Credentials, PublicEmailSettings } from '../../../lib/email/types';
|
|
14
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
14
15
|
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();
|
|
15
16
|
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();
|
|
16
17
|
const Input = z.discriminatedUnion('action',[
|
|
@@ -23,11 +24,11 @@ export async function publicSettings(repo:ContentRepository,locals?:unknown):Pro
|
|
|
23
24
|
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()};
|
|
24
25
|
}
|
|
25
26
|
export const GET:APIRoute=async({request,cookies,locals})=>{
|
|
26
|
-
try{const repo=await getRepo(locals);await
|
|
27
|
+
try{const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'services.manage');return json(await publicSettings(repo,locals));}catch(e){return apiError(e);}
|
|
27
28
|
};
|
|
28
29
|
export const POST:APIRoute=async({request,cookies,locals})=>{
|
|
29
30
|
try{
|
|
30
|
-
const repo=await getRepo(locals);await
|
|
31
|
+
const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'services.manage');
|
|
31
32
|
const userId=(await validateRequestSession(request, cookies, repo))!;
|
|
32
33
|
const input=Input.parse(await readJson(request,20000));
|
|
33
34
|
if(input.action==='save'){
|