@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
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import { safeNext } from '../../lib/editor/next';
3
+ import { afterSignIn, isAdminPath, signInReturn } from '../../lib/auth/return';
4
+ import { isStaffRole, roleHome, roleLabel } from '../../lib/roles';
3
5
  import { checkPassword, createSessionToken, verifyPassword, SESSION_COOKIE } from '../../lib/auth';
4
6
  import { getRequestAuth } from '../../lib/auth/request';
5
7
  import { AUTH } from '../../lib/auth/config';
@@ -13,23 +15,25 @@ import '../../styles/admin.css';
13
15
 
14
16
  // ?next= returns to the editor-app sign-in page only (never another site)
15
17
  const next = safeNext(Astro.url.searchParams.get('next'), Astro.url);
16
- // ?return= brings an owner back to the admin page they left to sign in again
17
- // (a fresh sign-in is the recent verification sensitive changes need)
18
- function adminReturn(value: string | null): string | null {
19
- if (!value || !value.startsWith('/admin') || value.includes('\\') || /[\u0000-\u001f]/.test(value)) return null;
20
- let url: URL;
21
- try { url = new URL(value, Astro.url.origin); } catch { return null; }
22
- if (url.origin !== Astro.url.origin || !/^\/admin(\/|$)/.test(url.pathname)) return null;
23
- if (/^\/admin\/(login|logout|app-auth)\b/.test(url.pathname)) return null;
24
- return url.pathname + url.search + url.hash;
25
- }
26
- const back = next ? null : adminReturn(Astro.url.searchParams.get('return'));
27
- const reverify = !!back && Astro.url.searchParams.get('reverify') === '1';
18
+ // ?return= brings people back to the page they signed in from: any page on
19
+ // this site (lib/auth/return.ts), e.g. an owner re-verifying on an admin page
20
+ // (a fresh sign-in is the recent verification sensitive changes need) or a
21
+ // class host coming back to /classes
22
+ const back = next ? null : signInReturn(Astro.url.searchParams.get('return'), Astro.url);
23
+ const reverify = !!back && isAdminPath(back) && Astro.url.searchParams.get('reverify') === '1';
28
24
  const clerkMode = AUTH.provider === 'clerk';
29
25
  const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
30
26
  if (auth.userId) {
31
27
  return Astro.redirect(next ?? back ?? '/admin');
32
28
  }
29
+ // signed in, but in a site role without admin access: back to the site,
30
+ // except from the editor app's sign-in, which can't take them (say so)
31
+ let member: { name: string; role: string } | null = null;
32
+ if (auth.person) {
33
+ if (!next) return Astro.redirect(afterSignIn(auth.person.role, back));
34
+ const who = await (await getRepo(Astro.locals)).getUser(auth.person.id);
35
+ member = { name: who?.name ?? '', role: auth.person.role };
36
+ }
33
37
 
34
38
  // Clerk mode: the provider's prebuilt components do every credential step on
35
39
  // this page. Local passwords, the owner password and recovery links are off.
@@ -69,6 +73,7 @@ if (!clerkMode && Astro.request.method === 'POST') {
69
73
  // a user account created in /admin/users.
70
74
  let sessionUserId: string | null = null;
71
75
  let sessionVersion = 0;
76
+ let sessionRole = 'owner', sessionName = '';
72
77
  let address = 'unknown';
73
78
  try { address = Astro.clientAddress || address; } catch { /* shared fallback bucket */ }
74
79
  const repo = await getRepo(Astro.locals);
@@ -79,7 +84,7 @@ if (!clerkMode && Astro.request.method === 'POST') {
79
84
  sessionUserId = null; // default token = env owner
80
85
  } else if (email) {
81
86
  const auth = await repo.getUserAuth(email);
82
- if (auth && (await verifyPassword(password, auth.passHash))) { sessionUserId = auth.user.id; sessionVersion = auth.sessionVersion; }
87
+ if (auth && (await verifyPassword(password, auth.passHash))) { sessionUserId = auth.user.id; sessionVersion = auth.sessionVersion; sessionRole = auth.user.role; sessionName = auth.user.name; }
83
88
  else {
84
89
  error = 'Wrong email or password.';
85
90
  }
@@ -95,7 +100,8 @@ if (!clerkMode && Astro.request.method === 'POST') {
95
100
  maxAge: 7 * 24 * 60 * 60,
96
101
  secure: Astro.url.protocol === 'https:',
97
102
  });
98
- return Astro.redirect(next ?? back ?? '/admin');
103
+ if (!isStaffRole(sessionRole) && next) member = { name: sessionName, role: sessionRole };
104
+ else return Astro.redirect(next && isStaffRole(sessionRole) ? next : afterSignIn(sessionRole, back));
99
105
  }
