@acms-subir/core 0.3.21 → 0.3.23
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/migrations.generated.mjs +6 -0
- package/docs/mobile.md +1 -1
- package/docs/sections.md +2 -2
- package/docs/server.md +210 -29
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +134 -1
- package/package.json +1 -1
- package/src/actions/index.ts +3 -1
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/RolesManager.tsx +15 -7
- package/src/components/calendar/CalendarBoard.tsx +5 -3
- package/src/components/sections/SectionFields.tsx +31 -10
- package/src/layouts/Admin.astro +2 -2
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/request.ts +19 -7
- package/src/lib/auth/return.ts +32 -0
- package/src/lib/auth.ts +17 -4
- package/src/lib/calendar/board.ts +12 -1
- package/src/lib/calendar/cards.ts +6 -4
- package/src/lib/calendar/member-form.ts +152 -0
- package/src/lib/calendar/requests.ts +284 -0
- package/src/lib/calendar/schedule.ts +1 -1
- package/src/lib/editor/auth.ts +5 -3
- package/src/lib/editor/schemas.ts +2 -1
- package/src/lib/editor/store.ts +5 -3
- package/src/lib/email/service.ts +16 -3
- package/src/lib/email/store.ts +3 -1
- package/src/lib/email/templates.ts +12 -0
- package/src/lib/mobile/calendar.ts +6 -2
- package/src/lib/permissions/catalog.ts +84 -36
- package/src/lib/permissions/service.ts +11 -8
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/repo/sql.ts +23 -5
- package/src/lib/repo/types.ts +5 -1
- package/src/lib/roles.ts +46 -4
- package/src/lib/schema.ts +43 -2
- package/src/lib/section-targets.ts +10 -0
- package/src/lib/sections.ts +7 -5
- package/src/lib/sync/catalog.ts +3 -0
- package/src/middleware.ts +8 -1
- package/src/pages/admin/app-auth.astro +5 -3
- package/src/pages/admin/calendar/events/[id].astro +249 -2
- package/src/pages/admin/calendar/events/index.astro +165 -12
- package/src/pages/admin/index.astro +22 -0
- package/src/pages/admin/login.astro +37 -16
- package/src/pages/admin/roles.astro +3 -0
- package/src/pages/admin/users.astro +15 -14
- package/src/pages/api/calendar/events/index.ts +8 -1
- package/src/pages/api/search.ts +3 -1
- package/src/pages/author/[slug].astro +3 -1
- package/src/pages/members/_EventForm.astro +89 -0
- package/src/pages/members/_Layout.astro +76 -0
- package/src/pages/members/_http.ts +25 -0
- package/src/pages/members/events/[id].astro +86 -0
- package/src/pages/members/events/index.astro +218 -0
- package/src/pages/members/events/new.astro +48 -0
- package/src/pages/members/index.ts +6 -0
- package/src/server/index.ts +111 -20
- package/src/theme-sdk/calendar.ts +6 -2
- package/src/theme-sdk/fields.mjs +7 -2
- package/src/virtual.d.ts +4 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
1
2
|
import type { Role } from '../schema';
|
|
3
|
+
import { SITE_ROLES, isStaffRole, roleRank, rolesByRank } from '../roles';
|
|
2
4
|
|
|
3
5
|
// What each role may do. The catalog and its defaults live in code; the
|
|
4
6
|
// owner's choices on Team → Roles are stored as differences from these
|
|
@@ -12,8 +14,13 @@ import type { Role } from '../schema';
|
|
|
12
14
|
//
|
|
13
15
|
// Owners always have every permission. Locked permissions stay owner-only and
|
|
14
16
|
// can't be granted, so an owner can never lock themselves out of recovery.
|
|
17
|
+
//
|
|
18
|
+
// Sites add their own roles (lib/roles.ts) and permissions (acms({
|
|
19
|
+
// permissions }), shown under "This site"). A role without admin access can
|
|
20
|
+
// only hold site permissions and the few core ones marked `members`; every
|
|
21
|
+
// other core permission is forced off for it, whatever is stored.
|
|
15
22
|
|
|
16
|
-
/** roles whose permissions an owner can change */
|
|
23
|
+
/** the core roles whose permissions an owner can change (site roles are editable too, see editableRoles) */
|
|
17
24
|
export const EDITABLE_ROLES = ['administrator', 'editor', 'author', 'contributor'] as const;
|
|
18
25
|
export type EditableRole = typeof EDITABLE_ROLES[number];
|
|
19
26
|
|
|
@@ -25,11 +32,17 @@ export interface PermissionDef {
|
|
|
25
32
|
label: string;
|
|
26
33
|
description: string;
|
|
27
34
|
/** default holders (owners hold everything) */
|
|
28
|
-
defaults: readonly
|
|
35
|
+
defaults: readonly string[];
|
|
29
36
|
/** owner-only, never grantable */
|
|
30
37
|
locked?: boolean;
|
|
31
38
|
/** grants code, credentials or people: the page asks owners to grant with care */
|
|
32
39
|
sensitive?: boolean;
|
|
40
|
+
/** roles without admin access can hold it too */
|
|
41
|
+
members?: boolean;
|
|
42
|
+
/** on by default for every site role (with `members`) */
|
|
43
|
+
siteRoleDefault?: boolean;
|
|
44
|
+
/** defined by the site (acms({ permissions })) */
|
|
45
|
+
site?: boolean;
|
|
33
46
|
}
|
|
34
47
|
export interface PermissionArea { id: string; label: string; permissions: readonly PermissionDef[] }
|
|
35
48
|
|
|
@@ -39,6 +52,10 @@ export const PERMISSION_AREAS = [
|
|
|
39
52
|
{ key: 'content.publish', label: 'Publish content', description: 'Publish, schedule and unpublish, and put staged edits live.', defaults: STAFF },
|
|
40
53
|
{ key: 'content.delete', label: 'Delete content', description: 'Delete posts, pages and events.', defaults: STAFF },
|
|
41
54
|
] },
|
|
55
|
+
{ id: 'requests', label: 'Event requests', permissions: [
|
|
56
|
+
// for the site's own roles (members): staff edit events directly, and approve requests with Publish content
|
|
57
|
+
{ key: 'events.request', label: 'Request events', description: 'Ask for new events and suggest changes to events they host, from My events on the site. Staff approve them. Cancelling a date of an event they host is always allowed.', defaults: NONE, members: true, siteRoleDefault: true },
|
|
58
|
+
] },
|
|
42
59
|
{ id: 'media', label: 'Media', permissions: [
|
|
43
60
|
{ key: 'media.upload', label: 'Upload media', description: 'Add images and files, and edit their descriptions.', defaults: STAFF },
|
|
44
61
|
{ key: 'media.delete', label: 'Delete media', description: 'Remove files from the media library.', defaults: STAFF },
|
|
@@ -75,29 +92,58 @@ export const PERMISSION_AREAS = [
|
|
|
75
92
|
] },
|
|
76
93
|
] as const satisfies readonly PermissionArea[];
|
|
77
94
|
|
|
95
|
+
/** a core permission key (site keys are plain strings) */
|
|
78
96
|
export type Permission = typeof PERMISSION_AREAS[number]['permissions'][number]['key'];
|
|
97
|
+
/** the core permissions */
|
|
79
98
|
export const PERMISSIONS: readonly PermissionDef[] = (PERMISSION_AREAS as readonly PermissionArea[]).flatMap((a) => [...a.permissions]);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
export const
|
|
99
|
+
|
|
100
|
+
/** the site's permissions; mutable so tests can add and remove some */
|
|
101
|
+
export const SITE_PERMISSIONS: PermissionDef[] = (userConfig.permissions ?? []).map((p) => ({ ...p, site: true, members: true }));
|
|
102
|
+
export const SITE_AREA_ID = 'site-defined';
|
|
103
|
+
|
|
104
|
+
/** core areas, then "This site" (when the site defines permissions), then "Owner only" */
|
|
105
|
+
export function permissionAreas(): PermissionArea[] {
|
|
106
|
+
const core = PERMISSION_AREAS as readonly PermissionArea[];
|
|
107
|
+
if (!SITE_PERMISSIONS.length) return [...core];
|
|
108
|
+
const owner = core.filter((a) => a.id === 'owner');
|
|
109
|
+
return [...core.filter((a) => a.id !== 'owner'), { id: SITE_AREA_ID, label: 'This site', permissions: SITE_PERMISSIONS }, ...owner];
|
|
110
|
+
}
|
|
111
|
+
export const allPermissions = (): PermissionDef[] => [...PERMISSIONS, ...SITE_PERMISSIONS];
|
|
112
|
+
const CORE_BY_KEY = new Map<string, PermissionDef>(PERMISSIONS.map((p) => [p.key, p]));
|
|
113
|
+
export const permissionDef = (key: string) => CORE_BY_KEY.get(key) ?? SITE_PERMISSIONS.find((p) => p.key === key);
|
|
114
|
+
/** a core or site permission */
|
|
115
|
+
export const isPermission = (key: string): boolean => !!permissionDef(key);
|
|
116
|
+
export const isCorePermission = (key: string): key is Permission => CORE_BY_KEY.has(key);
|
|
83
117
|
|
|
84
118
|
/** grants that differ from the defaults: { editor: { 'site.design': true } } */
|
|
85
|
-
export type PolicyOverrides = Partial<Record<
|
|
119
|
+
export type PolicyOverrides = Partial<Record<string, Partial<Record<string, boolean>>>>;
|
|
86
120
|
/** every editable role's full grant set */
|
|
87
|
-
export type Grants = Record<
|
|
121
|
+
export type Grants = Record<string, Record<string, boolean>>;
|
|
88
122
|
|
|
89
|
-
|
|
90
|
-
export const
|
|
123
|
+
/** roles whose permissions an owner can change: the core four, then the site's */
|
|
124
|
+
export const editableRoles = (): string[] => [...EDITABLE_ROLES, ...SITE_ROLES.map((r) => r.key)];
|
|
125
|
+
export const isEditableRole = (role: string): boolean => editableRoles().includes(role);
|
|
126
|
+
/** whether a role can hold a permission at all (locked ones are owner-only) */
|
|
127
|
+
export function applies(role: string, key: string): boolean {
|
|
128
|
+
const p = permissionDef(key);
|
|
129
|
+
if (!p || p.locked) return false;
|
|
130
|
+
return isStaffRole(role) || p.members === true;
|
|
131
|
+
}
|
|
132
|
+
export function defaultGrant(role: string, key: string): boolean {
|
|
133
|
+
const p = permissionDef(key);
|
|
134
|
+
if (!p || !applies(role, key)) return false;
|
|
135
|
+
if (p.defaults.includes(role)) return true;
|
|
136
|
+
return p.siteRoleDefault === true && SITE_ROLES.some((r) => r.key === role);
|
|
137
|
+
}
|
|
91
138
|
|
|
92
|
-
/** the effective grants: defaults, then overrides (unknown
|
|
139
|
+
/** the effective grants: defaults, then overrides (unknown, locked and non-applicable keys ignored) */
|
|
93
140
|
export function resolveGrants(overrides: PolicyOverrides = {}): Grants {
|
|
94
|
-
const out = {}
|
|
95
|
-
for (const role of
|
|
96
|
-
out[role] = {}
|
|
97
|
-
for (const p of
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
out[role][key] = p.locked ? false : typeof chosen === 'boolean' ? chosen : defaultGrant(role, key);
|
|
141
|
+
const out: Grants = {};
|
|
142
|
+
for (const role of editableRoles()) {
|
|
143
|
+
out[role] = {};
|
|
144
|
+
for (const p of allPermissions()) {
|
|
145
|
+
const chosen = overrides[role]?.[p.key];
|
|
146
|
+
out[role][p.key] = !applies(role, p.key) ? false : typeof chosen === 'boolean' ? chosen : defaultGrant(role, p.key);
|
|
101
147
|
}
|
|
102
148
|
}
|
|
103
149
|
return out;
|
|
@@ -106,41 +152,43 @@ export function resolveGrants(overrides: PolicyOverrides = {}): Grants {
|
|
|
106
152
|
/** a full grant set back to the minimal difference from defaults */
|
|
107
153
|
export function diffFromDefaults(grants: Partial<Record<string, Partial<Record<string, unknown>>>>): PolicyOverrides {
|
|
108
154
|
const out: PolicyOverrides = {};
|
|
109
|
-
for (const role of
|
|
110
|
-
for (const p of
|
|
111
|
-
if (p.
|
|
155
|
+
for (const role of editableRoles()) {
|
|
156
|
+
for (const p of allPermissions()) {
|
|
157
|
+
if (!applies(role, p.key)) continue;
|
|
112
158
|
const value = grants[role]?.[p.key];
|
|
113
|
-
if (typeof value !== 'boolean' || value === defaultGrant(role, p.key
|
|
114
|
-
(out[role] ??= {})[p.key
|
|
159
|
+
if (typeof value !== 'boolean' || value === defaultGrant(role, p.key)) continue;
|
|
160
|
+
(out[role] ??= {})[p.key] = value;
|
|
115
161
|
}
|
|
116
162
|
}
|
|
117
163
|
return out;
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
export function deniedMessage(permission: string) {
|
|
121
|
-
return `Your role doesn’t include “${
|
|
167
|
+
return `Your role doesn’t include “${permissionDef(permission)?.label ?? permission}”. Ask an owner if you need it.`;
|
|
122
168
|
}
|
|
123
169
|
|
|
124
|
-
|
|
170
|
+
/** owners hold every permission; everyone else needs an explicit grant that applies to their role */
|
|
171
|
+
export function allows(grants: Grants, role: Role, key: string): boolean {
|
|
172
|
+
if (!isPermission(key)) return false;
|
|
125
173
|
if (role === 'owner') return true;
|
|
126
|
-
return
|
|
174
|
+
return applies(role, key) && grants[role]?.[key] === true;
|
|
127
175
|
}
|
|
128
176
|
|
|
129
|
-
/**
|
|
130
|
-
export const ROLE_RANK: Record<Role, number> = { owner: 4, administrator: 3, editor: 2, author: 1, contributor: 0 };
|
|
131
|
-
export const ROLES_BY_RANK: readonly Role[] = ['owner', 'administrator', 'editor', 'author', 'contributor'];
|
|
177
|
+
/** people only invite or manage roles below their own (rolesByRank is highest first) */
|
|
132
178
|
export function assignableRoles(role: Role): Role[] {
|
|
133
|
-
if (role === 'owner') return
|
|
134
|
-
return
|
|
179
|
+
if (role === 'owner') return rolesByRank();
|
|
180
|
+
return rolesByRank().filter((r) => roleRank(r) > roleRank(role));
|
|
135
181
|
}
|
|
182
|
+
/** whether `role` ranks below `than` (unknown roles rank lowest) */
|
|
183
|
+
export const ranksBelow = (role: string, than: string) => roleRank(role) > roleRank(than);
|
|
136
184
|
|
|
137
185
|
/** human summary of what changed between two grant sets (audit + review dialog) */
|
|
138
|
-
export function describeChanges(before: Grants, after: Grants): Array<{ role:
|
|
139
|
-
const out: Array<{ role:
|
|
140
|
-
for (const role of
|
|
141
|
-
for (const p of
|
|
142
|
-
const key = p.key
|
|
143
|
-
if (
|
|
186
|
+
export function describeChanges(before: Grants, after: Grants): Array<{ role: string; permission: string; granted: boolean }> {
|
|
187
|
+
const out: Array<{ role: string; permission: string; granted: boolean }> = [];
|
|
188
|
+
for (const role of editableRoles()) {
|
|
189
|
+
for (const p of allPermissions()) {
|
|
190
|
+
const was = before[role]?.[p.key] === true, now = after[role]?.[p.key] === true;
|
|
191
|
+
if (was !== now) out.push({ role, permission: p.key, granted: now });
|
|
144
192
|
}
|
|
145
193
|
}
|
|
146
194
|
return out;
|
|
@@ -2,11 +2,10 @@ import { z } from 'zod';
|
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
3
|
import { OWNER_ENV } from '../auth';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
|
-
import {
|
|
5
|
+
import { CORE_ROLES, isStaffRole, roleLabel, roleSummary, rolesByRank } from '../roles';
|
|
6
6
|
import type { Role } from '../schema';
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
type Grants,
|
|
8
|
+
applies, describeChanges, diffFromDefaults, isEditableRole, isPermission, permissionAreas, resolveGrants,
|
|
10
9
|
} from './catalog';
|
|
11
10
|
import type { Access } from './access';
|
|
12
11
|
|
|
@@ -30,10 +29,10 @@ export async function rolesState(repo: ContentRepository, access: Access) {
|
|
|
30
29
|
canEdit: access.role === 'owner',
|
|
31
30
|
updatedAt: policy.updatedAt || null,
|
|
32
31
|
updatedBy: policy.updatedBy ? actorName(policy.updatedBy, names) : null,
|
|
33
|
-
roles:
|
|
34
|
-
areas:
|
|
32
|
+
roles: rolesByRank().map((role) => ({ role, label: roleLabel(role), summary: roleSummary(role), members: members(role), editable: isEditableRole(role), admin: isStaffRole(role), site: !(CORE_ROLES as readonly string[]).includes(role) })),
|
|
33
|
+
areas: permissionAreas().map((a) => ({
|
|
35
34
|
id: a.id, label: a.label,
|
|
36
|
-
permissions: a.permissions.map((p) => ({ key: p.key, label: p.label, description: p.description, locked:
|
|
35
|
+
permissions: a.permissions.map((p) => ({ key: p.key, label: p.label, description: p.description, locked: p.locked === true, sensitive: p.sensitive === true, members: p.members === true, site: p.site === true })),
|
|
37
36
|
})),
|
|
38
37
|
grants: resolveGrants(policy.overrides),
|
|
39
38
|
defaults: resolveGrants({}),
|
|
@@ -47,12 +46,16 @@ export async function saveRoles(repo: ContentRepository, access: Access, input:
|
|
|
47
46
|
if (access.role !== 'owner') throw new ApiError(403, 'Only owners can change role permissions.');
|
|
48
47
|
for (const [role, grants] of Object.entries(input.grants)) {
|
|
49
48
|
if (!isEditableRole(role)) throw new ApiError(400, `Unknown or fixed role “${role}”.`);
|
|
50
|
-
for (const key of Object.
|
|
49
|
+
for (const [key, value] of Object.entries(grants)) {
|
|
50
|
+
if (!isPermission(key)) throw new ApiError(400, `Unknown permission “${key}”.`);
|
|
51
|
+
// a core permission for a role without admin access (or a locked one) can't be granted
|
|
52
|
+
if (value && !applies(role, key)) throw new ApiError(400, `“${roleLabel(role)}” can’t be given “${key}”: that role has no admin access.`);
|
|
53
|
+
}
|
|
51
54
|
}
|
|
52
55
|
const current = await repo.permissions.policy();
|
|
53
56
|
if (current.revision !== input.revision) throw new ApiError(409, 'Permissions were changed by someone else since you opened this page.');
|
|
54
57
|
const overrides = diffFromDefaults(input.grants);
|
|
55
|
-
const changes = describeChanges(resolveGrants(current.overrides), resolveGrants(overrides)
|
|
58
|
+
const changes = describeChanges(resolveGrants(current.overrides), resolveGrants(overrides));
|
|
56
59
|
if (!changes.length) return current.revision;
|
|
57
60
|
return repo.permissions.save(overrides, input.revision, access.userId, changes);
|
|
58
61
|
}
|
|
@@ -662,6 +662,12 @@ export const MIGRATIONS: Migration[] = [
|
|
|
662
662
|
// [insert, delete, update]: only the update trigger names columns
|
|
663
663
|
contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'])[2],
|
|
664
664
|
] },
|
|
665
|
+
// event hosts and members' event requests (CalEvent.hostIds / .request);
|
|
666
|
+
// neither is public, so the content-version trigger stays as it is
|
|
667
|
+
{ version: 56, statements: [
|
|
668
|
+
`ALTER TABLE events ADD COLUMN host_ids TEXT NOT NULL DEFAULT '[]'`,
|
|
669
|
+
`ALTER TABLE events ADD COLUMN request TEXT`,
|
|
670
|
+
] },
|
|
665
671
|
];
|
|
666
672
|
|
|
667
673
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { LiveConfig } from '../live/types';
|
|
|
12
12
|
import { ApiError } from '../api';
|
|
13
13
|
import { EmailStore, bookingJobs } from '../email/store';
|
|
14
14
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
15
|
-
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
15
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
16
16
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
17
17
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
18
18
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -142,6 +142,11 @@ function rowToCalendar(r: Row): Calendar {
|
|
|
142
142
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
+
// a corrupt request snapshot reads as "no request" rather than breaking the event
|
|
146
|
+
function parseRequest(raw: unknown): EventRequest | null {
|
|
147
|
+
if (raw === null || raw === undefined || raw === '') return null;
|
|
148
|
+
try { const r = EventRequest.safeParse(JSON.parse(String(raw))); return r.success ? r.data : null; } catch { return null; }
|
|
149
|
+
}
|
|
145
150
|
function rowToEvent(r: Row): CalEvent {
|
|
146
151
|
return CalEvent.parse({
|
|
147
152
|
id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
@@ -152,6 +157,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
152
157
|
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
|
|
153
158
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
154
159
|
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
|
|
160
|
+
hostIds: json(r.host_ids, []), request: parseRequest(r.request),
|
|
155
161
|
startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
156
162
|
publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
|
|
157
163
|
scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
|
|
@@ -833,8 +839,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
833
839
|
await this.driver.run(
|
|
834
840
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
835
841
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
836
|
-
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
|
|
837
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
842
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
|
|
843
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
838
844
|
[
|
|
839
845
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
840
846
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -843,6 +849,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
843
849
|
JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
|
|
844
850
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
845
851
|
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
|
|
852
|
+
JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null,
|
|
846
853
|
]
|
|
847
854
|
);
|
|
848
855
|
await this.syncEventIndex(id);
|
|
@@ -936,6 +943,16 @@ export class SqlRepository implements ContentRepository {
|
|
|
936
943
|
await this.ready;
|
|
937
944
|
await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
|
|
938
945
|
}
|
|
946
|
+
// hosts and requests never change what the public sees, so they skip
|
|
947
|
+
// updateEvent (which would also drop a staged draft)
|
|
948
|
+
async setEventHosts(id: string, hostIds: string[]) {
|
|
949
|
+
await this.ready;
|
|
950
|
+
await this.driver.run('UPDATE events SET host_ids = ?, updated_at = ? WHERE id = ?', [JSON.stringify([...new Set(hostIds)]), Date.now(), id]);
|
|
951
|
+
}
|
|
952
|
+
async setEventRequest(id: string, request: EventRequest | null) {
|
|
953
|
+
await this.ready;
|
|
954
|
+
await this.driver.run('UPDATE events SET request = ?, updated_at = ? WHERE id = ?', [request ? JSON.stringify(request) : null, Date.now(), id]);
|
|
955
|
+
}
|
|
939
956
|
async deleteEvent(id: string) {
|
|
940
957
|
await this.ready;
|
|
941
958
|
const mutation = crypto.randomUUID();
|
|
@@ -1784,14 +1801,15 @@ export class SqlRepository implements ContentRepository {
|
|
|
1784
1801
|
...(content.events ?? []).map((e) => ({
|
|
1785
1802
|
sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
1786
1803
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
1787
|
-
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
|
|
1788
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1804
|
+
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
|
|
1805
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1789
1806
|
args: [
|
|
1790
1807
|
e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
|
|
1791
1808
|
JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
|
|
1792
1809
|
e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
|
|
1793
1810
|
e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
|
|
1794
1811
|
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
|
|
1812
|
+
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
|
|
1795
1813
|
],
|
|
1796
1814
|
})),
|
|
1797
1815
|
...(content.forms ?? []).map(formInsert),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
|
|
|
15
15
|
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
16
16
|
export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
|
|
17
17
|
|
|
18
|
-
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation } from '../schema';
|
|
18
|
+
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest } from '../schema';
|
|
19
19
|
|
|
20
20
|
export interface Stats {
|
|
21
21
|
publishedPages: number;
|
|
@@ -141,6 +141,10 @@ export interface ContentRepository {
|
|
|
141
141
|
createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
|
|
142
142
|
updateEvent(id: string, data: EventUpdate): Promise<void>;
|
|
143
143
|
saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
|
|
144
|
+
/** the Team members who host an event (lib/calendar/requests.ts) */
|
|
145
|
+
setEventHosts(id: string, hostIds: string[]): Promise<void>;
|
|
146
|
+
/** a member's pending or declined request on an event; null clears it */
|
|
147
|
+
setEventRequest(id: string, request: EventRequest | null): Promise<void>;
|
|
144
148
|
applyEventDraft(id: string): Promise<void>;
|
|
145
149
|
discardEventDraft(id: string): Promise<void>;
|
|
146
150
|
/** replace an event's cancelled occurrence keys at once, keeping any staged draft */
|
package/src/lib/roles.ts
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
import type { CoreRole, Role } from './schema';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
// Every role ACMS knows: the five core roles, then the site's own from
|
|
5
|
+
// acms({ roles }), ranked below Contributor in the order listed. A site role
|
|
6
|
+
// with admin: false signs people in for the site's own pages only; it never
|
|
7
|
+
// counts as staff (see lib/auth.ts and lib/auth/request.ts). A stored role
|
|
8
|
+
// that isn't defined any more (the site removed it) is treated as non-staff
|
|
9
|
+
// with no permissions, so it fails closed instead of breaking pages.
|
|
10
|
+
|
|
11
|
+
export interface SiteRole { key: string; label: string; summary: string; admin: boolean; home: string }
|
|
12
|
+
|
|
13
|
+
/** the site's roles; mutable so tests can add and remove some */
|
|
14
|
+
export const SITE_ROLES: SiteRole[] = [...(userConfig.roles ?? [])];
|
|
15
|
+
|
|
16
|
+
export const CORE_ROLES: readonly CoreRole[] = ['owner', 'administrator', 'editor', 'author', 'contributor'];
|
|
17
|
+
const isCoreRole = (role: string): role is CoreRole => (CORE_ROLES as readonly string[]).includes(role);
|
|
18
|
+
const siteRole = (role: string) => SITE_ROLES.find((r) => r.key === role);
|
|
19
|
+
|
|
20
|
+
const CORE_LABELS: Record<CoreRole, string> = {
|
|
4
21
|
contributor: 'Contributor', author: 'Author', editor: 'Editor', administrator: 'Administrator', owner: 'Owner',
|
|
5
22
|
};
|
|
6
|
-
|
|
7
23
|
/** one line per role for pickers (the defaults; owners can change what each role does on Team → Roles) */
|
|
8
|
-
|
|
24
|
+
const CORE_SUMMARIES: Record<CoreRole, string> = {
|
|
9
25
|
owner: 'Everything, including roles, backups and sign-in settings.',
|
|
10
26
|
administrator: 'Runs the site day to day and invites people.',
|
|
11
27
|
editor: 'Writes, edits and publishes content.',
|
|
12
28
|
author: 'Writes and publishes content.',
|
|
13
29
|
contributor: 'Writes and edits content.',
|
|
14
30
|
};
|
|
31
|
+
|
|
32
|
+
/** highest first: the core roles, then the site's */
|
|
33
|
+
export const rolesByRank = (): Role[] => [...CORE_ROLES, ...SITE_ROLES.map((r) => r.key)];
|
|
34
|
+
/** a configured role (core or site) */
|
|
35
|
+
export const isRole = (role: unknown): role is Role => typeof role === 'string' && (isCoreRole(role) || !!siteRole(role));
|
|
36
|
+
/** may use the admin: core roles and site roles with admin: true */
|
|
37
|
+
export const isStaffRole = (role: string | null | undefined): boolean => !!role && (isCoreRole(role) || siteRole(role)?.admin === true);
|
|
38
|
+
/** owner 0 … contributor 4, then site roles; unknown roles rank lowest */
|
|
39
|
+
export function roleRank(role: string): number {
|
|
40
|
+
const i = rolesByRank().indexOf(role);
|
|
41
|
+
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
|
|
42
|
+
}
|
|
43
|
+
export const roleLabel = (role: string): string => (isCoreRole(role) ? CORE_LABELS[role] : siteRole(role)?.label ?? role);
|
|
44
|
+
export function roleSummary(role: string): string {
|
|
45
|
+
if (isCoreRole(role)) return CORE_SUMMARIES[role];
|
|
46
|
+
const r = siteRole(role);
|
|
47
|
+
if (!r) return 'No longer defined by this site. People with it can’t use the admin.';
|
|
48
|
+
return r.summary || (r.admin ? 'Uses the admin with only what you grant it on Roles.' : 'Signs in to this site. No admin access.');
|
|
49
|
+
}
|
|
50
|
+
/** where someone with this role lands after signing in when nothing else is asked for */
|
|
51
|
+
export const roleHome = (role: string): string => (isStaffRole(role) ? '/admin' : siteRole(role)?.home ?? '/');
|
|
52
|
+
/** a role read from a form: must be configured */
|
|
53
|
+
export function parseRole(value: unknown): Role {
|
|
54
|
+
if (!isRole(value)) throw new Error('Choose a role from the list.');
|
|
55
|
+
return value;
|
|
56
|
+
}
|
package/src/lib/schema.ts
CHANGED
|
@@ -155,6 +155,18 @@ export type Post = z.infer<typeof Post>;
|
|
|
155
155
|
// and for post-list blocks.
|
|
156
156
|
export const isInternalTag = (name: string) => name.startsWith('#');
|
|
157
157
|
export const publicTags = (names: string[]) => names.filter((n) => !isInternalTag(n));
|
|
158
|
+
// Filters name a tag by its name (any case) or its slug: "Bible Study",
|
|
159
|
+
// "bible study" and "bible-study" all match a "Bible Study" tag. Internal
|
|
160
|
+
// tags match by name only, so "class" never picks up "#class".
|
|
161
|
+
export function hasTag(names: string[], wanted: string): boolean {
|
|
162
|
+
const w = wanted.trim().toLowerCase();
|
|
163
|
+
if (!w) return false;
|
|
164
|
+
const slug = /^[a-z0-9-]+$/.test(w);
|
|
165
|
+
return names.some((n) => n.trim().toLowerCase() === w || (slug && !isInternalTag(n.trim()) && slugify(n) === w));
|
|
166
|
+
}
|
|
167
|
+
/** only `tag` (when set), never `excludeTag` (when set) */
|
|
168
|
+
export const tagFilter = (tag?: string, excludeTag?: string) => (names: string[]) =>
|
|
169
|
+
(!tag?.trim() || hasTag(names, tag)) && (!excludeTag?.trim() || !hasTag(names, excludeTag));
|
|
158
170
|
|
|
159
171
|
export const MediaVariant = z.object({ w: z.number(), key: z.string() });
|
|
160
172
|
export type MediaVariant = z.infer<typeof MediaVariant>;
|
|
@@ -196,8 +208,13 @@ export const Snippet = z.object({
|
|
|
196
208
|
});
|
|
197
209
|
export type Snippet = z.infer<typeof Snippet>;
|
|
198
210
|
|
|
199
|
-
export const
|
|
200
|
-
export type
|
|
211
|
+
export const CoreRole = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
|
|
212
|
+
export type CoreRole = z.infer<typeof CoreRole>;
|
|
213
|
+
// A core role or one the site defines (acms({ roles })). Stored values are
|
|
214
|
+
// never rejected here, so a role a site later removes can't break a page:
|
|
215
|
+
// lib/roles.ts decides what any role may do, and an unknown one gets nothing.
|
|
216
|
+
export const Role = z.string().min(1).max(64);
|
|
217
|
+
export type Role = CoreRole | (string & {});
|
|
201
218
|
|
|
202
219
|
// Password hashes never leave the repo layer — User is the public shape.
|
|
203
220
|
export const User = z.object({
|
|
@@ -339,6 +356,26 @@ export const RoomHold = z.object({
|
|
|
339
356
|
});
|
|
340
357
|
export type RoomHold = z.infer<typeof RoomHold>;
|
|
341
358
|
|
|
359
|
+
// A member's event request (lib/calendar/requests.ts): a new event waiting
|
|
360
|
+
// for approval (kind 'new', the event stays a draft), or changes to a live
|
|
361
|
+
// event staged in its `draft` (kind 'changes'). Staff approve or decline;
|
|
362
|
+
// approving clears it. `note` is the requester's message to staff, `reply`
|
|
363
|
+
// staff's answer when declining.
|
|
364
|
+
export const EventRequest = z.object({
|
|
365
|
+
kind: z.enum(['new', 'changes']),
|
|
366
|
+
state: z.enum(['pending', 'declined']),
|
|
367
|
+
by: z.string(),
|
|
368
|
+
at: z.number(),
|
|
369
|
+
note: z.string().default(''),
|
|
370
|
+
reply: z.string().default(''),
|
|
371
|
+
reviewedBy: z.string().default(''),
|
|
372
|
+
reviewedAt: z.number().default(0),
|
|
373
|
+
// the event was made by this request (withdrawing it may delete it); false
|
|
374
|
+
// when a host asked to publish a draft staff wrote
|
|
375
|
+
created: z.boolean().default(false),
|
|
376
|
+
});
|
|
377
|
+
export type EventRequest = z.infer<typeof EventRequest>;
|
|
378
|
+
|
|
342
379
|
export const CalEvent = z.object({
|
|
343
380
|
id: z.string(),
|
|
344
381
|
slug: z.string(),
|
|
@@ -389,6 +426,10 @@ export const CalEvent = z.object({
|
|
|
389
426
|
// all day). Computed into availability, never stored as bookings. Drafts
|
|
390
427
|
// only hold when whileDraft is on; cancelled events never hold.
|
|
391
428
|
roomHold: RoomHold.default({}),
|
|
429
|
+
// Team members who host it: they see it on My events, can cancel its dates
|
|
430
|
+
// and suggest changes (lib/calendar/requests.ts)
|
|
431
|
+
hostIds: z.array(z.string()).default([]),
|
|
432
|
+
request: EventRequest.nullable().default(null),
|
|
392
433
|
startAt: z.number(),
|
|
393
434
|
endAt: z.number(),
|
|
394
435
|
translations: DocTranslations,
|
|
@@ -18,6 +18,8 @@ export interface SectionTargets {
|
|
|
18
18
|
calendars: { slug: string; name: string }[];
|
|
19
19
|
collections: { key: string; label: string }[];
|
|
20
20
|
tags: string[];
|
|
21
|
+
/** tags on published events (the events source picks from these) */
|
|
22
|
+
eventTags: string[];
|
|
21
23
|
resources: { slug: string; name: string }[];
|
|
22
24
|
features: { events: boolean; bookings: boolean };
|
|
23
25
|
/** AI help for text fields: provider configured + languages to translate into */
|
|
@@ -26,6 +28,13 @@ export interface SectionTargets {
|
|
|
26
28
|
i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// one entry per tag, ignoring case (the first spelling wins), A–Z
|
|
32
|
+
function uniqueTags(names: string[]) {
|
|
33
|
+
const seen = new Map<string, string>();
|
|
34
|
+
for (const n of names.map((x) => x.trim()).filter(Boolean)) if (!seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n);
|
|
35
|
+
return [...seen.values()].sort((a, b) => a.localeCompare(b));
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
|
|
30
39
|
const bookings = bookingsEnabled();
|
|
31
40
|
const settings = await repo.getSettings();
|
|
@@ -54,6 +63,7 @@ export async function sectionTargets(repo: ContentRepository, locals?: unknown):
|
|
|
54
63
|
calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
|
|
55
64
|
collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
|
|
56
65
|
tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
|
|
66
|
+
eventTags: uniqueTags(events.flatMap((e) => e.tags)),
|
|
57
67
|
resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
|
|
58
68
|
features: { events: CALENDAR.enabled, bookings },
|
|
59
69
|
ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
|
package/src/lib/sections.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import sanitizeHtml from 'sanitize-html';
|
|
2
2
|
import type { z } from 'zod';
|
|
3
3
|
import type { ContentRepository } from './repo/types';
|
|
4
|
-
import { authorSlug, type Settings, type User } from './schema';
|
|
4
|
+
import { authorSlug, tagFilter, type Settings, type User } from './schema';
|
|
5
5
|
import { htmlToPlaintext } from './plaintext';
|
|
6
6
|
import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
|
|
7
7
|
import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
|
|
@@ -21,7 +21,8 @@ import { localizeDoc, isExtraLocale } from './translations';
|
|
|
21
21
|
// • migrations/<type>.mjs upgrades older instances on the fly
|
|
22
22
|
// • link fields resolve their ref (page/post/event/booking) to today's URL
|
|
23
23
|
// • richtext is sanitized
|
|
24
|
-
// • source fields ("next 3 Youth events"
|
|
24
|
+
// • source fields ("next 3 Youth events", "events not tagged Class") become
|
|
25
|
+
// fresh item arrays
|
|
25
26
|
|
|
26
27
|
type Instance = z.infer<typeof SectionInstanceSchema>;
|
|
27
28
|
type Definition = z.infer<typeof SectionDefinition>;
|
|
@@ -121,7 +122,7 @@ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = s
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value: unknown, ctx: ResolveContext): Promise<unknown[]> {
|
|
124
|
-
const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; resource?: string; limit?: number };
|
|
125
|
+
const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; excludeTag?: string; resource?: string; limit?: number };
|
|
125
126
|
const limit = Math.max(1, Math.min(SOURCE_LIMIT_MAX, Number(cfg.limit) || 3));
|
|
126
127
|
const { repo, settings, now } = ctx;
|
|
127
128
|
// another language (settings.locale is set to it by the renderer)
|
|
@@ -137,7 +138,7 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
137
138
|
}
|
|
138
139
|
const tz = settings.timezone || 'UTC';
|
|
139
140
|
const locale = siteLocale(settings);
|
|
140
|
-
return (await upcomingEvents(repo, settings, now, { calendarIds, limit })).map((o): EventItem => {
|
|
141
|
+
return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
|
|
141
142
|
const e = lang ? localizeDoc(o.event, lang) : o.event;
|
|
142
143
|
const f = formatOccurrence(o, tz, locale);
|
|
143
144
|
const ctaHref = safeUrl(e.cta.href);
|
|
@@ -152,7 +153,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
152
153
|
if (field.source === 'posts') {
|
|
153
154
|
const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
|
|
154
155
|
let posts = await repo.listPublishedPosts(collection);
|
|
155
|
-
|
|
156
|
+
const tagged = tagFilter(cfg.tag, cfg.excludeTag);
|
|
157
|
+
posts = posts.filter((p) => tagged(p.tags));
|
|
156
158
|
const shown = posts.slice(0, limit);
|
|
157
159
|
// public byline only: name, profile URL, avatar — never emails or roles
|
|
158
160
|
const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
|
package/src/lib/sync/catalog.ts
CHANGED
package/src/middleware.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineMiddleware } from 'astro:middleware';
|
|
2
2
|
import type { APIContext, MiddlewareNext } from 'astro';
|
|
3
3
|
import { I18N } from './lib/i18n';
|
|
4
|
-
import { bindAuthEnvironment, getRequestAuth, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
|
|
4
|
+
import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
|
|
5
|
+
import { roleHome } from './lib/roles';
|
|
5
6
|
import { AUTH } from './lib/auth/config';
|
|
6
7
|
import { getRepo } from './lib/repo';
|
|
7
8
|
import { CONTENT } from './lib/content';
|
|
@@ -109,6 +110,12 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
|
|
|
109
110
|
if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
|
|
110
111
|
if (auth?.error === 'configuration' || auth?.error === 'unavailable') return new Response('Sign-in is temporarily unavailable. Check the authentication configuration or try again later.', { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
|
111
112
|
if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
|
|
113
|
+
// signed in, but in a site role without admin access: their own home
|
|
114
|
+
// page, never the admin (the editor-app sign-in explains on the login page)
|
|
115
|
+
const person = await requestPerson(context.request, context.cookies, context.locals);
|
|
116
|
+
if (person && !(pathname === '/admin/app-auth' && context.request.method === 'GET')) {
|
|
117
|
+
return withClerkHeaders(context.redirect(roleHome(person.role)), auth, context.request);
|
|
118
|
+
}
|
|
112
119
|
// editor-app sign-in comes back here after login (see login.astro's next)
|
|
113
120
|
if (pathname === '/admin/app-auth' && context.request.method === 'GET') {
|
|
114
121
|
return withClerkHeaders(context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`), auth, context.request);
|