@acms-subir/core 0.3.21 → 0.3.22

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 (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/cli/migrations.generated.mjs +6 -0
  3. package/docs/mobile.md +1 -1
  4. package/docs/server.md +203 -24
  5. package/integration.mjs +134 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +3 -1
  8. package/src/components/EmailSettings.tsx +1 -1
  9. package/src/components/RolesManager.tsx +15 -7
  10. package/src/components/calendar/CalendarBoard.tsx +5 -3
  11. package/src/layouts/Admin.astro +2 -2
  12. package/src/lib/auth/accounts.ts +2 -2
  13. package/src/lib/auth/request.ts +19 -7
  14. package/src/lib/auth/return.ts +32 -0
  15. package/src/lib/auth.ts +17 -4
  16. package/src/lib/calendar/board.ts +12 -1
  17. package/src/lib/calendar/member-form.ts +152 -0
  18. package/src/lib/calendar/requests.ts +284 -0
  19. package/src/lib/calendar/schedule.ts +1 -1
  20. package/src/lib/editor/auth.ts +5 -3
  21. package/src/lib/editor/schemas.ts +2 -1
  22. package/src/lib/editor/store.ts +5 -3
  23. package/src/lib/email/service.ts +16 -3
  24. package/src/lib/email/store.ts +3 -1
  25. package/src/lib/email/templates.ts +12 -0
  26. package/src/lib/permissions/catalog.ts +84 -36
  27. package/src/lib/permissions/service.ts +11 -8
  28. package/src/lib/repo/migrations.ts +6 -0
  29. package/src/lib/repo/sql.ts +23 -5
  30. package/src/lib/repo/types.ts +5 -1
  31. package/src/lib/roles.ts +46 -4
  32. package/src/lib/schema.ts +31 -2
  33. package/src/lib/sync/catalog.ts +3 -0
  34. package/src/middleware.ts +8 -1
  35. package/src/pages/admin/app-auth.astro +5 -3
  36. package/src/pages/admin/calendar/events/[id].astro +249 -2
  37. package/src/pages/admin/calendar/events/index.astro +165 -12
  38. package/src/pages/admin/index.astro +22 -0
  39. package/src/pages/admin/login.astro +37 -16
  40. package/src/pages/admin/roles.astro +3 -0
  41. package/src/pages/admin/users.astro +15 -14
  42. package/src/pages/api/search.ts +3 -1
  43. package/src/pages/author/[slug].astro +3 -1
  44. package/src/pages/members/_EventForm.astro +89 -0
  45. package/src/pages/members/_Layout.astro +76 -0
  46. package/src/pages/members/_http.ts +25 -0
  47. package/src/pages/members/events/[id].astro +86 -0
  48. package/src/pages/members/events/index.astro +218 -0
  49. package/src/pages/members/events/new.astro +48 -0
  50. package/src/pages/members/index.ts +6 -0
  51. package/src/server/index.ts +96 -18
  52. package/src/virtual.d.ts +4 -0
@@ -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 { roleLabels, roleSummaries } from '../roles';
5
+ import { CORE_ROLES, isStaffRole, roleLabel, roleSummary, rolesByRank } from '../roles';
6
6
  import type { Role } from '../schema';
7
7
  import {
8
- PERMISSION_AREAS, ROLES_BY_RANK, describeChanges, diffFromDefaults, isEditableRole, isPermission, resolveGrants,
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: ROLES_BY_RANK.map((role) => ({ role, label: roleLabels[role], summary: roleSummaries[role], members: members(role), editable: isEditableRole(role) })),
34
- areas: PERMISSION_AREAS.map((a) => ({
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: 'locked' in p && p.locked === true, sensitive: 'sensitive' in p && p.sensitive === true })),
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.keys(grants)) if (!isPermission(key)) throw new ApiError(400, `Unknown permission “${key}”.`);
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) as Grants);
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.
@@ -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),
@@ -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 type { Role } from './schema';
1
+ import { userConfig } from 'virtual:acms-config';
2
+ import type { CoreRole, Role } from './schema';
2
3
 
