@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/migrations.generated.mjs +6 -0
  3. package/docs/mobile.md +1 -1
  4. package/docs/sections.md +2 -2
  5. package/docs/server.md +210 -29
  6. package/docs/theme-authoring.md +1 -1
  7. package/integration.mjs +134 -1
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +3 -1
  10. package/src/components/EmailSettings.tsx +1 -1
  11. package/src/components/RolesManager.tsx +15 -7
  12. package/src/components/calendar/CalendarBoard.tsx +5 -3
  13. package/src/components/sections/SectionFields.tsx +31 -10
  14. package/src/layouts/Admin.astro +2 -2
  15. package/src/lib/auth/accounts.ts +2 -2
  16. package/src/lib/auth/request.ts +19 -7
  17. package/src/lib/auth/return.ts +32 -0
  18. package/src/lib/auth.ts +17 -4
  19. package/src/lib/calendar/board.ts +12 -1
  20. package/src/lib/calendar/cards.ts +6 -4
  21. package/src/lib/calendar/member-form.ts +152 -0
  22. package/src/lib/calendar/requests.ts +284 -0
  23. package/src/lib/calendar/schedule.ts +1 -1
  24. package/src/lib/editor/auth.ts +5 -3
  25. package/src/lib/editor/schemas.ts +2 -1
  26. package/src/lib/editor/store.ts +5 -3
  27. package/src/lib/email/service.ts +16 -3
  28. package/src/lib/email/store.ts +3 -1
  29. package/src/lib/email/templates.ts +12 -0
  30. package/src/lib/mobile/calendar.ts +6 -2
  31. package/src/lib/permissions/catalog.ts +84 -36
  32. package/src/lib/permissions/service.ts +11 -8
  33. package/src/lib/repo/migrations.ts +6 -0
  34. package/src/lib/repo/sql.ts +23 -5
  35. package/src/lib/repo/types.ts +5 -1
  36. package/src/lib/roles.ts +46 -4
  37. package/src/lib/schema.ts +43 -2
  38. package/src/lib/section-targets.ts +10 -0
  39. package/src/lib/sections.ts +7 -5
  40. package/src/lib/sync/catalog.ts +3 -0
  41. package/src/middleware.ts +8 -1
  42. package/src/pages/admin/app-auth.astro +5 -3
  43. package/src/pages/admin/calendar/events/[id].astro +249 -2
  44. package/src/pages/admin/calendar/events/index.astro +165 -12
  45. package/src/pages/admin/index.astro +22 -0
  46. package/src/pages/admin/login.astro +37 -16
  47. package/src/pages/admin/roles.astro +3 -0
  48. package/src/pages/admin/users.astro +15 -14
  49. package/src/pages/api/calendar/events/index.ts +8 -1
  50. package/src/pages/api/search.ts +3 -1
  51. package/src/pages/author/[slug].astro +3 -1
  52. package/src/pages/members/_EventForm.astro +89 -0
  53. package/src/pages/members/_Layout.astro +76 -0
  54. package/src/pages/members/_http.ts +25 -0
  55. package/src/pages/members/events/[id].astro +86 -0
  56. package/src/pages/members/events/index.astro +218 -0
  57. package/src/pages/members/events/new.astro +48 -0
  58. package/src/pages/members/index.ts +6 -0
  59. package/src/server/index.ts +111 -20
  60. package/src/theme-sdk/calendar.ts +6 -2
  61. package/src/theme-sdk/fields.mjs +7 -2
  62. 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 n = a.permissions.filter((p) => grant(r, p.key)).length;
118
- return n === a.permissions.length ? ['Full access', 'published'] : n === 0 ? ['No access', 'archived'] : ['Partial access', 'edited'];
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.status === 'draft' || it.status === 'scheduled' ? 'is-draft' : '',
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.status === 'draft' ? 'hollow' : ''} />
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.status === 'draft' || it.status === 'scheduled';
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 = `Calendar${cal ? ` → ${cal.name}` : ' · all public calendars'}`;
347
+ from = `${cal ? `Calendar → ${cal.name}` : 'All public calendars'}${tagged ? ` · events${tagged}` : ''}`;
327
348
  picker = (
328
- <select id={id} aria-label="Calendar" value={String(v.calendar ?? '')} onChange={(e) => set({ calendar: e.target.value })}>
329
- <option value="">All public calendars</option>
330
- {targets.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
331
- </select>
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'}${v.tag ? ` tagged “${v.tag}”` : ''}`;
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
- <select aria-label="Tag" value={String(v.tag ?? '')} onChange={(e) => set({ tag: e.target.value })}>
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 {
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { roleLabels } from '../lib/roles';
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: roleLabels[sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'],
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();
@@ -1,12 +1,12 @@
1
1
  import type { ContentRepository } from '../repo/types';
2
- import { Role } from '../schema';
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 = Role.parse(data.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);
@@ -1,12 +1,16 @@
1
1
  import type { ClerkClient } from '@clerk/backend';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { validateSession, SESSION_COOKIE } from '../auth';
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 validateSession(cookies.get(SESSION_COOKIE)?.value, localsOrRepo).then(userId => ({ userId, provider: 'builtin', clerk: null, headers: new Headers(), error: null, reverified: false }));
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 validateSession(cookies.get(SESSION_COOKIE)?.value, locals);
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
- result.userId = id && await repo.getUser(id) ? id : null;
137
- if (!result.userId) result.error = 'access_denied';
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
- export async function validateSession(token: string | undefined, localsOrRepo?: unknown): Promise<string | null> {
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 || id === OWNER_ENV) return 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 version = await repo.email.sessionVersion(id);
117
+ const state = await repo.email.sessionState(id);
115
118
  const expected = token!.startsWith('v.') ? Number(token!.split('.')[2]) : 0;
116
- return version !== null && version === expected ? id : null;
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.status as BoardStatus) : 'feed',
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 { CalEvent, Settings } from '../schema';
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
+ }