slotlock 0.3.0 → 0.5.1

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/dashboard.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // The dashboard: people sign in with GitHub and manage the API keys and calendar resources of a
2
- // personal tenant, `github:<user id>`. It is server-rendered HTML with no inline script or style,
2
+ // tenant: their own, `github:<user id>`, or with `organizations`, a GitHub organization's,
3
+ // `github-org:<organization id>`, shared by its owners and members. It is server-rendered HTML with no inline script or style,
3
4
  // so its Content-Security-Policy allows nothing but this server's own stylesheet and script.
4
5
  //
5
- // Sign-in is GitHub's OAuth web flow with `state` and PKCE (S256) and no scope: the token is used
6
- // once, to read who signed in, and dropped. A session is an HMAC-sealed cookie (`__Host-`,
6
+ // Sign-in is GitHub's OAuth web flow with `state` and PKCE (S256), with no scope (`read:org` with
7
+ // organizations): the token is used once, to read who signed in and their memberships, and dropped. A session is an HMAC-sealed cookie (`__Host-`,
7
8
  // HttpOnly, Secure, SameSite=Lax) that lasts 12 hours and is checked against the allowlist on every
8
9
  // request. Every form carries a CSRF token bound to the session, and every POST must come from this
9
10
  // server's own origin.
@@ -13,8 +14,11 @@
13
14
  // keeps these in Postgres, so they hold on every server that shares the database; the resource cap
14
15
  // is held in the database too.
15
16
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
+ import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
16
18
  import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
17
19
  import { ignoreRejection } from './hooks.js';
20
+ import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
21
+ import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
18
22
  import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
19
23
  /** Resources one dashboard tenant may hold. */
20
24
  export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
@@ -38,7 +42,14 @@ const POST_ROUTES = new Set([
38
42
  '/keys/rotate',
39
43
  '/keys/revoke',
40
44
  '/resources',
45
+ '/resources/rules',
46
+ '/resources/pool',
47
+ '/webhooks',
48
+ '/webhooks/delete',
49
+ '/webhooks/enable',
50
+ '/waitlist/leave',
41
51
  '/hours',
52
+ '/organization',
42
53
  ]);
43
54
  const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
44
55
  const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
@@ -77,20 +88,48 @@ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-sr
77
88
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
78
89
  */
79
90
  export function createSlotlockDashboardResources(store) {
91
+ const by = (actor) => (actor === undefined ? {} : { actor });
80
92
  return {
81
93
  list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
82
- add: (tenantRef, externalRef, timezone) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
94
+ add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
83
95
  tenantRef,
84
96
  externalRef,
85
97
  timezone,
86
98
  maxTenantResources: SLOTLOCK_DASHBOARD_MAX_RESOURCES,
87
- }), { isolation: 'read committed' }),
88
- setHours: (tenantRef, target, rules) => store.withTenant(tenantRef, async (tenant) => {
99
+ }), { isolation: 'read committed', ...by(actor) }),
100
+ setHours: (tenantRef, target, rules, actor) => store.withTenant(tenantRef, async (tenant) => {
89
101
  if (target === 'all')
90
102
  return tenant.setTenantAvailability({ tenantRef, rules });
91
103
  const updated = await tenant.setResourceAvailability({ tenantRef, id: target.id, rules });
92
104
  return updated ? 1 : 0;
93
- }),
105
+ }, by(actor)),
106
+ agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
107
+ recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
108
+ setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
109
+ setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
110
+ waitlist: {
111
+ list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
112
+ async leave(tenantRef, id, actor) {
113
+ const left = await store.withTenant(tenantRef, (tenant) => tenant.leaveWaitlist({ tenantRef, id }), by(actor));
114
+ // An offered entry's hold went with it: offer that time to the next, as the API does.
115
+ if (left) {
116
+ await store
117
+ .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }))
118
+ .catch(() => undefined);
119
+ }
120
+ return left;
121
+ },
122
+ },
123
+ webhooks: {
124
+ list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listWebhookEndpoints({ tenantRef })),
125
+ create: (tenantRef, url, eventTypes, actor) => store.withTenant(tenantRef, (tenant) => tenant.createWebhookEndpoint({
126
+ tenantRef,
127
+ url,
128
+ ...(eventTypes !== undefined ? { eventTypes } : {}),
129
+ }), by(actor)),
130
+ remove: async (tenantRef, id, actor) => (await store.withTenant(tenantRef, (tenant) => tenant.deleteWebhookEndpoint({ tenantRef, id }), by(actor))).deleted,
131
+ enable: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.enableWebhookEndpoint({ tenantRef, id }), by(actor)),
132
+ },
94
133
  };
95
134
  }
96
135
  function assertExpiry(expiresAt) {
@@ -102,6 +141,28 @@ function formLimitError() {
102
141
  return Object.assign(new Error(`Slotlock dashboard: ${SLOTLOCK_DASHBOARD_FORM_LIMIT} forms are live for this person`), { code: 'dashboard_form_limit' });
103
142
  }
104
143
  const ownerDigest = (owner) => createHash('sha256').update(`slotlock-dashboard-owner.${owner}`).digest();
144
+ /**
145
+ * Records accepted write confirmations (`SlotlockAgentConfirmationOptions.consumeAcceptance`) in
146
+ * Postgres, shared by every server on the database: true the first time a confirmation's nonce is
147
+ * recorded, false while it stays recorded, so an accepted confirmation runs its write once on any
148
+ * server. Kept until the confirmation's own expiry, through the same function and table as the
149
+ * dashboard's records (only a SHA-256 digest of the nonce is stored); pass a client, not a
150
+ * transaction.
151
+ */
152
+ export function createSlotlockAcceptanceRecords(sql) {
153
+ if (!('begin' in sql)) {
154
+ throw new Error('createSlotlockAcceptanceRecords needs a client, not a transaction: a rollback would undo its records');
155
+ }
156
+ const client = sql;
157
+ return async ({ nonce, expiresAt }) => {
158
+ const digest = createHash('sha256').update(`slotlock-confirmation.${nonce}`).digest();
159
+ return (await client.begin('isolation level read committed', async (tx) => {
160
+ const [row] = await tx `
161
+ SELECT slotlock.use_dashboard_token('confirmation', ${digest}, ${expiresAt}, NULL::bytea) AS used`;
162
+ return row?.used === true;
163
+ }));
164
+ };
165
+ }
105
166
  /**
106
167
  * The dashboard's records in Postgres, shared by every server on the database. Run `applySchema()`
107
168
  * and `grantApplicationRole()` first: the serving role reaches the records only through
@@ -189,6 +250,9 @@ export function createSlotlockMemoryDashboardState(now = Date.now) {
189
250
  },
190
251
  };
191
252
  }
253
+ /** Served organizations one session lists at most: the session is a cookie of at most 4 KB. */
254
+ const MAX_SESSION_ORGANIZATIONS = 20;
255
+ const ORGANIZATION_ID = /^[1-9]\d{0,19}$/;
192
256
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
193
257
  function escapeHtml(value) {
194
258
  return value
@@ -219,6 +283,100 @@ function html(strings, ...values) {
219
283
  });
220
284
  return new Html(out);
221
285
  }
