@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.
- package/CHANGELOG.md +18 -0
- package/cli/migrations.generated.mjs +6 -0
- package/docs/mobile.md +1 -1
- package/docs/server.md +203 -24
- package/integration.mjs +134 -1
- package/package.json +1 -1
- package/src/actions/index.ts +3 -1
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/RolesManager.tsx +15 -7
- package/src/components/calendar/CalendarBoard.tsx +5 -3
- package/src/layouts/Admin.astro +2 -2
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/request.ts +19 -7
- package/src/lib/auth/return.ts +32 -0
- package/src/lib/auth.ts +17 -4
- package/src/lib/calendar/board.ts +12 -1
- package/src/lib/calendar/member-form.ts +152 -0
- package/src/lib/calendar/requests.ts +284 -0
- package/src/lib/calendar/schedule.ts +1 -1
- package/src/lib/editor/auth.ts +5 -3
- package/src/lib/editor/schemas.ts +2 -1
- package/src/lib/editor/store.ts +5 -3
- package/src/lib/email/service.ts +16 -3
- package/src/lib/email/store.ts +3 -1
- package/src/lib/email/templates.ts +12 -0
- package/src/lib/permissions/catalog.ts +84 -36
- package/src/lib/permissions/service.ts +11 -8
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/repo/sql.ts +23 -5
- package/src/lib/repo/types.ts +5 -1
- package/src/lib/roles.ts +46 -4
- package/src/lib/schema.ts +31 -2
- package/src/lib/sync/catalog.ts +3 -0
- package/src/middleware.ts +8 -1
- package/src/pages/admin/app-auth.astro +5 -3
- package/src/pages/admin/calendar/events/[id].astro +249 -2
- package/src/pages/admin/calendar/events/index.astro +165 -12
- package/src/pages/admin/index.astro +22 -0
- package/src/pages/admin/login.astro +37 -16
- package/src/pages/admin/roles.astro +3 -0
- package/src/pages/admin/users.astro +15 -14
- package/src/pages/api/search.ts +3 -1
- package/src/pages/author/[slug].astro +3 -1
- package/src/pages/members/_EventForm.astro +89 -0
- package/src/pages/members/_Layout.astro +76 -0
- package/src/pages/members/_http.ts +25 -0
- package/src/pages/members/events/[id].astro +86 -0
- package/src/pages/members/events/index.astro +218 -0
- package/src/pages/members/events/new.astro +48 -0
- package/src/pages/members/index.ts +6 -0
- package/src/server/index.ts +96 -18
- 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 {
|
|
5
|
+
import { CORE_ROLES, isStaffRole, roleLabel, roleSummary, rolesByRank } from '../roles';
|
|
6
6
|
import type { Role } from '../schema';
|
|
7
7
|
import {
|
|
8
|
-
|
|
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:
|
|
34
|
-
areas:
|
|
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:
|
|
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.
|
|
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)
|
|
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.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
import type { CoreRole, Role } from './schema';
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
export type
|
|
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,
|
package/src/lib/sync/catalog.ts
CHANGED
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 {
|
|
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
|
-
|
|
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 ?
|
|
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>
|