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/CHANGELOG.md +265 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +591 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +447 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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} ·
|
|
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
|
-
<
|
|
541
|
-
<
|
|
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="
|
|
547
|
-
<h1>
|
|
548
|
-
<p>
|
|
549
|
-
${notice
|
|
550
|
-
<a class="button primary" href="${root}/sign-in"
|
|
551
|
-
<p class="fine"
|
|
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="
|
|
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
|
-
|
|
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
|
|
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
|
|
585
|
-
|
|
586
|
-
<section class="
|
|
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
|
|
589
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
607
|
-
|
|
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.
|
|
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>
|
|
965
|
+
<section class="panel">
|
|
966
|
+
<h2>Server hours</h2>
|
|
621
967
|
${rules.length === 0
|
|
622
|
-
? html `<p class="empty">
|
|
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">
|
|
625
|
-
|
|
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(
|
|
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}">
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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: #
|
|
1138
|
-
--
|
|
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: #
|
|
1143
|
-
--
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
.
|
|
1152
|
-
.
|
|
1153
|
-
.
|
|
1154
|
-
.
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
.
|
|
1160
|
-
.
|
|
1161
|
-
.
|
|
1162
|
-
.
|
|
1163
|
-
.
|
|
1164
|
-
.
|
|
1165
|
-
.
|
|
1166
|
-
.
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
.
|
|
1171
|
-
|
|
1172
|
-
|
|
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;
|
|
1176
|
-
th, td { text-align: left; padding: 10px
|
|
1177
|
-
th { font-weight: 600; color: var(--ink-3); font-size: 12px;
|
|
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
|
-
.
|
|
1180
|
-
.
|
|
1181
|
-
.
|
|
1182
|
-
.
|
|
1183
|
-
.act {
|
|
1184
|
-
.
|
|
1185
|
-
.
|
|
1186
|
-
.
|
|
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
|
|
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:
|
|
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"] {
|
|
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:
|
|
1205
|
-
.
|
|
1206
|
-
|
|
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
|
-
|
|
1211
|
-
td
|
|
1212
|
-
td[data-label]
|
|
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({
|