@acms-subir/core 0.3.12 → 0.3.13

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 (78) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/cli/migrations.generated.mjs +16 -1
  3. package/integration.mjs +2 -0
  4. package/package.json +1 -1
  5. package/src/actions/index.ts +76 -59
  6. package/src/components/RolesManager.tsx +276 -0
  7. package/src/layouts/Admin.astro +8 -3
  8. package/src/lib/auth/accounts.ts +2 -2
  9. package/src/lib/auth/store.ts +4 -4
  10. package/src/lib/content-service.ts +14 -0
  11. package/src/lib/design-service.ts +11 -3
  12. package/src/lib/editor/http.ts +7 -0
  13. package/src/lib/editor/me.ts +12 -8
  14. package/src/lib/editor/roles.ts +14 -6
  15. package/src/lib/editor/schedule.ts +12 -12
  16. package/src/lib/editor/sections.ts +11 -4
  17. package/src/lib/editor/site-data.ts +4 -2
  18. package/src/lib/editor/translations.ts +2 -1
  19. package/src/lib/email/accounts.ts +2 -2
  20. package/src/lib/email/store.ts +3 -3
  21. package/src/lib/email/types.ts +1 -1
  22. package/src/lib/permissions/access.ts +73 -0
  23. package/src/lib/permissions/catalog.ts +147 -0
  24. package/src/lib/permissions/migration.ts +17 -0
  25. package/src/lib/permissions/service.ts +59 -0
  26. package/src/lib/permissions/store.ts +54 -0
  27. package/src/lib/repo/migrations.ts +2 -0
  28. package/src/lib/repo/sql.ts +3 -0
  29. package/src/lib/repo/types.ts +2 -0
  30. package/src/lib/roles.ts +9 -0
  31. package/src/pages/admin/c/[collection]/index.astro +6 -0
  32. package/src/pages/admin/calendar/bookings.astro +2 -0
  33. package/src/pages/admin/calendar/events/index.astro +11 -0
  34. package/src/pages/admin/calendar/resources/[id].astro +2 -0
  35. package/src/pages/admin/calendar/resources/index.astro +2 -0
  36. package/src/pages/admin/calendar/resources/link.astro +2 -0
  37. package/src/pages/admin/calendar/settings.astro +2 -0
  38. package/src/pages/admin/email.astro +2 -2
  39. package/src/pages/admin/forms/[id]/share.astro +2 -0
  40. package/src/pages/admin/forms/[id]/submissions.astro +2 -0
  41. package/src/pages/admin/forms/index.astro +2 -0
  42. package/src/pages/admin/import.astro +3 -6
  43. package/src/pages/admin/integrations.astro +10 -12
  44. package/src/pages/admin/live.astro +3 -3
  45. package/src/pages/admin/mobile.astro +3 -3
  46. package/src/pages/admin/pages/index.astro +8 -1
  47. package/src/pages/admin/roles.astro +80 -0
  48. package/src/pages/admin/settings.astro +31 -28
  49. package/src/pages/admin/tags.astro +2 -0
  50. package/src/pages/admin/users.astro +87 -41
  51. package/src/pages/api/ai/clean.ts +3 -2
  52. package/src/pages/api/ai/draft.ts +3 -2
  53. package/src/pages/api/ai/settings.ts +4 -3
  54. package/src/pages/api/ai/test.ts +3 -2
  55. package/src/pages/api/design/export.ts +2 -1
  56. package/src/pages/api/design/import.ts +2 -1
  57. package/src/pages/api/design/keys.ts +3 -2
  58. package/src/pages/api/email/settings.ts +4 -3
  59. package/src/pages/api/export.ts +3 -2
  60. package/src/pages/api/import/asset.ts +3 -2
  61. package/src/pages/api/import/index.ts +4 -3
  62. package/src/pages/api/import/media.ts +3 -2
  63. package/src/pages/api/live/oauth/youtube/callback.ts +4 -4
  64. package/src/pages/api/live/settings.ts +4 -3
  65. package/src/pages/api/media/upload.ts +4 -0
  66. package/src/pages/api/mobile/access.ts +4 -3
  67. package/src/pages/api/monitoring.ts +4 -3
  68. package/src/pages/api/push/admin.ts +4 -3
  69. package/src/pages/api/roles.ts +36 -0
  70. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +2 -1
  71. package/src/pages/api/v1/admin/bookings/index.ts +3 -2
  72. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +3 -2
  73. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +3 -2
  74. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +3 -2
  75. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +3 -2
  76. package/src/pages/api/v1/admin/documents/index.ts +2 -1
  77. package/src/pages/api/v1/admin/links.ts +2 -1
  78. package/src/pages/api/v1/admin/media/index.ts +2 -1