286
+ /** Days of bookings the overview shows, how many it reads and lists, and its activity's length. */
287
+ const AGENDA_DAYS = 7;
288
+ const AGENDA_LIMIT = 200;
289
+ const AGENDA_SHOWN = 30;
290
+ const ACTIVITY_LIMIT = 20;
291
+ const WAITLIST_SHOWN = 200;
292
+ const RESOURCE_PAGE = /^\/resources\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
293
+ /** How the booking rules form names each rule. */
294
+ const RULE_LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
295
+ const RULE_NAMES = Object.freeze({
296
+ minNoticeMinutes: 'Minimum notice',
297
+ maxAdvanceDays: 'Book up to',
298
+ slotStepMinutes: 'Start step',
299
+ turnaroundMinutes: 'Turnaround',
300
+ checkInMinutes: 'Check-in release',
301
+ });
302
+ /** The booking rules form's fields, by rule. */
303
+ const RULE_FIELDS = Object.freeze({
304
+ minNoticeMinutes: 'min_notice_minutes',
305
+ maxAdvanceDays: 'max_advance_days',
306
+ slotStepMinutes: 'slot_step_minutes',
307
+ turnaroundMinutes: 'turnaround_minutes',
308
+ checkInMinutes: 'check_in_minutes',
309
+ });
310
+ const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
311
+ const ICONS = Object.freeze({
312
+ mark: new Html('<svg class="mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path d="M8 46 L26 14 L56 44"></path></svg>'),
313
+ overview: icon('<path d="M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-3H4zM14 7h6V4h-6z"/>'),
314
+ resources: icon('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
315
+ keys: icon('<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/>'),
316
+ waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
317
+ webhooks: icon('<path d="M9 7a3 3 0 1 1 4 2.8L16 15M6.5 13.5A3 3 0 1 0 9 18h6M15 18a3 3 0 1 0 1.5-5.6"/>'),
318
+ settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
319
+ check: icon('<path d="M5 12l5 5L20 7"/>'),
320
+ github: new Html('<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>'),
321
+ });
322
+ /** How the overview names each change the feed records. */
323
+ const CHANGE_LABELS = Object.freeze({
324
+ 'hold.created': { text: 'Hold placed', tone: 'neutral' },
325
+ 'hold.updated': { text: 'Hold changed', tone: 'neutral' },
326
+ 'hold.confirmed': { text: 'Hold booked', tone: 'good' },
327
+ 'hold.released': { text: 'Hold released', tone: 'neutral' },
328
+ 'hold.expired': { text: 'Hold expired', tone: 'neutral' },
329
+ 'reservation.created': { text: 'Booked', tone: 'good' },
330
+ 'reservation.updated': { text: 'Booking moved', tone: 'neutral' },
331
+ 'reservation.cancelled': { text: 'Booking cancelled', tone: 'bad' },
332
+ 'reservation.checked_in': { text: 'Checked in', tone: 'good' },
333
+ 'reservation.no_show': { text: 'No-show released', tone: 'bad' },
334
+ 'event.created': { text: 'Event added', tone: 'good' },
335
+ 'event.updated': { text: 'Event changed', tone: 'neutral' },
336
+ 'event.cancelled': { text: 'Event cancelled', tone: 'bad' },
337
+ 'event.timezone_shifted': { text: 'Event moved by a time zone change', tone: 'neutral' },
338
+ 'event.timezone_conflict': { text: 'Event clashes after a time zone change', tone: 'bad' },
339
+ 'coverage.stale': { text: 'Calendar sync is behind', tone: 'bad' },
340
+ 'waitlist.offered': { text: 'Waitlist offer sent', tone: 'good' },
341
+ 'waitlist.expired': { text: 'Waitlist offer lapsed', tone: 'neutral' },
342
+ 'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
343
+ });
344
+ const zoned = (zone, parts) => {
345
+ try {
346
+ return new Intl.DateTimeFormat('en-GB', { ...parts, timeZone: zone });
347
+ }
348
+ catch {
349
+ return new Intl.DateTimeFormat('en-GB', { ...parts, timeZone: 'UTC' });
350
+ }
351
+ };
352
+ /** `14:30` in the resource's own time zone. */
353
+ function clock(date, zone) {
354
+ return zoned(zone, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date);
355
+ }
356
+ /** The day a time falls on in a zone: `Today`, `Tomorrow`, or `Thu 30 Oct`, with a key to group by. */
357
+ function dayLabel(date, zone, nowMs) {
358
+ const key = (at) => zoned(zone, { year: 'numeric', month: '2-digit', day: '2-digit' }).format(at);
359
+ const day = key(date);
360
+ if (day === key(new Date(nowMs)))
361
+ return { key: day, label: 'Today' };
362
+ if (day === key(new Date(nowMs + DAY_MS)))
363
+ return { key: day, label: 'Tomorrow' };
364
+ return {
365
+ key: day,
366
+ label: zoned(zone, { weekday: 'short', day: 'numeric', month: 'short' }).format(date),
367
+ };
368
+ }
369
+ /** `just now`, `5 min ago`, `3 h ago`, `2 d ago`. */
370
+ function ago(date, nowMs) {
371
+ const minutes = Math.max(0, Math.floor((nowMs - date.getTime()) / 60_000));
372
+ if (minutes < 1)
373
+ return 'just now';
374
+ if (minutes < 60)
375
+ return `${minutes} min ago`;
376
+ if (minutes < 1_440)
377
+ return `${Math.floor(minutes / 60)} h ago`;
378
+ return `${Math.floor(minutes / 1_440)} d ago`;
379
+ }
222
380
  function safeEqual(a, b) {
223
381
  const left = createHash('sha256').update(a).digest();
224
382
  const right = createHash('sha256').update(b).digest();
@@ -424,6 +582,16 @@ export function createSlotlockDashboard(options) {
424
582
  const authorizeUrl = options.github.authorizeUrl ?? 'https://github.com/login/oauth/authorize';
425
583
  const tokenUrl = options.github.tokenUrl ?? 'https://github.com/login/oauth/access_token';
426
584
  const userUrl = options.github.userUrl ?? 'https://api.github.com/user';
585
+ const membershipsUrl = options.github.membershipsUrl ??
586
+ 'https://api.github.com/user/memberships/orgs?state=active&per_page=100';
587
+ const served = options.organizations ? new Set(options.organizations) : null;
588
+ if (served && (served.size === 0 || [...served].some((id) => !ORGANIZATION_ID.test(id)))) {
589
+ throw new Error('Slotlock dashboard organizations must be GitHub organization ids');
590
+ }
591
+ const brandName = options.name ?? 'Slotlock';
592
+ if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
593
+ throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
594
+ }
427
595
  const fetchImpl = options.fetch ?? fetch;
428
596
  const now = options.now ?? Date.now;
429
597
  if (Buffer.byteLength(options.sessionSecret, 'utf8') < 32) {
@@ -444,18 +612,26 @@ export function createSlotlockDashboard(options) {
444
612
  // A failing reporter must not change the response.
445
613
  }
446
614
  }
447
- const formMac = (session, nonce) => createHmac('sha256', secret)
448
- .update(`slotlock-dashboard-form-v1.${session.sid}.${nonce}`)
615
+ // Bound to the organization too: a page opened before a switch cannot write into the new one.
616
+ const formMac = (session, nonce, org = session.org ?? '') => createHmac('sha256', secret)
617
+ .update(`slotlock-dashboard-form-v2.${session.sid}.${org}.${nonce}`)
449
618
  .digest('base64url');
450
619
  /** Random, and bound to the session by a MAC: a made-up or another session's value is refused unwritten. */
451
620
  const formNonce = (session) => {
452
621
  const nonce = randomBytes(18).toString('base64url');
453
622
  return `${nonce}.${formMac(session, nonce)}`;
454
623
  };
455
- const isFormNonce = (session, once) => {
624
+ /** `ok`, `other_org` for a form from a page of another of the session's organizations, else `bad`. */
625
+ const checkFormNonce = (session, once) => {
456
626
  const match = FORM_NONCE.exec(once);
627
+ if (match === null)
628
+ return 'bad';
629
+ const [, nonce, mac] = match;
457
630
  // Compared as text, like the cookie MACs: decoding would drop the last character's spare bits.
458
- return match !== null && safeEqual(match[2], formMac(session, match[1]));
631
+ if (safeEqual(mac, formMac(session, nonce)))
632
+ return 'ok';
633
+ const other = session.orgs?.some((org) => org.id !== session.org && safeEqual(mac, formMac(session, nonce, org.id)));
634
+ return other ? 'other_org' : 'bad';
459
635
  };
460
636
  function seal(context, payload) {
461
637
  const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
@@ -493,15 +669,30 @@ export function createSlotlockDashboard(options) {
493
669
  typeof payload.login !== 'string' ||
494
670
  typeof payload.sid !== 'string' ||
495
671
  !allowed(payload.uid) ||
672
+ !organizationsHold(payload) ||
496
673
  (await options.state.sessionEnded(payload.sid, payload.exp))) {
497
674
  return null;
498
675
  }
499
676
  return payload;
500
677
  }
678
+ /**
679
+ * With organizations, the session names one the server still serves, from its own list; without
680
+ * them, it names none. A session from the other mode is signed out.
681
+ */
682
+ function organizationsHold(payload) {
683
+ if (!served)
684
+ return payload.org === undefined && payload.orgs === undefined;
685
+ if (typeof payload.org !== 'string' || !served.has(payload.org) || !Array.isArray(payload.orgs)) {
686
+ return false;
687
+ }
688
+ return payload.orgs.some((org) => isRecord(org) && org.id === payload.org && typeof org.login === 'string');
689
+ }
501
690
  const csrfToken = (session) => createHmac('sha256', secret)
502
691
  .update(`slotlock-dashboard-csrf-v1.${session.sid}`)
503
692
  .digest('base64url');
504
- const tenantOf = (session) => `github:${session.uid}`;
693
+ const tenantOf = (session) => served ? `github-org:${session.org}` : `github:${session.uid}`;
694
+ /** Who did something, for the record: the person, whatever tenant they act in. */
695
+ const personOf = (session) => `github:${session.uid}`;
505
696
  function respond(status, body, headers = {}, cookies = []) {
506
697
  const response = new Response(String(body), {
507
698
  status,
@@ -521,34 +712,75 @@ export function createSlotlockDashboard(options) {
521
712
  return response;
522
713
  }
523
714
  const redirect = (status, location, cookies = []) => respond(status, '', { Location: location }, cookies);
524
- function layout(title, content, session) {
525
- const account = session
526
- ? html `<div class="account"><span class="login">@${session.login}</span>
527
- <form method="post" action="${root}/sign-out"><input type="hidden" name="csrf" value="${csrfToken(session)}"><button class="link" type="submit">Sign out</button></form></div>`
528
- : html ``;
529
- return html `<!doctype html>
715
+ // Pages for what the adapter offers, so a custom one without a waitlist shows none. Read per
716
+ // request: on Workers the adapter exists only inside one.
717
+ const nav = () => [
718
+ { section: 'overview', label: 'Overview', path: '', icon: ICONS.overview },
719
+ { section: 'resources', label: 'Resources', path: '/resources', icon: ICONS.resources },
720
+ ...(options.resources.waitlist
721
+ ? [{ section: 'waitlist', label: 'Waitlist', path: '/waitlist', icon: ICONS.waitlist }]
722
+ : []),
723
+ { section: 'keys', label: 'API keys', path: '/keys', icon: ICONS.keys },
724
+ ...(options.resources.webhooks
725
+ ? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
726
+ : []),
727
+ { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
728
+ ];
729
+ /** The workspace the session acts in: its organization, or the person themselves. */
730
+ function workspace(session) {
731
+ const current = session.orgs?.find((org) => org.id === session.org);
732
+ const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
733
+ const name = current?.login ?? session.login;
734
+ const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
735
+ if (current && choices.length > 1) {
736
+ return html `<form class="workspace" method="post" action="${root}/organization">${badge}<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="organization">Organization</label><select id="organization" name="organization">${choices.map((org) => html `<option value="${org.id}"${org.id === current.id ? html ` selected` : html ``}>${org.login}</option>`)}</select><button class="quiet" type="submit">Switch</button></form>`;
737
+ }
738
+ return html `<div class="workspace">${badge}<span class="workspace-name">${current ? name : `@${name}`}</span></div>`;
739
+ }
740
+ function layout(title, content, session, section) {
741
+ const head = html `<!doctype html>
530
742
  <html lang="en">
531
743
  <head>
532
744
  <meta charset="utf-8">
533
745
  <meta name="viewport" content="width=device-width, initial-scale=1">
534
746
  <meta name="referrer" content="${REFERRER_POLICY}">
535
- <title>${title} · Slotlock</title>
747
+ <title>${title} · ${brandName}</title>
536
748
  <link rel="stylesheet" href="${root}/style.css?v=${ASSET_VERSIONS.css}">
537
749
  <script src="${root}/app.js?v=${ASSET_VERSIONS.js}" defer></script>
538
- </head>
750
+ </head>`;
751
+ const brand = html `<a class="brand" href="${root}">${ICONS.mark}<span class="name">${brandName}</span></a>`;
752
+ if (!session) {
753
+ return html `${head}
754
+ <body class="bare">
755
+ <header class="bare-bar">${brand}</header>
756
+ <main class="bare-main">${content}</main>
757
+ </body>
758
+ </html>`;
759
+ }
760
+ const links = nav().map((item) => html `<a href="${root}${item.path}"${item.section === section ? html ` aria-current="page"` : html ``}>${item.icon}<span>${item.label}</span></a>`);
761
+ return html `${head}
539
762
  <body>
540
- <header class="bar"><a class="brand" href="${root}"><svg class="mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path d="M8 46 L26 14 L56 44"></path></svg><span class="name">Slotlock</span> <span class="by">by Pylota</span></a>${account}</header>
541
- <main>${content}</main>
763
+ <div class="app">
764
+ <aside class="side">
765
+ <div class="side-top">${brand}${workspace(session)}</div>
766
+ <nav class="nav" aria-label="Dashboard">${links}</nav>
767
+ <div class="me"><span class="login">@${session.login}</span><form method="post" action="${root}/sign-out"><input type="hidden" name="csrf" value="${csrfToken(session)}"><button class="quiet" type="submit">Sign out</button></form></div>
768
+ </aside>
769
+ <main class="page">${content}</main>
770
+ </div>
542
771
  </body>
543
772
  </html>`;
544
773
  }
774
+ const noticeOf = (notice) => notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``;
545
775
  function signedOutPage(status, notice, cookies = []) {
546
- const content = html `<section class="hero">
547
- <h1>Your agents' calendar keys</h1>
548
- <p>Sign in with GitHub to create API keys for the Slotlock tools, add the resources your agents book, and connect an MCP or A2A client.</p>
549
- ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
550
- <a class="button primary" href="${root}/sign-in">Sign in with GitHub</a>
551
- <p class="fine">Slotlock reads only your public GitHub profile, to know who you are, and keeps nothing from GitHub but your user id and login.</p>
776
+ const content = html `<section class="sign-in">
777
+ <h1>Sign in to ${brandName}</h1>
778
+ <p>Create keys for your agents, add what they book, and watch what they do.</p>
779
+ ${noticeOf(notice)}
780
+ <a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>
781
+ <p class="fine">${served
782
+ ? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
783
+ : `${brandName} reads only your public GitHub profile, and keeps only your user id and login.`}</p>
552
784
  </section>`;
553
785
  return respond(status, layout('Sign in', content, null), {}, cookies);
554
786
  }
@@ -557,39 +789,132 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
557
789
  const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
558
790
  const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
559
791
  const actions = status === 'Active'
560
- ? html `<details class="act"><summary>Rotate</summary><p>A new secret replaces this one at once. The key keeps its name, access and bookings.</p>
561
- <form method="post" action="${root}/keys/rotate"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button type="submit">Rotate key</button></form></details>
562
- <details class="act danger"><summary>Revoke</summary><p>Requests with this key fail at once. What it booked stays on the calendar.</p>
563
- <form method="post" action="${root}/keys/revoke"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button class="danger" type="submit">Revoke key</button></form></details>`
792
+ ? html `<details class="act"><summary>Rotate</summary><div class="pop"><p>A new secret replaces this one at once. The key keeps its name, access and bookings.</p>
793
+ <form method="post" action="${root}/keys/rotate"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button type="submit">Rotate key</button></form></div></details>
794
+ <details class="act danger"><summary>Revoke</summary><div class="pop"><p>Requests with this key fail at once. What it booked stays on the calendar.</p>
795
+ <form method="post" action="${root}/keys/revoke"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button class="danger" type="submit">Revoke key</button></form></div></details>`
564
796
  : html ``;
565
797
  return html `<tr>
566
- <td data-label="Name">${key.name}</td>
798
+ <td data-label="Name" class="strong">${key.name}</td>
567
799
  <td data-label="Key"><code>${key.prefix}…</code></td>
568
800
  <td data-label="Access">${key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
569
801
  <td data-label="Created">${formatDate(key.createdAt)}</td>
802
+ ${served ? html `<td data-label="Created by">${key.createdBy === personOf(session) ? 'You' : key.createdBy?.startsWith('github:') ? `GitHub user ${key.createdBy.slice(7)}` : 'Unknown'}</td>` : html ``}
570
803
  <td data-label="Last used">${key.lastUsedAt ? formatDate(key.lastUsedAt) : 'Never'}</td>
571
804
  <td data-label="Expires">${key.expiresAt ? formatDate(key.expiresAt) : 'Never'}</td>
572
- <td data-label="Status"><span class="status ${status.toLowerCase()}">${status}</span></td>
805
+ <td data-label="Status"><span class="pill ${status.toLowerCase()}">${status}</span></td>
573
806
  <td class="actions">${actions}</td>
574
807
  </tr>`;
575
808
  });
576
809
  }
577
- async function dashboardPage(session, status = 200, notice) {
810
+ const pageHead = (title, action) => html `<header class="page-head"><h1>${title}</h1>${action ?? html ``}</header>`;
811
+ /** A resource as people name it: its reference, else the start of its id. */
812
+ const resourceName = (resource, id) => resource?.externalRef ?? `${id.slice(0, 8)}…`;
813
+ function agendaPanel(entries, byId, more) {
814
+ if (entries.length === 0) {
815
+ return html `<p class="empty">Nothing booked in the next ${String(AGENDA_DAYS)} days.</p>`;
816
+ }
817
+ const zones = new Set(entries.map((entry) => byId.get(entry.resourceId)?.timezone ?? 'UTC'));
818
+ const groups = new Map();
819
+ for (const entry of entries.slice(0, AGENDA_SHOWN)) {
820
+ const resource = byId.get(entry.resourceId);
821
+ const zone = resource?.timezone ?? 'UTC';
822
+ const day = dayLabel(entry.start, zone, now());
823
+ const group = groups.get(day.key) ?? { label: day.label, rows: [] };
824
+ const name = resourceName(resource, entry.resourceId);
825
+ // An event by its summary; a booking or hold by what it occupies, and a booking's own
826
+ // reference when it is one a person chose (not the id Slotlock gave it).
827
+ const title = entry.kind === 'event' ? (entry.summary ?? name) : name;
828
+ const meta = [
829
+ entry.kind === 'event' ? name : null,
830
+ entry.reference && !UUID_PATTERN.test(entry.reference) ? entry.reference : null,
831
+ zones.size > 1 ? zone : null,
832
+ entry.source && !entry.source.startsWith('calendar-event:') ? entry.source : null,
833
+ ].filter((part) => part !== null);
834
+ const pill = entry.kind === 'hold' && entry.expiresAt
835
+ ? html `<span class="pill held">Held until ${clock(entry.expiresAt, zone)}</span>`
836
+ : html `<span class="pill ${entry.kind}">${entry.kind === 'event' ? 'Event' : 'Booked'}</span>`;
837
+ group.rows.push(html `<li class="slot"><span class="when">${clock(entry.start, zone)}<span class="until">${clock(entry.end, zone)}</span></span><span class="what"><span class="title">${title}</span>${meta.length > 0 ? html `<span class="meta">${meta.join(' · ')}</span>` : html ``}</span>${pill}</li>`);
838
+ groups.set(day.key, group);
839
+ }
840
+ return html `${[...groups.values()].map((group) => html `<h3 class="day">${group.label}</h3><ul class="slots">${group.rows}</ul>`)}${more ? html `<p class="fine">Showing the first ${String(AGENDA_SHOWN)}.</p>` : html ``}`;
841
+ }
842
+ function activityPanel(changes, byId, keys, session) {
843
+ if (changes.length === 0) {
844
+ return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
845
+ }
846
+ const keyNames = new Map(keys.map((key) => [`api_key:${key.id}`, key.name]));
847
+ return html `<ol class="feed">${changes.map((change) => {
848
+ const label = CHANGE_LABELS[change.type] ?? { text: change.type, tone: 'neutral' };
849
+ const who = change.actor === null
850
+ ? brandName
851
+ : change.actor === personOf(session)
852
+ ? 'You'
853
+ : (keyNames.get(change.actor) ??
854
+ (change.actor.startsWith('api_key:')
855
+ ? 'A deleted key'
856
+ : /^github:\d+$/.test(change.actor)
857
+ ? `GitHub user ${change.actor.slice(7)}`
858
+ : change.actor.slice(0, 60)));
859
+ const where = change.resourceId
860
+ ? resourceName(byId.get(change.resourceId), change.resourceId)
861
+ : null;
862
+ return html `<li class="event"><span class="dot ${label.tone}" aria-hidden="true"></span><span class="what"><span class="title">${label.text}${where ? html ` <span class="on">on</span> ${where}` : html ``}</span><span class="meta">${who} · <time datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></span></span></li>`;
863
+ })}</ol>`;
864
+ }
865
+ async function overviewPage(session, status = 200, notice) {
578
866
  const tenantRef = tenantOf(session);
579
- const [keys, resources] = await Promise.all([
867
+ const start = new Date(now());
868
+ const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
869
+ const [keys, resources, agenda, changes] = await Promise.all([
580
870
  options.keys.list({ tenantRef }),
581
871
  options.resources.list(tenantRef),
872
+ options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
873
+ options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
582
874
  ]);
875
+ const byId = new Map(resources.map((resource) => [resource.id, resource]));
876
+ const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
877
+ const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
878
+ const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
879
+ const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
880
+ const stat = (label, value, href) => html `<a class="stat" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
881
+ const steps = [
882
+ { done: resources.length > 0, text: 'Add what your agents book', href: `${root}/resources` },
883
+ { done: active.length > 0, text: 'Create a key for an agent', href: `${root}/keys` },
884
+ { done: (changes?.length ?? 0) > 0, text: 'Connect the agent and book', href: `${root}/keys` },
885
+ ];
886
+ const content = html `${noticeOf(notice)}
887
+ ${pageHead('Overview')}
888
+ ${steps.every((step) => step.done)
889
+ ? html ``
890
+ : html `<section class="panel setup"><h2>Get started</h2><ol class="steps">${steps.map((step) => html `<li class="${step.done ? 'done' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span>${step.done ? html `<span>${step.text}</span>` : html `<a href="${step.href}">${step.text}</a>`}${step.done ? html `<span class="visually-hidden">(done)</span>` : html ``}</li>`)}</ol></section>`}
891
+ <div class="stats">
892
+ ${agenda ? stat(`Booked, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`) : html ``}
893
+ ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
894
+ ${stat('Resources', String(resources.length), `${root}/resources`)}
895
+ ${stat('Active keys', String(active.length), `${root}/keys`)}
896
+ </div>
897
+ <div class="split">
898
+ ${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
899
+ ${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
900
+ </div>`;
901
+ return respond(status, layout('Overview', content, session, 'overview'));
902
+ }
903
+ function endpoints() {
904
+ return html `<dl class="endpoints"><dt>MCP</dt><dd><code id="mcp-url">${mcpUrl}</code><button class="quiet" type="button" data-copy="mcp-url">Copy</button></dd><dt>A2A</dt><dd><code id="a2a-url">${a2aUrl}</code><button class="quiet" type="button" data-copy="a2a-url">Copy</button></dd></dl>`;
905
+ }
906
+ async function keysPage(session, status = 200, notice) {
907
+ const keys = await options.keys.list({ tenantRef: tenantOf(session) });
583
908
  const token = csrfToken(session);
584
- const rules = options.availability ?? [];
585
- const content = html `${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
586
- <section class="card">
909
+ const content = html `${noticeOf(notice)}
910
+ ${pageHead('API keys')}
911
+ <section class="panel">
587
912
  <h2>Connect an agent</h2>
588
- <p>Agents call Slotlock over MCP or A2A with one of your keys: <code>Authorization: Bearer slk_…</code>.</p>
589
- <dl class="endpoints"><dt>MCP</dt><dd><code>${mcpUrl}</code></dd><dt>A2A</dt><dd><code>${a2aUrl}</code></dd></dl>
913
+ <p>Agents call ${brandName} over MCP or A2A with a key: <code>Authorization: Bearer slk_…</code></p>
914
+ ${endpoints()}
590
915
  </section>
591
- <section>
592
- <div class="head"><div><h2>API keys</h2><p>Each key acts in your calendar only. Give every agent its own, with the least access it needs.</p></div></div>
916
+ <section class="panel">
917
+ <h2>New key</h2>
593
918
  <form class="create" method="post" action="${root}/keys">
594
919
  <input type="hidden" name="csrf" value="${token}">
595
920
  <input type="hidden" name="once" value="${formNonce(session)}">
@@ -598,33 +923,338 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
598
923
  <label>Expires<select name="expires"><option value="90">In 90 days</option><option value="30">In 30 days</option><option value="365">In a year</option><option value="never">Never</option></select></label>
599
924
  <button class="primary" type="submit">Create key</button>
600
925
  </form>
601
- ${keys.length === 0
602
- ? html `<p class="empty">No keys yet. Create one to connect an agent.</p>`
603
- : html `<div class="table"><table><thead><tr><th>Name</th><th>Key</th><th>Access</th><th>Created</th><th>Last used</th><th>Expires</th><th>Status</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
604
- <tbody>${keyRows(keys, token, session)}</tbody></table></div>`}
605
926
  </section>
606
- <section>
607
- <div class="head"><div><h2>Resources</h2><p>What your agents book: a car, a room, a person, a machine. Up to ${SLOTLOCK_DASHBOARD_MAX_RESOURCES}.</p></div></div>
927
+ ${keys.length === 0
928
+ ? html `<p class="empty">No keys yet. Give every agent its own, with the least access it needs.</p>`
929
+ : served
930
+ ? html `<p class="fine">Each key acts in this organization only, and every member can rotate or revoke it.</p><div class="table"><table><thead><tr><th>Name</th><th>Key</th><th>Access</th><th>Created</th><th>Created by</th><th>Last used</th><th>Expires</th><th>Status</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
931
+ <tbody>${keyRows(keys, token, session)}</tbody></table></div>`
932
+ : html `<div class="table"><table><thead><tr><th>Name</th><th>Key</th><th>Access</th><th>Created</th><th>Last used</th><th>Expires</th><th>Status</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
933
+ <tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
934
+ return respond(status, layout('API keys', content, session, 'keys'));
935
+ }
936
+ async function resourcesPage(session, status = 200, notice) {
937
+ const resources = await options.resources.list(tenantOf(session));
938
+ const token = csrfToken(session);
939
+ const content = html `${noticeOf(notice)}
940
+ ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : undefined)}
941
+ <section class="panel">
942
+ <h2>Add a resource</h2>
608
943
  <form class="create" method="post" action="${root}/resources">
609
944
  <input type="hidden" name="csrf" value="${token}">
610
945
  <input type="hidden" name="once" value="${formNonce(session)}">
611
946
  <label>Reference<input name="reference" required maxlength="200" placeholder="vehicle-42"></label>
612
947
  <label>Time zone<input name="timezone" required maxlength="64" value="UTC" placeholder="Europe/London"></label>
613
- <button type="submit">Add resource</button>
948
+ <button class="primary" type="submit">Add resource</button>
614
949
  </form>
950
+ <p class="fine">${String(resources.length)} of ${String(SLOTLOCK_DASHBOARD_MAX_RESOURCES)}. Adding a reference you have changes its time zone.</p>
951
+ </section>
615
952
  ${resources.length === 0
616
953
  ? html `<p class="empty">No resources yet. Agents can only book what is listed here.</p>`
617
- : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference">${resource.externalRef ?? ''}</td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours">${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></td><td data-label="Resource id"><code>${resource.id}</code></td></tr>`)}</tbody></table></div>`}
954
+ : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a href="${root}/resources/${resource.id}">${resourceName(resource, resource.id)}</a>${resource.pool ? html `<span class="meta">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours">${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></td><td data-label="Resource id"><code class="muted">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
955
+ return respond(status, layout('Resources', content, session, 'resources'));
956
+ }
957
+ function settingsPage(session) {
958
+ const rules = options.availability ?? [];
959
+ const current = session.orgs?.find((org) => org.id === session.org);
960
+ const content = html `${pageHead('Settings')}
961
+ <section class="panel">
962
+ <h2>Endpoints</h2>
963
+ ${endpoints()}
618
964
  </section>
619
- <section>
620
- <h2>Bookable hours</h2>
965
+ <section class="panel">
966
+ <h2>Server hours</h2>
621
967
  ${rules.length === 0
622
- ? html `<p class="empty">This server offers no bookable hours: agents find a free slot only on resources with hours of their own.</p>`
968
+ ? html `<p class="empty">None: agents find a free slot only on resources with hours of their own.</p>`
623
969
  : html `<ul class="hours">${rules.map((rule) => html `<li>${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}</li>`)}</ul>`}
624
- <p class="fine">The server's hours, for resources without hours of their own, in each resource's own time zone.</p>
625
- ${resources.length > 0 ? html `<p><a href="${root}/hours">Set hours for every resource</a></p>` : html ``}
970
+ <p class="fine">For resources without hours of their own, in each resource's time zone. Set by the server's operator.</p>
971
+ </section>
972
+ <section class="panel">
973
+ <h2>Account</h2>
974
+ <dl class="facts"><dt>Signed in as</dt><dd>@${session.login}</dd>${current
975
+ ? html `<dt>Organization</dt><dd>${current.login}</dd>${(session.orgs?.length ?? 0) > 1 ? html `<dt>Also a member of</dt><dd>${(session.orgs ?? []).filter((org) => org.id !== current.id).map((org) => org.login).join(', ')}</dd>` : html ``}`
976
+ : html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
977
+ ${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
626
978
  </section>`;
627
- return respond(status, layout('Dashboard', content, session));
979
+ return respond(200, layout('Settings', content, session, 'settings'));
980
+ }
981
+ /** The tenant's resource with that id and every resource (for names), or null for anything else. */
982
+ async function resourceDetailPage(session, id, status = 200, notice) {
983
+ const tenantRef = tenantOf(session);
984
+ const resource = await ownResource(session, id);
985
+ if (!resource)
986
+ return notFound(session);
987
+ const start = new Date(now());
988
+ const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
989
+ const [agenda, changes, keys] = await Promise.all([
990
+ options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
991
+ options.resources.recentChanges?.(tenantRef, 100),
992
+ options.keys.list({ tenantRef }),
993
+ ]);
994
+ const byId = new Map([[resource.id, resource]]);
995
+ const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
996
+ const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
997
+ const token = csrfToken(session);
998
+ const rules = resource.bookingRules ?? {};
999
+ const field = (name, label, unit) => html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"><span>${unit}</span></span></label>`;
1000
+ const content = html `${noticeOf(notice)}
1001
+ <p><a class="back" href="${root}/resources">Resources</a></p>
1002
+ ${pageHead(resourceName(resource, resource.id))}
1003
+ <dl class="facts"><dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></dd><dt>Resource id</dt><dd><code class="muted">${resource.id}</code></dd></dl>
1004
+ <div class="split">
1005
+ <div class="stack">
1006
+ ${options.resources.setRules
1007
+ ? html `<section class="panel">
1008
+ <h2>Booking rules</h2>
1009
+ <form method="post" action="${root}/resources/rules">
1010
+ <input type="hidden" name="csrf" value="${token}">
1011
+ <input type="hidden" name="once" value="${formNonce(session)}">
1012
+ <input type="hidden" name="id" value="${resource.id}">
1013
+ <div class="grid-form">
1014
+ ${field('minNoticeMinutes', 'Minimum notice', 'min')}
1015
+ ${field('maxAdvanceDays', 'Book up to', 'days ahead')}
1016
+ ${field('slotStepMinutes', 'Start on a step of', 'min')}
1017
+ ${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
1018
+ ${field('checkInMinutes', 'Release if not checked in after', 'min')}
1019
+ </div>
1020
+ <label class="choice"><input type="checkbox" name="enforce_hours" value="1"${rules.enforceHours ? html ` checked` : html ``}> Only inside bookable hours</label>
1021
+ <label class="choice"><input type="checkbox" name="require_certain" value="1"${rules.requireCertain ? html ` checked` : html ``}> Only in time its calendar syncs have read today</label>
1022
+ <p class="fine">Leave a field empty for no rule.</p>
1023
+ <button class="primary" type="submit">Save rules</button>
1024
+ </form>
1025
+ </section>`
1026
+ : html ``}
1027
+ ${options.resources.setPool
1028
+ ? html `<section class="panel">
1029
+ <h2>Pool</h2>
1030
+ <form class="create" method="post" action="${root}/resources/pool">
1031
+ <input type="hidden" name="csrf" value="${token}">
1032
+ <input type="hidden" name="once" value="${formNonce(session)}">
1033
+ <input type="hidden" name="id" value="${resource.id}">
1034
+ <label>Pool name<input name="pool" maxlength="100" value="${resource.pool ?? ''}" placeholder="suv"></label>
1035
+ <button type="submit">Save pool</button>
1036
+ </form>
1037
+ <p class="fine">Agents can book any free member of a pool. Leave it empty for none.</p>
1038
+ </section>`
1039
+ : html ``}
1040
+ </div>
1041
+ <div class="stack">
1042
+ ${mine ? html `<section class="panel"><h2>Upcoming</h2>${agendaPanel(mine, byId, mine.length > AGENDA_SHOWN)}</section>` : html ``}
1043
+ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activity, byId, keys, session)}</section>` : html ``}
1044
+ </div>
1045
+ </div>`;
1046
+ return respond(status, layout(resourceName(resource, resource.id), content, session, 'resources'));
1047
+ }
1048
+ async function saveRules(session, form) {
1049
+ const id = form.get('id') ?? '';
1050
+ const resource = await ownResource(session, id);
1051
+ if (!resource || !options.resources.setRules)
1052
+ return notFound(session);
1053
+ const rules = {};
1054
+ for (const [name, key] of Object.entries(RULE_FIELDS)) {
1055
+ const value = (form.get(key) ?? '').trim();
1056
+ if (value === '')
1057
+ continue;
1058
+ const [min, max] = RULE_LIMITS[name];
1059
+ if (!/^\d{1,7}$/.test(value) || Number(value) < min || Number(value) > max) {
1060
+ return resourceDetailPage(session, id, 400, `${RULE_NAMES[name]} is a whole number from ${min.toLocaleString('en')} to ${max.toLocaleString('en')}. Leave it empty for none.`);
1061
+ }
1062
+ rules[name] = Number(value);
1063
+ }
1064
+ if (form.get('enforce_hours') === '1')
1065
+ rules.enforceHours = true;
1066
+ if (form.get('require_certain') === '1')
1067
+ rules.requireCertain = true;
1068
+ try {
1069
+ await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
1070
+ }
1071
+ catch (error) {
1072
+ if (error.code !== 'invalid_booking_rules')
1073
+ throw error;
1074
+ return resourceDetailPage(session, id, 400, 'Those rules are not ones Slotlock can keep.');
1075
+ }
1076
+ return redirect(303, `${root}/resources/${id}`);
1077
+ }
1078
+ async function savePool(session, form) {
1079
+ const id = form.get('id') ?? '';
1080
+ const resource = await ownResource(session, id);
1081
+ if (!resource || !options.resources.setPool)
1082
+ return notFound(session);
1083
+ const pool = (form.get('pool') ?? '').trim();
1084
+ try {
1085
+ await options.resources.setPool(tenantOf(session), id, pool === '' ? null : pool, personOf(session));
1086
+ }
1087
+ catch (error) {
1088
+ if (error.code !== 'invalid_pool')
1089
+ throw error;
1090
+ return resourceDetailPage(session, id, 400, 'A pool name is 1 to 100 letters, digits, dots, dashes or underscores.');
1091
+ }
1092
+ return redirect(303, `${root}/resources/${id}`);
1093
+ }
1094
+ function endpointState(endpoint) {
1095
+ if (endpoint.disabledAt)
1096
+ return { text: 'Disabled', tone: 'revoked' };
1097
+ if (endpoint.failingSince)
1098
+ return { text: 'Failing', tone: 'held' };
1099
+ return { text: 'Delivering', tone: 'active' };
1100
+ }
1101
+ async function webhooksPage(session, status = 200, notice) {
1102
+ const hooks = options.resources.webhooks;
1103
+ if (!hooks)
1104
+ return notFound(session);
1105
+ const endpoints = await hooks.list(tenantOf(session));
1106
+ const token = csrfToken(session);
1107
+ const failing = endpoints.filter((endpoint) => endpoint.failingSince && !endpoint.disabledAt);
1108
+ const disabled = endpoints.filter((endpoint) => endpoint.disabledAt);
1109
+ const rows = endpoints.map((endpoint) => {
1110
+ const state = endpointState(endpoint);
1111
+ const problem = endpoint.disabledAt
1112
+ ? `${endpoint.disabledReason ?? 'Disabled'} (${formatDate(endpoint.disabledAt)})`
1113
+ : endpoint.failingSince
1114
+ ? `Since ${formatDate(endpoint.failingSince)}, ${String(endpoint.attempts)} attempts${endpoint.lastStatus ? `, last HTTP ${endpoint.lastStatus}` : ''}${endpoint.lastError ? `: ${endpoint.lastError.slice(0, 120)}` : ''}`
1115
+ : '';
1116
+ return html `<tr>
1117
+ <td data-label="URL" class="strong wrap"><code>${endpoint.url}</code></td>
1118
+ <td data-label="Events">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}</td>
1119
+ <td data-label="Status"><span class="cell"><span class="pill ${state.tone}">${state.text}</span>${problem ? html `<span class="meta wrap">${problem}</span>` : html ``}</span></td>
1120
+ <td data-label="Added">${formatDate(endpoint.createdAt)}</td>
1121
+ <td class="actions">${endpoint.disabledAt
1122
+ ? html `<form class="inline" method="post" action="${root}/webhooks/enable"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${endpoint.id}"><button type="submit">Enable</button></form>`
1123
+ : html ``}<details class="act danger"><summary>Delete</summary><div class="pop"><p>Nothing more is sent to it. The feed keeps its changes.</p>
1124
+ <form method="post" action="${root}/webhooks/delete"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${endpoint.id}"><button class="danger" type="submit">Delete webhook</button></form></div></details></td>
1125
+ </tr>`;
1126
+ });
1127
+ const content = html `${noticeOf(notice)}
1128
+ ${pageHead('Webhooks')}
1129
+ <div class="stats">
1130
+ <div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
1131
+ <div class="stat"><span class="stat-label">Failing</span><span class="stat-value">${String(failing.length)}</span></div>
1132
+ <div class="stat"><span class="stat-label">Disabled</span><span class="stat-value">${String(disabled.length)}</span></div>
1133
+ </div>
1134
+ <section class="panel">
1135
+ <h2>Add an endpoint</h2>
1136
+ <form method="post" action="${root}/webhooks">
1137
+ <input type="hidden" name="csrf" value="${token}">
1138
+ <input type="hidden" name="once" value="${formNonce(session)}">
1139
+ <label>URL<input name="url" type="url" required maxlength="2048" placeholder="https://example.com/slotlock"></label>
1140
+ <details class="types"><summary>Events: all, or choose</summary><div class="checks">${SLOTLOCK_CHANGE_TYPES.map((type) => html `<label class="choice"><input type="checkbox" name="event_type" value="${type}"> <code>${type}</code></label>`)}</div></details>
1141
+ <button class="primary" type="submit">Add endpoint</button>
1142
+ </form>
1143
+ <p class="fine">Signed as Standard Webhooks with a secret shown once. An endpoint failing for a day is disabled.</p>
1144
+ </section>
1145
+ ${endpoints.length === 0
1146
+ ? html `<p class="empty">No webhooks. Add one to hear about bookings as they change.</p>`
1147
+ : html `<div class="table"><table><thead><tr><th>URL</th><th>Events</th><th>Status</th><th>Added</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`}`;
1148
+ return respond(status, layout('Webhooks', content, session, 'webhooks'));
1149
+ }
1150
+ async function addWebhook(session, form) {
1151
+ const hooks = options.resources.webhooks;
1152
+ if (!hooks)
1153
+ return notFound(session);
1154
+ const types = form.getAll('event_type');
1155
+ const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
1156
+ if (!created.ok) {
1157
+ const message = created.code === 'webhook_limit_reached'
1158
+ ? `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
1159
+ : created.code === 'invalid_webhook_url'
1160
+ ? 'Give an https URL, with no user name, password or #fragment.'
1161
+ : 'Choose events from the list.';
1162
+ return webhooksPage(session, created.code === 'webhook_limit_reached' ? 409 : 400, message);
1163
+ }
1164
+ const content = html `<section class="card once">
1165
+ <h1>Webhook added</h1>
1166
+ <p><code>${created.endpoint.url}</code></p>
1167
+ <p class="notice warn" role="alert">Copy the signing secret now. It will not be shown again.</p>
1168
+ <div class="copy"><input id="secret" readonly value="${created.secret}" aria-label="Signing secret"><button type="button" data-copy="secret">Copy</button></div>
1169
+ <p>Verify each delivery's <code>webhook-signature</code> with it, using a Standard Webhooks library.</p>
1170
+ <a class="button primary" href="${root}/webhooks">Done</a>
1171
+ </section>`;
1172
+ return respond(200, layout('Webhook added', content, session, 'webhooks'));
1173
+ }
1174
+ async function changeWebhook(session, form, change) {
1175
+ const hooks = options.resources.webhooks;
1176
+ if (!hooks)
1177
+ return notFound(session);
1178
+ const id = form.get('id') ?? '';
1179
+ if (change === 'remove') {
1180
+ if (!(await hooks.remove(tenantOf(session), id, personOf(session)))) {
1181
+ return webhooksPage(session, 404, 'That webhook is not one of yours.');
1182
+ }
1183
+ return redirect(303, `${root}/webhooks`);
1184
+ }
1185
+ const enabled = await hooks.enable(tenantOf(session), id, personOf(session));
1186
+ if (enabled === null)
1187
+ return webhooksPage(session, 404, 'That webhook is not one of yours.');
1188
+ if ('code' in enabled) {
1189
+ return webhooksPage(session, 409, 'It missed changes older than the feed keeps. Delete it, add it again, and read what it missed from the feed.');
1190
+ }
1191
+ return redirect(303, `${root}/webhooks`);
1192
+ }
1193
+ async function waitlistPage(session, status = 200, notice) {
1194
+ const waitlist = options.resources.waitlist;
1195
+ if (!waitlist)
1196
+ return notFound(session);
1197
+ const tenantRef = tenantOf(session);
1198
+ const [entries, resources] = await Promise.all([
1199
+ waitlist.list(tenantRef, WAITLIST_SHOWN),
1200
+ options.resources.list(tenantRef),
1201
+ ]);
1202
+ const byId = new Map(resources.map((resource) => [resource.id, resource]));
1203
+ const token = csrfToken(session);
1204
+ const rows = entries.map((entry) => {
1205
+ const resource = entry.resourceId ? byId.get(entry.resourceId) : undefined;
1206
+ const zone = resource?.timezone ?? 'UTC';
1207
+ const day = dayLabel(entry.start, zone, now());
1208
+ const what = entry.resourceId
1209
+ ? resourceName(resource, entry.resourceId)
1210
+ : `Pool ${entry.pool ?? ''}`;
1211
+ const offered = entry.offeredResourceId
1212
+ ? resourceName(byId.get(entry.offeredResourceId), entry.offeredResourceId)
1213
+ : null;
1214
+ const tone = entry.status === 'offered' ? 'active' : entry.status === 'expired' ? 'revoked' : 'held';
1215
+ return html `<tr>
1216
+ <td data-label="When" class="strong">${day.label} ${clock(entry.start, zone)}–${clock(entry.end, zone)}</td>
1217
+ <td data-label="For">${what}</td>
1218
+ <td data-label="Holder"><code>${entry.holderRef}</code></td>
1219
+ <td data-label="Status"><span class="cell"><span class="pill ${tone}">${entry.status === 'offered' ? 'Offered' : entry.status === 'expired' ? 'Expired' : 'Waiting'}</span>${offered && entry.offeredAt ? html `<span class="meta">${offered}, ${ago(entry.offeredAt, now())}</span>` : html ``}</span></td>
1220
+ <td data-label="Joined">${ago(entry.createdAt, now())}</td>
1221
+ <td class="actions"><details class="act danger"><summary>Remove</summary><div class="pop"><p>${entry.status === 'offered' ? 'Its offered hold is released and offered to the next.' : 'It stops waiting.'}</p>
1222
+ <form method="post" action="${root}/waitlist/leave"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${entry.id}"><button class="danger" type="submit">Remove entry</button></form></div></details></td>
1223
+ </tr>`;
1224
+ });
1225
+ const waiting = entries.filter((entry) => entry.status === 'waiting').length;
1226
+ const offered = entries.filter((entry) => entry.status === 'offered').length;
1227
+ const content = html `${noticeOf(notice)}
1228
+ ${pageHead('Waitlist')}
1229
+ <div class="stats">
1230
+ <div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
1231
+ <div class="stat"><span class="stat-label">Offered</span><span class="stat-value">${String(offered)}</span></div>
1232
+ </div>
1233
+ ${entries.length === 0
1234
+ ? html `<p class="empty">Nobody is waiting. Agents add entries when the time they want is taken.</p>`
1235
+ : html `<div class="table"><table><thead><tr><th>When</th><th>For</th><th>Holder</th><th>Status</th><th>Joined</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>${entries.length >= WAITLIST_SHOWN ? html `<p class="fine">Showing the first ${String(WAITLIST_SHOWN)}.</p>` : html ``}`}`;
1236
+ return respond(status, layout('Waitlist', content, session, 'waitlist'));
1237
+ }
1238
+ async function leaveWaitlist(session, form) {
1239
+ const waitlist = options.resources.waitlist;
1240
+ if (!waitlist)
1241
+ return notFound(session);
1242
+ if (!(await waitlist.leave(tenantOf(session), form.get('id') ?? '', personOf(session)))) {
1243
+ return waitlistPage(session, 404, 'That entry is not on your waitlist.');
1244
+ }
1245
+ return redirect(303, `${root}/waitlist`);
1246
+ }
1247
+ /** The page a form was sent from, to show what went wrong with it. */
1248
+ function pageFor(route) {
1249
+ if (route.startsWith('/keys'))
1250
+ return keysPage;
1251
+ if (route.startsWith('/webhooks') && options.resources.webhooks)
1252
+ return webhooksPage;
1253
+ if (route.startsWith('/waitlist') && options.resources.waitlist)
1254
+ return waitlistPage;
1255
+ if (route.startsWith('/resources') || route === '/hours')
1256
+ return resourcesPage;
1257
+ return overviewPage;
628
1258
  }
629
1259
  function hoursPage(session, state) {
630
1260
  const { target, mode } = state;
@@ -646,7 +1276,7 @@ ${resources.length > 0 ? html `<p><a href="${root}/hours">Set hours for every re
646
1276
  });
647
1277
  const content = html `${state.notice ? html `<p class="notice" role="alert">${state.notice}</p>` : html ``}
648
1278
  <section>
649
- <p><a href="${root}">Back to the dashboard</a></p>
1279
+ <p><a class="back" href="${root}/resources">Resources</a></p>
650
1280
  <h1>${title}</h1>
651
1281
  <p>${target === 'all'
652
1282
  ? 'The same hours for every resource, each in its own time zone. Saving replaces their hours.'
@@ -668,7 +1298,7 @@ ${state.unshown
668
1298
  <button class="primary" type="submit">Save hours</button>
669
1299
  </form>
670
1300
  </section>`;
671
- return respond(state.status ?? 200, layout('Bookable hours', content, session));
1301
+ return respond(state.status ?? 200, layout('Bookable hours', content, session, 'resources'));
672
1302
  }
673
1303
  /** The tenant's resource with that id, or null for anything else. */
674
1304
  async function ownResource(session, id) {
@@ -741,26 +1371,39 @@ ${state.unshown
741
1371
  });
742
1372
  }
743
1373
  }
744
- const changed = await options.resources.setHours(tenantOf(session), target === 'all' ? 'all' : { id: target.id }, rules);
1374
+ const changed = await options.resources.setHours(tenantOf(session), target === 'all' ? 'all' : { id: target.id }, rules, personOf(session));
745
1375
  if (changed === 0) {
746
1376
  return target === 'all'
747
- ? dashboardPage(session, 409, 'Add a resource first, then set its hours.')
1377
+ ? resourcesPage(session, 409, 'Add a resource first, then set its hours.')
748
1378
  : notFound(session);
749
1379
  }
750
- return redirect(303, root);
1380
+ return redirect(303, `${root}/resources`);
751
1381
  }
752
1382
  function shownOncePage(session, key, apiKey, rotated) {
1383
+ const org = session.orgs?.find((candidate) => candidate.id === session.org);
753
1384
  const content = html `<section class="card once">
754
1385
  <h1>${rotated ? 'Key rotated' : 'Key created'}: ${apiKey.name}</h1>
1386
+ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html ``}
755
1387
  <p class="notice warn" role="alert">Copy this key now. Slotlock keeps only its fingerprint, so it will not be shown again.${rotated ? ' The old key no longer works.' : ''}</p>
756
1388
  <div class="copy"><input id="key" readonly value="${key}" aria-label="API key"><button type="button" data-copy="key">Copy</button></div>
757
1389
  <h2>Add it to an MCP client</h2>
758
1390
  <pre><code id="command">claude mcp add --transport http slotlock ${mcpUrl} \\
759
1391
  --header "Authorization: Bearer ${key}"</code></pre>
760
1392
  <p>Keep the key in a secret manager. Rotate it if it leaks; revoke it when the agent is retired.</p>
761
- <a class="button primary" href="${root}">Done</a>
1393
+ <a class="button primary" href="${root}/keys">Done</a>
762
1394
  </section>`;
763
- return respond(200, layout('Your new key', content, session));
1395
+ return respond(200, layout('Your new key', content, session, 'keys'));
1396
+ }
1397
+ /** Act in another of the session's organizations: the same session, sealed again. */
1398
+ function switchOrganization(session, form) {
1399
+ const wanted = form.get('organization') ?? '';
1400
+ const org = session.orgs?.find((candidate) => candidate.id === wanted && served?.has(candidate.id));
1401
+ if (!served || !org) {
1402
+ return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization.</p>`, session));
1403
+ }
1404
+ const sealed = seal('slotlock-dashboard-session-v1', { ...session, org: org.id });
1405
+ const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
1406
+ return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
764
1407
  }
765
1408
  async function startSignIn() {
766
1409
  const state = randomBytes(32).toString('base64url');
@@ -773,6 +1416,8 @@ ${state.unshown
773
1416
  state,
774
1417
  code_challenge: challenge,
775
1418
  code_challenge_method: 'S256',
1419
+ // Reading organization membership needs read:org; a personal tenant needs no scope at all.
1420
+ ...(served ? { scope: 'read:org' } : {}),
776
1421
  }).toString();
777
1422
  const sealed = seal('slotlock-dashboard-sign-in-v1', {
778
1423
  state,
@@ -839,10 +1484,62 @@ ${state.unshown
839
1484
  }
840
1485
  return { uid: String(body.id), login: body.login };
841
1486
  }
1487
+ /**
1488
+ * The served organizations the token's person is an active member of, by login, or null when
1489
+ * GitHub does not answer. Only `state: active` counts; at most MAX_SESSION_ORGANIZATIONS.
1490
+ */
1491
+ async function readMemberships(token) {
1492
+ const response = await fetchImpl(membershipsUrl, {
1493
+ headers: {
1494
+ Authorization: `Bearer ${token}`,
1495
+ Accept: 'application/vnd.github+json',
1496
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
1497
+ 'User-Agent': USER_AGENT,
1498
+ },
1499
+ redirect: 'manual',
1500
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
1501
+ });
1502
+ if (!response.ok) {
1503
+ report(`GitHub membership lookup failed: HTTP ${response.status}`);
1504
+ return null;
1505
+ }
1506
+ const body = await response.json().catch(() => null);
1507
+ if (!Array.isArray(body))
1508
+ return null;
1509
+ const found = new Map();
1510
+ for (const membership of body) {
1511
+ // Owners and members only: a billing manager is not a member, and may invite more of them.
1512
+ if (!isRecord(membership) ||
1513
+ membership.state !== 'active' ||
1514
+ (membership.role !== 'admin' && membership.role !== 'member')) {
1515
+ continue;
1516
+ }
1517
+ const organization = membership.organization;
1518
+ if (!isRecord(organization) ||
1519
+ !Number.isSafeInteger(organization.id) ||
1520
+ organization.id <= 0 ||
1521
+ typeof organization.login !== 'string' ||
1522
+ !GITHUB_LOGIN.test(organization.login)) {
1523
+ continue;
1524
+ }
1525
+ const id = String(organization.id);
1526
+ if (served?.has(id))
1527
+ found.set(id, { id, login: organization.login });
1528
+ }
1529
+ return [...found.values()]
1530
+ .sort((a, b) => a.login.localeCompare(b.login))
1531
+ .slice(0, MAX_SESSION_ORGANIZATIONS);
1532
+ }
842
1533
  async function askGitHub(code, verifier) {
843
1534
  try {
844
1535
  const token = await exchangeCode(code, verifier);
845
- return token === null ? null : await readGitHubUser(token);
1536
+ if (token === null)
1537
+ return null;
1538
+ const user = await readGitHubUser(token);
1539
+ if (!user || !served)
1540
+ return user;
1541
+ const orgs = await readMemberships(token);
1542
+ return orgs === null ? null : { ...user, orgs };
846
1543
  }
847
1544
  catch (error) {
848
1545
  report(`GitHub sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
@@ -901,11 +1598,16 @@ ${state.unshown
901
1598
  clearSignIn,
902
1599
  ]);
903
1600
  }
1601
+ const orgs = user.orgs ?? [];
1602
+ if (served && orgs.length === 0) {
1603
+ return signedOutPage(403, `@${user.login} is not a member of an organization this Slotlock server serves.`, [clearSignIn]);
1604
+ }
904
1605
  const session = seal('slotlock-dashboard-session-v1', {
905
1606
  uid: user.uid,
906
1607
  login: user.login,
907
1608
  sid: randomBytes(24).toString('base64url'),
908
1609
  exp: now() + SESSION_TTL_MS,
1610
+ ...(served ? { orgs, org: orgs[0].id } : {}),
909
1611
  });
910
1612
  return redirect(303, root, [
911
1613
  clearSignIn,
@@ -942,7 +1644,7 @@ ${state.unshown
942
1644
  const scopes = KEY_ACCESS[form.get('access') ?? ''];
943
1645
  const expiry = form.get('expires') ?? '';
944
1646
  if (!scopes || !Object.hasOwn(KEY_EXPIRY_DAYS, expiry)) {
945
- return dashboardPage(session, 400, 'Choose an access level and an expiry from the lists.');
1647
+ return keysPage(session, 400, 'Choose an access level and an expiry from the lists.');
946
1648
  }
947
1649
  const days = KEY_EXPIRY_DAYS[expiry];
948
1650
  try {
@@ -950,7 +1652,7 @@ ${state.unshown
950
1652
  tenantRef: tenantOf(session),
951
1653
  name: form.get('name') ?? '',
952
1654
  scopes,
953
- createdBy: tenantOf(session),
1655
+ createdBy: personOf(session),
954
1656
  ...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
955
1657
  });
956
1658
  return shownOncePage(session, created.key, created.apiKey, false);
@@ -958,10 +1660,10 @@ ${state.unshown
958
1660
  catch (error) {
959
1661
  const code = error.code;
960
1662
  if (code === 'invalid_api_key_name') {
961
- return dashboardPage(session, 400, 'Give the key a name of 1 to 100 characters.');
1663
+ return keysPage(session, 400, 'Give the key a name of 1 to 100 characters.');
962
1664
  }
963
1665
  if (code === 'api_key_limit_reached') {
964
- return dashboardPage(session, 409, 'You have as many keys as Slotlock allows. Revoke one first.');
1666
+ return keysPage(session, 409, 'You have as many keys as Slotlock allows. Revoke one first.');
965
1667
  }
966
1668
  throw error;
967
1669
  }
@@ -969,41 +1671,41 @@ ${state.unshown
969
1671
  async function changeKey(session, form, change) {
970
1672
  const id = form.get('id') ?? '';
971
1673
  if (!UUID_PATTERN.test(id))
972
- return dashboardPage(session, 404, 'That key is not one of yours.');
1674
+ return keysPage(session, 404, 'That key is not one of yours.');
973
1675
  if (change === 'rotate') {
974
1676
  const rotated = await options.keys.rotate({ tenantRef: tenantOf(session), id });
975
1677
  if (!rotated)
976
- return dashboardPage(session, 404, 'That key is not an active key of yours.');
1678
+ return keysPage(session, 404, 'That key is not an active key of yours.');
977
1679
  return shownOncePage(session, rotated.key, rotated.apiKey, true);
978
1680
  }
979
1681
  const revoked = await options.keys.revoke({ tenantRef: tenantOf(session), id });
980
1682
  if (!revoked)
981
- return dashboardPage(session, 404, 'That key is not one of yours.');
982
- return redirect(303, root);
1683
+ return keysPage(session, 404, 'That key is not one of yours.');
1684
+ return redirect(303, `${root}/keys`);
983
1685
  }
984
1686
  async function addResource(session, form) {
985
1687
  const reference = (form.get('reference') ?? '').trim();
986
1688
  const timezone = (form.get('timezone') ?? '').trim();
987
1689
  if (reference.length === 0 || reference.length > 200) {
988
- return dashboardPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
1690
+ return resourcesPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
989
1691
  }
990
1692
  try {
991
- await options.resources.add(tenantOf(session), reference, timezone);
1693
+ await options.resources.add(tenantOf(session), reference, timezone, personOf(session));
992
1694
  }
993
1695
  catch (error) {
994
1696
  const code = error.code;
995
1697
  if (code === 'resource_limit_reached') {
996
- return dashboardPage(session, 409, `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
1698
+ return resourcesPage(session, 409, `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
997
1699
  }
998
1700
  if (code === 'invalid_timezone') {
999
- return dashboardPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
1701
+ return resourcesPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
1000
1702
  }
1001
1703
  if (code === 'invalid_external_ref') {
1002
- return dashboardPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
1704
+ return resourcesPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
1003
1705
  }
1004
1706
  throw error;
1005
1707
  }
1006
- return redirect(303, root);
1708
+ return redirect(303, `${root}/resources`);
1007
1709
  }
1008
1710
  async function handlePost(request, route) {
1009
1711
  // A browser sends Origin with every form POST; anything else did not come from these pages.
@@ -1035,19 +1737,23 @@ ${state.unshown
1035
1737
  return respond(400, layout('Refused', html `<p class="notice">That form could not be read.</p>`, session));
1036
1738
  }
1037
1739
  if (once !== null) {
1740
+ const checked = checkFormNonce(session, once);
1741
+ if (checked === 'other_org') {
1742
+ return pageFor(route)(session, 409, 'That page was for another organization. Nothing was changed: check the organization and try again.');
1743
+ }
1038
1744
  let first;
1039
1745
  try {
1040
1746
  first =
1041
- isFormNonce(session, once) &&
1747
+ checked === 'ok' &&
1042
1748
  (await options.state.use('form', once, session.exp, session.uid));
1043
1749
  }
1044
1750
  catch (error) {
1045
1751
  if (error.code !== 'dashboard_form_limit')
1046
1752
  throw error;
1047
- return dashboardPage(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
1753
+ return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
1048
1754
  }
1049
1755
  if (!first) {
1050
- return dashboardPage(session, 409, 'That form was already sent. Reload the page to start again.');
1756
+ return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
1051
1757
  }
1052
1758
  }
1053
1759
  switch (route) {
@@ -1064,6 +1770,20 @@ ${state.unshown
1064
1770
  return addResource(session, form);
1065
1771
  case '/hours':
1066
1772
  return saveHours(session, form);
1773
+ case '/organization':
1774
+ return switchOrganization(session, form);
1775
+ case '/resources/rules':
1776
+ return saveRules(session, form);
1777
+ case '/resources/pool':
1778
+ return savePool(session, form);
1779
+ case '/webhooks':
1780
+ return addWebhook(session, form);
1781
+ case '/webhooks/delete':
1782
+ return changeWebhook(session, form, 'remove');
1783
+ case '/webhooks/enable':
1784
+ return changeWebhook(session, form, 'enable');
1785
+ case '/waitlist/leave':
1786
+ return leaveWaitlist(session, form);
1067
1787
  default:
1068
1788
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
1069
1789
  }
@@ -1081,7 +1801,25 @@ ${state.unshown
1081
1801
  switch (route) {
1082
1802
  case '/': {
1083
1803
  const session = await readSession(request);
1084
- return session ? await dashboardPage(session) : signedOutPage(200);
1804
+ return session ? await overviewPage(session) : signedOutPage(200);
1805
+ }
1806
+ case '/keys':
1807
+ case '/resources':
1808
+ case '/webhooks':
1809
+ case '/waitlist':
1810
+ case '/settings': {
1811
+ const session = await readSession(request);
1812
+ if (!session)
1813
+ return redirect(303, root);
1814
+ if (route === '/keys')
1815
+ return await keysPage(session);
1816
+ if (route === '/resources')
1817
+ return await resourcesPage(session);
1818
+ if (route === '/webhooks')
1819
+ return await webhooksPage(session);
1820
+ if (route === '/waitlist')
1821
+ return await waitlistPage(session);
1822
+ return settingsPage(session);
1085
1823
  }
1086
1824
  case '/style.css':
1087
1825
  return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
@@ -1095,8 +1833,16 @@ ${state.unshown
1095
1833
  const session = await readSession(request);
1096
1834
  return session ? await hoursEditor(session, url) : redirect(303, root);
1097
1835
  }
1098
- default:
1836
+ default: {
1837
+ const resourceId = RESOURCE_PAGE.exec(route)?.[1];
1838
+ if (resourceId !== undefined) {
1839
+ const session = await readSession(request);
1840
+ return session
1841
+ ? await resourceDetailPage(session, resourceId.toLowerCase())
1842
+ : redirect(303, root);
1843
+ }
1099
1844
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, null));
1845
+ }
1100
1846
  }
1101
1847
  }
1102
1848
  if (request.method === 'POST')
@@ -1106,6 +1852,10 @@ ${state.unshown
1106
1852
  });
1107
1853
  }
1108
1854
  catch (error) {
1855
+ // A suspended tenant may look, not change: the store refuses its writes.
1856
+ if (error?.code === 'tenant_suspended') {
1857
+ return respond(403, layout('Suspended', html `<p class="notice">This account is suspended: its data can be seen, not changed.</p>`, null));
1858
+ }
1109
1859
  try {
1110
1860
  ignoreRejection(options.onError?.(error));
1111
1861
  }
@@ -1134,82 +1884,188 @@ const DASHBOARD_JS = `document.addEventListener('click', (event) => {
1134
1884
  });
1135
1885
  `;
1136
1886
  const DASHBOARD_CSS = `:root {
1137
- --ink: #0b0d12; --ink-2: #3a3f4b; --ink-3: #6b7180; --line: #e6e8ee; --paper: #ffffff; --wash: #f6f7f9;
1138
- --accent: #2655ff; --accent-ink: #ffffff; --danger: #c62828; --warn: #8a5a00; --warn-wash: #fff6e0;
1887
+ --ink: #0f1115; --ink-2: #464b57; --ink-3: #7a808d; --line: #e7e8ec; --line-2: #f0f1f4;
1888
+ --paper: #ffffff; --wash: #f7f7f9; --side: #fafafb; --hover: #f1f2f5;
1889
+ --accent: #2655ff; --accent-wash: #eef2ff; --accent-ink: #ffffff;
1890
+ --good: #12805c; --good-wash: #e8f6f0; --bad: #c4321f; --bad-wash: #fdeeec;
1891
+ --warn: #8a5a00; --warn-wash: #fff6e0; --held: #8a5a00; --held-wash: #fff4dc;
1892
+ --shadow: 0 1px 2px rgba(15, 17, 21, 0.04), 0 1px 1px rgba(15, 17, 21, 0.03);
1139
1893
  color-scheme: light dark;
1140
1894
  }
1141
1895
  @media (prefers-color-scheme: dark) {
1142
- :root { --ink: #eef0f5; --ink-2: #c3c7d1; --ink-3: #8b91a0; --line: #2a2e38; --paper: #0e1016; --wash: #161922;
1143
- --accent: #6f8cff; --accent-ink: #0b0d12; --danger: #ff6b6b; --warn: #ffcf70; --warn-wash: #2b2410; }
1896
+ :root { --ink: #eceef3; --ink-2: #b9bdc8; --ink-3: #868c99; --line: #262932; --line-2: #1d2027;
1897
+ --paper: #0d0f13; --wash: #14171d; --side: #101217; --hover: #1b1e25;
1898
+ --accent: #7b93ff; --accent-wash: #1a2145; --accent-ink: #0d0f13;
1899
+ --good: #4cc79a; --good-wash: #0f2a20; --bad: #ff7b6b; --bad-wash: #34150f;
1900
+ --warn: #ffcf70; --warn-wash: #2b2410; --held: #ffcf70; --held-wash: #2b2410;
1901
+ --shadow: none; }
1144
1902
  }
1145
1903
  * { box-sizing: border-box; }
1146
- body { margin: 0; background: var(--paper); color: var(--ink); font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
1147
- code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
1148
- .bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
1149
- .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); text-decoration: none; }
1150
- .mark { width: 20px; height: 20px; fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
1151
- .name { font-weight: 650; font-size: 16px; }
1152
- .by { color: var(--ink-3); font-size: 12px; }
1153
- .account { display: flex; align-items: center; gap: 12px; color: var(--ink-2); }
1154
- .account form { margin: 0; }
1155
- main { max-width: 1040px; margin: 0 auto; padding: 32px 24px 64px; display: grid; gap: 40px; }
1156
- h1 { font-size: 28px; line-height: 1.2; margin: 0 0 12px; letter-spacing: -0.01em; }
1157
- h2 { font-size: 18px; margin: 0 0 6px; }
1158
- p { margin: 0 0 12px; color: var(--ink-2); }
1159
- .fine { font-size: 13px; color: var(--ink-3); }
1160
- .hero { max-width: 560px; margin: 48px auto; text-align: center; }
1161
- .card { border: 1px solid var(--line); border-radius: 12px; padding: 20px; background: var(--wash); }
1162
- .head { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin-bottom: 12px; }
1163
- .endpoints { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 12px 0 0; }
1164
- .endpoints dt { color: var(--ink-3); }
1165
- .endpoints dd { margin: 0; overflow-wrap: anywhere; }
1166
- .create { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin: 0 0 16px; }
1167
- label { display: grid; gap: 4px; font-size: 13px; color: var(--ink-2); }
1168
- input, select { font: inherit; height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); color: var(--ink); min-width: 0; }
1169
- button, .button { font: inherit; font-weight: 600; height: 38px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--paper); color: var(--ink); cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }
1170
- .primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
1171
- button.danger { border-color: var(--danger); color: var(--danger); }
1172
- button.link { border: 0; background: none; padding: 0; height: auto; color: var(--ink-2); font-weight: 500; text-decoration: underline; }
1904
+ html { -webkit-text-size-adjust: 100%; }
1905
+ body { margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
1906
+ a { color: var(--accent); text-decoration: none; }
1907
+ a:hover { text-decoration: underline; }
1908
+ code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
1909
+ .icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
1910
+ .icon .fill { fill: currentColor; stroke: none; }
1911
+ .mark { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
1912
+ .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 650; font-size: 15px; letter-spacing: -0.01em; }
1913
+ .brand:hover { text-decoration: none; }
1914
+ .app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; background: linear-gradient(to right, var(--side) 239px, var(--line) 239px, var(--line) 240px, var(--paper) 240px); }
1915
+ .side { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 16px; padding: 16px 12px; }
1916
+ .side-top { display: grid; gap: 14px; padding: 0 6px; }
1917
+ .workspace { display: flex; align-items: center; gap: 8px; margin: 0; padding: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); min-width: 0; }
1918
+ .workspace-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1919
+ .workspace select { flex: 1; min-width: 0; height: 28px; border: 0; padding: 0 2px; font-weight: 600; background: transparent; }
1920
+ .avatar { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 6px; background: var(--accent-wash); color: var(--accent); font-size: 12px; font-weight: 700; }
1921
+ .nav { display: grid; gap: 2px; }
1922
+ .nav a { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 7px; color: var(--ink-2); font-weight: 500; }
1923
+ .nav a:hover { background: var(--hover); color: var(--ink); text-decoration: none; }
1924
+ .nav a[aria-current="page"] { background: var(--hover); color: var(--ink); font-weight: 600; }
1925
+ .nav a[aria-current="page"] .icon { color: var(--accent); }
1926
+ .me { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 10px 0; border-top: 1px solid var(--line); color: var(--ink-3); font-size: 13px; }
1927
+ .me form { margin: 0; }
1928
+ .login { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1929
+ .page { width: 100%; max-width: 1120px; padding: 28px 40px 64px; display: grid; gap: 20px; align-content: start; }
1930
+ .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1931
+ h1 { font-size: 22px; line-height: 1.25; margin: 0; letter-spacing: -0.015em; font-weight: 650; }
1932
+ h2 { font-size: 14px; margin: 0 0 12px; font-weight: 600; }
1933
+ h3 { font-size: 12px; margin: 0; font-weight: 600; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.04em; }
1934
+ p { margin: 0 0 10px; color: var(--ink-2); }
1935
+ .fine { font-size: 12.5px; color: var(--ink-3); margin: 8px 0 0; }
1936
+ .muted { color: var(--ink-3); }
1937
+ .panel, .card { border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; background: var(--paper); box-shadow: var(--shadow); min-width: 0; }
1938
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
1939
+ .stat { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); box-shadow: var(--shadow); color: var(--ink); }
1940
+ .stat:hover { border-color: var(--ink-3); text-decoration: none; }
1941
+ .stat-label { font-size: 12.5px; color: var(--ink-3); }
1942
+ .stat-value { font-size: 24px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
1943
+ .split { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 20px; align-items: start; }
1944
+ .setup .steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
1945
+ .steps li { display: flex; align-items: center; gap: 10px; color: var(--ink-2); }
1946
+ .steps li.done { color: var(--ink-3); text-decoration: line-through; }
1947
+ .check { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 999px; border: 1.5px solid var(--line); color: var(--accent-ink); }
1948
+ .done .check { background: var(--good); border-color: var(--good); }
1949
+ .check .icon { width: 12px; height: 12px; stroke-width: 3; }
1950
+ .day { margin: 14px 0 6px; }
1951
+ .day:first-of-type { margin-top: 0; }
1952
+ .slots, .feed { list-style: none; margin: 0; padding: 0; }
1953
+ .slot { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--line-2); }
1954
+ .slot:last-child { border-bottom: 0; }
1955
+ .when { display: grid; font-weight: 600; font-variant-numeric: tabular-nums; }
1956
+ .until { font-weight: 400; font-size: 12px; color: var(--ink-3); }
1957
+ .what { display: grid; min-width: 0; }
1958
+ .title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1959
+ .on { color: var(--ink-3); font-weight: 400; }
1960
+ .meta { font-size: 12.5px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1961
+ .event { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 12px; align-items: start; padding: 8px 0; }
1962
+ .dot { width: 8px; height: 8px; margin-top: 6px; border-radius: 999px; background: var(--ink-3); }
1963
+ .dot.good { background: var(--good); }
1964
+ .dot.bad { background: var(--bad); }
1965
+ .pill { display: inline-block; font-size: 11.5px; font-weight: 600; line-height: 18px; padding: 1px 8px; border-radius: 999px; background: var(--wash); color: var(--ink-2); white-space: nowrap; }
1966
+ .pill.booking, .pill.active { background: var(--good-wash); color: var(--good); }
1967
+ .pill.event { background: var(--accent-wash); color: var(--accent); }
1968
+ .pill.held { background: var(--held-wash); color: var(--held); }
1969
+ .pill.revoked, .pill.expired { color: var(--ink-3); }
1970
+ .endpoints { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; margin: 12px 0 0; align-items: center; }
1971
+ .endpoints dt { color: var(--ink-3); font-weight: 600; font-size: 12.5px; }
1972
+ .endpoints dd { margin: 0; display: flex; align-items: center; gap: 10px; min-width: 0; }
1973
+ .endpoints code { overflow-wrap: anywhere; }
1974
+ .facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
1975
+ .facts dt { color: var(--ink-3); }
1976
+ .facts dd { margin: 0; }
1977
+ .create { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin: 0; }
1978
+ label { display: grid; gap: 5px; font-size: 12.5px; font-weight: 500; color: var(--ink-2); }
1979
+ input, select { font: inherit; height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); color: var(--ink); min-width: 0; }
1980
+ input:focus, select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-wash); }
1981
+ button, .button { font: inherit; font-weight: 600; height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--paper); color: var(--ink); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; box-shadow: var(--shadow); }
1982
+ button:hover, .button:hover { background: var(--hover); text-decoration: none; }
1983
+ .primary, .primary:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
1984
+ .primary:hover { filter: brightness(1.06); }
1985
+ .wide { width: 100%; height: 40px; }
1986
+ button.danger { border-color: var(--bad); color: var(--bad); }
1987
+ button.quiet { height: auto; padding: 2px 6px; border: 0; background: none; box-shadow: none; color: var(--ink-3); font-weight: 500; font-size: 12.5px; }
1988
+ button.quiet:hover { color: var(--ink); background: var(--hover); }
1173
1989
  :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1174
- .table { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; }
1175
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
1176
- th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
1177
- th { font-weight: 600; color: var(--ink-3); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; background: var(--wash); }
1990
+ .table { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); box-shadow: var(--shadow); }
1991
+ table { width: 100%; border-collapse: collapse; }
1992
+ th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
1993
+ th { font-weight: 600; color: var(--ink-3); font-size: 12px; background: var(--wash); border-bottom-color: var(--line); white-space: nowrap; }
1994
+ td { white-space: nowrap; }
1995
+ td.strong { font-weight: 600; white-space: normal; min-width: 120px; }
1178
1996
  tr:last-child td { border-bottom: 0; }
1179
- .status { justify-self: start; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--wash); }
1180
- .status.active { color: var(--accent); }
1181
- .status.revoked, .status.expired { color: var(--ink-3); }
1182
- .actions { white-space: nowrap; }
1183
- .act { display: inline-block; margin-right: 8px; }
1184
- .act summary { cursor: pointer; color: var(--ink-2); }
1185
- .act.danger summary { color: var(--danger); }
1186
- .act[open] { display: block; max-width: 280px; white-space: normal; }
1187
- .act p { font-size: 13px; margin: 8px 0; }
1188
- .notice { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: var(--wash); color: var(--ink); }
1997
+ .actions { white-space: nowrap; text-align: right; }
1998
+ .act { display: inline-block; position: relative; margin-left: 8px; text-align: left; }
1999
+ .act summary { cursor: pointer; color: var(--ink-2); font-weight: 500; list-style: none; }
2000
+ .act summary::-webkit-details-marker { display: none; }
2001
+ .act.danger summary { color: var(--bad); }
2002
+ .pop { position: absolute; right: 0; z-index: 2; width: 260px; margin-top: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); box-shadow: 0 8px 24px rgba(15, 17, 21, 0.12); white-space: normal; }
2003
+ .pop p { font-size: 12.5px; }
2004
+ .notice { border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; background: var(--wash); color: var(--ink); margin: 0; }
1189
2005
  .notice.warn { background: var(--warn-wash); color: var(--warn); border-color: transparent; }
1190
- .empty { color: var(--ink-3); }
2006
+ .empty { color: var(--ink-3); margin: 0; }
1191
2007
  .once { max-width: 720px; }
1192
2008
  .copy { display: flex; gap: 8px; margin: 12px 0 20px; }
1193
2009
  .copy input { flex: 1; }
1194
2010
  pre { background: var(--wash); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
1195
- .hours { margin: 0 0 8px; padding-left: 20px; color: var(--ink-2); }
2011
+ .hours { margin: 0; padding-left: 18px; color: var(--ink-2); }
2012
+ .back::before { content: "← "; }
1196
2013
  .modes { border: 0; padding: 0; margin: 0 0 16px; display: grid; gap: 8px; }
1197
2014
  .modes legend { font-weight: 600; color: var(--ink); padding: 0; margin-bottom: 4px; }
1198
- .choice { display: flex; align-items: center; gap: 8px; font-size: 15px; color: var(--ink); }
2015
+ .choice { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--ink); }
1199
2016
  .choice input { height: auto; }
1200
2017
  .week input { width: 100%; }
1201
- .week th[scope="row"] { text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--ink); background: none; }
2018
+ .week th[scope="row"] { font-size: 13px; color: var(--ink); background: none; }
1202
2019
  form > .table { margin-bottom: 12px; }
2020
+ .bare { background: var(--wash); }
2021
+ .bare-bar { padding: 20px 24px; }
2022
+ .bare-main { display: grid; place-items: start center; padding: 8vh 16px 64px; }
2023
+ .sign-in { width: 100%; max-width: 400px; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); box-shadow: var(--shadow); display: grid; gap: 12px; }
2024
+ .sign-in h1 { font-size: 20px; }
2025
+ .sign-in p { margin: 0; }
2026
+ .stack { display: grid; gap: 20px; align-content: start; min-width: 0; }
2027
+ .grid-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 12px; align-items: end; }
2028
+ .cell { display: grid; justify-items: start; gap: 2px; }
2029
+ .unit-input { display: flex; align-items: center; gap: 8px; }
2030
+ .unit-input input { width: 100%; }
2031
+ .unit-input span { color: var(--ink-3); white-space: nowrap; }
2032
+ form .choice { margin: 4px 0 10px; }
2033
+ .types { margin: 12px 0; }
2034
+ .types summary { cursor: pointer; color: var(--ink-2); font-size: 13px; }
2035
+ .checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px 16px; margin-top: 10px; }
2036
+ .checks .choice { font-size: 13px; margin: 0; }
2037
+ td .meta { display: block; margin-top: 2px; }
2038
+ td[data-label] > .pill { justify-self: start; }
2039
+ td.wrap, .wrap { white-space: normal; overflow-wrap: anywhere; }
2040
+ form.inline { display: inline; margin: 0; }
2041
+ form > label { margin-bottom: 4px; }
1203
2042
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1204
- @media (max-width: 720px) {
1205
- .bar { padding: 12px 16px; }
1206
- main { padding: 24px 16px 48px; }
2043
+ @media (max-width: 1000px) {
2044
+ .split { grid-template-columns: minmax(0, 1fr); }
2045
+ }
2046
+ @media (max-width: 760px) {
2047
+ .app { grid-template-columns: minmax(0, 1fr); background: var(--paper); }
2048
+ .side { position: sticky; z-index: 3; height: auto; background: var(--side); border-bottom: 1px solid var(--line); flex-direction: row; flex-wrap: wrap; align-items: center; gap: 10px 12px; padding: 10px 16px 0; border-right: 0; }
2049
+ .side-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0; flex: 1 1 100%; min-width: 0; }
2050
+ .workspace { max-width: 60%; }
2051
+ .nav { display: flex; gap: 2px; overflow-x: auto; flex: 1 1 auto; margin: 0 -4px; padding-bottom: 8px; scrollbar-width: none; }
2052
+ .nav a { flex: none; padding: 6px 10px; }
2053
+ .me { margin: 0 0 8px; padding: 0; border: 0; flex: none; }
2054
+ .me .login { display: none; }
2055
+ .page { padding: 20px 16px 48px; gap: 16px; }
2056
+ .panel, .card { padding: 16px; }
2057
+ .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2058
+ .stat-value { font-size: 20px; }
1207
2059
  thead { display: none; }
1208
2060
  table, tbody, tr, td { display: block; }
1209
2061
  tr { border-bottom: 1px solid var(--line); padding: 8px 0; }
1210
- td { border: 0; padding: 3px 12px; }
1211
- td[data-label] { display: grid; grid-template-columns: 88px 1fr; gap: 8px; }
1212
- td[data-label]::before { content: attr(data-label); font-size: 12px; color: var(--ink-3); padding-top: 2px; }
2062
+ tr:last-child { border-bottom: 0; }
2063
+ td { border: 0; padding: 3px 14px; white-space: normal; }
2064
+ td[data-label] { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; }
2065
+ td[data-label]::before { content: attr(data-label); font-size: 12px; color: var(--ink-3); }
2066
+ .actions { text-align: left; }
2067
+ .act { margin: 4px 12px 0 0; }
2068
+ .pop { left: 0; right: auto; }
1213
2069
  }
1214
2070
  `;
1215
2071
  const ASSET_VERSIONS = Object.freeze({