@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
|
@@ -74,6 +74,8 @@ export default function RolesManager() {
|
|
|
74
74
|
const customized = (r: RoleKey) => diff(state.defaults, draft).some((d) => d.role === r);
|
|
75
75
|
const unsaved = (r: RoleKey) => pending.some((d) => d.role === r);
|
|
76
76
|
const grant = (r: string, key: string) => r === 'owner' || (editable(r) && (draft[r] as Record<string, boolean>)[key] === true);
|
|
77
|
+
// a role without admin access can only hold site permissions (and core ones marked for members)
|
|
78
|
+
const usable = (r: string, p: { members: boolean; locked: boolean }) => r === 'owner' || (!p.locked && (state.roles.find((x) => x.role === r)?.admin === true || p.members));
|
|
77
79
|
|
|
78
80
|
const toggle = (r: RoleKey, key: string) => {
|
|
79
81
|
if (readOnly) return;
|
|
@@ -114,8 +116,10 @@ export default function RolesManager() {
|
|
|
114
116
|
};
|
|
115
117
|
|
|
116
118
|
const areaSummary = (r: string, a: Area) => {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
+
const open = a.permissions.filter((p) => usable(r, p));
|
|
120
|
+
if (!open.length) return ['Needs admin access', 'archived'];
|
|
121
|
+
const n = open.filter((p) => grant(r, p.key)).length;
|
|
122
|
+
return n === open.length ? ['Full access', 'published'] : n === 0 ? ['No access', 'archived'] : ['Partial access', 'edited'];
|
|
119
123
|
};
|
|
120
124
|
const selected = state.roles.find((r) => r.role === role)!;
|
|
121
125
|
const rolesChanged = new Set(pending.map((p) => p.role)).size;
|
|
@@ -143,7 +147,8 @@ export default function RolesManager() {
|
|
|
143
147
|
{tab === 'edit' ? (
|
|
144
148
|
<div className="rp-grid">
|
|
145
149
|
<nav className="rp-roles" aria-label="Roles">
|
|
146
|
-
{state.roles.map((r) =>
|
|
150
|
+
{state.roles.map((r, i) => [
|
|
151
|
+
r.site && !state.roles[i - 1]?.site && <span key={`${r.role}-sep`} className="rp-role-sep">This site</span>,
|
|
147
152
|
<button key={r.role} type="button" className={`rp-role${role === r.role ? ' on' : ''}`} aria-current={role === r.role} onClick={() => setRole(r.role)}>
|
|
148
153
|
<span className="rp-role-name">
|
|
149
154
|
{editable(r.role) && unsaved(r.role) && <span className="rp-dot" title="Unsaved changes" />}
|
|
@@ -151,8 +156,8 @@ export default function RolesManager() {
|
|
|
151
156
|
{editable(r.role) && customized(r.role) && <span className="pill draft">Customized</span>}
|
|
152
157
|
</span>
|
|
153
158
|
<small>{r.members} {r.members === 1 ? 'person' : 'people'}</small>
|
|
154
|
-
</button
|
|
155
|
-
)
|
|
159
|
+
</button>,
|
|
160
|
+
])}
|
|
156
161
|
</nav>
|
|
157
162
|
|
|
158
163
|
<section className="card rp-panel" aria-labelledby="rp-role-h">
|
|
@@ -160,6 +165,7 @@ export default function RolesManager() {
|
|
|
160
165
|
<div>
|
|
161
166
|
<h2 id="rp-role-h">{selected.label} {editable(role) && <span className={`pill ${customized(role) ? 'draft' : 'archived'}`}>{customized(role) ? 'Customized' : 'Default'}</span>}</h2>
|
|
162
167
|
<p className="hint">{role === 'owner' ? 'Owners always have full access, so the site can never be locked out.' : selected.summary}</p>
|
|
168
|
+
{!selected.admin && <p className="rp-note">This role can’t use the admin, so only the permissions under “This site” (and “Request events”) can be turned on.</p>}
|
|
163
169
|
</div>
|
|
164
170
|
{!readOnly && editable(role) && <button className="btn sm" type="button" disabled={!customized(role)} onClick={() => setConfirm({ kind: 'restore', role: role as RoleKey })}>Restore defaults</button>}
|
|
165
171
|
</header>
|
|
@@ -171,13 +177,14 @@ export default function RolesManager() {
|
|
|
171
177
|
{a.permissions.map((p) => {
|
|
172
178
|
const changed = editable(role) && pending.some((d) => d.role === role && d.key === p.key);
|
|
173
179
|
const on = grant(role, p.key);
|
|
180
|
+
const open = usable(role, p);
|
|
174
181
|
return (
|
|
175
|
-
<div className={`rp-row${changed ? ' changed' : ''}`} key={p.key}>
|
|
182
|
+
<div className={`rp-row${changed ? ' changed' : ''}${open ? '' : ' na'}`} key={p.key}>
|
|
176
183
|
<div className="rp-text">
|
|
177
184
|
<span className="rp-label">{p.label}{p.sensitive && <span className="pill edited" title="Grants code, credentials or people. Grant with care.">Sensitive</span>}{changed && <span className="pill draft">Changed</span>}</span>
|
|
178
185
|
<small>{p.description}</small>
|
|
179
186
|
</div>
|
|
180
|
-
<Switch on={on} label={`${p.label} for ${selected.label}`} disabled={readOnly || role === 'owner' || p.locked} onChange={() => editable(role) && toggle(role, p.key)} />
|
|
187
|
+
<Switch on={on} label={`${p.label} for ${selected.label}`} disabled={readOnly || role === 'owner' || p.locked || !open} onChange={() => editable(role) && open && toggle(role, p.key)} />
|
|
181
188
|
</div>
|
|
182
189
|
);
|
|
183
190
|
})}
|
|
@@ -197,6 +204,7 @@ export default function RolesManager() {
|
|
|
197
204
|
<tr key={p.key}>
|
|
198
205
|
<td>{p.label}</td>
|
|
199
206
|
{state.roles.map((r) => {
|
|
207
|
+
if (!usable(r.role, p)) return <td key={r.role} className="rp-cell" title="Needs admin access" aria-label="Needs admin access">·</td>;
|
|
200
208
|
const on = grant(r.role, p.key);
|
|
201
209
|
const changed = editable(r.role) && pending.some((d) => d.role === r.role && d.key === p.key);
|
|
202
210
|
return <td key={r.role} className={`rp-cell${on ? ' on' : ''}${changed ? ' changed' : ''}`} aria-label={on ? 'Allowed' : 'Not allowed'}>{on ? '✓' : '—'}</td>;
|
|
@@ -43,6 +43,8 @@ const rangeOf = (view: View, anchor: Iso, weekStart: 0 | 1): [Iso, Iso] => {
|
|
|
43
43
|
const keyOf = (r: [Iso, Iso]) => `${r[0]}|${r[1]}`;
|
|
44
44
|
const filterKey = (it: BoardItem) => (it.kind === 'booking' || it.kind === 'block' ? BOOKINGS : it.calendarId || NONE);
|
|
45
45
|
const editable = (it: BoardItem) => it.kind === 'event';
|
|
46
|
+
// a member's event request waiting for approval is a draft too (dashed, hollow marker)
|
|
47
|
+
const isDraftLike = (it: BoardItem) => it.status === 'draft' || (it.kind === 'event' && it.status === 'pending');
|
|
46
48
|
|
|
47
49
|
export default function CalendarBoard(props: Props) {
|
|
48
50
|
const { today, weekStart, locale } = props;
|
|
@@ -299,7 +301,7 @@ export default function CalendarBoard(props: Props) {
|
|
|
299
301
|
try { return f.formatRange(utc(w[0]), utc(w[6])); } catch { return `${f.format(utc(w[0]))} – ${f.format(utc(w[6]))}`; }
|
|
300
302
|
};
|
|
301
303
|
const chipClass = (it: BoardItem) => ['sch-chip',
|
|
302
|
-
it
|
|
304
|
+
isDraftLike(it) || it.status === 'scheduled' ? 'is-draft' : '',
|
|
303
305
|
it.kind === 'booking' || it.kind === 'block' ? 'is-booking' : '',
|
|
304
306
|
it.status === 'pending' ? 'is-pending' : '',
|
|
305
307
|
it.kind === 'feed' ? 'is-feed' : '',
|
|
@@ -465,7 +467,7 @@ export default function CalendarBoard(props: Props) {
|
|
|
465
467
|
<div className="sch-peek-layer" onMouseDown={(e) => { if (e.target === e.currentTarget) setPeek(null); }}>
|
|
466
468
|
<div className="sch-peek" style={style} role="dialog" aria-label={it.title}>
|
|
467
469
|
<div className="sch-peek-head">
|
|
468
|
-
<i style={{ background: it.color }} className={it.kind === 'booking' || it.kind === 'block' || it
|
|
470
|
+
<i style={{ background: it.color }} className={it.kind === 'booking' || it.kind === 'block' || isDraftLike(it) ? 'hollow' : ''} />
|
|
469
471
|
<b>{it.title}</b>
|
|
470
472
|
<button type="button" className="sch-x" aria-label="Close" onClick={() => setPeek(null)}>✕</button>
|
|
471
473
|
</div>
|
|
@@ -612,7 +614,7 @@ function shortTime(min: number) {
|
|
|
612
614
|
}
|
|
613
615
|
|
|
614
616
|
function DayRow({ it, status, onOpen }: { it: BoardItem; status: string; onOpen: (e: ReactMouseEvent) => void }) {
|
|
615
|
-
const hollow = it.kind === 'booking' || it.kind === 'block' || it
|
|
617
|
+
const hollow = it.kind === 'booking' || it.kind === 'block' || isDraftLike(it) || it.status === 'scheduled';
|
|
616
618
|
return (
|
|
617
619
|
<button type="button" className={`sch-row ${hollow ? 'hollow' : ''} ${it.cancelled ? 'is-cancelled' : ''}`} style={{ ['--c' as string]: it.color }} onClick={onOpen}>
|
|
618
620
|
<i />
|
|
@@ -25,6 +25,8 @@ export interface SectionTargets {
|
|
|
25
25
|
calendars: { slug: string; name: string }[];
|
|
26
26
|
collections: { key: string; label: string }[];
|
|
27
27
|
tags: string[];
|
|
28
|
+
/** tags on published events (the events source picks from these) */
|
|
29
|
+
eventTags?: string[];
|
|
28
30
|
resources: { slug: string; name: string }[];
|
|
29
31
|
features: { events: boolean; bookings: boolean };
|
|
30
32
|
ai?: { enabled: boolean; languages: string[] };
|
|
@@ -321,27 +323,46 @@ function SourceInput({ id, field, value, onChange, targets }: InputProps<SourceF
|
|
|
321
323
|
const off = (field.source === 'events' && !targets.features.events) || (field.source === 'bookings' && !targets.features.bookings);
|
|
322
324
|
let from = '';
|
|
323
325
|
let picker: ReactNode = null;
|
|
326
|
+
// only items with one tag, and/or leaving out another ("events not tagged Class")
|
|
327
|
+
const tag = String(v.tag ?? ''), excludeTag = String(v.excludeTag ?? '');
|
|
328
|
+
const tagged = `${tag ? ` tagged “${tag}”` : ''}${tag && excludeTag ? ',' : ''}${excludeTag ? ` not tagged “${excludeTag}”` : ''}`;
|
|
329
|
+
const tagPickers = (names: string[]) => {
|
|
330
|
+
// a saved tag no item uses any more stays listed, so the picker shows what's stored
|
|
331
|
+
const list = [...new Set([...names, tag, excludeTag].filter(Boolean))];
|
|
332
|
+
return (
|
|
333
|
+
<>
|
|
334
|
+
<select aria-label="Only items with this tag" value={tag} onChange={(e) => set({ tag: e.target.value })}>
|
|
335
|
+
<option value="">Any tag</option>
|
|
336
|
+
{list.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
337
|
+
</select>
|
|
338
|
+
<select aria-label="Leave out items with this tag" value={excludeTag} onChange={(e) => set({ excludeTag: e.target.value })}>
|
|
339
|
+
<option value="">Leave nothing out</option>
|
|
340
|
+
{list.map((t) => <option key={t} value={t}>Leave out “{t}”</option>)}
|
|
341
|
+
</select>
|
|
342
|
+
</>
|
|
343
|
+
);
|
|
344
|
+
};
|
|
324
345
|
if (field.source === 'events') {
|
|
325
346
|
const cal = targets.calendars.find((c) => c.slug === v.calendar);
|
|
326
|
-
from =
|
|
347
|
+
from = `${cal ? `Calendar → ${cal.name}` : 'All public calendars'}${tagged ? ` · events${tagged}` : ''}`;
|
|
327
348
|
picker = (
|
|
328
|
-
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
349
|
+
<>
|
|
350
|
+
<select id={id} aria-label="Calendar" value={String(v.calendar ?? '')} onChange={(e) => set({ calendar: e.target.value })}>
|
|
351
|
+
<option value="">All public calendars</option>
|
|
352
|
+
{targets.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
|
353
|
+
</select>
|
|
354
|
+
{tagPickers(targets.eventTags ?? targets.tags)}
|
|
355
|
+
</>
|
|
332
356
|
);
|
|
333
357
|
} else if (field.source === 'posts') {
|
|
334
358
|
const col = targets.collections.find((c) => c.key === (v.collection ?? 'posts'));
|
|
335
|
-
from = `${col?.label ?? 'Posts'}${
|
|
359
|
+
from = `${col?.label ?? 'Posts'}${tagged}`;
|
|
336
360
|
picker = (
|
|
337
361
|
<>
|
|
338
362
|
<select id={id} aria-label="Collection" value={String(v.collection ?? 'posts')} onChange={(e) => set({ collection: e.target.value === 'posts' ? '' : e.target.value })}>
|
|
339
363
|
{targets.collections.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
|
340
364
|
</select>
|
|
341
|
-
|
|
342
|
-
<option value="">Any tag</option>
|
|
343
|
-
{targets.tags.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
344
|
-
</select>
|
|
365
|
+
{tagPickers(targets.tags)}
|
|
345
366
|
</>
|
|
346
367
|
);
|
|
347
368
|
} else {
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import {
|
|
2
|
+
import { roleLabel } from '../lib/roles';
|
|
3
3
|
import { getRepo } from '../lib/repo';
|
|
4
4
|
import { BRAND } from '../lib/brand';
|
|
5
5
|
import { CONTENT } from '../lib/content';
|
|
@@ -37,7 +37,7 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
|
|
|
37
37
|
const pendingBookings = CALENDAR.enabled ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'], ...(!bookingsEnabled() ? { kinds: ['rsvp'] as const as ['rsvp'] } : {}) })).length : 0;
|
|
38
38
|
const me = {
|
|
39
39
|
name: sessionUser?.name || 'Admin',
|
|
40
|
-
role:
|
|
40
|
+
role: roleLabel(sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'),
|
|
41
41
|
avatar: sessionUser?.avatar || '',
|
|
42
42
|
};
|
|
43
43
|
const initials = me.name.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase();
|
package/src/lib/auth/accounts.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
|
-
import {
|
|
2
|
+
import { parseRole } from '../roles';
|
|
3
3
|
import { AUTH, clerkConfig } from './config';
|
|
4
4
|
import { clerkClient, clerkOperation } from './request';
|
|
5
5
|
import { validAddress } from '../email/config';
|
|
6
6
|
|
|
7
7
|
export async function inviteClerkUser(repo: ContentRepository, data: { email: string; name: string; role: unknown; invitedBy?: string }, locals?: unknown) {
|
|
8
8
|
if (AUTH.provider !== 'clerk') throw new Error('Clerk authentication is not enabled.');
|
|
9
|
-
const config = clerkConfig(locals), email = data.email.trim().toLowerCase(), role =
|
|
9
|
+
const config = clerkConfig(locals), email = data.email.trim().toLowerCase(), role = parseRole(data.role);
|
|
10
10
|
if (!validAddress(email)) throw new Error('Enter a valid email address.');
|
|
11
11
|
if (await repo.getUserAuth(email)) throw new Error('This account already exists. Link its Clerk user ID instead.');
|
|
12
12
|
const client = await clerkClient(locals);
|
package/src/lib/auth/request.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { ClerkClient } from '@clerk/backend';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import {
|
|
3
|
+
import { validatePerson, SESSION_COOKIE } from '../auth';
|
|
4
|
+
import { isStaffRole } from '../roles';
|
|
4
5
|
import { AUTH, clerkConfig } from './config';
|
|
5
6
|
import type { ClerkBinding } from './store';
|
|
6
7
|
|
|
7
8
|
type Cookies = { get(name: string): { value: string } | undefined };
|
|
8
9
|
export interface RequestAuth {
|
|
10
|
+
/** the signed-in staff member (null for signed-out visitors and for site roles without admin access) */
|
|
9
11
|
userId: string | null;
|
|
12
|
+
/** whoever is signed in, staff or not, with their role (what currentUser() and the login page read) */
|
|
13
|
+
person: { id: string; role: string } | null;
|
|
10
14
|
provider: 'builtin' | 'clerk';
|
|
11
15
|
clerk: ClerkBinding | null;
|
|
12
16
|
headers: Headers;
|
|
@@ -77,7 +81,7 @@ export async function activeClerkSession(client: ClerkClient, binding: ClerkBind
|
|
|
77
81
|
return active;
|
|
78
82
|
}
|
|
79
83
|
export function getRequestAuth(request: Request, cookies: Cookies, localsOrRepo?: unknown): Promise<RequestAuth> {
|
|
80
|
-
if (AUTH.provider === 'builtin') return
|
|
84
|
+
if (AUTH.provider === 'builtin') return validatePerson(cookies.get(SESSION_COOKIE)?.value, localsOrRepo).then(person => ({ userId: person && isStaffRole(person.role) ? person.id : null, person, provider: 'builtin', clerk: null, headers: new Headers(), error: null, reverified: false }));
|
|
81
85
|
let cached = requests.get(request);
|
|
82
86
|
if (!cached) {
|
|
83
87
|
if (!(localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo)) environments.set(request, localsOrRepo);
|
|
@@ -88,11 +92,11 @@ export function getRequestAuth(request: Request, cookies: Cookies, localsOrRepo?
|
|
|
88
92
|
return cached;
|
|
89
93
|
}
|
|
90
94
|
export async function validatePreviewSession(cookies: Cookies, locals?: unknown) {
|
|
91
|
-
if (AUTH.provider === 'builtin') return
|
|
95
|
+
if (AUTH.provider === 'builtin') return validatePerson(cookies.get(SESSION_COOKIE)?.value, locals).then((p) => (p && isStaffRole(p.role) ? p.id : null));
|
|
92
96
|
return locals && typeof locals === 'object' ? (await contexts.get(locals))?.userId ?? null : null;
|
|
93
97
|
}
|
|
94
98
|
async function resolveClerk(request: Request, localsOrRepo?: unknown): Promise<RequestAuth> {
|
|
95
|
-
const result: RequestAuth = { userId: null, provider: 'clerk', clerk: null, headers: new Headers(), error: null, reverified: false };
|
|
99
|
+
const result: RequestAuth = { userId: null, person: null, provider: 'clerk', clerk: null, headers: new Headers(), error: null, reverified: false };
|
|
96
100
|
if (!shouldResolveClerk(request)) return result;
|
|
97
101
|
let config: ReturnType<typeof clerkConfig>;
|
|
98
102
|
try { config = clerkConfig(authEnvironment(request, localsOrRepo)); } catch { return { ...result, error: 'configuration' }; }
|
|
@@ -133,14 +137,22 @@ async function resolveClerk(request: Request, localsOrRepo?: unknown): Promise<R
|
|
|
133
137
|
id = await repo.auth.identity(config.issuer, auth.userId);
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
const user = id ? await repo.getUser(id) : null;
|
|
141
|
+
// a site role without admin access is signed in (person) but not staff (userId)
|
|
142
|
+
result.person = user ? { id: user.id, role: user.role } : null;
|
|
143
|
+
result.userId = user && isStaffRole(user.role) ? user.id : null;
|
|
144
|
+
if (!user) result.error = 'access_denied';
|
|
138
145
|
return result;
|
|
139
|
-
} catch { return { ...result, userId: null, error: 'unavailable' }; }
|
|
146
|
+
} catch { return { ...result, userId: null, person: null, error: 'unavailable' }; }
|
|
140
147
|
}
|
|
148
|
+
/** the signed-in staff member's id (null for everyone else, site roles without admin access included) */
|
|
141
149
|
export async function validateRequestSession(request: Request, cookies: Cookies, localsOrRepo?: unknown) {
|
|
142
150
|
return (await getRequestAuth(request, cookies, localsOrRepo)).userId;
|
|
143
151
|
}
|
|
152
|
+
/** whoever is signed in on this request, staff or not, with their current role */
|
|
153
|
+
export async function requestPerson(request: Request, cookies: Cookies, localsOrRepo?: unknown) {
|
|
154
|
+
return (await getRequestAuth(request, cookies, localsOrRepo)).person;
|
|
155
|
+
}
|
|
144
156
|
/** Stable browser-session binding for consent and third-party OAuth state. */
|
|
145
157
|
export async function requestSessionBinding(request: Request, cookies: Cookies, localsOrRepo?: unknown) {
|
|
146
158
|
const auth = await getRequestAuth(request, cookies, localsOrRepo);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { isStaffRole, roleHome } from '../roles';
|
|
2
|
+
|
|
3
|
+
// Where /admin/login sends someone after they sign in. `?return=` may name any
|
|
4
|
+
// page on this site (a site's "Sign in" link on /classes brings people back
|
|
5
|
+
// there), never another site, and never a page that would loop or act:
|
|
6
|
+
// login/logout/app sign-in, APIs, actions and the account (invite/reset) pages.
|
|
7
|
+
|
|
8
|
+
const BLOCKED = /^\/(?:admin\/(?:login|logout|app-auth)(?:\/|$)|api(?:\/|$)|_actions(?:\/|$)|account(?:\/|$))/;
|
|
9
|
+
|
|
10
|
+
/** a safe same-site path + query + hash from ?return=, or null */
|
|
11
|
+
export function signInReturn(value: string | null | undefined, here: URL): string | null {
|
|
12
|
+
if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value)) return null;
|
|
13
|
+
// dot segments (also %2e-encoded) would normalise "/.//evil.example" into "//evil.example"
|
|
14
|
+
const path = value.split(/[?#]/)[0];
|
|
15
|
+
if (/(^|\/)(\.|%2e){1,2}(\/|$)/i.test(path)) return null;
|
|
16
|
+
let url: URL;
|
|
17
|
+
try { url = new URL(value, here.origin); } catch { return null; }
|
|
18
|
+
if (url.origin !== here.origin || url.pathname.startsWith('//') || BLOCKED.test(url.pathname)) return null;
|
|
19
|
+
return url.pathname + url.search + url.hash;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const isAdminPath = (path: string) => /^\/admin(?:[/?#]|$)/.test(path);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Staff go back where they asked, else to the admin. Everyone else (site
|
|
26
|
+
* roles without admin access) goes back where they asked unless that's the
|
|
27
|
+
* admin, else to their role's home page.
|
|
28
|
+
*/
|
|
29
|
+
export function afterSignIn(role: string, back: string | null): string {
|
|
30
|
+
if (isStaffRole(role)) return back ?? '/admin';
|
|
31
|
+
return back && !isAdminPath(back) ? back : roleHome(role);
|
|
32
|
+
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AUTH } from './auth/config';
|
|
2
2
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { env } from './env';
|
|
4
|
+
import { isStaffRole } from './roles';
|
|
4
5
|
|
|
5
6
|
const COOKIE = 'acms_session';
|
|
6
7
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -104,14 +105,26 @@ export async function verifyPassword(password: string, stored: string): Promise<
|
|
|
104
105
|
export const SESSION_COOKIE = COOKIE;
|
|
105
106
|
|
|
106
107
|
// All authorization paths must consult the database, not just the signature.
|
|
107
|
-
|
|
108
|
+
/** whoever this session cookie signs in, staff or not, with their current role */
|
|
109
|
+
export async function validatePerson(token: string | undefined, localsOrRepo?: unknown): Promise<{ id: string; role: string } | null> {
|
|
108
110
|
if (AUTH.provider !== 'builtin') return null;
|
|
109
111
|
const id = verifySessionToken(token);
|
|
110
|
-
if (!id
|
|
112
|
+
if (!id) return null;
|
|
113
|
+
if (id === OWNER_ENV) return { id, role: 'owner' };
|
|
111
114
|
const { getRepo } = await import('./repo');
|
|
112
115
|
const repo = localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo
|
|
113
116
|
? localsOrRepo as import('./repo/types').ContentRepository : await getRepo(localsOrRepo);
|
|
114
|
-
const
|
|
117
|
+
const state = await repo.email.sessionState(id);
|
|
115
118
|
const expected = token!.startsWith('v.') ? Number(token!.split('.')[2]) : 0;
|
|
116
|
-
return
|
|
119
|
+
return state && state.version === expected ? { id, role: state.role } : null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The signed-in staff member's id (null for everyone else). Every admin, API,
|
|
123
|
+
* action and preview check goes through here, so a site role without admin
|
|
124
|
+
* access (lib/roles.ts) counts as signed out for all of them; site pages use
|
|
125
|
+
* validatePerson / currentUser() instead.
|
|
126
|
+
*/
|
|
127
|
+
export async function validateSession(token: string | undefined, localsOrRepo?: unknown): Promise<string | null> {
|
|
128
|
+
const person = await validatePerson(token, localsOrRepo);
|
|
129
|
+
return person && isStaffRole(person.role) ? person.id : null;
|
|
117
130
|
}
|
|
@@ -65,6 +65,17 @@ export function todayIn(tz: string, now = Date.now()): Iso {
|
|
|
65
65
|
return isoOf(tzParts(now, tz || 'UTC'));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* An event's status on the board. A member's new event that is still waiting
|
|
70
|
+
* for approval (lib/calendar/requests.ts) shows as 'pending' ("Needs
|
|
71
|
+
* approval"). Suggested changes to a live event don't: the event stays as
|
|
72
|
+
* it is until they're approved.
|
|
73
|
+
*/
|
|
74
|
+
export function eventBoardStatus(e: Pick<CalEvent, 'status' | 'request'>): BoardStatus {
|
|
75
|
+
if (e.status === 'draft' && e.request?.state === 'pending' && e.request.kind === 'new') return 'pending';
|
|
76
|
+
return e.status as BoardStatus;
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
// local day + minutes for an instant; `endExclusive` maps midnight to the
|
|
69
80
|
// previous day's 24:00 so an event ending at midnight stays on its day
|
|
70
81
|
function local(ms: number, tz: string, endExclusive = false) {
|
|
@@ -118,7 +129,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
118
129
|
color: v.color ?? (cal ? safeColor(cal.color) : '#8a8378'),
|
|
119
130
|
calendarId: cal?.id ?? NO_CALENDAR,
|
|
120
131
|
calendarName: cal?.name ?? '',
|
|
121
|
-
status: e ? (e
|
|
132
|
+
status: e ? eventBoardStatus(e) : 'feed',
|
|
122
133
|
recurring: Boolean(e?.rrule),
|
|
123
134
|
repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
|
|
124
135
|
rooms: e ? e.roomHold.resourceIds.map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// block (data-dyn="events"), the /calendar page and embeds. Stable
|
|
3
3
|
// .pc-ev-card* classes are the styling API, like .pc-cal-* for the grid.
|
|
4
4
|
import type { ContentRepository } from '../repo/types';
|
|
5
|
-
import type
|
|
5
|
+
import { tagFilter, type CalEvent, type Settings } from '../schema';
|
|
6
6
|
import { siteLocale } from '../dates';
|
|
7
7
|
import { eventImage } from './images';
|
|
8
8
|
import { eventUrl } from './config';
|
|
@@ -62,21 +62,23 @@ export function eventCardHtml(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// next occurrence of each distinct event, soonest first
|
|
65
|
+
// next occurrence of each distinct event, soonest first; tag/excludeTag
|
|
66
|
+
// narrow by event tag (name or slug, any case)
|
|
66
67
|
export async function upcomingEvents(
|
|
67
68
|
repo: ContentRepository,
|
|
68
69
|
settings: Settings,
|
|
69
70
|
now: number,
|
|
70
|
-
opts: { calendarIds?: string[]; limit: number; horizonDays?: number }
|
|
71
|
+
opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string }
|
|
71
72
|
) {
|
|
72
73
|
const tz = settings.timezone || 'UTC';
|
|
74
|
+
const tagged = tagFilter(opts.tag, opts.excludeTag);
|
|
73
75
|
const { events } = await collectViewEvents(repo, settings, {
|
|
74
76
|
displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
|
|
75
77
|
}, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
|
|
76
78
|
const seen = new Set<string>();
|
|
77
79
|
const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
|
|
78
80
|
for (const v of events) {
|
|
79
|
-
if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
|
|
81
|
+
if (!v.event || v.end <= now || seen.has(v.event.id) || !tagged(v.event.tags)) continue;
|
|
80
82
|
seen.add(v.event.id);
|
|
81
83
|
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled) });
|
|
82
84
|
if (out.length >= opts.limit) break;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { CalEvent, EventRequest } from '../schema';
|
|
2
|
+
import type { EventRequestInput } from './requests';
|
|
3
|
+
import { CALENDAR, type EventFieldConfig } from './config';
|
|
4
|
+
import { describeRepeat } from './rrule';
|
|
5
|
+
|
|
6
|
+
// The event request form on the built-in "My events" pages, as pure
|
|
7
|
+
// functions: the HTML form's fields → EventRequestInput (requests.ts checks
|
|
8
|
+
// and saves it), and an event → the form's values for editing. A site that
|
|
9
|
+
// builds its own request page can reuse them or post its own input.
|
|
10
|
+
|
|
11
|
+
/** what the form holds; times are wall-clock in the site's time zone */
|
|
12
|
+
export interface MemberEventForm {
|
|
13
|
+
title: string;
|
|
14
|
+
/** YYYY-MM-DD */
|
|
15
|
+
date: string;
|
|
16
|
+
/** HH:mm ('' for all-day events) */
|
|
17
|
+
startTime: string;
|
|
18
|
+
endTime: string;
|
|
19
|
+
/** days from `date` to the last day: kept (hidden) so a multi-day event stays multi-day when its date moves */
|
|
20
|
+
days: number;
|
|
21
|
+
allDay: boolean;
|
|
22
|
+
/** an RRULE body: one of REPEAT_CHOICES, or what the event had */
|
|
23
|
+
repeat: string;
|
|
24
|
+
where: string;
|
|
25
|
+
/** plain text */
|
|
26
|
+
description: string;
|
|
27
|
+
calendarId: string;
|
|
28
|
+
/** the site's non-private custom event fields */
|
|
29
|
+
fields: Record<string, string>;
|
|
30
|
+
note: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** the request form's "Repeats" choices */
|
|
34
|
+
export const REPEAT_CHOICES: readonly { value: string; label: string }[] = [
|
|
35
|
+
{ value: '', label: 'Does not repeat' },
|
|
36
|
+
{ value: 'FREQ=DAILY', label: 'Every day' },
|
|
37
|
+
{ value: 'FREQ=WEEKLY', label: 'Every week' },
|
|
38
|
+
{ value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Every 2 weeks' },
|
|
39
|
+
{ value: 'FREQ=MONTHLY', label: 'Every month' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** the patterns requests.ts accepts from members (kept in step with its REPEAT) */
|
|
43
|
+
const MEMBER_REPEAT = /^FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=[1-9]\d?)?(;COUNT=[1-9]\d{0,2})?$/;
|
|
44
|
+
/** maxlength for the inputs (requests.ts trims to the same) */
|
|
45
|
+
export const MEMBER_FORM_LIMITS = { title: 200, description: 5000, where: 300, note: 1000, field: 500 } as const;
|
|
46
|
+
const MAX_DAYS = 366;
|
|
47
|
+
const DAY = 86_400_000;
|
|
48
|
+
const YMD = /^\d{4}-\d{2}-\d{2}$/;
|
|
49
|
+
|
|
50
|
+
/** the custom event fields members fill in (private ones are staff-only) */
|
|
51
|
+
export const memberEventFields = (fields: readonly EventFieldConfig[] = CALENDAR.eventFields) => fields.filter((f) => !f.private);
|
|
52
|
+
|
|
53
|
+
export function blankEventForm(fields: readonly EventFieldConfig[] = memberEventFields()): MemberEventForm {
|
|
54
|
+
return {
|
|
55
|
+
title: '', date: '', startTime: '', endTime: '', days: 0, allDay: false, repeat: '', where: '', description: '', calendarId: '',
|
|
56
|
+
fields: Object.fromEntries(fields.map((f) => [f.key, ''])), note: '',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ymdMs = (ymd: string) => {
|
|
61
|
+
if (!YMD.test(ymd)) return NaN;
|
|
62
|
+
const [y, m, d] = ymd.split('-').map(Number);
|
|
63
|
+
const ms = Date.UTC(y, m - 1, d);
|
|
64
|
+
const back = new Date(ms);
|
|
65
|
+
return back.getUTCFullYear() === y && back.getUTCMonth() === m - 1 && back.getUTCDate() === d ? ms : NaN;
|
|
66
|
+
};
|
|
67
|
+
const addDays = (ymd: string, n: number) => {
|
|
68
|
+
const ms = ymdMs(ymd);
|
|
69
|
+
return Number.isFinite(ms) ? new Date(ms + n * DAY).toISOString().slice(0, 10) : ymd;
|
|
70
|
+
};
|
|
71
|
+
const dayDiff = (from: string, to: string) => {
|
|
72
|
+
const a = ymdMs(from), b = ymdMs(to);
|
|
73
|
+
return Number.isFinite(a) && Number.isFinite(b) ? Math.round((b - a) / DAY) : 0;
|
|
74
|
+
};
|
|
75
|
+
const clampDays = (n: number) => (Number.isFinite(n) ? Math.min(MAX_DAYS, Math.max(0, Math.trunc(n))) : 0);
|
|
76
|
+
const time = (v: string) => (/^\d{2}:\d{2}/.test(v.trim()) ? v.trim().slice(0, 5) : '');
|
|
77
|
+
|
|
78
|
+
/** anything with get(name): FormData, URLSearchParams */
|
|
79
|
+
export interface FormLike { get(name: string): FormDataEntryValue | null }
|
|
80
|
+
|
|
81
|
+
/** the posted form → its values (unchecked; requests.ts validates) */
|
|
82
|
+
export function readEventForm(form: FormLike, fields: readonly EventFieldConfig[] = memberEventFields()): MemberEventForm {
|
|
83
|
+
const s = (name: string) => { const v = form.get(name); return typeof v === 'string' ? v : ''; };
|
|
84
|
+
const all = s('allDay').toLowerCase();
|
|
85
|
+
const values: MemberEventForm = {
|
|
86
|
+
title: s('title'), date: s('date').trim(), startTime: time(s('startTime')), endTime: time(s('endTime')),
|
|
87
|
+
days: clampDays(Number(s('days') || 0)), allDay: all !== '' && all !== '0' && all !== 'false',
|
|
88
|
+
repeat: s('repeat').trim(), where: s('where'), description: s('description'), calendarId: s('calendarId').trim(),
|
|
89
|
+
fields: {}, note: s('note'),
|
|
90
|
+
};
|
|
91
|
+
// a field the form didn't include is left as it is (undefined), not cleared
|
|
92
|
+
for (const f of fields) {
|
|
93
|
+
const v = form.get(`field_${f.key}`);
|
|
94
|
+
if (typeof v === 'string') values.fields[f.key] = v;
|
|
95
|
+
}
|
|
96
|
+
return values;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** the form's values → what submitEventRequest / updateEventRequest take */
|
|
100
|
+
export function eventRequestInput(v: MemberEventForm): EventRequestInput {
|
|
101
|
+
const lastDay = v.days > 0 ? addDays(v.date, v.days) : v.date;
|
|
102
|
+
// a timed event without a start time stays a bare date, which requests.ts answers with "Pick a date and start time."
|
|
103
|
+
const start = v.allDay || !v.startTime ? v.date : `${v.date}T${v.startTime}`;
|
|
104
|
+
const end = v.allDay ? (v.days > 0 ? lastDay : '') : v.endTime ? `${lastDay}T${v.endTime}` : '';
|
|
105
|
+
return {
|
|
106
|
+
title: v.title, description: v.description, start, end, allDay: v.allDay, repeat: v.repeat, where: v.where,
|
|
107
|
+
calendarId: v.calendarId, fields: v.fields, note: v.note,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type FormEvent = Pick<CalEvent, 'title' | 'start' | 'end' | 'allDay' | 'rrule' | 'location' | 'calendarIds' | 'fields' | 'request'>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* An event (with its staged changes merged: mergeEventDraft) → the form's
|
|
115
|
+
* values. `description` is bodyToText(event); `calendarIds` are the
|
|
116
|
+
* calendars the form offers (the event's first one among them is picked).
|
|
117
|
+
*/
|
|
118
|
+
export function eventFormValues(e: FormEvent, opts: { description: string; calendarIds?: readonly string[] }, fields: readonly EventFieldConfig[] = memberEventFields()): MemberEventForm {
|
|
119
|
+
const date = e.start.slice(0, 10);
|
|
120
|
+
const lastDay = e.end ? e.end.slice(0, 10) : date;
|
|
121
|
+
return {
|
|
122
|
+
title: e.title, date, allDay: e.allDay,
|
|
123
|
+
startTime: e.allDay ? '' : time(e.start.slice(11)),
|
|
124
|
+
endTime: e.allDay || !e.end ? '' : time(e.end.slice(11)),
|
|
125
|
+
days: clampDays(dayDiff(date, lastDay)),
|
|
126
|
+
repeat: e.rrule, where: e.location.name, description: opts.description,
|
|
127
|
+
calendarId: e.calendarIds.find((id) => !opts.calendarIds || opts.calendarIds.includes(id)) ?? '',
|
|
128
|
+
fields: Object.fromEntries(fields.map((f) => [f.key, e.fields[f.key] ?? ''])),
|
|
129
|
+
// a waiting request's note is theirs to edit; after an answer they write a new one
|
|
130
|
+
note: e.request?.state === 'pending' ? e.request.note : '',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The "Repeats" select for a value: the standard choices, plus the event's
|
|
136
|
+
* own pattern (e.g. every 3 weeks, or one only the admin can set, like weekly
|
|
137
|
+
* on two days), selected, so sending the form unchanged keeps it.
|
|
138
|
+
*/
|
|
139
|
+
export function repeatChoices(current: string, start: string): { options: { value: string; label: string }[]; value: string; note: string } {
|
|
140
|
+
const rule = current.trim().replace(/^RRULE:/i, '').toUpperCase();
|
|
141
|
+
const options = [...REPEAT_CHOICES];
|
|
142
|
+
if (!rule || options.some((o) => o.value === rule)) return { options, value: rule, note: '' };
|
|
143
|
+
if (MEMBER_REPEAT.test(rule)) return { options: [...options, { value: rule, label: describeRepeat(rule, start) }], value: rule, note: '' };
|
|
144
|
+
// set up by the team: members can keep it (requests.ts accepts an unchanged rule) or pick another
|
|
145
|
+
return { options: [...options, { value: rule, label: `${describeRepeat(rule, start)} (as set by the team)` }], value: rule, note: '' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** how a request reads on "My events" */
|
|
149
|
+
export function requestLabel(r: Pick<EventRequest, 'kind' | 'state'>): string {
|
|
150
|
+
if (r.kind === 'changes') return r.state === 'pending' ? 'Changes waiting for approval' : 'Changes declined';
|
|
151
|
+
return r.state === 'pending' ? 'Waiting for approval' : 'Declined';
|
|
152
|
+
}
|