@@ -5,6 +5,7 @@ import { GuestStore } from '../mobile/guest-store';
5
5
  import { LiveStore } from '../live/store';
6
6
  import { PushStore } from '../push/store';
7
7
  import { EditorStore } from '../editor/store';
8
+ import { PermissionStore } from '../permissions/store';
8
9
  import { BackupStore } from '../backup/store';
9
10
  import { LiveConfig } from '../live/types';
10
11
  import { ApiError } from '../api';
@@ -269,6 +270,7 @@ export class SqlRepository implements ContentRepository {
269
270
  readonly push: PushStore;
270
271
  readonly editor: EditorStore;
271
272
  readonly backups: BackupStore;
273
+ readonly permissions: PermissionStore;
272
274
 
273
275
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
274
276
  this.ready = this.init(driver);
@@ -280,6 +282,7 @@ export class SqlRepository implements ContentRepository {
280
282
  this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
281
283
  this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
282
284
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
285
+ this.permissions = new PermissionStore(async () => { await this.ready; return this.driver; });
283
286
  }
284
287
 
285
288
  private async init(driver: SqlDriver | (() => Promise<SqlDriver>)) {
@@ -8,6 +8,7 @@ import type { BookingRequest } from '../backup/content';
8
8
  import type { EmailStore } from '../email/store';
9
9
  import type { PushStore } from '../push/store';
10
10
  import type { EditorStore } from '../editor/store';
11
+ import type { PermissionStore } from '../permissions/store';
11
12
  export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
12
13
  export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
13
14
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
@@ -60,6 +61,7 @@ export interface ContentRepository {
60
61
  readonly live: LiveStore;
61
62
  readonly push: PushStore;
62
63
  readonly editor: EditorStore;
64
+ readonly permissions: PermissionStore;
63
65
  // Bounded public catalog; fetch limit + 1 IDs without loading every body.
64
66
  listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
65
67
  exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
package/src/lib/roles.ts CHANGED
@@ -3,3 +3,12 @@ import type { Role } from './schema';
3
3
  export const roleLabels: Record<Role, string> = {
4
4
  contributor: 'Contributor', author: 'Author', editor: 'Editor', administrator: 'Administrator', owner: 'Owner',
5
5
  };
6
+
7
+ /** one line per role for pickers (the defaults; owners can change what each role does on Team → Roles) */
8
+ export const roleSummaries: Record<Role, string> = {
9
+ owner: 'Everything, including roles, backups and sign-in settings.',
10
+ administrator: 'Runs the site day to day and invites people.',
11
+ editor: 'Writes, edits and publishes content.',
12
+ author: 'Writes and publishes content.',
13
+ contributor: 'Writes and edits content.',
14
+ };
@@ -5,6 +5,7 @@ import ContentList, { type ContentItem } from '../../../../components/ContentLis
5
5
  import { getRepo } from '../../../../lib/repo';
6
6
  import { slugify, type Status } from '../../../../lib/schema';
7
7
  import { CONTENT, collectionUrl } from '../../../../lib/content';
8
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
8
9
 
9
10
  // One list page for every collection; [collection] is the config key from
10
11
  // acms({ content.collections }). Unknown keys 404 — content in removed
@@ -21,6 +22,8 @@ if (Astro.request.method === 'POST') {
21
22
  const form = await Astro.request.formData();
22
23
  const op = String(form.get('op'));
23
24
  if (op === 'create') {
25
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit');
26
+ if (denied) return denied;
24
27
  const title = String(form.get('title') || 'Untitled');
25
28
  let slug = slugify(title);
26
29
  let n = 1;
@@ -32,6 +35,9 @@ if (Astro.request.method === 'POST') {
32
35
  if (op === 'bulk') {
33
36
  const to = String(form.get('to'));
34
37
  const ids = form.getAll('ids').map(String).filter(Boolean);
38
+ const touchesLive = to === 'published' || (await Promise.all(ids.map((id) => repo.getPost(id)))).some((p) => p?.status === 'published' || p?.status === 'scheduled');
39
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, to === 'delete' ? 'content.delete' : touchesLive ? 'content.publish' : 'content.edit');
40
+ if (denied) return denied;
35
41
  for (const id of ids) {
36
42
  const p = await repo.getPost(id);
37
43
  if (!p || p.collection !== key) continue;
@@ -15,12 +15,14 @@ import { resolveConfig } from '../../../lib/email/config';
15
15
  import { emailReadiness } from '../../../lib/email/readiness';
16
16
  import { siteOrigin } from '../../../lib/seo';
17
17
  import { waitUntilFrom } from '../../../lib/webhooks';
18
+ import { forbiddenUnless } from '../../../lib/permissions/access';
18
19
 
19
20
  // Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
20
21
  // requests first, a Day view with one column per resource, a details panel
21
22
  // (?b=<id>) with private notes, plus Block time and Add booking.
22
23
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
23
24
  const repo = await getRepo(Astro.locals);
25
+ { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.bookings'); if (denied) return denied; }
24
26
  const settings = await repo.getSettings();
25
27
  const tz = settings.timezone || 'UTC';
26
28
  const f = fmt(settings, tz);
@@ -19,6 +19,8 @@ import { siteOrigin } from '../../../../lib/seo';
19
19
  import { siteLocale } from '../../../../lib/dates';
20
20
  import { waitUntilFrom } from '../../../../lib/webhooks';
21
21
  import { invalidateRedirectCache } from '../../../../middleware';
22
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
23
+ import type { Permission } from '../../../../lib/permissions/catalog';
22
24
 
23
25
  // Schedule → List: upcoming first, grouped by the month each event next
24
26
  // happens. Subscribed calendars appear as read-only rows with a "Make an
@@ -63,6 +65,15 @@ if (Astro.request.method === 'POST') {
63
65
  const form = await Astro.request.formData();
64
66
  const op = String(form.get('op'));
65
67
  const hookOpts = { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache };
68
+ // create, copy and import make drafts; bulk status changes that touch live events need Publish
69
+ let needs: Permission = 'content.edit';
70
+ if (op === 'bulk') {
71
+ const to = String(form.get('to'));
72
+ const before = await Promise.all(form.getAll('ids').map(String).filter(Boolean).map((id) => repo.getEvent(id)));
73
+ needs = to === 'delete' ? 'content.delete' : to === 'published' || before.some((e) => e?.status === 'published' || e?.status === 'scheduled') ? 'content.publish' : 'content.edit';
74
+ }
75
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
76
+ if (denied) return denied;
66
77
 
67
78
  if (op === 'create') {
68
79
  const title = String(form.get('title') || 'Untitled event').slice(0, 300);
@@ -12,11 +12,13 @@ import { siteOrigin } from '../../../../lib/seo';
12
12
  import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
13
13
  import { eventHolds } from '../../../../lib/booking/holds';
14
14
  import { fmt } from '../../../../lib/booking/view';
15
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
15
16
 
16
17
  // One resource: details, weekly hours, date overrides, calendars that block
17
18
  // time, who gets notified, the staff feed, and its booking links.
18
19
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
19
20
  const repo = await getRepo(Astro.locals);
21
+ if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage'); if (denied) return denied; }
20
22
  const id = Astro.params.id!;
21
23
  let resource = await repo.getResource(id);
22
24
  if (!resource) return new Response(null, { status: 404 });
@@ -10,12 +10,14 @@ import { DEFAULT_HOURS, int } from '../../../../lib/booking/forms';
10
10
  import { availabilityContext } from '../../../../lib/booking/service';
11
11
  import { describeLink, nextOpenings } from '../../../../lib/booking/availability';
12
12
  import { fmt } from '../../../../lib/booking/view';
13
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
13
14
 
14
15
  // Bookings → Rooms & people: everything people can book (rooms, people,
15
16
  // vehicles, equipment). Each row shows its booking links, whether they
16
17
  // confirm instantly, and when it's next free.
17
18
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
18
19
  const repo = await getRepo(Astro.locals);
20
+ if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage'); if (denied) return denied; }
19
21
  const settings = await repo.getSettings();
20
22
 
21
23
  if (Astro.request.method === 'POST') {
@@ -10,12 +10,14 @@ import { describeLink } from '../../../../lib/booking/availability';
10
10
  import { siteOrigin } from '../../../../lib/seo';
11
11
  import { APPROVAL_LABEL, linkApproval, siteApproval } from '../../../../lib/booking/approval';
12
12
  import { ApprovalRules } from '../../../../lib/schema';
13
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
13
14
 
14
15
  // One booking link: how people book, how bookings get approved (follow the
15
16
  // site setting, or its own mode + rules), when it's open (within the room or
16
17
  // person's hours), and what we ask.
17
18
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
18
19
  const repo = await getRepo(Astro.locals);
20
+ if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage'); if (denied) return denied; }
19
21
  const resource = await repo.getResource(Astro.params.id!);
20
22
  let link = resource ? await repo.getBookingLink(Astro.params.link!) : null;
21
23
  if (!resource || !link || link.resourceId !== resource.id) return new Response(null, { status: 404 });
@@ -13,6 +13,7 @@ import { placeUsage, savePlace } from '../../../lib/maps/places';
13
13
  import { parseMapLink } from '../../../lib/maps/parse';
14
14
  import { ApiError } from '../../../lib/api';
15
15
  import type { Place } from '../../../lib/schema';
16
+ import { forbiddenUnless } from '../../../lib/permissions/access';
16
17
 
17
18
  // Calendar settings — one page behind the ⚙ on Schedule and Bookings:
18
19
  // calendars (groups + colors), saved places (picked in an event's Where),
@@ -22,6 +23,7 @@ import type { Place } from '../../../lib/schema';
22
23
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
23
24
 
24
25
  const repo = await getRepo(Astro.locals);
26
+ if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage'); if (denied) return denied; }
25
27
  const settings = await repo.getSettings();
26
28
  const origin = siteOrigin(settings, Astro.url);
27
29
  const back = (notice?: string, hash = '') => Astro.redirect(`/admin/calendar/settings${notice ? `?notice=${encodeURIComponent(notice)}` : ''}${hash}`);
@@ -2,9 +2,9 @@
2
2
  import Admin from '../../layouts/Admin.astro';
3
3
  import EmailSettings from '../../components/EmailSettings';
4
4
  import { getRepo } from '../../lib/repo';
5
- import { requireSession } from '../../lib/api';
5
+ import { requirePermission } from '../../lib/permissions/access';
6
6
  const repo=await getRepo(Astro.locals);
7
- try { await requireSession(Astro.request,Astro.cookies,repo,true); }
7
+ try { await requirePermission(Astro.request, Astro.cookies, repo, 'services.manage'); }
8
8
  catch { return new Response('Only owners can manage email.',{status:403}); }
9
9
  ---
10
10
  <Admin title="Email" section="settings">
@@ -7,8 +7,10 @@ import { escapeForm } from '../../../../lib/forms/fields';
7
7
  import { siteOrigin } from '../../../../lib/seo';
8
8
  import { CONTENT } from '../../../../lib/content';
9
9
  import '../../../../styles/forms-admin.css';
10
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
10
11
 
11
12
  const repo = await getRepo(Astro.locals);
13
+ if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'forms.manage'); if (denied) return denied; }
12
14
  let form = await repo.forms.get(Astro.params.id || '');
13
15
  if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
14
16
  Astro.response.headers.set('Cache-Control', 'private, no-store');
@@ -8,8 +8,10 @@ import { csv, displayAnswers, formVersion } from '../../../../lib/forms/service'
8
8
  import { answerFields, relativeTime, versionLabeler, wideRows } from '../../../../lib/forms/export';
9
9
  import type { FormSubmission } from '../../../../lib/forms/schema';
10
10
  import '../../../../styles/forms-admin.css';
11
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
11
12
 
12
13
  const repo = await getRepo(Astro.locals);
14
+ { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'forms.responses'); if (denied) return denied; }
13
15
  const form = await repo.forms.get(Astro.params.id || '');
14
16
  if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
15
17
  Astro.response.headers.set('Cache-Control', 'private, no-store');
@@ -6,8 +6,10 @@ import { FormDefinition, starter, type SavedForm } from '../../../lib/forms/sche
6
6
  import { readForm } from '../../../lib/forms/http';
7
7
  import { relativeTime } from '../../../lib/forms/export';
8
8
  import '../../../styles/forms-admin.css';
9
+ import { forbiddenUnless } from '../../../lib/permissions/access';
9
10
 
10
11
  const repo = await getRepo(Astro.locals);
12
+ if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'forms.manage'); if (denied) return denied; }
11
13
  Astro.response.headers.set('Cache-Control', 'private, no-store');
12
14
  const BLANK = { title: '', intro: '', submitLabel: '', confirmation: 'Thank you. Your response has been received.', fields: [] };
13
15
  const STARTERS = [
@@ -3,14 +3,11 @@ import Admin from '../../layouts/Admin.astro';
3
3
  import ImportExport from '../../components/import/ImportExport';
4
4
  import { AUTH } from '../../lib/auth/config';
5
5
  import { getRepo } from '../../lib/repo';
6
- import { OWNER_ENV } from '../../lib/auth';
7
- import { validateRequestSession } from '../../lib/auth/request';
6
+ import { accessFor } from '../../lib/permissions/access';
8
7
 
9
- // owner-only: an import can create accounts and change site settings
8
+ // an import can create accounts and change site settings (owners by default)
10
9
  const repo = await getRepo(Astro.locals);
11
- const sessionUserId = await validateRequestSession(Astro.request, Astro.cookies, Astro.locals);
12
- const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
13
- if (sessionUserId !== OWNER_ENV && sessionUser?.role !== 'owner') return Astro.redirect('/admin/settings');
10
+ if (!(await accessFor(Astro.request, Astro.cookies, repo))?.can('data.import')) return Astro.redirect('/admin/settings');
14
11
  const tab = Astro.url.searchParams.get('tab');
15
12
  ---
16
13
  <Admin title="Import & export" section="settings" wide>
@@ -3,8 +3,7 @@ import Admin from '../../layouts/Admin.astro';
3
3
  import Modal from '../../components/Modal.astro';
4
4
  import { getRepo } from '../../lib/repo';
5
5
  import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
6
- import { OWNER_ENV } from '../../lib/auth';
7
- import { validateRequestSession } from '../../lib/auth/request';
6
+ import { accessFor } from '../../lib/permissions/access';
8
7
  import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
9
8
  import { issueApiKey } from '../../lib/api-keys';
10
9
  import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
@@ -16,9 +15,8 @@ import { PROVIDERS } from '../../lib/maps/providers';
16
15
  // Site-wide integrations. Owners manage keys and webhook subscriptions;
17
16
  // calendar-specific feeds and API help appear only when enabled.
18
17
  const repo = await getRepo(Astro.locals);
19
- const sessionId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
20
- const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
21
- const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
18
+ // owners, or roles granted “API keys and webhooks” on Team → Roles
19
+ const canManage = (await accessFor(Astro.request, Astro.cookies, repo))?.can('integrations.manage') ?? false;
22
20
  const settings = await repo.getSettings();
23
21
  let notice = '';
24
22
  let error = '';
@@ -27,7 +25,7 @@ let newSecret = '';
27
25
  const dev = import.meta.env.DEV;
28
26
 
29
27
  if (Astro.request.method === 'POST') {
30
- if (!isOwner) return new Response('Owners only', { status: 403 });
28
+ if (!canManage) return new Response('Your role doesn’t include “API keys and webhooks”.', { status: 403 });
31
29
  const form = await Astro.request.formData();
32
30
  const op = String(form.get('op'));
33
31
  try {
@@ -105,7 +103,7 @@ const SCOPE_LABEL: Record<string, string> = {
105
103
  </div>
106
104
  {notice && <p class="notice" role="status">{notice}</p>}
107
105
  {error && <p class="notice warn" role="alert">{error}</p>}
108
- {!isOwner && <p class="notice">Only owners can create keys or webhooks.</p>}
106
+ {!canManage && <p class="notice">Your role can view integrations but not create keys or webhooks.</p>}
109
107
  {newKey && (
110
108
  <div class="notice reveal">
111
109
  <b>Copy this key now — it won’t be shown again.</b>
@@ -124,20 +122,20 @@ const SCOPE_LABEL: Record<string, string> = {
124
122
  <div class="res-grid">
125
123
  <div>
126
124
  <section class="card card-pad">
127
- <div class="card-head"><b>API keys</b>{isOwner && <button class="btn sm" type="button" data-modal="new-key">New key</button>}</div>
125
+ <div class="card-head"><b>API keys</b>{canManage && <button class="btn sm" type="button" data-modal="new-key">New key</button>}</div>
128
126
  {keys.length === 0 && <p class="hint">No keys yet. Public calendars and bookings don’t need one — keys unlock private data and changes.</p>}
129
127
  {keys.map((k) => (
130
128
  <div class="int-row">
131
129
  <div><b>{k.name}</b> <code>{k.prefix}…</code><div class="int-scopes">{k.scopes.map((s) => <span class="pill lang">{s}</span>)}</div></div>
132
130
  <span class="hint">used {ago(k.lastUsedAt)}</span>
133
- {isOwner && <form method="post"><input type="hidden" name="op" value="revoke" /><input type="hidden" name="id" value={k.id} /><button class="btn sm ghost danger" data-confirm={`Revoke “${k.name}”?`}>Revoke</button></form>}
131
+ {canManage && <form method="post"><input type="hidden" name="op" value="revoke" /><input type="hidden" name="id" value={k.id} /><button class="btn sm ghost danger" data-confirm={`Revoke “${k.name}”?`}>Revoke</button></form>}
134
132
  </div>
135
133
  ))}
136
134
  <p class="hint">Content-agent keys can access calendar integrations. Design and media APIs require explicitly scoped keys.</p>
137
135
  </section>
138
136
 
139
137
  <section class="card card-pad">
140
- <div class="card-head"><b>Webhooks</b>{isOwner && <button class="btn sm" type="button" data-modal="new-hook">Add endpoint</button>}</div>
138
+ <div class="card-head"><b>Webhooks</b>{canManage && <button class="btn sm" type="button" data-modal="new-hook">Add endpoint</button>}</div>
141
139
  {hooks.length === 0 && <p class="hint">Get a signed POST when content goes live, a design is published, media changes or a booking comes in.</p>}
142
140
  {hooks.map((h) => {
143
141
  const log = logs.get(h.id) ?? [];
@@ -147,7 +145,7 @@ const SCOPE_LABEL: Record<string, string> = {
147
145
  <div class="int-row">
148
146
  <div><b class="mono">{h.url.replace(/^https?:\/\//, '').slice(0, 60)}</b><div class="int-scopes">{h.events.map((e) => <span class="pill lang">{e}</span>)}</div></div>
149
147
  <span class={`int-status ${h.lastStatus === null ? '' : ok ? 'ok' : 'bad'}`}>{h.lastStatus === null ? 'no deliveries yet' : `${h.lastStatus || 'no answer'} · ${ago(h.lastAt)}`}</span>
150
- {isOwner && (
148
+ {canManage && (
151
149
  <form method="post" class="int-acts">
152
150
  <input type="hidden" name="id" value={h.id} />
153
151
  <button class="btn sm" name="op" value="test">Send test</button>
@@ -156,7 +154,7 @@ const SCOPE_LABEL: Record<string, string> = {
156
154
  </form>
157
155
  )}
158
156
  </div>
159
- {isOwner && <details class="int-log">
157
+ {canManage && <details class="int-log">
160
158
  <summary>Edit event subscriptions</summary>
161
159
  <form method="post">
162
160
  <input type="hidden" name="op" value="subscriptions" />
@@ -2,10 +2,10 @@
2
2
  import Admin from '../../layouts/Admin.astro';
3
3
  import LiveManager from '../../components/LiveManager';
4
4
  import { getRepo } from '../../lib/repo';
5
- import { requireSession } from '../../lib/api';
5
+ import { requirePermission } from '../../lib/permissions/access';
6
6
  const repo = await getRepo(Astro.locals);
7
- try { await requireSession(Astro.request, Astro.cookies, repo, true); }
8
- catch { return new Response('Owner access is required.', { status: 403 }); }
7
+ try { await requirePermission(Astro.request, Astro.cookies, repo, 'services.manage'); }
8
+ catch { return new Response('Your role doesn’t include “Connected services”.', { status: 403 }); }
9
9
  ---
10
10
  <Admin title="Live video" section="integrations">
11
11
  <LiveManager client:only="react" />
@@ -3,11 +3,11 @@ import Admin from '../../layouts/Admin.astro';
3
3
  import AppAccess from '../../components/AppAccess';
4
4
  import MobileApp from '../../components/MobileApp';
5
5
  import { getRepo } from '../../lib/repo';
6
- import { requireSession } from '../../lib/api';
6
+ import { requirePermission } from '../../lib/permissions/access';
7
7
  import { PUSH } from '../../lib/push/config';
8
8
  const repo = await getRepo(Astro.locals);
9
- try { await requireSession(Astro.request, Astro.cookies, repo, true); }
10
- catch { return new Response('Owner access is required.', { status: 403 }); }
9
+ try { await requirePermission(Astro.request, Astro.cookies, repo, 'services.manage'); }
10
+ catch { return new Response('Your role doesn’t include “Connected services”.', { status: 403 }); }
11
11
  ---
12
12
  <Admin title="Mobile app" section="mobile">
13
13
  <div class="page-head">
@@ -5,6 +5,7 @@ import ContentList, { type ContentItem } from '../../../components/ContentList.a
5
5
  import { getRepo } from '../../../lib/repo';
6
6
  import { slugify, type Status } from '../../../lib/schema';
7
7
  import { I18N, localeHomeUrl } from '../../../lib/i18n';
8
+ import { forbiddenUnless } from '../../../lib/permissions/access';
8
9
 
9
10
  const repo = await getRepo(Astro.locals);
10
11
 
@@ -12,6 +13,8 @@ if (Astro.request.method === 'POST') {
12
13
  const form = await Astro.request.formData();
13
14
  const op = String(form.get('op'));
14
15
  if (op === 'create') {
16
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit');
17
+ if (denied) return denied;
15
18
  const title = String(form.get('title') || 'Untitled');
16
19
  let slug = slugify(String(form.get('slug') || title));
17
20
  // avoid slug collisions
@@ -22,7 +25,11 @@ if (Astro.request.method === 'POST') {
22
25
  }
23
26
  if (op === 'bulk') {
24
27
  const to = String(form.get('to'));
25
- for (const id of form.getAll('ids').map(String).filter(Boolean)) {
28
+ const ids = form.getAll('ids').map(String).filter(Boolean);
29
+ const touchesLive = to === 'published' || (await Promise.all(ids.map((id) => repo.getPage(id)))).some((p) => p?.status === 'published' || p?.status === 'scheduled');
30
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, to === 'delete' ? 'content.delete' : touchesLive ? 'content.publish' : 'content.edit');
31
+ if (denied) return denied;
32
+ for (const id of ids) {
26
33
  if (to === 'delete') await repo.deletePage(id);
27
34
  else if (to === 'published' || to === 'draft' || to === 'archived') await repo.updatePage(id, { status: to as Status });
28
35
  }
@@ -0,0 +1,80 @@
1
+ ---
2
+ import Admin from '../../layouts/Admin.astro';
3
+ import RolesManager from '../../components/RolesManager';
4
+ import { getRepo } from '../../lib/repo';
5
+ import { accessFor } from '../../lib/permissions/access';
6
+
7
+ // Team → Roles: owners edit what each role can do; administrators can look.
8
+ const repo = await getRepo(Astro.locals);
9
+ const access = await accessFor(Astro.request, Astro.cookies, repo);
10
+ if (access?.role !== 'owner' && access?.role !== 'administrator') return Astro.redirect('/admin/users');
11
+ ---
12
+ <Admin title="Roles" section="roles">
13
+ <div class="page-head">
14
+ <div>
15
+ <h1>Roles &amp; permissions</h1>
16
+ <p class="sub">Choose what each role can do. Changes stay in your draft until you save.</p>
17
+ </div>
18
+ </div>
19
+ <RolesManager client:only="react" />
20
+ </Admin>
21
+
22
+ <style is:global>
23
+ .rp { padding-bottom: 72px; }
24
+ .rp-bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
25
+ .rp-flash { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
26
+ .rp-flash form { margin: 0; }
27
+ .rp-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 16px; align-items: start; }
28
+ .rp-roles { display: grid; gap: 2px; position: sticky; top: 16px; }
29
+ .rp-role { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 9px 11px; border: 0; border-radius: var(--r-md); background: none; color: var(--text-2); font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
30
+ .rp-role:hover { background: var(--hover); }
31
+ .rp-role.on { background: var(--sel); color: var(--text); font-weight: 600; }
32
+ .rp-role-name { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
33
+ .rp-role small { color: var(--muted); font-weight: 400; font-size: 12px; white-space: nowrap; }
34
+ .rp-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; margin-right: 2px; }
35
+ .rp-panel { padding: 4px 20px 16px; }
36
+ .rp-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 16px 0 10px; }
37
+ .rp-head h2 { font-size: 18px; margin: 0 0 2px; display: flex; align-items: center; gap: 8px; }
38
+ .rp-head .hint { font-size: 13px; color: var(--muted); margin: 0; }
39
+ .rp-area { border-top: 1px solid var(--line); padding: 12px 0 4px; }
40
+ .rp-area-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-size: 14px; }
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
+ .rp-row.changed { background: var(--accent-soft); }
43
+ .rp-text { display: grid; gap: 1px; }
44
+ .rp-label { font-size: 14px; display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
45
+ .rp-text small { color: var(--muted); font-size: 12.5px; }
46
+ .rp-switch { width: 34px; height: 20px; border-radius: 99px; border: 0; padding: 0; background: var(--line-strong); position: relative; cursor: pointer; flex: none; }
47
+ .rp-switch i { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / 25%); transition: left 0.15s; }
48
+ .rp-switch.on { background: var(--accent); }
49
+ .rp-switch.on i { left: 16px; }
50
+ .rp-switch:disabled { cursor: not-allowed; opacity: 0.55; }
51
+ .rp-switch:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; }
52
+ .rp-compare { overflow-x: auto; }
53
+ .rp-compare th, .rp-compare td { text-align: center; white-space: nowrap; }
54
+ .rp-compare th:first-child, .rp-compare td:first-child { text-align: left; white-space: normal; }
55
+ .rp-compare-area td { background: var(--panel-2); font-size: 13px; }
56
+ .rp-cell { color: var(--faint); }
57
+ .rp-cell.on { color: var(--green); font-weight: 700; }
58
+ .rp-cell.changed { background: var(--accent-soft); }
59
+ .rp-compare-note { padding: 10px 16px; margin: 0; }
60
+ .rp-history { margin-top: 16px; }
61
+ .rp-history ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
62
+ .rp-history li { display: grid; gap: 2px; font-size: 13.5px; }
63
+ .rp-history small { color: var(--muted); }
64
+ .rp-savebar { position: fixed; left: 50%; bottom: calc(16px + var(--tabbar-h)); transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: 16px; padding: 8px 8px 8px 16px; border-radius: var(--r-lg); background: var(--code-bg); color: var(--code-text); box-shadow: var(--shadow-pop); font-size: 14px; max-width: calc(100vw - 32px); flex-wrap: wrap; }
65
+ .rp-savebar-actions { display: flex; gap: 8px; }
66
+ .rp-savebar .btn:not(.primary) { background: transparent; color: var(--code-text); border-color: rgb(255 255 255 / 18%); }
67
+ .rp-overlay { position: fixed; inset: 0; z-index: 40; background: var(--dim); display: flex; align-items: center; justify-content: center; padding: 20px; }
68
+ .rp-dialog { background: var(--panel); border-radius: var(--r-lg); box-shadow: var(--shadow-pop); width: min(460px, 100%); max-height: calc(100vh - 40px); overflow: auto; padding: 20px; }
69
+ .rp-dialog h2 { font-size: 17px; margin: 0 0 12px; }
70
+ .rp-dialog-body p { margin: 0 0 10px; font-size: 14px; }
71
+ .rp-review-role { margin-bottom: 12px; font-size: 14px; }
72
+ .rp-review-role ul { list-style: none; margin: 4px 0 0; padding: 0; }
73
+ .rp-review-role li { padding: 2px 0; }
74
+ .rp-review-role li.plus { color: var(--green); }
75
+ .rp-review-role li.minus { color: var(--red); }
76
+ @media (max-width: 760px) {
77
+ .rp-grid { grid-template-columns: 1fr; }
78
+ .rp-roles { position: static; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
79
+ }
80
+ </style>