@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.22
4
+
5
+ ### Patch Changes
6
+
7
+ - dc7206a: Add roles defined by the site: `acms({ roles: [{ key, label, summary?, admin?, home? }] })`, up to 10. They rank below Contributor in the order listed, so administrators can invite people to them, and they show on Team → Users and Team → Roles under "This site". A role with `admin: false` (the default) signs people in at `/admin/login` for the site's own pages only: every admin page, API, action, preview and editor-app check treats them as signed out, `/admin` sends them to the role's `home` (default: the My events pages, else `/`), and they get no public author page, don't appear in public search and aren't offered as post authors. A role with `admin: true` is staff and starts with only what an owner grants it on Team → Roles. A role the site later removes fails closed: people still holding it get no admin access and no permissions, and Team → Users marks them "No longer defined". The build fails with the fix in the message for a bad, duplicate or core role key, or a `home` inside `/admin` or `/api`. Sites without site roles see no change.
8
+
9
+ Add permissions defined by the site: `acms({ permissions: [{ key, label, description?, defaults?, sensitive? }] })`, up to 50, with dotted keys like `classes.manageAll` (the core namespaces are reserved). Owners turn them on and off per role in a "This site" area on Team → Roles, and site code checks them with `currentUser().can(key)`. A role without admin access can only hold site permissions and **Request events**; the other core permissions are greyed out for it on Team → Roles and refused if saved.
10
+
11
+ `/admin/login?return=` now accepts any page on the site, not only admin pages, so a "Sign in" link on `/classes` brings people back there. Other sites, the login, logout and app sign-in pages, `/api`, `/_actions` and `/account` are refused. Without `return`, staff go to `/admin` and everyone else to their role's home; people without admin access are never sent into the admin.
12
+
13
+ Add members' event requests ("My events"). With the calendar on, `acms({ members: { base?, notify? } })` (on by default, at `/members`) adds pages in the site's theme where members see the events they host and their requests, ask for a new event (it waits as a draft) and suggest changes to a live event they host (the event stays as it is until approved). Hosts can always cancel or restore a single date of their own events, at once. The new core permission **Request events** (`events.request`) is on by default for every site role; approving and declining needs **Publish content**. Staff answer requests from Calendar → Events, and the event editor shows the request and a Hosts picker. New requests and suggested changes are emailed to `members.notify`, or to the owners, and follow Settings → Email → Staff notifications; the member is emailed when a request is approved or declined, with staff's note. A file at the same path in the site's `src/pages/` replaces any one page, and `members: false` turns them all off. Events gain `hostIds` and `request`, which never appear in public output; migration 56 adds both columns.
14
+
15
+ `@acms-subir/core/server` adds `listMyEvents`, `listEventRequests`, `submitEventRequest`, `updateEventRequest`, `withdrawEventRequest`, `approveEventRequest`, `declineEventRequest`, `setHostedOccurrence`, a `hostedBy` option for `listEvents`, and the `EventRequestInput` and `Role` types, so a site can build its own request pages with the same rules. Unlike `cancelOccurrence`, the request functions check the person acting. `currentUser()` now returns anyone signed in, adds `staff`, and its `can()` covers site permissions. Run `acms docs server` for the guide and an example request page.
16
+
17
+ Editor apps: only staff roles get tokens, including a site role with `admin: true`. Moving someone to a role without admin access refuses their signed-in devices from the next request, and the app's sign-in page tells them their account can't use the app. Client and native types widen the editor's `role` from the five core roles to any string, and the native editor keeps stored sign-ins whose role is a site role.
18
+
19
+ Check site code that calls `currentUser()`: once a site defines roles without admin access, it also returns those people (with `staff: false`), where before it returned staff only. Code that treated any signed-in result as permission to change things should check `user.staff` or `user.can(...)` instead.
20
+
3
21
  ## 0.3.21
4
22
 
5
23
  ### Patch Changes
@@ -1059,6 +1059,12 @@ var MIGRATIONS = [
1059
1059
  `DROP TRIGGER content_version_events_update`,
1060
1060
  // [insert, delete, update]: only the update trigger names columns
1061
1061
  contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"])[2]
1062
+ ] },
1063
+ // event hosts and members' event requests (CalEvent.hostIds / .request);
1064
+ // neither is public, so the content-version trigger stays as it is
1065
+ { version: 56, statements: [
1066
+ `ALTER TABLE events ADD COLUMN host_ids TEXT NOT NULL DEFAULT '[]'`,
1067
+ `ALTER TABLE events ADD COLUMN request TEXT`
1062
1068
  ] }
1063
1069
  ];