3
- export const roleLabels: Record<Role, string> = {
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
- export const roleSummaries: Record<Role, string> = {
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
@@ -196,8 +196,13 @@ export const Snippet = z.object({
196
196
  });
197
197
  export type Snippet = z.infer<typeof Snippet>;
198
198
 
199
- export const Role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
200
- export type Role = z.infer<typeof Role>;
199
+ export const CoreRole = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
200
+ export type CoreRole = z.infer<typeof CoreRole>;
201
+ // A core role or one the site defines (acms({ roles })). Stored values are
202
+ // never rejected here, so a role a site later removes can't break a page:
203
+ // lib/roles.ts decides what any role may do, and an unknown one gets nothing.
204
+ export const Role = z.string().min(1).max(64);
205
+ export type Role = CoreRole | (string & {});
201
206
 
202
207
  // Password hashes never leave the repo layer — User is the public shape.
203
208
  export const User = z.object({
@@ -339,6 +344,26 @@ export const RoomHold = z.object({
339
344
  });
340
345
  export type RoomHold = z.infer<typeof RoomHold>;
341
346
 
347
+ // A member's event request (lib/calendar/requests.ts): a new event waiting
348
+ // for approval (kind 'new', the event stays a draft), or changes to a live
349
+ // event staged in its `draft` (kind 'changes'). Staff approve or decline;
350
+ // approving clears it. `note` is the requester's message to staff, `reply`
351
+ // staff's answer when declining.
352
+ export const EventRequest = z.object({
353
+ kind: z.enum(['new', 'changes']),
354
+ state: z.enum(['pending', 'declined']),
355
+ by: z.string(),
356
+ at: z.number(),
357
+ note: z.string().default(''),
358
+ reply: z.string().default(''),
359
+ reviewedBy: z.string().default(''),
360
+ reviewedAt: z.number().default(0),
361
+ // the event was made by this request (withdrawing it may delete it); false
362
+ // when a host asked to publish a draft staff wrote
363
+ created: z.boolean().default(false),
364
+ });
365
+ export type EventRequest = z.infer<typeof EventRequest>;
366
+
342
367
  export const CalEvent = z.object({
343
368
  id: z.string(),
344
369
  slug: z.string(),
@@ -389,6 +414,10 @@ export const CalEvent = z.object({
389
414
  // all day). Computed into availability, never stored as bookings. Drafts
390
415
  // only hold when whileDraft is on; cancelled events never hold.
391
416
  roomHold: RoomHold.default({}),
417
+ // Team members who host it: they see it on My events, can cancel its dates
418
+ // and suggest changes (lib/calendar/requests.ts)
419
+ hostIds: z.array(z.string()).default([]),
420
+ request: EventRequest.nullable().default(null),
392
421
  startAt: z.number(),
393
422
  endAt: z.number(),
394
423
  translations: DocTranslations,
@@ -38,6 +38,9 @@ export const schemas = {
38
38
  startAt: true,
39
39
  endAt: true,
40
40
  sourceUid: true,
41
+ // people and their requests belong to one environment (user ids differ)
42
+ hostIds: true,
43
+ request: true,
41
44
  }).strict(),
42
45
  resource: Resource.omit({
43
46
  ...common,
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);
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { roleLabels } from '../../lib/roles';
2
+ import { isStaffRole, roleLabel } from '../../lib/roles';
3
3
  // Editor-app sign-in (OAuth 2.0 authorization code + PKCE, RFC 8252). The
4
4
  // app opens this page in the system browser; the middleware has already sent
5
5
  // signed-out visitors to /admin/login?next=… and back. Approving issues a
@@ -59,7 +59,9 @@ if (Astro.request.method === 'POST' && params && user) {
59
59
  else if (!checkConsentTicket(String(form.get('ticket') ?? ''), session, params)) error = 'This sign-in page expired. Start again from the app.';
60
60
  else if (form.get('op') === 'cancel') return leave(redirectWith(params.redirectUri, { error: 'access_denied', error_description: 'Sign-in was cancelled.', state: params.state, iss }));
61
61
  else if (form.get('op') === 'approve') {
62
- if (!await repo.email.rateLimit(`editor:approve:${user.id}`, 10, 60_000)) error = 'Too many sign-ins in a minute. Wait a moment and try again.';
62
+ // only staff get app tokens (the session check already says so; belt and braces)
63
+ if (!isStaffRole(user.role)) error = 'This account can’t use the editor app.';
64
+ else if (!await repo.email.rateLimit(`editor:approve:${user.id}`, 10, 60_000)) error = 'Too many sign-ins in a minute. Wait a moment and try again.';
63
65
  else {
64
66
  const version = await repo.email.sessionVersion(user.id);
65
67
  if (version === null) error = 'Your account changed. Sign in again.';
@@ -138,7 +140,7 @@ const can = [
138
140
  <b>{user.name}</b>
139
141
  <span>{user.email}</span>
140
142
  </span>
141
- <span class="consent-role"><i class={owner ? 'is-owner' : ''} aria-hidden="true"></i>{user ? roleLabels[user.role] : 'Owner'}</span>
143
+ <span class="consent-role"><i class={owner ? 'is-owner' : ''} aria-hidden="true"></i>{user ? roleLabel(user.role) : 'Owner'}</span>
142
144
  </div>
143
145
 
144
146
  <p class="consent-label">The app will be able to</p>