100
106
  }
101
107
  ---
@@ -120,7 +126,22 @@ if (!clerkMode && Astro.request.method === 'POST') {
120
126
  <div class="brand-name">{siteName}</div>
121
127
  </div>
122
128
 
123
- {!clerkMode && (
129
+ {member && (
130
+ <section class="login-state" aria-labelledby="login-state-h">
131
+ <span class="login-state-icon is-warn" aria-hidden="true">
132
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
133
+ </span>
134
+ <h1 id="login-state-h">No access to the editor app</h1>
135
+ <p>You’re signed in{member.name ? <> as <b>{member.name}</b></> : ''} ({roleLabel(member.role)}).</p>
136
+ <p class="hint">This account can sign in to the site, but not the admin or the editor app.</p>
137
+ <div class="login-actions">
138
+ <a class="btn primary block" href={roleHome(member.role)}>Go to the site</a>
139
+ <form method="post" action="/admin/logout"><button class="btn block" type="submit">Sign out</button></form>
140
+ </div>
141
+ </section>
142
+ )}
143
+
144
+ {!member && !clerkMode && (
124
145
  <>
125
146
  {next && !error && <p class="hint login-for-app">Sign in to connect the app to your account.</p>}
126
147
  {error && <div class="flash error">{error}</div>}
@@ -140,7 +161,7 @@ if (!clerkMode && Astro.request.method === 'POST') {
140
161
  </>
141
162
  )}
142
163
 
143
- {clerkMode && (
164
+ {!member && clerkMode && (
144
165
  <div class="login-clerk" data-acms-login data-view={clerkView} data-next={next ?? back ?? '/admin'} data-keep={keep || undefined} data-logout={logoutAction} data-signed-out={signedOut ? '1' : undefined}>
145
166
  {(clerkView === 'sign-in' || clerkView === 'sign-up') && (
146
167
  <>
@@ -40,6 +40,9 @@ if (access?.role !== 'owner' && access?.role !== 'administrator') return Astro.r
40
40
  .rp-area-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-size: 14px; }
41
41
  .rp-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 8px 10px; margin: 0 -10px; border-radius: var(--r-md); }
42
42
  .rp-row.changed { background: var(--accent-soft); }
43
+ .rp-row.na .rp-text { opacity: 0.55; }
44
+ .rp-role-sep { font-size: 11px; font-weight: 600; color: var(--muted); padding: 12px 11px 4px; }
45
+ .rp-note { font-size: 12.5px; color: var(--text-2); background: var(--panel-2); border-radius: var(--r-md); padding: 8px 11px; margin: 8px 0 0; }
43
46
  .rp-text { display: grid; gap: 1px; }
44
47
  .rp-label { font-size: 14px; display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
45
48
  .rp-text small { color: var(--muted); font-size: 12.5px; }
@@ -12,10 +12,10 @@ import { issueAccountToken } from '../../lib/email/accounts';
12
12
  import { kickEmail } from '../../lib/email/service';
13
13
  import { waitUntilFrom } from '../../lib/webhooks';
14
14
  import { requireSession } from '../../lib/api';
15
- import { slugify, authorSlug, Role } from '../../lib/schema';
16
- import { roleLabels, roleSummaries } from '../../lib/roles';
15
+ import { slugify, authorSlug, type Role } from '../../lib/schema';
16
+ import { isRole, parseRole, roleLabel, roleSummary, rolesByRank } from '../../lib/roles';
17
17
  import { accessFor } from '../../lib/permissions/access';
18
- import { ROLE_RANK, assignableRoles } from '../../lib/permissions/catalog';
18
+ import { assignableRoles, ranksBelow } from '../../lib/permissions/catalog';
19
19
  import { EDITOR } from '../../lib/editor/config';
20
20
 
21
21
  const repo = await getRepo(Astro.locals);
@@ -39,7 +39,7 @@ const canInvite = access?.can('team.invite') ?? false;
39
39
  const canManage = access?.can('team.manage') ?? false;
40
40
  // roles this person may give out (owners: all; everyone else: below their own)
41
41
  const assignable = access ? assignableRoles(access.role) : [];
42
- const below = (role: Role) => isOwner || (!!access && ROLE_RANK[role] < ROLE_RANK[access.role]);
42
+ const below = (role: Role) => isOwner || (!!access && ranksBelow(role, access.role));
43
43
  const manages = (u: { id: string; role: Role }) => canManage && below(u.role) && (isOwner || u.id !== sessionUserId);
44
44
 
45
45
  let error = '';
@@ -90,16 +90,16 @@ if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
90
90
  if (inviteOp && !canInvite) throw new Error('Your role doesn’t include “Invite people”. Ask an owner if you need it.');
91
91
  if (!inviteOp && !canManage) throw new Error('Your role doesn’t include “Manage members”. Ask an owner if you need it.');
92
92
  if (op === 'link' && !isOwner) throw new Error('Only owners can link sign-in accounts.');
93
- if (op === 'create' && !assignable.includes(Role.parse(form.get('role')))) throw new Error('You can only invite people to roles below your own.');
93
+ if (op === 'create' && !assignable.includes(parseRole(form.get('role')))) throw new Error('You can only invite people to roles below your own.');
94
94
  if (op === 'resend' || op === 'revoke') {
95
95
  const target = clerkMode ? await repo.auth.invitation(issuer, String(form.get('id'))) : await repo.email.token(String(form.get('id')));
96
- if (target && !below(Role.parse(target.role))) throw new Error('Only an owner can change invitations for this role.');
96
+ if (target && !below(String(target.role))) throw new Error('Only an owner can change invitations for this role.');
97
97
  }
98
98
  if (['update', 'delete', 'email'].includes(op)) {
99
99
  const target = await repo.getUser(String(form.get('id')));
100
100
  if (!target) throw new Error('The account no longer exists.');
101
101
  if (!manages(target)) throw new Error('You can only manage people in roles below your own.');
102
- if (op === 'update' && !assignable.includes(Role.parse(form.get('role')))) throw new Error('You can only assign roles below your own.');
102
+ if (op === 'update' && !assignable.includes(parseRole(form.get('role')))) throw new Error('You can only assign roles below your own.');
103
103
  }
104
104
  if (clerkMode) {
105
105
  if (!clerkReady) throw new Error('Clerk sign-in isn’t fully configured, so accounts can’t be changed right now.');
@@ -132,7 +132,7 @@ if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
132
132
  if(!await repo.email.rateLimit(`invite:${sessionUserId}`,10,60000))throw new Error('Please wait before sending more invitations.');
133
133
  const previous=op==='resend'?await repo.email.token(String(form.get('id'))):null;
134
134
  if(op==='resend'&&(!previous||previous.purpose!=='invite'||previous.used_by))throw new Error('This invitation cannot be resent.');
135
- const invitedRole = Role.parse(previous ? previous.role : form.get('role'));
135
+ const invitedRole = parseRole(previous ? previous.role : form.get('role'));
136
136
  await issueAccountToken(repo,{purpose:'invite',email:previous?String(previous.email):String(form.get('email')||''),
137
137
  name:previous?String(previous.name):String(form.get('name')||''),
138
138
  role:invitedRole,invitedBy:sessionUserId},Astro.locals);
@@ -150,7 +150,7 @@ if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
150
150
  if (password && (password.length < 12 || password.length > 128)) throw new Error('Use a password between 12 and 128 characters.');
151
151
  await repo.updateUser(id, {
152
152
  name: String(form.get('name') ?? '').trim() || undefined,
153
- role: Role.parse(form.get('role')),
153
+ role: parseRole(form.get('role')),
154
154
  bio: String(form.get('bio') ?? ''),
155
155
  avatar: String(form.get('avatar') ?? '').trim(),
156
156
  slug: String(form.get('slug') ?? '').trim() ? slugify(String(form.get('slug'))) : '',
@@ -266,12 +266,12 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
266
266
  </label>
267
267
  <fieldset class="invite-roles">
268
268
  <legend>Role</legend>
269
- {Role.options.slice().reverse().map((role) => {
269
+ {rolesByRank().map((role) => {
270
270
  const allowed = assignable.includes(role);
271
271
  return (
272
272
  <label class:list={['invite-role', { 'is-off': !allowed }]}>
273
273
  <input type="radio" name="role" value={role} checked={role === (assignable.includes('editor') ? 'editor' : assignable[assignable.length - 1])} disabled={!allowed} />
274
- <span><b>{roleLabels[role]}</b>{!allowed && <span class="pill archived">Owner only</span>}<small>{roleSummaries[role]}</small></span>
274
+ <span><b>{roleLabel(role)}</b>{!allowed && <span class="pill archived">Owner only</span>}<small>{roleSummary(role)}</small></span>
275
275
  </label>
276
276
  );
277
277
  })}
@@ -313,7 +313,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
313
313
  </span>
314
314
  </div>
315
315
  </td>
316
- <td><span class={`pill ${u.role === 'owner' ? 'draft' : 'scheduled'}`}>{roleLabels[u.role]}</span></td>
316
+ <td><span class={`pill ${u.role === 'owner' ? 'draft' : 'scheduled'}`}>{roleLabel(u.role)}</span>{!isRole(u.role) && <span class="pill edited" title="This site no longer defines this role. People with it can’t use the admin; choose another role for them.">No longer defined</span>}</td>
317
317
  {clerkMode && isOwner && clerkReady && (
318
318
  <td>{linked.has(u.id)
319
319
  ? <span class="pill published" title={linked.get(u.id)}>Connected</span>
@@ -366,7 +366,8 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
366
366
  <label class="field">
367
367
  <span>Role</span>
368
368
  <select name="role" form={formId}>
369
- {Role.options.filter((role) => assignable.includes(role)).map(role => <option value={role} selected={u.role === role}>{roleLabels[role]}</option>)}
369
+ {!isRole(u.role) && <option value="" selected disabled>{u.role} (no longer defined)</option>}
370
+ {rolesByRank().filter((role) => assignable.includes(role)).map(role => <option value={role} selected={u.role === role}>{roleLabel(role)}</option>)}
370
371
  </select>
371
372
  </label>
372
373
  {!clerkMode && (
@@ -471,7 +472,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
471
472
  {invitations.map(i=>{
472
473
  const [label, tone] = clerkMode ? (stateLabel[i.state] ?? [i.state, 'archived']) : [i.state, ''];
473
474
  const inviter = i.invitedBy ? (userById.get(i.invitedBy)?.name ?? (i.invitedBy === OWNER_ENV ? 'Owner' : 'Removed user')) : '';
474
- return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}{(inviter || i.createdAt) && <div class="t-sub">{inviter && <>Invited by {inviter}</>}{inviter && i.createdAt ? ' · ' : ''}{i.createdAt ? day(i.createdAt) : ''}</div>}</td><td>{roleLabels[i.role] ?? i.role}</td>
475
+ return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}{(inviter || i.createdAt) && <div class="t-sub">{inviter && <>Invited by {inviter}</>}{inviter && i.createdAt ? ' · ' : ''}{i.createdAt ? day(i.createdAt) : ''}</div>}</td><td>{roleLabel(i.role)}</td>
475
476
  <td>{clerkMode ? <span class={`pill ${tone}`}>{label}</span> : i.state}{clerkMode && i.state === 'pending' && <div class="t-sub">Expires {day(i.expiresAt)}</div>}</td>
476
477
  <td class="td-actions">{i.state!=='accepted'&&!below(i.role)&&<span class="hint">Owner only</span>}{i.state!=='accepted'&&below(i.role)&&<form method="post" class="inline-form" data-reverify><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{(i.state==='pending'||(clerkMode&&i.state==='sending'))&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>;
477
478
  })}
@@ -14,14 +14,16 @@ import { readJson } from '../../../../lib/api';
14
14
  import { runAgentTool } from '../../../../lib/agent';
15
15
  import { waitUntilFrom } from '../../../../lib/webhooks';
16
16
  import { fail } from '../../../../lib/calendar/http';
17
+ import { publicTags, tagFilter } from '../../../../lib/schema';
17
18
 
18
19
  // Public read API for other sites, apps and lobby screens.
19
20
  // GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
21
+ // GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
20
22
  // → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
21
23
  // GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
22
24
  // Anonymous callers see public calendars (and an unlisted one when asked by
23
25
  // slug). A key with events:read also sees private calendars and private
24
- // custom fields.
26
+ // custom fields, and its tag filters also match internal "#" tags.
25
27
  const CORS = {
26
28
  'Access-Control-Allow-Origin': '*',
27
29
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -55,6 +57,11 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
55
57
  return full ? true : eventIsListed(e, map);
56
58
  });
57
59
  if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
60
+ const tag = url.searchParams.get('tag')?.slice(0, 100), excludeTag = url.searchParams.get('excludeTag')?.slice(0, 100);
61
+ if (tag || excludeTag) {
62
+ const tagged = tagFilter(tag, excludeTag);
63
+ events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
64
+ }
58
65
 
59
66
  if (url.searchParams.get('expand') === '0') {
60
67
  return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from 'astro';
2
+ import { isStaffRole } from '../../lib/roles';
2
3
  import { getRepo } from '../../lib/repo';
3
4
  import { authorSlug, publicTags } from '../../lib/schema';
4
5
  import { collectionUrl, tagUrl } from '../../lib/content';
@@ -52,7 +53,8 @@ export const GET: APIRoute = async ({ locals, url }) => {
52
53
  };
53
54
  }),
54
55
  tags: results.tags.slice(0, LIMITS.tags).map(({ tag, count }) => ({ name: tag.name, url: tagUrl(tag.slug), count })),
55
- authors: results.authors.slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
56
+ // staff only: people in the site's own roles don't write posts and have no public profile
57
+ authors: results.authors.filter((u) => isStaffRole(u.role)).slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
56
58
  };
57
59
  return new Response(JSON.stringify(body), { headers });
58
60
  };
@@ -3,6 +3,7 @@ import Site from '../../layouts/Site.astro';
3
3
  import PostCard from '../../components/site/PostCard.astro';
4
4
  import Pager from '../../components/site/Pager.astro';
5
5
  import { getRepo } from '../../lib/repo';
6
+ import { isStaffRole } from '../../lib/roles';
6
7
  import { tagUrl } from '../../lib/content';
7
8
  import { publicTags, slugify } from '../../lib/schema';
8
9
  import { authorSlug } from '../../lib/schema';
@@ -14,7 +15,8 @@ const { slug } = Astro.params;
14
15
  const repo = await getRepo(Astro.locals);
15
16
  const [users, settings, tags] = await Promise.all([repo.listUsers(), repo.getSettings(), repo.listTags()]);
16
17
  // stored slug wins; slugified name is the fallback so profiles work untouched
17
- const author = users.find((u) => authorSlug(u) === slug);
18
+ // staff only: people in the site's own roles have no public profile
19
+ const author = users.find((u) => isStaffRole(u.role) && authorSlug(u) === slug);
18
20
  if (!author) return new Response(null, { status: 404, statusText: 'Not found' });
19
21
 
20
22
  // includes co-authored posts (authorIds), not just primary authorship
@@ -0,0 +1,89 @@
1
+ ---
2
+ import type { EventFieldConfig } from '../../lib/calendar/config';
3
+ import { MEMBER_FORM_LIMITS as L, repeatChoices, type MemberEventForm } from '../../lib/calendar/member-form';
4
+
5
+ // The event request form (new requests and suggested changes). Plain HTML:
6
+ // it posts to the page it's on, with `op` saying what to do.
7
+ interface Props {
8
+ values: MemberEventForm;
9
+ /** the calendars members may choose (native, public); none hides the choice */
10
+ calendars: { id: string; name: string }[];
11
+ /** the site's non-private custom event fields */
12
+ fields: readonly EventFieldConfig[];
13
+ op: 'submit' | 'update';
14
+ submitLabel: string;
15
+ error?: string;
16
+ }
17
+ const { values: v, calendars, fields, op, submitLabel, error = '' } = Astro.props;
18
+ const repeat = repeatChoices(v.repeat, v.date);
19
+ const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' : t === 'url' ? 'url' : 'text');
20
+ ---
21
+
22
+ <form method="post" class="pc-mem-form">
23
+ {error && <p class="pc-bk-error" role="alert">{error}</p>}
24
+ <input type="hidden" name="op" value={op} />
25
+ <label class="pc-bk-field">
26
+ <span>Title</span>
27
+ <input name="title" required maxlength={L.title} value={v.title} />
28
+ </label>
29
+ <fieldset class="pc-mem-when">
30
+ <legend>When</legend>
31
+ <div class="pc-mem-row">
32
+ <label class="pc-bk-field"><span>Date</span><input type="date" name="date" required value={v.date} /></label>
33
+ <label class="pc-bk-field pc-mem-time"><span>Starts</span><input type="time" name="startTime" value={v.startTime} /></label>
34
+ <label class="pc-bk-field pc-mem-time"><span>Ends <em>(optional)</em></span><input type="time" name="endTime" value={v.endTime} /></label>
35
+ </div>
36
+ <label class="pc-bk-check"><input type="checkbox" name="allDay" value="1" checked={v.allDay} /> <span>All day <small>No start or end time</small></span></label>
37
+ {v.days > 0 && (
38
+ <>
39
+ <input type="hidden" name="days" value={String(v.days)} />
40
+ <p class="pc-mem-hint">This event runs over {v.days + 1} days. Changing the date moves all of them.</p>
41
+ </>
42
+ )}
43
+ <label class="pc-bk-field">
44
+ <span>Repeats</span>
45
+ <select name="repeat">
46
+ {repeat.options.map((o) => <option value={o.value} selected={o.value === repeat.value}>{o.label}</option>)}
47
+ </select>
48
+ {repeat.note && <small>{repeat.note}</small>}
49
+ </label>
50
+ </fieldset>
51
+ <label class="pc-bk-field">
52
+ <span>Where</span>
53
+ <input name="where" maxlength={L.where} value={v.where} placeholder="A room, an address, or “Online”" />
54
+ </label>
55
+ <label class="pc-bk-field">
56
+ <span>Description</span>
57
+ <textarea name="description" rows="6" maxlength={L.description}>{v.description}</textarea>
58
+ <small>Plain text. Leave a blank line between paragraphs.</small>
59
+ </label>
60
+ {calendars.length > 0 && (
61
+ <label class="pc-bk-field">
62
+ <span>Calendar</span>
63
+ <select name="calendarId">
64
+ <option value="">Not sure (the team will choose)</option>
65
+ {calendars.map((c) => <option value={c.id} selected={c.id === v.calendarId}>{c.name}</option>)}
66
+ </select>
67
+ </label>
68
+ )}
69
+ {fields.map((f) => (
70
+ <label class="pc-bk-field">
71
+ <span>{f.label}</span>
72
+ {f.type === 'textarea' ? (
73
+ <textarea name={`field_${f.key}`} rows="3" maxlength={L.field}>{v.fields[f.key] ?? ''}</textarea>
74
+ ) : f.type === 'select' ? (
75
+ <select name={`field_${f.key}`}>
76
+ <option value="">Choose…</option>
77
+ {f.options.map((o) => <option value={o} selected={o === v.fields[f.key]}>{o}</option>)}
78
+ </select>
79
+ ) : (
80
+ <input type={fieldType(f.type)} name={`field_${f.key}`} maxlength={f.type === 'number' ? undefined : L.field} step={f.type === 'number' ? 'any' : undefined} value={v.fields[f.key] ?? ''} />
81
+ )}
82
+ </label>
83
+ ))}
84
+ <label class="pc-bk-field">
85
+ <span>Note for the team <em>(only staff see this)</em></span>
86
+ <textarea name="note" rows="3" maxlength={L.note}>{v.note}</textarea>
87
+ </label>
88
+ <button class="pc-bk-submit" type="submit">{submitLabel}</button>
89
+ </form>
@@ -0,0 +1,76 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+
4
+ // The frame of the built-in member pages: the site's own layout and theme,
5
+ // never indexed, plus who's signed in. `.pc-mem-*` classes are theme hooks;
6
+ // form controls reuse the booking pages' `.pc-bk-*` ones.
7
+ interface Props {
8
+ title: string;
9
+ user: { name: string; email: string; staff: boolean };
10
+ }
11
+ const { title, user } = Astro.props;
12
+ ---
13
+
14
+ <Site title={title} noindex staffPreview>
15
+ <div class="pc-mem">
16
+ <div class="pc-mem-top">
17
+ <span>Signed in as {user.name || user.email}</span>
18
+ <span class="pc-mem-top-links">
19
+ {user.staff && <a href="/admin">Open the admin</a>}
20
+ <form method="post" action="/admin/logout"><button type="submit">Sign out</button></form>
21
+ </span>
22
+ </div>
23
+ <slot />
24
+ </div>
25
+ </Site>
26
+
27
+ <style is:global>
28
+ @layer base {
29
+ .pc-mem { display: grid; gap: 26px; }
30
+ .pc-mem-top { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; font-size: 13.5px; color: var(--muted); }
31
+ .pc-mem-top-links { display: inline-flex; gap: 14px; align-items: baseline; }
32
+ .pc-mem-top a, .pc-mem-top button { color: var(--muted); }
33
+ .pc-mem-top form { margin: 0; }
34
+ .pc-mem-top button { background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
35
+ .pc-mem-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
36
+ .pc-mem-head h1 { font-size: 30px; margin: 0; }
37
+ .pc-mem-back { font-size: 14px; color: var(--muted); }
38
+ .pc-mem-lede { color: var(--muted); margin: 6px 0 0; }
39
+ .pc-mem-flash { background: color-mix(in srgb, var(--success) 10%, var(--panel)); color: var(--success); border-radius: var(--r-sm); padding: 9px 12px; font-size: 14px; margin: 0; }
40
+ .pc-mem .pc-bk-error { margin: 0; }
41
+ .pc-mem section > h2 { font-size: 19px; margin: 0 0 10px; }
42
+ .pc-mem-empty { color: var(--muted); font-size: 14.5px; margin: 0; }
43
+ .pc-mem-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
44
+ .pc-mem-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); padding: 14px 16px; box-shadow: var(--sh-1); }
45
+ .pc-mem-card h3 { font-size: 16.5px; margin: 0; }
46
+ .pc-mem-meta { color: var(--muted); font-size: 14px; margin: 3px 0 0; }
47
+ .pc-mem-state { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; margin: 0 0 4px; }
48
+ .pc-mem-state::before { content: ''; width: 7px; height: 7px; border-radius: 2px; background: currentColor; }
49
+ .pc-mem-state.is-pending { color: var(--warning); }
50
+ .pc-mem-state.is-declined { color: var(--danger); }
51
+ .pc-mem-reply { margin: 10px 0 0; padding: 9px 12px; background: var(--soft); border-radius: var(--r-sm); font-size: 14px; white-space: pre-line; }
52
+ .pc-mem > .pc-mem-reply { margin: 0; }
53
+ .pc-mem-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
54
+ .pc-mem-actions form, .pc-mem-dates form { margin: 0; }
55
+ .pc-mem-btn { display: inline-flex; align-items: center; height: 36px; padding: 0 13px; border: 1px solid var(--line-2); border-radius: 10px; background: var(--panel); font: 550 14px/1.2 var(--font-body); color: var(--ink); text-decoration: none; cursor: pointer; white-space: nowrap; }
56
+ .pc-mem-btn:hover { background: var(--soft); }
57
+ .pc-mem-btn.is-primary { background: var(--btn); color: var(--btn-ink); border-color: transparent; box-shadow: var(--sh-1); }
58
+ .pc-mem-btn.is-primary:hover { background: var(--btn); box-shadow: var(--sh-2); }
59
+ .pc-mem-btn.is-danger { color: var(--danger); }
60
+ .pc-mem-btn.is-small { height: 30px; padding: 0 10px; font-size: 13px; }
61
+ .pc-mem-dates { list-style: none; margin: 10px 0 0; padding: 0; font-size: 14px; }
62
+ .pc-mem-dates li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px; padding: 5px 0; border-top: 1px solid var(--line); font-variant-numeric: tabular-nums; }
63
+ .pc-mem-dates li.is-cancelled > span { color: var(--muted); }
64
+ .pc-mem-dates em { font-style: normal; font-size: 12.5px; font-weight: 600; color: var(--danger); margin-left: 8px; }
65
+ .pc-mem-past > summary { cursor: pointer; font-weight: 600; font-size: 15px; }
66
+ .pc-mem-past .pc-mem-list { margin-top: 10px; }
67
+ /* the request form */
68
+ .pc-mem-form { display: grid; gap: 14px; }
69
+ .pc-mem-form .pc-bk-field, .pc-mem-form .pc-bk-check { margin: 0; }
70
+ .pc-mem-when { border: 0; margin: 0; padding: 0; min-width: 0; display: grid; gap: 10px; }
71
+ .pc-mem-when > legend { padding: 0; margin-bottom: 6px; font-size: 13px; font-weight: 600; }
72
+ .pc-mem-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
73
+ .pc-mem-when:has(input[name='allDay']:checked) .pc-mem-time { opacity: 0.45; }
74
+ .pc-mem-hint { color: var(--muted); font-size: 13px; margin: 0; }
75
+ }
76
+ </style>
@@ -0,0 +1,25 @@
1
+ import { ApiError } from '../../lib/api';
2
+
3
+ // Shared by the built-in "My events" pages: they're personal, so nothing
4
+ // they answer (redirects included) may be cached or indexed.
5
+
6
+ export const NO_STORE: Record<string, string> = { 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' };
7
+
8
+ export function keepPrivate(headers: Headers) {
9
+ for (const [k, v] of Object.entries(NO_STORE)) headers.set(k, v);
10
+ }
11
+
12
+ /** 303 after a POST (the browser GETs the list), 302 otherwise */
13
+ export const redirectTo = (location: string, status = 303) => new Response(null, { status, headers: { Location: location, ...NO_STORE } });
14
+
15
+ /** signed out: sign in, then come back here */
16
+ export const signInRedirect = (url: URL) => redirectTo(`/admin/login?return=${encodeURIComponent(url.pathname + url.search)}`, 302);
17
+
18
+ export const notFound = () => new Response(null, { status: 404, statusText: 'Not found', headers: NO_STORE });
19
+
20
+ /** an error from requests.ts → a message safe to show, and the status to answer with */
21
+ export function failure(err: unknown): { message: string; status: number } {
22
+ if (err instanceof ApiError) return { message: err.message, status: err.status };
23
+ console.error(err);
24
+ return { message: 'Something went wrong. Please try again.', status: 500 };
25
+ }
@@ -0,0 +1,86 @@
1
+ ---
2
+ import Layout from '../_Layout.astro';
3
+ import EventForm from '../_EventForm.astro';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { currentUser } from '../../../server';
6
+ import { ApiError } from '../../../lib/api';
7
+ import { readForm } from '../../../lib/forms/http';
8
+ import { MEMBERS, memberEventsPath, bodyToText, isHost, updateEventRequest, withdrawEventRequest } from '../../../lib/calendar/requests';
9
+ import { eventFormValues, eventRequestInput, memberEventFields, readEventForm } from '../../../lib/calendar/member-form';
10
+ import { mergeEventDraft } from '../../../lib/drafts';
11
+ import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
12
+
13
+ // Edit a request, or suggest changes to a live event the member hosts: the
14
+ // form starts from the event with any changes already waiting merged in.
15
+ // A live event stays as it is until the team approves (updateEventRequest).
16
+ if (!MEMBERS.enabled) return notFound();
17
+ const user = await currentUser(Astro);
18
+ if (!user) return signInRedirect(Astro.url);
19
+ keepPrivate(Astro.response.headers);
20
+ const repo = await getRepo(Astro.locals);
21
+ const home = memberEventsPath();
22
+ const hooks = { url: Astro.url, locals: Astro.locals };
23
+ const id = Astro.params.id ?? '';
24
+ const event = await repo.getEvent(id);
25
+ if (!event || event.status === 'archived' || !isHost(event, user.id)) return notFound();
26
+
27
+ const fields = memberEventFields();
28
+ const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native' && c.visibility === 'public');
29
+ const live = event.status === 'published' || event.status === 'scheduled';
30
+ // the host's own waiting changes, never staff's unpublished edits
31
+ const merged = event.request?.kind === 'changes' && event.request.state === 'pending' ? mergeEventDraft(event) : event;
32
+ let values = eventFormValues(merged, { description: bodyToText(merged), calendarIds: calendars.map((c) => c.id) }, fields);
33
+
34
+ let error = '';
35
+ if (Astro.request.method === 'POST') {
36
+ try {
37
+ const form = await readForm(Astro.request);
38
+ const op = String(form.get('op') ?? '');
39
+ if (op === 'withdraw') {
40
+ await withdrawEventRequest(repo, user, id, hooks);
41
+ return redirectTo(`${home}?withdrawn=1`);
42
+ }
43
+ if (op !== 'update') throw new ApiError(400, 'That action isn’t available here.');
44
+ values = readEventForm(form, fields);
45
+ await updateEventRequest(repo, user, id, eventRequestInput(values), hooks);
46
+ return redirectTo(`${home}?${live ? 'changes' : 'updated'}=1`);
47
+ } catch (err) {
48
+ const f = failure(err);
49
+ error = f.message;
50
+ Astro.response.status = f.status;
51
+ }
52
+ }
53
+
54
+ const r = event.request;
55
+ const canRequest = user.can('events.request');
56
+ const heading = live ? 'Suggest changes' : 'Edit your request';
57
+ const lede = live
58
+ ? `“${event.title}” stays as it is on the site until the team approves your changes.`
59
+ : r?.state === 'declined' ? 'Make your changes and send it again.'
60
+ : r ? 'It’s waiting for approval. Saving sends the updated request.'
61
+ : 'It isn’t on the calendar yet. Send it to the team when it’s ready.';
62
+ const waiting = live && r?.kind === 'changes' && r.state === 'pending';
63
+ const reply = r?.state === 'declined' ? r.reply : '';
64
+ const canWithdraw = canRequest && r && !(r.kind === 'changes' && r.state === 'declined');
65
+ ---
66
+
67
+ <Layout title={heading} user={user}>
68
+ <div>
69
+ <a class="pc-mem-back" href={home}>← My events</a>
70
+ <div class="pc-mem-head"><h1>{heading}</h1></div>
71
+ <p class="pc-mem-lede">{lede}</p>
72
+ </div>
73
+ {waiting && <p class="pc-mem-reply">The changes you sent are waiting for approval; they’re filled in below. Sending again replaces them.</p>}
74
+ {r?.state === 'declined' && (
75
+ <p class="pc-mem-reply"><strong>{r.kind === 'changes' ? 'The team declined your last changes.' : 'The team declined this request.'}</strong>{reply && <> They said: {reply}</>}</p>
76
+ )}
77
+ {canRequest
78
+ ? <EventForm values={values} calendars={calendars} fields={fields} op="update" submitLabel={live ? 'Send changes for approval' : 'Submit for approval'} error={error} />
79
+ : <p class="pc-bk-error" role="alert">Your role doesn’t include “Request events”. Ask the site’s team if you need it.</p>}
80
+ {canWithdraw && (
81
+ <form method="post" onsubmit={r!.kind === 'new' ? "return confirm('Withdraw this request?')" : "return confirm('Withdraw these changes?')"}>
82
+ <input type="hidden" name="op" value="withdraw" />
83
+ <button class="pc-mem-btn is-danger" type="submit">{r!.kind === 'new' ? 'Withdraw request' : 'Withdraw changes'}</button>
84
+ </form>
85
+ )}
86
+ </Layout>