1064
1070
  var CURRENT_CONTENT_VERSION_COLUMNS = {
package/docs/mobile.md CHANGED
@@ -365,7 +365,7 @@ data on sign-out.
365
365
 
366
366
  | Hook | Gives you |
367
367
  | --- | --- |
368
- | `useAuth()` | `status` (`loading`/`signedOut`/`signingIn`/`signedIn`), `user`, `role`, `device`, `error`, `signedOutReason`, `signIn()`, `signOut()` |
368
+ | `useAuth()` | `status` (`loading`/`signedOut`/`signingIn`/`signedIn`), `user`, `role` (a core role or a staff role the site defines, so any string), `device`, `error`, `signedOutReason`, `signIn()`, `signOut()` |
369
369
  | `useSiteInfo()` / `useMe()` | public site name + logo / the person, site features and `permissions` |
370
370
  | `useDocuments(query)` | infinite list (`kind`, `status`, `collection`, `q`); flat `items`, `total` |
371
371
  | `useCreateDocument()` | mutation: `{ kind, title, collection? \| start? }` |
package/docs/server.md CHANGED
@@ -1,51 +1,80 @@
1
1
  # Site server code: `@acms-subir/core/server`
2
2
 
3
3
  A small, stable API for code in your site's own `src/`: Astro pages, endpoints and
4
- actions. Use it to read events, find out who is signed in, and cancel single dates of
5
- a repeating event. It is a public contract: these functions keep their signatures
6
- across minor versions. Import only from `@acms-subir/core/server`. Deep imports into
7
- `src/lib` are blocked by the package's exports map and can change at any time.
4
+ actions. Use it to read events, find out who is signed in, cancel single dates of a
5
+ repeating event, and run members' event requests. It is a public contract: these
6
+ functions keep their signatures across minor versions. Import only from
7
+ `@acms-subir/core/server`. Deep imports into `src/lib` are blocked by the package's
8
+ exports map and can change at any time.
8
9
 
9
10
  ```ts
10
11
  import {
11
12
  getRepo, currentUser,
12
13
  listEvents, getEvent, listOccurrences,
13
14
  cancelOccurrence, restoreOccurrence,
15
+ listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
16
+ withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
14
17
  } from '@acms-subir/core/server';
15
18
  ```
16
19
 
20
+ These are primitives. Roles, permissions and the member pages are config
21
+ (`acms({ roles, permissions, members })`, see [roles and
22
+ permissions](../../../docs/roles-permissions.md)); this API is what the built-in pages
23
+ use, so a site can replace any of them, or turn them all off, and keep the same rules.
24
+
17
25
  ## Signatures
18
26
 
19
27
  ```ts
20
28
  getRepo(locals: unknown): Promise<Repo>
21
29
  currentUser(ctx: { request: Request; cookies: AstroCookies; locals: unknown }): Promise<ServerUser | null>
22
- listEvents(repo: Repo, opts?: { publishedOnly?: boolean; calendar?: string }): Promise<CalEvent[]>
30
+ listEvents(repo: Repo, opts?: { publishedOnly?: boolean; calendar?: string; hostedBy?: string }): Promise<CalEvent[]>
23
31
  getEvent(repo: Repo, id: string): Promise<CalEvent | null>
24
32
  listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number }): Promise<Occurrence[]>
25
- cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: { url: URL; locals: unknown }): Promise<CalEvent>
26
- restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: { url: URL; locals: unknown }): Promise<CalEvent>
33
+ cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
34
+ restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
35
+
36
+ // event requests: `who` is currentUser()'s result (anything with `id` and `can()` works)
37
+ listMyEvents(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
38
+ listEventRequests(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
39
+ submitEventRequest(repo: Repo, who: ServerUser | null, input: EventRequestInput, ctx: WriteContext): Promise<CalEvent>
40
+ updateEventRequest(repo: Repo, who: ServerUser | null, eventId: string, input: EventRequestInput, ctx: WriteContext): Promise<CalEvent>
41
+ withdrawEventRequest(repo: Repo, who: ServerUser | null, eventId: string, ctx: WriteContext): Promise<{ deleted: boolean }>
42
+ approveEventRequest(repo: Repo, who: ServerUser | null, eventId: string, ctx: WriteContext, seen?: number): Promise<CalEvent>
43
+ declineEventRequest(repo: Repo, who: ServerUser | null, eventId: string, reply: string, ctx: WriteContext, seen?: number): Promise<CalEvent>
44
+ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, cancelled: boolean, ctx: WriteContext): Promise<CalEvent>
27
45
 
28
- interface ServerUser { id: string; email: string; name: string; role: Role; can(permission: string): boolean }
46
+ type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
47
+ interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
29
48
  interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean }
49
+ interface WriteContext { url: URL; locals: unknown }
30
50
  ```
31
51
 
32
- The types `Repo`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
33
- `OccurrenceWindow` and `WriteContext` are exported too.
52
+ The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
53
+ `OccurrenceWindow`, `WriteContext` and `EventRequestInput` are exported too.
34
54
 
35
55
  - **`getRepo(locals)`**: the site's content store for this request. Pass `Astro.locals`
36
56
  (or the endpoint/action's `locals`). `Repo` is an opaque handle; pass it to the other
37
57
  functions.
38
- - **`currentUser(ctx)`**: the person signed in to the admin, or `null`. It works on any
39
- page, public ones included. With Clerk sign-in, a signed-in browser sends the
40
- `__session` cookie and is resolved everywhere except `/media`, `/embed` and
41
- `/api/v1`. `role` is their ACMS role. `can('content.edit')` checks a permission from
42
- Team → Roles; unknown permission keys return `false`. A session from
43
- `ADMIN_PASSWORD` has `id: '__owner_env__'`, role `owner` and an empty `email`.
44
- A page that reads `currentUser` is personal, so don't let a shared cache keep it. A
45
- request with a session cookie is never cached publicly.
58
+ - **`currentUser(ctx)`**: the person signed in, or `null`: staff, and people in the
59
+ site's own roles who can't use the admin. It works on any page, public ones included.
60
+ With Clerk sign-in, a signed-in browser sends the `__session` cookie and is resolved
61
+ everywhere except `/media`, `/embed` and `/api/v1`.
62
+ - `role` is a core role or a key from `acms({ roles })`, e.g. `'host'`.
63
+ - `staff` is true for the core roles and site roles with `admin: true`: the people
64
+ who can use the admin. Everyone else is signed in for your pages only.
65
+ - `can(key)` checks a permission from Team → Roles: a core one (`'content.edit'`) or
66
+ one from `acms({ permissions })` (`'classes.manageAll'`). Unknown keys return
67
+ `false`. A role without admin access only ever holds site permissions and
68
+ `'events.request'`. Owners hold every permission. A role the site has since
69
+ removed holds none.
70
+ - A session from `ADMIN_PASSWORD` has `id: '__owner_env__'`, role `owner`, name
71
+ `Owner` and an empty `email`.
72
+ - A page that reads `currentUser` is personal, so don't let a shared cache keep it. A
73
+ request with a session cookie is never cached publicly.
46
74
  - **`listEvents(repo, opts)`**: events by first start date, earliest first. By default,
47
75
  every status except archived. `publishedOnly: true` returns only published events.
48
- `calendar` (an id or slug) limits the list to one calendar. Values are the live ones;
76
+ `calendar` (an id or slug) limits the list to one calendar. `hostedBy` (a user id)
77
+ limits it to events that person hosts (`event.hostIds`). Values are the live ones;
49
78
  unpublished edits staged in the admin are not merged in.
50
79
  - **`getEvent(repo, id)`**: one event, or `null`.
51
80
  - **`listOccurrences(repo, { eventId?, from, to })`**: occurrences in `[from, to)`,
@@ -72,7 +101,8 @@ people:
72
101
  | 400 | the event doesn't repeat; the key isn't one of its dates (wrong format, not generated by the rule, skipped, or the series moved); the date has started or passed |
73
102
 
74
103
  > **They do no authorization.** Anyone who can reach your code can cancel a date. Check
75
- > who is calling (`currentUser`, your own signed link, etc.) before you call them.
104
+ > who is calling (`currentUser`, your own signed link, etc.) before you call them. The
105
+ > event-request functions below are different: they check the person themselves.
76
106
 
77
107
  ## Occurrence keys
78
108
 
@@ -105,7 +135,18 @@ search, or the events that theme helpers and sections receive.
105
135
 
106
136
  ## Example: let a class's host cancel one week
107
137
 
108
- `src/actions/index.ts` in the site:
138
+ Give hosts a role and give a few staff a permission to cancel any class:
139
+
140
+ ```js
141
+ // astro.config.mjs
142
+ acms({
143
+ roles: [{ key: 'host', label: 'Class host', home: '/classes' }],
144
+ permissions: [{ key: 'classes.manageAll', label: 'Cancel any class', defaults: ['administrator'] }],
145
+ })
146
+ ```
147
+
148
+ Staff pick each class's hosts in the event editor (Hosts), which fills
149
+ `event.hostIds`. `src/actions/index.ts` in the site:
109
150
 
110
151
  ```ts
111
152
  import { defineAction, ActionError } from 'astro:actions';
@@ -121,8 +162,8 @@ export const server = {
121
162
  const repo = await getRepo(ctx.locals);
122
163
  const event = await getEvent(repo, eventId);
123
164
  const me = await currentUser(ctx);
124
- // your rule: staff who can edit content, or the host named on the event
125
- const allowed = me && (me.can('content.edit') || me.email === event?.fields.host_email);
165
+ // your rule: anyone given "Cancel any class", or a host of this event
166
+ const allowed = me && event && (me.can('classes.manageAll') || event.hostIds.includes(me.id));
126
167
  if (!allowed) throw new ActionError({ code: 'FORBIDDEN', message: 'You can’t cancel this class.' });
127
168
  try {
128
169
  const updated = await cancelOccurrence(repo, eventId, date, { url: ctx.url, locals: ctx.locals });
@@ -136,9 +177,147 @@ export const server = {
136
177
  };
137
178
  ```
138
179
 
180
+ List a host's classes with `listEvents(repo, { hostedBy: me.id, publishedOnly: true })`.
139
181
  Show the dates to choose from with `listOccurrences(repo, { eventId, from: Date.now(), to:
140
- Date.now() + 90 * 86_400_000 })` and pass `o.key` as `date`.
182
+ Date.now() + 90 * 86_400_000 })` and pass `o.key` as `date`. If hosts are the only people
183
+ who cancel, `setHostedOccurrence` (below) does the host check for you.
141
184
 
142
185
  Writes made in an action or another non-GET request also clear the site's shared cache
143
186
  and send pending notifications once the request finishes. The core middleware does this
144
187
  for every route, including yours.
188
+
189
+ ## Event requests
190
+
191
+ Members ask for events; staff approve them. A member is anyone whose role holds
192
+ **Request events** (`events.request`), which every site role has by default. Each event
193
+ carries:
194
+
195
+ - `hostIds`: the people who host it. Staff set them in the event editor; a member who
196
+ asks for an event hosts it.
197
+ - `request`: `null`, or `{ kind, state, by, at, note, reply, reviewedBy, reviewedAt }`.
198
+ `kind` is `'new'` (a draft waiting for approval) or `'changes'` (suggested changes to
199
+ a live event, staged in `event.draft`; the live event shows what it showed before).
200
+ `state` is `'pending'` or `'declined'`. `note` is the member's message to staff and
201
+ `reply` staff's answer when declining. Neither field is public.
202
+
203
+ The functions take the person acting (`currentUser(ctx)`) and check them on every call:
204
+
205
+ - **`listMyEvents(repo, who)`**: the events they host plus their own new requests,
206
+ soonest first, archived ones left out. Drafts are included. `[]` when `who` is `null`.
207
+ - **`listEventRequests(repo, who)`**: requests waiting for staff, oldest first. Needs
208
+ `content.publish` (the same rule as approving them).
209
+ - **`submitEventRequest(repo, who, input, ctx)`**: needs `events.request`. Creates a
210
+ draft event they host with a pending `'new'` request, and emails staff.
211
+ - **`updateEventRequest(repo, who, eventId, input, ctx)`**: needs `events.request` and
212
+ to host the event. A request that isn't live yet is updated and sent again (a
213
+ declined one too). On a live event the changes are staged and wait for approval. A
214
+ description or place left as it was keeps whatever staff added (formatting, address,
215
+ map pin). While staff have unpublished edits of their own on the live event, it
216
+ answers 409 so the two never mix.
217
+ - **`withdrawEventRequest(repo, who, eventId, ctx)`**: needs `events.request` and to
218
+ host the event. A new event their own request made, and that never went live, is
219
+ deleted (`{ deleted: true }`); a draft staff wrote stays. Suggested changes are dropped.
220
+ - **`approveEventRequest(repo, who, eventId, ctx)`**: needs **Publish content**
221
+ (`content.publish`). A new event is published; suggested changes go live. The member
222
+ is emailed. Pass `seen`, the `request.at` of the version you showed staff, and it
223
+ answers 409 if the member changed the request since (so nothing is approved unseen).
224
+ - **`declineEventRequest(repo, who, eventId, reply, ctx)`**: needs **Publish content**.
225
+ A new request stays a draft the member can edit and send again; suggested changes are
226
+ dropped. The member is emailed with `reply`. `seen` works as for approving.
227
+ - **`setHostedOccurrence(repo, who, eventId, key, cancelled, ctx)`**: a host cancels
228
+ (`true`) or restores (`false`) one date of an event they host, at once. No permission
229
+ needed beyond being in `hostIds`. Same rules and errors as `cancelOccurrence`.
230
+
231
+ `EventRequestInput` is what a member may fill in. Staff can change anything else in the
232
+ admin.
233
+
234
+ | field | |
235
+ |---|---|
236
+ | `title` | required, up to 200 characters |
237
+ | `start`, `end?` | `YYYY-MM-DDTHH:mm` in the site's time zone (what `<input type="datetime-local">` sends), or `YYYY-MM-DD` with `allDay: true` |
238
+ | `allDay?` | boolean |
239
+ | `repeat?` | `''` (once), or `FREQ=DAILY`, `FREQ=WEEKLY` or `FREQ=MONTHLY` with an optional `;INTERVAL=n` and `;COUNT=n` |
240
+ | `description?` | plain text, up to 5,000 characters; blank lines start new paragraphs. Stored escaped |
241
+ | `where?` | the place, as text, up to 300 characters |
242
+ | `calendarId?` | one of the site's own public calendars |
243
+ | `fields?` | the site's custom event fields; private ones are ignored, and a select must use one of its options |
244
+ | `note?` | a message for staff, up to 1,000 characters; never public |
245
+
246
+ Errors carry a `status` and a message that is safe to show people:
247
+
248
+ | status | when |
249
+ |---|---|
250
+ | 400 | bad input (no title, bad date or repeat, unknown calendar or option); nothing to withdraw |
251
+ | 401 | `who` is `null` |
252
+ | 403 | the role lacks `events.request` (members) or `content.publish` (approve, decline) |
253
+ | 404 | the event doesn't exist or they don't host it (member functions) |
254
+ | 409 | there's no pending request on the event: already answered or withdrawn (approve, decline) |
255
+ | 429 | more than 20 requests or changes in an hour, or 20 requests already waiting |
256
+
257
+ New requests and suggested changes are emailed to `acms({ members: { notify } })`, or to
258
+ the owners who have an email address. Email needs Settings → Email set up; its **Staff
259
+ notifications** switch also turns these off.
260
+
261
+ ### Built-in pages
262
+
263
+ With the calendar on, `acms({ members })` adds "My events" in the site's theme:
264
+
265
+ | path | page |
266
+ |---|---|
267
+ | `/members/events` | the events they host and their requests |
268
+ | `/members/events/new` | the request form |
269
+ | `/members/events/[id]` | one of their events and its request |
270
+
271
+ `/members` itself redirects to `/members/events`.
272
+
273
+ `members: { base: '/club' }` moves them. A file at the same path in your `src/pages/`
274
+ replaces that one page. `members: false` removes them all; the functions above keep
275
+ working. While the pages are on, site roles without admin access land on
276
+ `/members/events` after signing in unless their role sets `home`.
277
+
278
+ ### Example: your own request page
279
+
280
+ `src/pages/classes/new.astro`:
281
+
282
+ ```astro
283
+ ---
284
+ import { getRepo, currentUser, submitEventRequest } from '@acms-subir/core/server';
285
+
286
+ const me = await currentUser(Astro);
287
+ if (!me) return Astro.redirect(`/admin/login?return=${encodeURIComponent(Astro.url.pathname)}`);
288
+ if (!me.can('events.request')) return new Response('Your role can’t ask for classes.', { status: 403 });
289
+
290
+ let error = '';
291
+ if (Astro.request.method === 'POST') {
292
+ const form = await Astro.request.formData();
293
+ try {
294
+ await submitEventRequest(await getRepo(Astro.locals), me, {
295
+ title: String(form.get('title') ?? ''),
296
+ start: String(form.get('start') ?? ''),
297
+ repeat: form.get('weekly') ? 'FREQ=WEEKLY' : '',
298
+ where: String(form.get('where') ?? ''),
299
+ note: String(form.get('note') ?? ''),
300
+ }, { url: Astro.url, locals: Astro.locals });
301
+ return Astro.redirect('/classes/mine');
302
+ } catch (e) {
303
+ error = (e as Error).message;
304
+ }
305
+ }
306
+ ---
307
+ <!-- wrap in your own page layout -->
308
+ <main>
309
+ <h1>Ask for a class</h1>
310
+ {error && <p role="alert">{error}</p>}
311
+ <form method="post">
312
+ <label>Title <input name="title" required /></label>
313
+ <label>Starts <input name="start" type="datetime-local" required /></label>
314
+ <label><input name="weekly" type="checkbox" /> Every week</label>
315
+ <label>Where <input name="where" /></label>
316
+ <label>Note for the team <textarea name="note"></textarea></label>
317
+ <button>Send request</button>
318
+ </form>
319
+ </main>
320
+ ```
321
+
322
+ `/classes/mine` would list `listMyEvents(repo, me)` and show `e.request?.state`
323
+ (`'pending'`, `'declined'` with `e.request.reply`, or no request once approved).
package/integration.mjs CHANGED
@@ -408,6 +408,97 @@ function resolveCalendar(calendar, contentBases, locales) {
408
408
  return resolved;
409
409
  }
410
410
 
411
+ // Defaults + validation for acms({ roles, permissions, members }). Site roles
412
+ // rank below Contributor in the order listed; `admin: false` (the default)
413
+ // signs people in for the site's own pages only. Site permissions show on
414
+ // Team → Roles under "This site" and answer user.can(key). Core keeps the
415
+ // core role names and these permission namespaces for itself (kept in sync
416
+ // with lib/permissions/catalog.ts by test/site-roles.test.ts).
417
+ export const CORE_ROLE_KEYS = ['owner', 'administrator', 'editor', 'author', 'contributor'];
418
+ export const CORE_PERMISSION_NAMESPACES = ['content', 'media', 'forms', 'site', 'schedule', 'events', 'team', 'integrations', 'services', 'data', 'roles'];
419
+ const unknownKeys = (label, value, known) => {
420
+ const extra = Object.keys(value).filter((k) => !known.includes(k));
421
+ if (extra.length) throw new Error(`acms: unknown ${label} key${extra.length > 1 ? 's' : ''} ${extra.map((k) => `"${k}"`).join(', ')} — valid keys are ${known.join(', ')}`);
422
+ };
423
+ const isSitePath = (p) => typeof p === 'string' && p.startsWith('/') && !p.startsWith('//') && !/[\\\s\u0000-\u001f]/.test(p);
424
+ const isAdminPath = (p) => /^\/(admin|api|_actions)(\/|$|\?|#)/.test(p);
425
+ function resolveMembers(members, calendar) {
426
+ if (members === false) return { enabled: false, base: '/members', notify: [] };
427
+ if (members === undefined || members === true) members = {};
428
+ if (!members || typeof members !== 'object' || Array.isArray(members)) throw new Error('acms: members must be an object (or false to turn off the built-in member pages)');
429
+ unknownKeys('members', members, ['base', 'notify']);
430
+ // who hears about new event requests (default: the owners)
431
+ const notify = members.notify ?? [];
432
+ if (!Array.isArray(notify) || notify.length > 20 || notify.some((e) => typeof e !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))) {
433
+ throw new Error('acms: members.notify must be a list of at most 20 email addresses');
434
+ }
435
+ const base = members.base ?? '/members';
436
+ if (typeof base !== 'string' || !/^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(base)) {
437
+ throw new Error(`acms: members.base must start with "/" and use lowercase letters, digits, and hyphens (no trailing slash) — got "${base}"`);
438
+ }
439
+ if (RESERVED_BASES.has(base) || base === '/account') throw new Error(`acms: members.base "${base}" collides with a built-in route — pick another path`);
440
+ // the built-in pages are "My events": they need the calendar
441
+ return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()) };
442
+ }
443
+ function resolveRoles(roles, members) {
444
+ if (roles === undefined) return [];
445
+ if (!Array.isArray(roles)) throw new Error('acms: roles must be an array of { key, label } objects');
446
+ if (roles.length > 10) throw new Error('acms: roles allows at most 10 site roles');
447
+ const keys = new Set();
448
+ return roles.map((r, i) => {
449
+ if (!r || typeof r !== 'object' || Array.isArray(r)) throw new Error(`acms: roles[${i}] must be an object`);
450
+ unknownKeys(`roles[${i}]`, r, ['key', 'label', 'summary', 'admin', 'home']);
451
+ const key = r.key;
452
+ if (typeof key !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(key)) {
453
+ throw new Error(`acms: roles[${i}].key must be 1–32 lowercase letters, digits, "_" or "-", starting with a letter — got "${key}"`);
454
+ }
455
+ if (CORE_ROLE_KEYS.includes(key)) throw new Error(`acms: roles[${i}].key "${key}" is a built-in role — pick another key`);
456
+ if (keys.has(key)) throw new Error(`acms: roles has two roles with the key "${key}"`);
457
+ keys.add(key);
458
+ if (typeof r.label !== 'string' || !r.label.trim() || r.label.length > 40) throw new Error(`acms: roles "${key}".label must be a name of 1–40 characters`);
459
+ const summary = r.summary ?? '';
460
+ if (typeof summary !== 'string' || summary.length > 160) throw new Error(`acms: roles "${key}".summary must be a string of at most 160 characters`);
461
+ const admin = r.admin ?? false;
462
+ if (typeof admin !== 'boolean') throw new Error(`acms: roles "${key}".admin must be true or false`);
463
+ // non-admin roles land on their home page after signing in: the member
464
+ // pages when they're on, else the site's home page
465
+ const home = r.home ?? (members.enabled ? `${members.base}/events` : '/');
466
+ if (!isSitePath(home) || isAdminPath(home)) throw new Error(`acms: roles "${key}".home must be a path on this site outside /admin and /api, like "/classes" — got "${home}"`);
467
+ return { key, label: r.label.trim(), summary: summary.trim(), admin, home };
468
+ });
469
+ }
470
+ function resolvePermissions(permissions, roles) {
471
+ if (permissions === undefined) return [];
472
+ if (!Array.isArray(permissions)) throw new Error('acms: permissions must be an array of { key, label } objects');
473
+ if (permissions.length > 50) throw new Error('acms: permissions allows at most 50 site permissions');
474
+ const roleKeys = ['administrator', 'editor', 'author', 'contributor', ...roles.map((r) => r.key)];
475
+ const keys = new Set();
476
+ return permissions.map((p, i) => {
477
+ if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error(`acms: permissions[${i}] must be an object`);
478
+ unknownKeys(`permissions[${i}]`, p, ['key', 'label', 'description', 'defaults', 'sensitive']);
479
+ const key = p.key;
480
+ if (typeof key !== 'string' || key.length > 64 || !/^[a-z][a-zA-Z0-9_-]*(\.[a-zA-Z0-9_-]+)+$/.test(key)) {
481
+ throw new Error(`acms: permissions[${i}].key must be dotted, like "classes.manageAll" — got "${key}"`);
482
+ }
483
+ const ns = key.split('.')[0];
484
+ if (CORE_PERMISSION_NAMESPACES.includes(ns)) throw new Error(`acms: permissions "${key}" uses "${ns}.", which is kept for built-in permissions — pick another prefix`);
485
+ if (keys.has(key)) throw new Error(`acms: permissions has two permissions with the key "${key}"`);
486
+ keys.add(key);
487
+ if (typeof p.label !== 'string' || !p.label.trim() || p.label.length > 80) throw new Error(`acms: permissions "${key}".label must be a name of 1–80 characters`);
488
+ const description = p.description ?? '';
489
+ if (typeof description !== 'string' || description.length > 240) throw new Error(`acms: permissions "${key}".description must be a string of at most 240 characters`);
490
+ const defaults = p.defaults ?? [];
491
+ if (!Array.isArray(defaults)) throw new Error(`acms: permissions "${key}".defaults must be an array of role keys`);
492
+ for (const role of defaults) {
493
+ if (role === 'owner') throw new Error(`acms: permissions "${key}".defaults lists "owner" — owners hold every permission already`);
494
+ if (!roleKeys.includes(role)) throw new Error(`acms: permissions "${key}".defaults lists "${role}" — use ${roleKeys.map((k) => `"${k}"`).join(', ')}`);
495
+ }
496
+ const sensitive = p.sensitive ?? false;
497
+ if (typeof sensitive !== 'boolean') throw new Error(`acms: permissions "${key}".sensitive must be true or false`);
498
+ return { key, label: p.label.trim(), description: description.trim(), defaults: [...new Set(defaults)], sensitive };
499
+ });
500
+ }
501
+
411
502
  const semverLt = (a, b) => {
412
503
  const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
413
504
  for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
@@ -535,6 +626,27 @@ function acmsVitePlugin(userConfig) {
535
626
  * change clears the cache (Workers Cache, or the Cloudflare zone when
536
627
  * CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN are set). `memory: false` turns
537
628
  * off the Node in-process API cache. `cache: false` turns all of it off.
629
+ * @param {Array<{key: string, label: string, summary?: string, admin?: boolean, home?: string}>} [options.roles]
630
+ * Roles defined by the site, ranked below Contributor in the order listed
631
+ * (administrators can invite people to them). `admin: false` (the default)
632
+ * lets people sign in for the site's own pages (`currentUser()` returns
633
+ * them) but keeps them out of the admin, its APIs, previews and the editor
634
+ * app; `admin: true` makes the role staff with only the permissions an
635
+ * owner grants on Team → Roles. `home` is where people in a role without
636
+ * admin access land after signing in (default: the member pages, else '/');
637
+ * staff always land in the admin.
638
+ * @param {Array<{key: string, label: string, description?: string, defaults?: string[], sensitive?: boolean}>} [options.permissions]
639
+ * Permissions defined by the site, e.g. `{ key: 'classes.manageAll', label:
640
+ * 'Cancel any class', defaults: ['administrator'] }`. They show on Team →
641
+ * Roles under "This site" and answer `currentUser().can(key)`. Owners hold
642
+ * every permission; `defaults` lists the other roles that start with it.
643
+ * @param {false | {base?: string, notify?: string[]}} [options.members]
644
+ * Built-in "My events" pages for signed-in members (default base
645
+ * '/members', needs the calendar): the events they host, their event
646
+ * requests and a request form. `false` turns the pages off (the server API
647
+ * in @acms-subir/core/server still works, so a site can build its own); a
648
+ * site file at the same path replaces any one page. `notify` lists who is
649
+ * emailed about new event requests (default: the owners).
538
650
  * @returns {import('astro').AstroIntegration}
539
651
  */
540
652
  export default function acms(options = {}) {
@@ -548,6 +660,13 @@ export default function acms(options = {}) {
548
660
  const cache = resolveCache(options.cache);
549
661
  const mobile = resolveMobile(options.mobile);
550
662
  const calendar = resolveCalendar(options.calendar, contentBases, i18n.locales);
663
+ const members = resolveMembers(options.members, calendar);
664
+ if (members.enabled) {
665
+ const clash = [...contentBases.keys(), calendar.base, calendar.eventsBase, calendar.bookBase].find((b) => b === members.base);
666
+ if (clash) throw new Error(`acms: members.base "${members.base}" collides with another configured path — pick another`);
667
+ }
668
+ const roles = resolveRoles(options.roles, members);
669
+ const permissions = resolvePermissions(options.permissions, roles);
551
670
  const maps = resolveMaps(options.maps);
552
671
  const legal = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
553
672
  if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
@@ -569,6 +688,9 @@ export default function acms(options = {}) {
569
688
  calendar,
570
689
  maps,
571
690
  legal,
691
+ roles,
692
+ permissions,
693
+ members,
572
694
  };
573
695
  const routes = [...ROUTES,
574
696
  ['/api/v1/site', 'api/v1/site.ts'],
@@ -706,6 +828,17 @@ export default function acms(options = {}) {
706
828
  }
707
829
  routes.push(['/api/calendar/events/[id]', 'api/calendar/events/[id].ts']);
708
830
  }
831
+ // "My events" for signed-in members (acms({ members })): thin pages over
832
+ // lib/calendar/requests.ts. A site file at any of these paths replaces that
833
+ // page; `members: false` drops them all (members.enabled needs the calendar).
834
+ if (members.enabled) {
835
+ routes.push(
836
+ [members.base, 'members/index.ts'],
837
+ [`${members.base}/events`, 'members/events/index.astro'],
838
+ [`${members.base}/events/new`, 'members/events/new.astro'],
839
+ [`${members.base}/events/[id]`, 'members/events/[id].astro'],
840
+ );
841
+ }
709
842
  let cacheDir = '';
710
843
  return {
711
844
  name: 'acms',
@@ -715,7 +848,7 @@ export default function acms(options = {}) {
715
848
  if (config.root) {
716
849
  const discoveryDir = fileURLToPath(new URL('.astro/', config.root));
717
850
  mkdirSync(discoveryDir, {recursive:true});
718
- writeFileSync(join(discoveryDir,'acms-project.json'), JSON.stringify({ themeApiVersion:1, collections:userConfig.content.collections, calendar:userConfig.calendar, mobile:userConfig.mobile ?? {} }));
851
+ writeFileSync(join(discoveryDir,'acms-project.json'), JSON.stringify({ themeApiVersion:1, collections:userConfig.content.collections, calendar:userConfig.calendar, mobile:userConfig.mobile ?? {}, roles:userConfig.roles, permissions:userConfig.permissions, members:userConfig.members }));
719
852
  }
720
853
  updateConfig({ vite: { plugins: [acmsVitePlugin(userConfig)] } });
721
854
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -10,6 +10,7 @@ import { getMediaStore } from '../lib/media/store';
10
10
  import { validateRequestSession } from '../lib/auth/request';
11
11
  import { accessFor } from '../lib/permissions/access';
12
12
  import { deniedMessage, type Permission } from '../lib/permissions/catalog';
13
+ import { isStaffRole } from '../lib/roles';
13
14
  import { DocTranslations, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
14
15
  import { env } from '../lib/env';
15
16
  import { CONTENT, collectionUrl } from '../lib/content';
@@ -749,7 +750,8 @@ export const server = {
749
750
  handler: async (_input, ctx) => {
750
751
  await requireAdmin(ctx);
751
752
  const repo = await getRepo(ctx.locals);
752
- return { users: await repo.listUsers() };
753
+ // the author picker: staff only (site roles without admin access don't write content)
754
+ return { users: (await repo.listUsers()).filter((u) => isStaffRole(u.role)) };
753
755
  },
754
756
  }),
755
757
  },
@@ -477,7 +477,7 @@ export default function EmailSettings() {
477
477
  </label>
478
478
  <label className="ems-toggle">
479
479
  <input type="checkbox" checked={notify.staff} onChange={(e) => setNotify((n) => ({ ...n, staff: e.target.checked }))} />
480
- <span>Staff notifications<small>Tell site staff about new booking requests</small></span>
480
+ <span>Staff notifications<small>Tell site staff about new booking and event requests</small></span>
481
481
  </label>
482
482
  {pwField(notify.pw, (v) => setNotify((n) => ({ ...n, pw: v })), 'pw-notify')}
483
483
  <div className="ems-actions">