slotlock 0.6.3 → 0.8.0
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 +511 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +70 -1
- package/assistant.js +478 -50
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +227 -7
- package/dashboard.js +2313 -247
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
package/dashboard.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// keeps these in Postgres, so they hold on every server that shares the database; the resource cap
|
|
15
15
|
// is held in the database too.
|
|
16
16
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
|
|
17
18
|
import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
|
|
18
|
-
import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore,
|
|
19
|
+
import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
|
|
19
20
|
import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
|
|
20
21
|
import { createSlotlockRateLimiter } from './rate-limit.js';
|
|
21
22
|
import { createSlotlockGuardedFetch } from './webhooks.js';
|
|
@@ -23,12 +24,30 @@ import { lookup as dnsLookup } from 'node:dns/promises';
|
|
|
23
24
|
import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
|
|
24
25
|
import { ignoreRejection } from './hooks.js';
|
|
25
26
|
import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
|
|
27
|
+
import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
|
|
28
|
+
import { SLOTLOCK_SETTINGS_CSS } from './dashboard-settings-ui.js';
|
|
29
|
+
import { renderSlotlockMarkdown } from './dashboard-markdown.js';
|
|
30
|
+
import { SLOTLOCK_ASSISTANT_CSS, slotlockAssistantActionVerb, slotlockAssistantScript, slotlockAssistantStepLabel, } from './dashboard-assistant-ui.js';
|
|
31
|
+
import { SLOTLOCK_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
|
|
32
|
+
import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
|
|
33
|
+
import { slotlockQuotaPeriod } from './quotas.js';
|
|
34
|
+
import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
|
|
26
35
|
import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
|
|
36
|
+
import { SlotlockAgentOperationError } from './agent-server.js';
|
|
37
|
+
import { runSlotlockApprovalGrant } from './agent-store-backend.js';
|
|
38
|
+
import { SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
27
39
|
import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
|
|
28
40
|
/** Resources one dashboard tenant may hold. */
|
|
29
|
-
export const SLOTLOCK_DASHBOARD_MAX_RESOURCES =
|
|
41
|
+
export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
|
|
30
42
|
const SESSION_TTL_MS = 12 * 3_600_000;
|
|
31
43
|
const SIGN_IN_TTL_MS = 10 * 60_000;
|
|
44
|
+
/**
|
|
45
|
+
* How recent a sign-in must be to turn an organization on or off: who owns it is read from GitHub
|
|
46
|
+
* as one signs in, so a change of owners reaches the dashboard by signing in again.
|
|
47
|
+
*/
|
|
48
|
+
const FRESH_SIGN_IN_MS = 15 * 60_000;
|
|
49
|
+
/** How long Google's signing keys are kept when its answer does not say (Cache-Control max-age). */
|
|
50
|
+
const GOOGLE_KEYS_DEFAULT_TTL_MS = 3_600_000;
|
|
32
51
|
const MAX_FORM_BYTES = 8_192;
|
|
33
52
|
/** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
|
|
34
53
|
const MAX_ASSISTANT_FORM_BYTES = 24_576;
|
|
@@ -48,27 +67,48 @@ const POST_ROUTES = new Set([
|
|
|
48
67
|
'/keys',
|
|
49
68
|
'/keys/rotate',
|
|
50
69
|
'/keys/revoke',
|
|
70
|
+
'/keys/limits',
|
|
51
71
|
'/resources',
|
|
52
72
|
'/resources/rules',
|
|
53
73
|
'/resources/pool',
|
|
74
|
+
'/resources/profile',
|
|
75
|
+
'/resources/place',
|
|
76
|
+
'/resources/parent',
|
|
77
|
+
'/locations',
|
|
78
|
+
'/locations/delete',
|
|
79
|
+
'/groups',
|
|
80
|
+
'/groups/delete',
|
|
54
81
|
'/webhooks',
|
|
55
82
|
'/webhooks/delete',
|
|
56
83
|
'/webhooks/enable',
|
|
57
84
|
'/waitlist/leave',
|
|
85
|
+
'/approvals/decide',
|
|
86
|
+
'/sandbox/seed',
|
|
87
|
+
'/sandbox/reset',
|
|
88
|
+
'/sandbox/fast-forward',
|
|
58
89
|
'/hours',
|
|
59
90
|
'/organization',
|
|
91
|
+
'/environment',
|
|
92
|
+
'/teams/enable',
|
|
93
|
+
'/teams/disable',
|
|
60
94
|
'/assistant',
|
|
95
|
+
'/assistant/stream',
|
|
61
96
|
'/assistant/approve',
|
|
62
97
|
'/assistant/dismiss',
|
|
63
98
|
'/assistant/reset',
|
|
64
99
|
'/settings/assistant',
|
|
65
100
|
'/settings/assistant/test',
|
|
101
|
+
'/settings/assistant/models',
|
|
66
102
|
'/settings/assistant/remove',
|
|
103
|
+
'/plan/upgrade',
|
|
104
|
+
'/plan/manage',
|
|
67
105
|
]);
|
|
68
106
|
const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
|
|
69
107
|
const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
|
|
70
108
|
const MAX_COOKIE_CHARACTERS = 4_096;
|
|
71
109
|
const GITHUB_TIMEOUT_MS = 10_000;
|
|
110
|
+
/** A Google account's subject: ASCII, at most 255 characters (OpenID Connect); Google's are digits. */
|
|
111
|
+
const GOOGLE_SUBJECT = /^[A-Za-z0-9._-]{1,255}$/;
|
|
72
112
|
/** The REST API version GitHub documents for GET /user (docs.github.com, read 2026-10-10). */
|
|
73
113
|
const GITHUB_API_VERSION = '2026-03-10';
|
|
74
114
|
const USER_AGENT = 'slotlock-dashboard';
|
|
@@ -96,16 +136,85 @@ const KEY_EXPIRY_DAYS = Object.freeze({
|
|
|
96
136
|
* still sends no referrer to any other site, so the callback URL's code never leaves.
|
|
97
137
|
*/
|
|
98
138
|
const REFERRER_POLICY = 'same-origin';
|
|
99
|
-
const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
139
|
+
const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
140
|
+
/** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
|
|
141
|
+
function policyFor(billing) {
|
|
142
|
+
if (!billing)
|
|
143
|
+
return CONTENT_SECURITY_POLICY;
|
|
144
|
+
const contact = new URL(billing.contact);
|
|
145
|
+
if (contact.protocol !== 'https:' && contact.protocol !== 'mailto:') {
|
|
146
|
+
throw new Error('billing.contact must be an https or mailto URL');
|
|
147
|
+
}
|
|
148
|
+
const origins = billing.formOrigins.map((value) => {
|
|
149
|
+
const parsed = new URL(value);
|
|
150
|
+
if (parsed.protocol !== 'https:' || parsed.origin !== value.replace(/\/$/, '')) {
|
|
151
|
+
throw new Error(`billing.formOrigins must be https origins: ${value}`);
|
|
152
|
+
}
|
|
153
|
+
return parsed.origin;
|
|
154
|
+
});
|
|
155
|
+
return CONTENT_SECURITY_POLICY.replace("form-action 'self'", ["form-action 'self'", ...origins].join(' '));
|
|
156
|
+
}
|
|
100
157
|
/**
|
|
101
158
|
* The dashboard's resources, in the store under each tenant's row-level security. Adds hold the cap
|
|
102
159
|
* in the database (a READ COMMITTED transaction, under a per-tenant lock).
|
|
103
160
|
*/
|
|
104
|
-
export function createSlotlockDashboardResources(store) {
|
|
161
|
+
export function createSlotlockDashboardResources(store, options = {}) {
|
|
105
162
|
// Every change here is the dashboard's, made by the person signed in (actor) when known.
|
|
106
163
|
const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
|
|
164
|
+
const agentBackend = options.agentBackend;
|
|
107
165
|
return {
|
|
166
|
+
sandbox: {
|
|
167
|
+
seed: (tenantRef, scenario) => seedSlotlockSandbox(store, tenantRef, scenario),
|
|
168
|
+
async reset(tenantRef) {
|
|
169
|
+
await resetSlotlockSandbox(store, tenantRef);
|
|
170
|
+
},
|
|
171
|
+
async fastForward(tenantRef, minutes) {
|
|
172
|
+
const forward = await fastForwardSlotlockSandbox(store, tenantRef, minutes);
|
|
173
|
+
return { holds: forward.holds, approvals: forward.approvals };
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
...(agentBackend
|
|
177
|
+
? {
|
|
178
|
+
approvals: {
|
|
179
|
+
list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listApprovals({ tenantRef, limit })),
|
|
180
|
+
async decide(tenantRef, id, decision, actor, reason) {
|
|
181
|
+
const decidedBy = actor ?? 'dashboard';
|
|
182
|
+
if (decision === 'decline') {
|
|
183
|
+
const declined = await store.withTenant(tenantRef, (tenant) => tenant.declineApproval({ tenantRef, id, decidedBy, ...(reason ? { reason } : {}) }), by(actor));
|
|
184
|
+
if (!declined.ok)
|
|
185
|
+
return declined;
|
|
186
|
+
// Its held time is free: offer it to the waitlist, as a release does.
|
|
187
|
+
await store
|
|
188
|
+
.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), { origin: 'maintenance' })
|
|
189
|
+
.catch(() => undefined);
|
|
190
|
+
return { ok: true };
|
|
191
|
+
}
|
|
192
|
+
const approval = await store.withTenant(tenantRef, (tenant) => tenant.getApproval({ tenantRef, id }));
|
|
193
|
+
if (!approval)
|
|
194
|
+
return { ok: false, code: 'approval_not_found' };
|
|
195
|
+
if (approval.status !== 'pending')
|
|
196
|
+
return { ok: false, code: 'approval_not_pending' };
|
|
197
|
+
try {
|
|
198
|
+
await runSlotlockApprovalGrant(agentBackend, approval, {
|
|
199
|
+
tenantRef,
|
|
200
|
+
decidedBy,
|
|
201
|
+
signal: new AbortController().signal,
|
|
202
|
+
origin: 'dashboard',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error instanceof SlotlockAgentOperationError)
|
|
207
|
+
return { ok: false, code: error.code };
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
return { ok: true };
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
: {}),
|
|
108
215
|
list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
|
|
216
|
+
count: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.countResources({ tenantRef })),
|
|
217
|
+
usage: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.readUsage({ tenantRef, from, to })),
|
|
109
218
|
add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
|
|
110
219
|
tenantRef,
|
|
111
220
|
externalRef,
|
|
@@ -123,6 +232,39 @@ export function createSlotlockDashboardResources(store) {
|
|
|
123
232
|
setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
|
|
124
233
|
ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
|
|
125
234
|
setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
|
|
235
|
+
setParent: (tenantRef, id, parentId, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceParent({ tenantRef, id, parentId }), by(actor)),
|
|
236
|
+
setProfile: (tenantRef, id, profile, actor) => store.withTenant(tenantRef, (tenant) => tenant.updateResourceProfile({ tenantRef, id, profile }), by(actor)),
|
|
237
|
+
grouping: {
|
|
238
|
+
locations: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listLocations({ tenantRef })),
|
|
239
|
+
putLocation: (tenantRef, location, actor) => store.withTenant(tenantRef, (tenant) => tenant.putLocation({ tenantRef, ...location }), by(actor)),
|
|
240
|
+
deleteLocation: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteLocation({ tenantRef, ref }), by(actor)),
|
|
241
|
+
groups: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listGroups({ tenantRef })),
|
|
242
|
+
putGroup: (tenantRef, group, actor) => store.withTenant(tenantRef, (tenant) => tenant.putGroup({ tenantRef, ...group }), by(actor)),
|
|
243
|
+
deleteGroup: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteGroup({ tenantRef, ref }), by(actor)),
|
|
244
|
+
place: (tenantRef, id, place, actor) => store.withTenant(tenantRef, async (tenant) => {
|
|
245
|
+
// The location first, which finds the resource (null when it is not the tenant's);
|
|
246
|
+
// then its groups, in the same transaction, so a refused group moves nothing.
|
|
247
|
+
const moved = await tenant.updateResourceProfile({
|
|
248
|
+
tenantRef,
|
|
249
|
+
id,
|
|
250
|
+
profile: {},
|
|
251
|
+
location: place.location,
|
|
252
|
+
});
|
|
253
|
+
if (!moved)
|
|
254
|
+
return null;
|
|
255
|
+
const wanted = new Set(place.groups);
|
|
256
|
+
const current = new Set(moved.groups ?? []);
|
|
257
|
+
for (const group of current) {
|
|
258
|
+
if (!wanted.has(group))
|
|
259
|
+
await tenant.removeGroupMember({ tenantRef, group, resourceId: id });
|
|
260
|
+
}
|
|
261
|
+
for (const group of wanted) {
|
|
262
|
+
if (!current.has(group))
|
|
263
|
+
await tenant.addGroupMember({ tenantRef, group, resourceId: id });
|
|
264
|
+
}
|
|
265
|
+
return tenant.getResource({ tenantRef, id });
|
|
266
|
+
}, by(actor)),
|
|
267
|
+
},
|
|
126
268
|
waitlist: {
|
|
127
269
|
list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
|
|
128
270
|
async leave(tenantRef, id, actor) {
|
|
@@ -308,6 +450,8 @@ const AGENDA_LIMIT = 200;
|
|
|
308
450
|
const AGENDA_SHOWN = 30;
|
|
309
451
|
const ACTIVITY_LIMIT = 20;
|
|
310
452
|
const WAITLIST_SHOWN = 200;
|
|
453
|
+
/** Approvals the dashboard lists, pending and recent decided together, newest first. */
|
|
454
|
+
const APPROVALS_SHOWN = 100;
|
|
311
455
|
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;
|
|
312
456
|
/** How the booking rules form names each rule. */
|
|
313
457
|
const RULE_LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
|
|
@@ -378,12 +522,122 @@ const ICONS = Object.freeze({
|
|
|
378
522
|
waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
|
|
379
523
|
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"/>'),
|
|
380
524
|
assistant: icon('<path d="M4 5h16v11H9l-5 4z"/><path d="M8 9h8M8 12h5"/>'),
|
|
525
|
+
plan: icon('<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h4"/>'),
|
|
526
|
+
teams: icon('<circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19c0-3.3 2.7-5 6-5s6 1.7 6 5M15 14.5c3 0 6 1.4 6 4.5"/>'),
|
|
381
527
|
settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
|
|
382
528
|
check: icon('<path d="M5 12l5 5L20 7"/>'),
|
|
529
|
+
approvals: icon('<circle cx="12" cy="12" r="8"/><path d="M8.5 12.5l2.5 2.5 4.5-5"/>'),
|
|
383
530
|
signOut: icon('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 17l-5-5 5-5M5 12h11"/>'),
|
|
384
531
|
plus: icon('<path d="M12 5v14M5 12h14"/>'),
|
|
532
|
+
google: new Html('<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M15.68 8.18c0-.57-.05-1.11-.15-1.64H8v3.1h4.3a3.68 3.68 0 0 1-1.6 2.41v2h2.58c1.51-1.39 2.4-3.44 2.4-5.87zM8 16c2.16 0 3.97-.72 5.29-1.94l-2.58-2c-.72.48-1.63.76-2.71.76-2.08 0-3.85-1.41-4.48-3.3H.86v2.07A8 8 0 0 0 8 16zM3.52 9.52a4.8 4.8 0 0 1 0-3.04V4.41H.86a8 8 0 0 0 0 7.18zM8 3.18c1.17 0 2.23.4 3.06 1.2l2.29-2.3A8 8 0 0 0 .86 4.41l2.66 2.07C4.15 4.59 5.92 3.18 8 3.18z"/></svg>'),
|
|
385
533
|
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>'),
|
|
386
534
|
});
|
|
535
|
+
/** How the dashboard names each kind of resource. */
|
|
536
|
+
const KIND_NAMES = Object.freeze({
|
|
537
|
+
person: 'Person',
|
|
538
|
+
room: 'Room',
|
|
539
|
+
desk: 'Desk',
|
|
540
|
+
space: 'Space',
|
|
541
|
+
vehicle: 'Vehicle',
|
|
542
|
+
machine: 'Machine',
|
|
543
|
+
equipment: 'Equipment',
|
|
544
|
+
service: 'Service',
|
|
545
|
+
other: 'Not said',
|
|
546
|
+
});
|
|
547
|
+
/** What a profile field it could not keep must be, in a person's words. */
|
|
548
|
+
const PROFILE_PROBLEMS = Object.freeze({
|
|
549
|
+
name: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.`,
|
|
550
|
+
description: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
|
|
551
|
+
kind: 'Choose a kind from the list.',
|
|
552
|
+
capacity: `Capacity is a whole number from 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity.toLocaleString('en')}. Leave it empty for none.`,
|
|
553
|
+
labels: `Up to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.labels} labels, each 1 to 64 letters, digits, dots, dashes or underscores.`,
|
|
554
|
+
attributes: 'Those attributes are not ones Slotlock can keep.',
|
|
555
|
+
other: 'That profile is not one Slotlock can keep.',
|
|
556
|
+
});
|
|
557
|
+
const GROUPING_REF_RULE = 'A reference is 1 to 100 lowercase letters, digits, dots, dashes or underscores, starting with a letter or digit.';
|
|
558
|
+
/** What a person is told when a location or group change is refused, by the store's code. */
|
|
559
|
+
const GROUPING_PROBLEMS = Object.freeze({
|
|
560
|
+
location_ref: { status: 400, text: GROUPING_REF_RULE },
|
|
561
|
+
location_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
|
|
562
|
+
location_parent: { status: 400, text: 'Choose where it is from the list, or nowhere.' },
|
|
563
|
+
location_timezone: {
|
|
564
|
+
status: 400,
|
|
565
|
+
text: 'A time zone is a name such as Europe/London. Leave it empty to take the one of the location it is in.',
|
|
566
|
+
},
|
|
567
|
+
location_other: { status: 400, text: 'That location is not one Slotlock can keep.' },
|
|
568
|
+
location_not_found: { status: 404, text: 'That location is not one of yours.' },
|
|
569
|
+
location_too_deep: { status: 409, text: `Locations go ${SLOTLOCK_MAX_LOCATION_DEPTH} levels deep at most.` },
|
|
570
|
+
location_cycle: { status: 409, text: 'A location cannot be inside itself, or inside a location that is inside it.' },
|
|
571
|
+
location_limit_reached: {
|
|
572
|
+
status: 409,
|
|
573
|
+
text: `You have ${SLOTLOCK_MAX_LOCATIONS.toLocaleString('en')} locations, the most there can be.`,
|
|
574
|
+
},
|
|
575
|
+
location_in_use: {
|
|
576
|
+
status: 409,
|
|
577
|
+
text: 'Move its resources, and the locations inside it, somewhere else first.',
|
|
578
|
+
},
|
|
579
|
+
group_ref: { status: 400, text: GROUPING_REF_RULE },
|
|
580
|
+
group_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
|
|
581
|
+
group_description: {
|
|
582
|
+
status: 400,
|
|
583
|
+
text: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
|
|
584
|
+
},
|
|
585
|
+
group_other: { status: 400, text: 'That group is not one Slotlock can keep.' },
|
|
586
|
+
group_not_found: { status: 404, text: 'That group is not one of yours.' },
|
|
587
|
+
group_limit_reached: {
|
|
588
|
+
status: 409,
|
|
589
|
+
text: `You have ${SLOTLOCK_MAX_RESOURCE_GROUPS.toLocaleString('en')} groups, the most there can be.`,
|
|
590
|
+
},
|
|
591
|
+
resource_group_limit_reached: {
|
|
592
|
+
status: 409,
|
|
593
|
+
text: `A resource is in ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} groups at most.`,
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
/** The sentence for a refused location or group change, or null when it is not one. */
|
|
597
|
+
function groupingProblem(error) {
|
|
598
|
+
const code = error?.code;
|
|
599
|
+
if (code === 'invalid_location' || code === 'invalid_group') {
|
|
600
|
+
const kind = code === 'invalid_location' ? 'location' : 'group';
|
|
601
|
+
const field = error.field;
|
|
602
|
+
return GROUPING_PROBLEMS[`${kind}_${typeof field === 'string' ? field : 'other'}`] ?? GROUPING_PROBLEMS[`${kind}_other`] ?? null;
|
|
603
|
+
}
|
|
604
|
+
return typeof code === 'string' ? (GROUPING_PROBLEMS[code] ?? null) : null;
|
|
605
|
+
}
|
|
606
|
+
/** Each location's place from the top, as "London › Floor 3", by reference. */
|
|
607
|
+
function locationPaths(locations) {
|
|
608
|
+
const byRef = new Map(locations.map((location) => [location.ref, location]));
|
|
609
|
+
const paths = new Map();
|
|
610
|
+
for (const location of locations) {
|
|
611
|
+
const names = [];
|
|
612
|
+
let at = location;
|
|
613
|
+
for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
|
|
614
|
+
names.unshift(at.name);
|
|
615
|
+
at = at.parentRef === null ? undefined : byRef.get(at.parentRef);
|
|
616
|
+
}
|
|
617
|
+
paths.set(location.ref, names.join(' › '));
|
|
618
|
+
}
|
|
619
|
+
return paths;
|
|
620
|
+
}
|
|
621
|
+
/** Whether `ref` is `ancestor`, or a location inside it. */
|
|
622
|
+
function locationWithin(locations, ref, ancestor) {
|
|
623
|
+
let at = ref;
|
|
624
|
+
for (let steps = 0; at !== null && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
|
|
625
|
+
if (at === ancestor)
|
|
626
|
+
return true;
|
|
627
|
+
at = locations.get(at)?.parentRef ?? null;
|
|
628
|
+
}
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
/** The time zone a location gives a resource: its own, or the nearest one above it, and where from. */
|
|
632
|
+
function locationZone(locations, location) {
|
|
633
|
+
let at = location;
|
|
634
|
+
for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
|
|
635
|
+
if (at.timezone !== null)
|
|
636
|
+
return { timezone: at.timezone, from: at };
|
|
637
|
+
at = at.parentRef === null ? undefined : locations.get(at.parentRef);
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
387
641
|
/** How the overview names each change the feed records. */
|
|
388
642
|
const CHANGE_LABELS = Object.freeze({
|
|
389
643
|
'hold.created': { text: 'Hold placed', tone: 'neutral' },
|
|
@@ -404,7 +658,26 @@ const CHANGE_LABELS = Object.freeze({
|
|
|
404
658
|
'coverage.stale': { text: 'Calendar sync is behind', tone: 'bad' },
|
|
405
659
|
'waitlist.offered': { text: 'Waitlist offer sent', tone: 'good' },
|
|
406
660
|
'waitlist.expired': { text: 'Waitlist offer lapsed', tone: 'neutral' },
|
|
661
|
+
'approval.requested': { text: 'Waiting for approval', tone: 'neutral' },
|
|
662
|
+
'approval.granted': { text: 'Approved and booked', tone: 'good' },
|
|
663
|
+
'approval.declined': { text: 'Approval declined', tone: 'bad' },
|
|
664
|
+
'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
|
|
407
665
|
'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
|
|
666
|
+
'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
|
|
667
|
+
'usage.period_closed': { text: 'Month of use closed', tone: 'neutral' },
|
|
668
|
+
'resource.created': { text: 'Resource added', tone: 'good' },
|
|
669
|
+
'resource.updated': { text: 'Resource changed', tone: 'neutral' },
|
|
670
|
+
'resource.archived': { text: 'Resource archived', tone: 'neutral' },
|
|
671
|
+
'resource.restored': { text: 'Resource restored', tone: 'good' },
|
|
672
|
+
'resource.deleted': { text: 'Resource deleted', tone: 'bad' },
|
|
673
|
+
'key.created': { text: 'API key created', tone: 'good' },
|
|
674
|
+
'key.rotated': { text: 'API key rotated', tone: 'neutral' },
|
|
675
|
+
'key.revoked': { text: 'API key revoked', tone: 'bad' },
|
|
676
|
+
'webhook.created': { text: 'Webhook added', tone: 'good' },
|
|
677
|
+
'webhook.enabled': { text: 'Webhook enabled', tone: 'good' },
|
|
678
|
+
'webhook.deleted': { text: 'Webhook deleted', tone: 'neutral' },
|
|
679
|
+
'coverage.recorded': { text: 'Calendar synced', tone: 'neutral' },
|
|
680
|
+
'coverage.deleted': { text: 'Calendar sync removed', tone: 'neutral' },
|
|
408
681
|
});
|
|
409
682
|
const zoned = (zone, parts) => {
|
|
410
683
|
try {
|
|
@@ -510,6 +783,10 @@ function formatDate(date) {
|
|
|
510
783
|
return '—';
|
|
511
784
|
return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
512
785
|
}
|
|
786
|
+
/** A UTC day as people write it: `9 Dec 2026`. */
|
|
787
|
+
function dayOf(date) {
|
|
788
|
+
return zoned('UTC', { day: 'numeric', month: 'short', year: 'numeric' }).format(date);
|
|
789
|
+
}
|
|
513
790
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
514
791
|
/** A table's date: the day (`19 Oct 2026`), with the full UTC time on hover and for machines. */
|
|
515
792
|
function dateCell(date) {
|
|
@@ -645,6 +922,8 @@ export function createSlotlockDashboard(options) {
|
|
|
645
922
|
const base = publicUrl.pathname.replace(/\/$/, '');
|
|
646
923
|
const root = `${base}/dashboard`;
|
|
647
924
|
const callbackUrl = `${origin}${root}/callback`;
|
|
925
|
+
const publicRoot = `${origin}${root}`;
|
|
926
|
+
const contentSecurityPolicy = policyFor(options.billing);
|
|
648
927
|
// Its cookies are Secure, but a first visit over http:// could still be answered by someone else:
|
|
649
928
|
// a browser that has seen this goes straight to https:// for a year. Not for a local http one.
|
|
650
929
|
const hsts = publicUrl.protocol === 'https:' ? { 'Strict-Transport-Security': 'max-age=31536000' } : {};
|
|
@@ -657,14 +936,45 @@ export function createSlotlockDashboard(options) {
|
|
|
657
936
|
const userUrl = options.github.userUrl ?? 'https://api.github.com/user';
|
|
658
937
|
const membershipsUrl = options.github.membershipsUrl ??
|
|
659
938
|
'https://api.github.com/user/memberships/orgs?state=active&per_page=100';
|
|
660
|
-
const served = options.organizations ? new Set(options.organizations) : null;
|
|
939
|
+
const served = Array.isArray(options.organizations) ? new Set(options.organizations) : null;
|
|
661
940
|
if (served && (served.size === 0 || [...served].some((id) => !ORGANIZATION_ID.test(id)))) {
|
|
662
941
|
throw new Error('Slotlock dashboard organizations must be GitHub organization ids');
|
|
663
942
|
}
|
|
943
|
+
// Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
|
|
944
|
+
const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
|
|
945
|
+
// Plans whether or not billing sells them; billing's own reader when only it is given.
|
|
946
|
+
const plans = options.plans ??
|
|
947
|
+
(options.billing
|
|
948
|
+
? {
|
|
949
|
+
plan: options.billing.plan,
|
|
950
|
+
...(options.billing.enrol ? { enrol: options.billing.enrol } : {}),
|
|
951
|
+
contact: options.billing.contact,
|
|
952
|
+
}
|
|
953
|
+
: undefined);
|
|
954
|
+
if (plans?.contact) {
|
|
955
|
+
const protocol = new URL(plans.contact).protocol;
|
|
956
|
+
if (protocol !== 'https:' && protocol !== 'mailto:')
|
|
957
|
+
throw new Error('plans.contact must be an https or mailto URL');
|
|
958
|
+
}
|
|
959
|
+
if (options.organizations === 'enabled' && !teams) {
|
|
960
|
+
throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
|
|
961
|
+
}
|
|
962
|
+
/** Sign-in reads the person's organizations: a fixed list, or those turned on. */
|
|
963
|
+
const readsOrganizations = served !== null || teams !== null;
|
|
964
|
+
/** Each session's organizations turned on, read with it (teams mode). */
|
|
965
|
+
const enabledOf = new WeakMap();
|
|
664
966
|
const brandName = options.name ?? 'Slotlock';
|
|
665
967
|
if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
|
|
666
968
|
throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
|
|
667
969
|
}
|
|
970
|
+
for (const [label, url] of [
|
|
971
|
+
['termsUrl', options.termsUrl],
|
|
972
|
+
['privacyUrl', options.privacyUrl],
|
|
973
|
+
]) {
|
|
974
|
+
if (url !== undefined && !/^https:\/\/[^\s"'<>`]+$/.test(url)) {
|
|
975
|
+
throw new Error(`Slotlock dashboard ${label} must be an https URL`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
668
978
|
const fetchImpl = options.fetch ?? fetch;
|
|
669
979
|
const now = options.now ?? Date.now;
|
|
670
980
|
if (Buffer.byteLength(options.sessionSecret, 'utf8') < 32) {
|
|
@@ -672,8 +982,10 @@ export function createSlotlockDashboard(options) {
|
|
|
672
982
|
}
|
|
673
983
|
const secret = Buffer.from(options.sessionSecret, 'utf8');
|
|
674
984
|
// A list, or exactly '*': any other string would match ids by substring.
|
|
675
|
-
|
|
676
|
-
|
|
985
|
+
// A GitHub person by their bare id, a Google one as google:<subject>.
|
|
986
|
+
const allowed = (uid, idp) => options.allowedUsers === '*' ||
|
|
987
|
+
(Array.isArray(options.allowedUsers) && options.allowedUsers.includes(idp === 'google' ? `google:${uid}` : uid));
|
|
988
|
+
const google = served ? null : (options.google ?? null);
|
|
677
989
|
let signInsInFlight = 0;
|
|
678
990
|
/** Sign-ins in flight by client address. */
|
|
679
991
|
const signInsByClient = new Map();
|
|
@@ -741,18 +1053,39 @@ export function createSlotlockDashboard(options) {
|
|
|
741
1053
|
typeof payload.uid !== 'string' ||
|
|
742
1054
|
typeof payload.login !== 'string' ||
|
|
743
1055
|
typeof payload.sid !== 'string' ||
|
|
744
|
-
|
|
1056
|
+
(payload.idp !== undefined && (payload.idp !== 'google' || !google)) ||
|
|
1057
|
+
!allowed(payload.uid, payload.idp) ||
|
|
745
1058
|
!organizationsHold(payload) ||
|
|
1059
|
+
(payload.env !== undefined && payload.env !== 'test') ||
|
|
746
1060
|
(await options.state.sessionEnded(payload.sid, payload.exp))) {
|
|
747
1061
|
return null;
|
|
748
1062
|
}
|
|
749
|
-
|
|
1063
|
+
const session = payload;
|
|
1064
|
+
if (teams) {
|
|
1065
|
+
// Which of the person's organizations are on now: one turned off since is left at once.
|
|
1066
|
+
const enabled = session.orgs?.length ? await teams.enabled(session.orgs.map((org) => org.id)) : new Set();
|
|
1067
|
+
if (session.org !== undefined && !enabled.has(session.org)) {
|
|
1068
|
+
const { org: _, ...personal } = session;
|
|
1069
|
+
enabledOf.set(personal, enabled);
|
|
1070
|
+
return personal;
|
|
1071
|
+
}
|
|
1072
|
+
enabledOf.set(session, enabled);
|
|
1073
|
+
}
|
|
1074
|
+
return session;
|
|
750
1075
|
}
|
|
751
1076
|
/**
|
|
752
1077
|
* With organizations, the session names one the server still serves, from its own list; without
|
|
753
1078
|
* them, it names none. A session from the other mode is signed out.
|
|
754
1079
|
*/
|
|
755
1080
|
function organizationsHold(payload) {
|
|
1081
|
+
if (teams) {
|
|
1082
|
+
if (payload.orgs === undefined)
|
|
1083
|
+
return payload.org === undefined;
|
|
1084
|
+
if (!Array.isArray(payload.orgs))
|
|
1085
|
+
return false;
|
|
1086
|
+
const listed = payload.orgs.filter((org) => isRecord(org) && typeof org.id === 'string' && typeof org.login === 'string');
|
|
1087
|
+
return payload.org === undefined || (typeof payload.org === 'string' && listed.some((org) => org.id === payload.org));
|
|
1088
|
+
}
|
|
756
1089
|
if (!served)
|
|
757
1090
|
return payload.org === undefined && payload.orgs === undefined;
|
|
758
1091
|
if (typeof payload.org !== 'string' || !served.has(payload.org) || !Array.isArray(payload.orgs)) {
|
|
@@ -763,15 +1096,20 @@ export function createSlotlockDashboard(options) {
|
|
|
763
1096
|
const csrfToken = (session) => createHmac('sha256', secret)
|
|
764
1097
|
.update(`slotlock-dashboard-csrf-v1.${session.sid}`)
|
|
765
1098
|
.digest('base64url');
|
|
766
|
-
|
|
1099
|
+
/** The tenant the session acts in: the workspace's, or its test environment's (`~test`). */
|
|
1100
|
+
const tenantOf = (session) => `${session.org !== undefined ? `github-org:${session.org}` : personOf(session)}${session.env === 'test' ? SLOTLOCK_TEST_TENANT_SUFFIX : ''}`;
|
|
767
1101
|
/** Who did something, for the record: the person, whatever tenant they act in. */
|
|
768
|
-
|
|
1102
|
+
function personOf(session) {
|
|
1103
|
+
return session.idp === 'google' ? `google:${session.uid}` : `github:${session.uid}`;
|
|
1104
|
+
}
|
|
1105
|
+
/** How the dashboard shows the person: their GitHub login, or their Google account's email. */
|
|
1106
|
+
const shownAs = (session) => (session.idp === 'google' ? session.login : `@${session.login}`);
|
|
769
1107
|
function respond(status, body, headers = {}, cookies = []) {
|
|
770
1108
|
const response = new Response(String(body), {
|
|
771
1109
|
status,
|
|
772
1110
|
headers: {
|
|
773
1111
|
'Content-Type': 'text/html; charset=utf-8',
|
|
774
|
-
'Content-Security-Policy':
|
|
1112
|
+
'Content-Security-Policy': contentSecurityPolicy,
|
|
775
1113
|
'Cache-Control': 'no-store',
|
|
776
1114
|
'X-Frame-Options': 'DENY',
|
|
777
1115
|
'X-Content-Type-Options': 'nosniff',
|
|
@@ -794,6 +1132,9 @@ export function createSlotlockDashboard(options) {
|
|
|
794
1132
|
...(options.resources.waitlist
|
|
795
1133
|
? [{ section: 'waitlist', label: 'Waitlist', path: '/waitlist', icon: ICONS.waitlist }]
|
|
796
1134
|
: []),
|
|
1135
|
+
...(options.resources.approvals
|
|
1136
|
+
? [{ section: 'approvals', label: 'Approvals', path: '/approvals', icon: ICONS.approvals }]
|
|
1137
|
+
: []),
|
|
797
1138
|
{ section: 'keys', label: 'API keys', path: '/keys', icon: ICONS.keys },
|
|
798
1139
|
...(options.resources.webhooks
|
|
799
1140
|
? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
|
|
@@ -801,14 +1142,25 @@ export function createSlotlockDashboard(options) {
|
|
|
801
1142
|
...(assistantStore()
|
|
802
1143
|
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
803
1144
|
: []),
|
|
1145
|
+
...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
|
|
1146
|
+
...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
|
|
804
1147
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
805
1148
|
];
|
|
1149
|
+
/** Live or Test (RFC 0003 G9): the same workspace, a tenant of its own for trying things. */
|
|
1150
|
+
function environmentSwitch(session) {
|
|
1151
|
+
const choice = (value, label) => html `<button type="submit" name="environment" value="${value}"${(session.env === 'test') === (value === 'test') ? html ` aria-pressed="true"` : html ` aria-pressed="false"`}>${label}</button>`;
|
|
1152
|
+
return html `<form class="environment" method="post" action="${root}/environment" aria-label="Environment"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">${choice('live', 'Live')}${choice('test', 'Test')}</form>`;
|
|
1153
|
+
}
|
|
806
1154
|
/** The workspace the session acts in: its organization, or the person themselves. */
|
|
807
1155
|
function workspace(session) {
|
|
808
1156
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
809
1157
|
const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
|
|
810
1158
|
const name = current?.login ?? session.login;
|
|
811
1159
|
const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
|
|
1160
|
+
const enabled = teams ? (session.orgs ?? []).filter((org) => enabledOf.get(session)?.has(org.id)) : [];
|
|
1161
|
+
if (teams && enabled.length > 0) {
|
|
1162
|
+
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">Workspace</label><select id="organization" name="organization" title="${name}"><option value=""${current ? html `` : html ` selected`}>${shownAs(session)}</option>${enabled.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>`;
|
|
1163
|
+
}
|
|
812
1164
|
if (current && choices.length > 1) {
|
|
813
1165
|
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" title="${current.login}">${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>`;
|
|
814
1166
|
}
|
|
@@ -823,6 +1175,7 @@ export function createSlotlockDashboard(options) {
|
|
|
823
1175
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
824
1176
|
<meta name="referrer" content="${REFERRER_POLICY}">
|
|
825
1177
|
<title>${title} · ${brandName}</title>
|
|
1178
|
+
<link rel="icon" href="${root}/favicon.svg?v=${ASSET_VERSIONS.icon}" type="image/svg+xml">
|
|
826
1179
|
<link rel="stylesheet" href="${root}/style.css?v=${ASSET_VERSIONS.css}">
|
|
827
1180
|
<script src="${root}/app.js?v=${ASSET_VERSIONS.js}" defer></script>
|
|
828
1181
|
</head>`;
|
|
@@ -846,44 +1199,77 @@ ${live}
|
|
|
846
1199
|
${skip}
|
|
847
1200
|
<div class="app">
|
|
848
1201
|
<header class="side">
|
|
849
|
-
<div class="side-top">${brand}${workspace(session)}</div>
|
|
1202
|
+
<div class="side-top">${brand}${workspace(session)}${environmentSwitch(session)}</div>
|
|
850
1203
|
<nav class="nav" aria-label="Dashboard">${links}</nav>
|
|
851
|
-
<div class="me"><span class="avatar me-avatar" aria-hidden="true">${session.login.slice(0, 1).toUpperCase()}</span><span class="login" title="
|
|
1204
|
+
<div class="me"><span class="avatar me-avatar" aria-hidden="true">${session.login.slice(0, 1).toUpperCase()}</span><span class="login" title="${shownAs(session)}">${shownAs(session)}</span><form method="post" action="${root}/sign-out"><input type="hidden" name="csrf" value="${csrfToken(session)}"><button class="quiet sign-out" type="submit" title="Sign out">${ICONS.signOut}<span class="sign-out-label">Sign out</span></button></form></div>
|
|
852
1205
|
</header>
|
|
853
|
-
<main class="page" id="main">${
|
|
1206
|
+
<main class="page" id="main">${session.env === 'test'
|
|
1207
|
+
? html `<p class="test-banner">Test environment: its own resources, keys, webhooks and history, on your plan's limits and never billed. Its keys start <code>slk_test_</code>.</p>`
|
|
1208
|
+
: html ``}${content}</main>
|
|
854
1209
|
</div>
|
|
855
1210
|
${live}
|
|
856
1211
|
</body>
|
|
857
1212
|
</html>`;
|
|
858
1213
|
}
|
|
859
|
-
|
|
1214
|
+
// A notice is an alert only when the page answers with an error: a done or saved message is a
|
|
1215
|
+
// status, so success is never painted in the error colour.
|
|
1216
|
+
const noticeOf = (notice, status = 200) => notice
|
|
1217
|
+
? status >= 400
|
|
1218
|
+
? html `<p class="notice" role="alert">${notice}</p>`
|
|
1219
|
+
: html `<p class="notice done" role="status">${notice}</p>`
|
|
1220
|
+
: html ``;
|
|
860
1221
|
function signedOutPage(status, notice, cookies = []) {
|
|
861
1222
|
const content = html `<section class="sign-in">
|
|
862
1223
|
<span class="sign-in-mark">${ICONS.mark}</span>
|
|
863
1224
|
<h1>Sign in to ${brandName}</h1>
|
|
864
1225
|
<p>Create keys for your agents, add what they book, and watch what they do.</p>
|
|
865
|
-
${noticeOf(notice)}
|
|
866
|
-
<a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a
|
|
867
|
-
|
|
868
|
-
?
|
|
869
|
-
|
|
1226
|
+
${noticeOf(notice, status)}
|
|
1227
|
+
<a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>${google ? html `<a class="button wide" href="${root}/sign-in?with=google">${ICONS.google}Continue with Google</a>` : html ``}
|
|
1228
|
+
${options.termsUrl
|
|
1229
|
+
? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
|
|
1230
|
+
? html ` The <a href="${options.privacyUrl}">Privacy notice</a> says what is kept.`
|
|
1231
|
+
: html ``}</p>`
|
|
1232
|
+
: html ``}
|
|
1233
|
+
<p class="fine">${teams
|
|
1234
|
+
? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and up to ${String(MAX_SESSION_ORGANIZATIONS)} of those organizations, for you to work in the ones turned on.`
|
|
1235
|
+
: served
|
|
1236
|
+
? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
|
|
1237
|
+
: `${brandName} reads only your public GitHub profile, and keeps only your user id and login.`}${google ? ` With Google, it reads your account's id and email address, and keeps only those.` : ''}</p>
|
|
870
1238
|
</section>`;
|
|
871
1239
|
return respond(status, layout('Sign in', content, null), {}, cookies);
|
|
872
1240
|
}
|
|
1241
|
+
/** A key's own limits (RFC 0004 A5) as fields, filled with what it has. */
|
|
1242
|
+
const keyLimitFields = (limits) => html `<label>Live holds at most<input name="max_holds" inputmode="numeric" pattern="[0-9]*" maxlength="5" value="${limits?.maxHolds !== undefined ? String(limits.maxHolds) : ''}" placeholder="Server's"></label>
|
|
1243
|
+
<label>Hours held at most<input name="max_held_hours" inputmode="numeric" pattern="[0-9]*" maxlength="6" value="${limits?.maxHeldHours !== undefined ? String(limits.maxHeldHours) : ''}" placeholder="Server's"></label>
|
|
1244
|
+
<label>Requests a minute<input name="requests_per_minute" inputmode="numeric" pattern="[0-9]*" maxlength="6" value="${limits?.requestsPerMinute !== undefined ? String(limits.requestsPerMinute) : ''}" placeholder="Server's"></label>
|
|
1245
|
+
<label class="choice"><input type="checkbox" name="requires_approval" value="on"${limits?.requiresApproval ? html ` checked` : html ``}> Its bookings wait for approval</label>`;
|
|
1246
|
+
/** A key's limits in words, for its row. */
|
|
1247
|
+
const keyLimitsText = (limits) => [
|
|
1248
|
+
limits.maxHolds !== undefined ? `${String(limits.maxHolds)} live holds` : '',
|
|
1249
|
+
limits.maxHeldHours !== undefined ? `${String(limits.maxHeldHours)} hours held` : '',
|
|
1250
|
+
limits.requestsPerMinute !== undefined ? `${String(limits.requestsPerMinute)} requests a minute` : '',
|
|
1251
|
+
limits.requiresApproval ? 'bookings need approval' : '',
|
|
1252
|
+
]
|
|
1253
|
+
.filter(Boolean)
|
|
1254
|
+
.join(', ');
|
|
873
1255
|
function keyRows(keys, token, session) {
|
|
874
1256
|
return keys.map((key) => {
|
|
875
1257
|
const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
|
|
876
1258
|
const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
|
|
877
1259
|
const actions = status === 'Active'
|
|
878
|
-
? html `<details class="act"><summary>
|
|
1260
|
+
? html `<details class="act"><summary>Limits</summary><div class="pop"><p>Hold this key below the server's limits. Empty is the server's.</p>
|
|
1261
|
+
<form method="post" action="${root}/keys/limits"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}">${keyLimitFields(key.limits)}<button type="submit">Save limits</button></form></div></details>
|
|
1262
|
+
<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>
|
|
879
1263
|
<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>
|
|
880
1264
|
<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>
|
|
881
1265
|
<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>`
|
|
882
1266
|
: html ``;
|
|
883
1267
|
return html `<tr>
|
|
884
|
-
<td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${
|
|
1268
|
+
<td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${session.org !== undefined ? html `<span class="meta">Created by ${key.createdBy === personOf(session) ? 'you' : key.createdBy?.startsWith('github:') ? `GitHub user ${key.createdBy.slice(7)}` : key.createdBy?.startsWith('google:') ? 'a Google user' : 'someone unknown'}</span>` : html ``}</span></td>
|
|
885
1269
|
<td data-label="Key"><code class="chip">${key.prefix}…</code></td>
|
|
886
|
-
<td data-label="Access">${key.
|
|
1270
|
+
<td data-label="Access">${key.resourceScope || key.limits
|
|
1271
|
+
? html `<span class="cell"><span>${accessOf(key)}</span>${key.resourceScope ? html `<span class="meta wrap">Only ${[...key.resourceScope.locations, ...key.resourceScope.groups.map((group) => `group ${group}`)].join(', ')}</span>` : html ``}${key.limits ? html `<span class="meta wrap">At most ${keyLimitsText(key.limits)}</span>` : html ``}</span>`
|
|
1272
|
+
: accessOf(key)}</td>
|
|
887
1273
|
<td data-label="Created">${dateCell(key.createdAt)}</td>
|
|
888
1274
|
<td data-label="Last used">${key.lastUsedAt ? dateCell(key.lastUsedAt) : html `<span class="muted">Never</span>`}</td>
|
|
889
1275
|
<td data-label="Expires">${key.expiresAt ? dateCell(key.expiresAt) : html `<span class="muted">Never</span>`}</td>
|
|
@@ -896,7 +1282,8 @@ ${noticeOf(notice)}
|
|
|
896
1282
|
/** A list with nothing in it yet: what goes here, and the one thing to do next. */
|
|
897
1283
|
const emptyState = (glyph, text, action) => html `<div class="empty-state"><span class="empty-icon">${glyph}</span><p class="empty">${text}</p>${action ? html `<a class="button" href="${action.href}">${ICONS.plus}${action.label}</a>` : html ``}</div>`;
|
|
898
1284
|
/** A resource as people name it: its reference, else the start of its id. */
|
|
899
|
-
|
|
1285
|
+
// Its profile's name, else its reference, else the start of its id.
|
|
1286
|
+
const resourceName = (resource, id) => resource?.name ?? resource?.externalRef ?? `${id.slice(0, 8)}…`;
|
|
900
1287
|
function agendaPanel(entries, byId, more) {
|
|
901
1288
|
if (entries.length === 0) {
|
|
902
1289
|
return html `<p class="empty">Nothing booked in the next ${String(AGENDA_DAYS)} days.</p>`;
|
|
@@ -926,78 +1313,269 @@ ${noticeOf(notice)}
|
|
|
926
1313
|
}
|
|
927
1314
|
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 ``}`;
|
|
928
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* What each change says in the feed, as a sentence: who did it (`verb` then the resource), or what
|
|
1318
|
+
* happened (`passive`, the resource in place of {X}). Every change type the feed records has one,
|
|
1319
|
+
* so the feed never shows a raw type such as resource.updated.
|
|
1320
|
+
*/
|
|
1321
|
+
const ACTIVITY = Object.freeze({
|
|
1322
|
+
'hold.created': { verb: 'held' },
|
|
1323
|
+
'hold.updated': { verb: 'changed the hold on' },
|
|
1324
|
+
'hold.confirmed': { verb: 'booked', after: ' from a hold' },
|
|
1325
|
+
'hold.released': { verb: 'released the hold on' },
|
|
1326
|
+
'hold.expired': { passive: 'The hold on {X} expired' },
|
|
1327
|
+
'reservation.created': { verb: 'booked' },
|
|
1328
|
+
'reservation.updated': { verb: 'moved a booking on' },
|
|
1329
|
+
'reservation.cancelled': { verb: 'cancelled a booking on' },
|
|
1330
|
+
'reservation.checked_in': { verb: 'checked in to' },
|
|
1331
|
+
'reservation.no_show': { passive: '{X} was released after a no-show' },
|
|
1332
|
+
'event.created': { verb: 'added an event on' },
|
|
1333
|
+
'event.updated': { verb: 'changed an event on' },
|
|
1334
|
+
'event.cancelled': { verb: 'cancelled an event on' },
|
|
1335
|
+
'event.timezone_shifted': { passive: 'An event on {X} moved with a time zone change' },
|
|
1336
|
+
'event.timezone_conflict': { passive: 'An event on {X} clashes after a time zone change' },
|
|
1337
|
+
'coverage.stale': { passive: 'The calendar sync for {X} is behind' },
|
|
1338
|
+
'coverage.recorded': { passive: 'The calendar for {X} synced' },
|
|
1339
|
+
'coverage.deleted': { verb: 'removed the calendar sync for' },
|
|
1340
|
+
'waitlist.offered': { passive: 'A waitlist offer went out for {X}' },
|
|
1341
|
+
'waitlist.expired': { passive: 'A waitlist offer for {X} lapsed' },
|
|
1342
|
+
'approval.requested': { passive: 'A booking on {X} waits for approval' },
|
|
1343
|
+
'approval.granted': { verb: 'approved a booking on' },
|
|
1344
|
+
'approval.declined': { verb: 'declined a booking on' },
|
|
1345
|
+
'approval.expired': { passive: 'A booking on {X} was not approved in time' },
|
|
1346
|
+
'resource.created': { verb: 'added' },
|
|
1347
|
+
'resource.updated': { verb: 'edited' },
|
|
1348
|
+
'resource.archived': { verb: 'archived' },
|
|
1349
|
+
'resource.restored': { verb: 'restored' },
|
|
1350
|
+
'resource.deleted': { verb: 'deleted' },
|
|
1351
|
+
'key.created': { verb: 'created an API key' },
|
|
1352
|
+
'key.rotated': { verb: 'rotated an API key' },
|
|
1353
|
+
'key.revoked': { verb: 'revoked an API key' },
|
|
1354
|
+
'webhook.created': { verb: 'added a webhook' },
|
|
1355
|
+
'webhook.enabled': { verb: 'turned a webhook back on' },
|
|
1356
|
+
'webhook.disabled': { passive: 'A webhook was turned off' },
|
|
1357
|
+
'webhook.deleted': { verb: 'deleted a webhook' },
|
|
1358
|
+
'usage.period_closed': { passive: 'A month of use closed' },
|
|
1359
|
+
});
|
|
1360
|
+
/** Changes about a resource, which name it; the rest stand alone. */
|
|
1361
|
+
const ABOUT_A_RESOURCE = /^(hold|reservation|event|coverage|waitlist|approval|resource)\./;
|
|
929
1362
|
function activityPanel(changes, byId, keys, session) {
|
|
930
1363
|
if (changes.length === 0) {
|
|
931
1364
|
return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
|
|
932
1365
|
}
|
|
933
1366
|
const keyNames = new Map(keys.map((key) => [`api_key:${key.id}`, key.name]));
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
?
|
|
938
|
-
:
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
(
|
|
942
|
-
?
|
|
943
|
-
: /^
|
|
944
|
-
?
|
|
945
|
-
: /^dashboard-assistant:github
|
|
946
|
-
?
|
|
947
|
-
? `Assistant for
|
|
948
|
-
:
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1367
|
+
const whoOf = (actor) => actor === null
|
|
1368
|
+
? brandName
|
|
1369
|
+
: actor === personOf(session)
|
|
1370
|
+
? 'You'
|
|
1371
|
+
: (keyNames.get(actor) ??
|
|
1372
|
+
(actor.startsWith('api_key:')
|
|
1373
|
+
? 'A deleted key'
|
|
1374
|
+
: /^github:\d+$/.test(actor)
|
|
1375
|
+
? `GitHub user ${actor.slice(7)}`
|
|
1376
|
+
: /^google:/.test(actor)
|
|
1377
|
+
? 'A Google user'
|
|
1378
|
+
: /^dashboard-assistant:(github|google):/.test(actor)
|
|
1379
|
+
? actor === assistantActor(session)
|
|
1380
|
+
? `Assistant for ${shownAs(session)}`
|
|
1381
|
+
: actor.startsWith('dashboard-assistant:google:')
|
|
1382
|
+
? 'Assistant for a Google user'
|
|
1383
|
+
: `Assistant for GitHub user ${actor.slice(27)}`
|
|
1384
|
+
: actor.slice(0, 60)));
|
|
1385
|
+
// Repeats in a row (one actor, one change, one resource) read as one line with a count.
|
|
1386
|
+
const runs = [];
|
|
1387
|
+
for (const change of changes) {
|
|
1388
|
+
const last = runs.at(-1);
|
|
1389
|
+
if (last && last.change.actor === change.actor && last.change.type === change.type && last.change.resourceId === change.resourceId) {
|
|
1390
|
+
last.times += 1;
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
runs.push({ change, times: 1 });
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return html `<ol class="feed">${runs.map(({ change, times }) => {
|
|
1397
|
+
const tone = CHANGE_LABELS[change.type]?.tone ?? 'neutral';
|
|
1398
|
+
const where = change.resourceId ? resourceName(byId.get(change.resourceId), change.resourceId) : null;
|
|
1399
|
+
const object = where ?? (ABOUT_A_RESOURCE.test(change.type) ? 'a resource' : null);
|
|
1400
|
+
const words = ACTIVITY[change.type];
|
|
1401
|
+
let sentence;
|
|
1402
|
+
if (change.type === 'usage.threshold_reached') {
|
|
1403
|
+
const metric = typeof change.data.metric === 'string' ? change.data.metric : 'use';
|
|
1404
|
+
const percent = typeof change.data.percent === 'number' ? change.data.percent : null;
|
|
1405
|
+
sentence = html `${percent === null ? 'A monthly quota' : `${String(percent)}% of this month's ${metric}`} used`;
|
|
1406
|
+
}
|
|
1407
|
+
else if (words?.verb) {
|
|
1408
|
+
sentence = html `<strong>${whoOf(change.actor)}</strong> ${words.verb}${object ? html ` <strong>${object}</strong>` : html ``}${words.after ?? ''}`;
|
|
1409
|
+
}
|
|
1410
|
+
else if (words?.passive) {
|
|
1411
|
+
const [before, rest] = words.passive.split('{X}');
|
|
1412
|
+
sentence = rest === undefined ? html `${before}` : html `${before}<strong>${object ?? 'a resource'}</strong>${rest}`;
|
|
1413
|
+
}
|
|
1414
|
+
else {
|
|
1415
|
+
// A change type this dashboard does not know yet: its label, or its name in words.
|
|
1416
|
+
const label = CHANGE_LABELS[change.type]?.text ?? change.type.replace(/[._]/g, ' ').replace(/^./, (first) => first.toUpperCase());
|
|
1417
|
+
sentence = html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`;
|
|
1418
|
+
}
|
|
1419
|
+
return html `<li class="event"><span class="dot ${tone}" aria-hidden="true"></span><span class="what"><span class="title">${sentence}${times > 1 ? html ` <span class="times">${times === 2 ? 'twice' : `${String(times)} times`}</span>` : html ``}</span></span><time class="when" datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></li>`;
|
|
954
1420
|
})}</ol>`;
|
|
955
1421
|
}
|
|
1422
|
+
/** The MCP and A2A endpoints with Copy, and the command that adds Slotlock to an MCP client. */
|
|
1423
|
+
const connectAgent = () => html `${endpoints()}<details class="command"><summary>Claude Code command</summary><div class="copy-block"><pre><code id="mcp-command">claude mcp add --transport http slotlock ${mcpUrl} --header "Authorization: Bearer YOUR_KEY"</code></pre><button class="quiet" type="button" data-copy="mcp-command">Copy</button></div></details>`;
|
|
956
1424
|
async function overviewPage(session, status = 200, notice) {
|
|
957
1425
|
const tenantRef = tenantOf(session);
|
|
958
1426
|
const start = new Date(now());
|
|
959
1427
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
960
|
-
const
|
|
1428
|
+
const assistant = assistantStore();
|
|
1429
|
+
const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
|
|
961
1430
|
options.keys.list({ tenantRef }),
|
|
962
1431
|
options.resources.list(tenantRef),
|
|
963
1432
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
964
1433
|
options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
|
|
1434
|
+
options.resources.approvals?.list(tenantRef, APPROVALS_SHOWN),
|
|
1435
|
+
options.resources.waitlist?.list(tenantRef, WAITLIST_SHOWN),
|
|
1436
|
+
assistant?.getSettings(tenantRef),
|
|
965
1437
|
]);
|
|
1438
|
+
// The plan is Live's: the test environment has none of its own.
|
|
1439
|
+
const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
|
|
966
1440
|
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
967
1441
|
const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
|
|
968
1442
|
const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
|
|
969
1443
|
const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
|
|
970
1444
|
const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
|
|
971
|
-
const
|
|
1445
|
+
const waiting = approvals?.filter((approval) => approval.status === 'pending').length ?? 0;
|
|
1446
|
+
const attention = [
|
|
1447
|
+
...(standing?.attention ?? []),
|
|
1448
|
+
...(waiting > 0
|
|
1449
|
+
? [
|
|
1450
|
+
attentionItem('info', `${String(waiting)} ${waiting === 1 ? 'booking waits' : 'bookings wait'} for your approval.`, {
|
|
1451
|
+
href: `${root}/approvals`,
|
|
1452
|
+
label: 'Review',
|
|
1453
|
+
}),
|
|
1454
|
+
]
|
|
1455
|
+
: []),
|
|
1456
|
+
];
|
|
1457
|
+
const admin = isAdmin(session);
|
|
972
1458
|
const steps = [
|
|
973
|
-
{ done: resources.length > 0,
|
|
974
|
-
{ done: active.length > 0,
|
|
975
|
-
|
|
1459
|
+
{ done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
|
|
1460
|
+
{ done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
|
|
1461
|
+
// Done once an agent has used a key.
|
|
1462
|
+
{ done: active.some((key) => key.lastUsedAt !== null), title: 'Connect an agent', action: { href: `${root}#connect`, label: 'Connect' } },
|
|
1463
|
+
...(assistant
|
|
1464
|
+
? [
|
|
1465
|
+
admin
|
|
1466
|
+
? { done: Boolean(model), title: 'Connect a model to the assistant', action: { href: `${root}/settings`, label: 'Connect' } }
|
|
1467
|
+
: { done: Boolean(model), title: 'Try the assistant', action: { href: `${root}/assistant`, label: 'Open' } },
|
|
1468
|
+
]
|
|
1469
|
+
: []),
|
|
976
1470
|
];
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
1471
|
+
const done = steps.filter((step) => step.done).length;
|
|
1472
|
+
const next = steps.findIndex((step) => !step.done);
|
|
1473
|
+
// Nothing made and nothing happened yet: only the way in.
|
|
1474
|
+
const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
|
|
1475
|
+
const setupCard = next === -1
|
|
980
1476
|
? html ``
|
|
981
|
-
: 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
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
${
|
|
986
|
-
|
|
987
|
-
|
|
1477
|
+
: html `<section class="panel setup-card"><div class="panel-head"><h2>Get started</h2><span class="meta">${String(done)} of ${String(steps.length)}</span></div><div class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</div><ol class="setup-steps">${steps.map((step, index) => html `<li class="${step.done ? 'done' : index === next ? 'next' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span><span class="step-title">${step.title}${step.done ? html `<span class="visually-hidden"> (done)</span>` : html ``}</span>${index === next ? html `<a class="button primary" href="${step.action.href}">${step.action.label}</a>` : html ``}</li>`)}</ol></section>`;
|
|
1478
|
+
// Mostly set up: one line that leads to the next step.
|
|
1479
|
+
const setupRow = next === -1
|
|
1480
|
+
? html ``
|
|
1481
|
+
: html `<a class="setup-row" href="${steps[next]?.action.href ?? root}"><span class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</span><span>Get started · ${String(done)} of ${String(steps.length)} · <strong>${steps[next]?.title ?? ''}</strong></span></a>`;
|
|
1482
|
+
const stat = (label, value, href, tone = '') => html `<a class="stat${tone}" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
|
|
1483
|
+
const tiles = [
|
|
1484
|
+
...(agenda ? [stat(`Bookings, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`), stat('Live holds', counted(held), `${root}#upcoming`)] : []),
|
|
1485
|
+
...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
|
|
1486
|
+
...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
|
|
1487
|
+
stat('Resources', String(resources.length), `${root}/resources`),
|
|
1488
|
+
stat('Active keys', String(active.length), `${root}/keys`),
|
|
1489
|
+
].slice(0, 4);
|
|
1490
|
+
const planCard = standing
|
|
1491
|
+
? html `<section class="panel plan-card"><div class="panel-head"><h2>${standing.name}</h2><a href="${root}/plan">Usage</a></div><div class="meters stacked">${standing.meters}</div>${standing.canUpgrade && standing.pressing
|
|
1492
|
+
? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
|
|
1493
|
+
: html ``}</section>`
|
|
1494
|
+
: html ``;
|
|
1495
|
+
const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
|
|
1496
|
+
const body = fresh
|
|
1497
|
+
? standing
|
|
1498
|
+
? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
|
|
1499
|
+
: setupCard
|
|
1500
|
+
: html `${done * 2 >= steps.length ? setupRow : setupCard}
|
|
1501
|
+
<div class="stats">${tiles}</div>
|
|
1502
|
+
${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
988
1503
|
<div class="split">
|
|
1504
|
+
<div class="column">
|
|
989
1505
|
${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
|
|
990
|
-
${changes ? html `<section class="panel"><h2>
|
|
1506
|
+
${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
|
|
1507
|
+
</div>
|
|
1508
|
+
<div class="column">${planCard}${connectCard}</div>
|
|
991
1509
|
</div>`;
|
|
1510
|
+
const content = html `${noticeOf(notice, status)}
|
|
1511
|
+
${pageHead('Overview')}
|
|
1512
|
+
${attentionList(attention)}
|
|
1513
|
+
${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
1514
|
+
${body}`;
|
|
992
1515
|
return respond(status, layout('Overview', content, session, 'overview'));
|
|
993
1516
|
}
|
|
1517
|
+
/** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
|
|
1518
|
+
function sandboxPanel(session, resourceCount) {
|
|
1519
|
+
const token = csrfToken(session);
|
|
1520
|
+
const hidden = html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
1521
|
+
const names = { 'car-rental': 'Car rental', clinic: 'Clinic', desks: 'Desks and a hall' };
|
|
1522
|
+
return html `<section class="panel" id="sandbox"><h2>Sandbox</h2>
|
|
1523
|
+
<p class="fine">Test only. Try an agent against sample data, make holds and approvals that would lapse lapse now, or start again. Keys and webhooks stay.</p>
|
|
1524
|
+
${resourceCount === 0
|
|
1525
|
+
? html `<form class="create" method="post" action="${root}/sandbox/seed">${hidden}<label>Sample data<select name="scenario">${SLOTLOCK_SANDBOX_SCENARIOS.map((scenario) => html `<option value="${scenario}">${names[scenario]}</option>`)}</select></label><button class="primary" type="submit">Load it</button></form>`
|
|
1526
|
+
: html `<form class="create" method="post" action="${root}/sandbox/fast-forward">${hidden}<label>Move waiting forward<select name="minutes"><option value="15">15 minutes</option><option value="60">An hour</option><option value="1440">A day</option></select></label><button type="submit">Move forward</button></form>
|
|
1527
|
+
<details class="act danger"><summary>Start again</summary><div class="pop"><p>Every resource, booking, hold and policy in Test goes. Keys and webhooks stay.</p><form method="post" action="${root}/sandbox/reset">${hidden}<button class="danger" type="submit">Empty Test</button></form></div></details>`}
|
|
1528
|
+
</section>`;
|
|
1529
|
+
}
|
|
1530
|
+
async function sandboxAction(session, form, action) {
|
|
1531
|
+
const sandbox = options.resources.sandbox;
|
|
1532
|
+
if (!sandbox || session.env !== 'test')
|
|
1533
|
+
return overviewPage(session, 400, 'The sandbox is in Test only.');
|
|
1534
|
+
const tenantRef = tenantOf(session);
|
|
1535
|
+
try {
|
|
1536
|
+
if (action === 'seed') {
|
|
1537
|
+
const scenario = form.get('scenario');
|
|
1538
|
+
if (!SLOTLOCK_SANDBOX_SCENARIOS.includes(scenario))
|
|
1539
|
+
return overviewPage(session, 400, 'Choose a sample from the list.');
|
|
1540
|
+
const seeded = await sandbox.seed(tenantRef, scenario);
|
|
1541
|
+
return overviewPage(session, 200, `Loaded ${String(seeded.resources)} resources and ${String(seeded.bookings)} bookings for tomorrow.`);
|
|
1542
|
+
}
|
|
1543
|
+
if (action === 'reset') {
|
|
1544
|
+
await sandbox.reset(tenantRef);
|
|
1545
|
+
return redirect(303, `${root}#sandbox`);
|
|
1546
|
+
}
|
|
1547
|
+
const minutes = Number(form.get('minutes'));
|
|
1548
|
+
if (![15, 60, 1440].includes(minutes))
|
|
1549
|
+
return overviewPage(session, 400, 'Choose how far from the list.');
|
|
1550
|
+
const moved = await sandbox.fastForward(tenantRef, minutes);
|
|
1551
|
+
return overviewPage(session, 200, `${String(moved.holds)} holds and ${String(moved.approvals)} approvals lapsed.`);
|
|
1552
|
+
}
|
|
1553
|
+
catch (error) {
|
|
1554
|
+
if (error.code === 'sandbox_not_empty') {
|
|
1555
|
+
return overviewPage(session, 409, 'Test has resources already. Start again first.');
|
|
1556
|
+
}
|
|
1557
|
+
throw error;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
994
1560
|
function endpoints() {
|
|
995
1561
|
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>`;
|
|
996
1562
|
}
|
|
1563
|
+
const accessOf = (key) => key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only';
|
|
997
1564
|
async function keysPage(session, status = 200, notice) {
|
|
998
|
-
const
|
|
1565
|
+
const tenantRef = tenantOf(session);
|
|
1566
|
+
const grouping = options.resources.grouping;
|
|
1567
|
+
const [keys, locations, groups] = await Promise.all([
|
|
1568
|
+
options.keys.list({ tenantRef }),
|
|
1569
|
+
grouping?.locations(tenantRef),
|
|
1570
|
+
grouping?.groups(tenantRef),
|
|
1571
|
+
]);
|
|
999
1572
|
const token = csrfToken(session);
|
|
1000
|
-
const
|
|
1573
|
+
const paths = locationPaths(locations ?? []);
|
|
1574
|
+
// A key limited to locations or groups (RFC 0003 G6), when the tenant has any to choose.
|
|
1575
|
+
const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
|
|
1576
|
+
? html `<details class="types"><summary>Limit to locations or groups (optional)</summary><div class="checks">${(locations ?? []).map((location) => html `<label class="choice"><input type="checkbox" name="limit_location" value="${location.ref}"> ${paths.get(location.ref) ?? location.name}</label>`)}${(groups ?? []).map((group) => html `<label class="choice"><input type="checkbox" name="limit_group" value="${group.ref}"> Group: ${group.name}</label>`)}</div><p class="fine">The key then reaches only the resources there (a location with every location in it), read and write only. Developer plan and up.</p></details>`
|
|
1577
|
+
: html ``;
|
|
1578
|
+
const content = html `${noticeOf(notice, status)}
|
|
1001
1579
|
${pageHead('API keys')}
|
|
1002
1580
|
<section class="panel">
|
|
1003
1581
|
<h2>Connect an agent</h2>
|
|
@@ -1013,19 +1591,39 @@ ${endpoints()}
|
|
|
1013
1591
|
<label>Access<select name="access"><option value="read_write">Read and write</option><option value="read">Read only</option><option value="full">Full, with the application API</option></select></label>
|
|
1014
1592
|
<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>
|
|
1015
1593
|
<button class="primary" type="submit">Create key</button>
|
|
1594
|
+
${limitChoices}
|
|
1595
|
+
<details class="types"><summary>Agent limits (optional)</summary><div class="checks">${keyLimitFields()}</div><p class="fine">Below the server's own limits, never above them. A key whose bookings wait for approval sends each to the Approvals page.</p></details>
|
|
1016
1596
|
</form>
|
|
1017
1597
|
</section>
|
|
1018
1598
|
${keys.length === 0
|
|
1019
1599
|
? emptyState(ICONS.keys, 'No keys yet. Give every agent its own, with the least access it needs.', { href: '#key-name', label: 'Create a key' })
|
|
1020
|
-
: html `${
|
|
1600
|
+
: html `${session.org !== undefined ? html `<p class="fine">Each key acts in this organization only, and every member can rotate or revoke it.</p>` : 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>
|
|
1021
1601
|
<tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
|
|
1022
1602
|
return respond(status, layout('API keys', content, session, 'keys'));
|
|
1023
1603
|
}
|
|
1604
|
+
/**
|
|
1605
|
+
* How many resources the tenant may have here, and how many it has as that limit counts them: the
|
|
1606
|
+
* dashboard's own limit, or its plan's when lower (archived resources count against a plan).
|
|
1607
|
+
*/
|
|
1608
|
+
async function resourceAllowance(tenantRef, resources) {
|
|
1609
|
+
const plan = plans ? await plans.plan(tenantRef) : null;
|
|
1610
|
+
if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
|
|
1611
|
+
return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
|
|
1612
|
+
}
|
|
1613
|
+
const counted = options.resources.count ? await options.resources.count(tenantRef) : resources.length;
|
|
1614
|
+
return {
|
|
1615
|
+
used: counted,
|
|
1616
|
+
limit: plan.limits.resources,
|
|
1617
|
+
planned: true,
|
|
1618
|
+
archived: Math.max(0, counted - resources.length),
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1024
1621
|
async function resourcesPage(session, status = 200, notice) {
|
|
1025
1622
|
const resources = await options.resources.list(tenantOf(session));
|
|
1623
|
+
const allowance = await resourceAllowance(tenantOf(session), resources);
|
|
1026
1624
|
const token = csrfToken(session);
|
|
1027
|
-
const content = html `${noticeOf(notice)}
|
|
1028
|
-
${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` :
|
|
1625
|
+
const content = html `${noticeOf(notice, status)}
|
|
1626
|
+
${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}${resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : html ``}</div>`)}
|
|
1029
1627
|
<section class="panel" id="add-resource">
|
|
1030
1628
|
<h2>Add a resource</h2>
|
|
1031
1629
|
<form class="create" method="post" action="${root}/resources">
|
|
@@ -1035,39 +1633,124 @@ ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${r
|
|
|
1035
1633
|
<label>Time zone<input name="timezone" required maxlength="64" value="UTC" placeholder="Europe/London"></label>
|
|
1036
1634
|
<button class="primary" type="submit">Add resource</button>
|
|
1037
1635
|
</form>
|
|
1038
|
-
<p class="fine">${String(
|
|
1636
|
+
<p class="fine">${String(allowance.used)} of ${String(allowance.limit)}${allowance.planned ? html ` on your plan${allowance.archived > 0 ? html `, ${String(allowance.archived)} of them archived (archived resources count until deleted)` : html ``}` : html ``}. Adding a reference you have changes its time zone.</p>
|
|
1039
1637
|
</section>
|
|
1040
1638
|
${resources.length === 0
|
|
1041
1639
|
? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
|
|
1042
|
-
: 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 class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${hoursSummary(resource.availabilityRules)}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
|
|
1640
|
+
: 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 class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${hoursSummary(resource.availabilityRules)}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
|
|
1043
1641
|
return respond(status, layout('Resources', content, session, 'resources'));
|
|
1044
1642
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
${
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1643
|
+
/** The tenant's locations and groups (RFC 0003 G2, G3): what they are, and adding and changing them. */
|
|
1644
|
+
async function locationsPage(session, status = 200, notice) {
|
|
1645
|
+
const grouping = options.resources.grouping;
|
|
1646
|
+
if (!grouping)
|
|
1647
|
+
return nothingHere(session);
|
|
1648
|
+
const tenantRef = tenantOf(session);
|
|
1649
|
+
const [locations, groups] = await Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]);
|
|
1650
|
+
const token = csrfToken(session);
|
|
1651
|
+
const byRef = new Map(locations.map((location) => [location.ref, location]));
|
|
1652
|
+
const paths = locationPaths(locations);
|
|
1653
|
+
const sent = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
1654
|
+
// Where a location may be: nowhere, or any location but itself and those inside it.
|
|
1655
|
+
const insideChoices = (selected, except) => html `<option value=""${selected === null ? html ` selected` : html ``}>Nowhere (at the top)</option>${locations
|
|
1656
|
+
.filter((location) => except === undefined || !locationWithin(byRef, location.ref, except))
|
|
1657
|
+
.map((location) => html `<option value="${location.ref}"${location.ref === selected ? html ` selected` : html ``}>${paths.get(location.ref) ?? location.name}</option>`)}`;
|
|
1658
|
+
const nameLimit = String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name);
|
|
1659
|
+
const locationRows = locations.map((location) => {
|
|
1660
|
+
const parent = location.parentRef === null ? undefined : paths.get(location.parentRef);
|
|
1661
|
+
const zone = locationZone(byRef, location);
|
|
1662
|
+
return html `<tr>
|
|
1663
|
+
<td data-label="Location" class="strong"><span class="cell"><span class="truncate" title="${paths.get(location.ref) ?? location.name}">${location.name}</span><span class="meta">${location.ref}${parent ? ` · in ${parent}` : ''}</span></span></td>
|
|
1664
|
+
<td data-label="Time zone">${zone ? (zone.from === location ? zone.timezone : html `<span class="cell"><span>${zone.timezone}</span><span class="meta">from ${zone.from.name}</span></span>`) : html `<span class="muted">None</span>`}</td>
|
|
1665
|
+
<td data-label="Resources">${String(location.resources)}</td>
|
|
1666
|
+
<td class="actions"><details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/locations">${sent()}<input type="hidden" name="ref" value="${location.ref}">
|
|
1667
|
+
<label>Name<input name="name" required maxlength="${nameLimit}" value="${location.name}"></label>
|
|
1668
|
+
<label>Inside<select name="parent">${insideChoices(location.parentRef, location.ref)}</select></label>
|
|
1669
|
+
<label>Time zone<input name="timezone" maxlength="100" value="${location.timezone ?? ''}" placeholder="As where it is"></label>
|
|
1670
|
+
<button type="submit">Save location</button></form></div></details><details class="act danger"><summary>Delete</summary><div class="pop"><p>${location.resources > 0 ? 'Move its resources somewhere else first.' : 'Locations inside it must be moved or deleted first.'}</p>
|
|
1671
|
+
<form method="post" action="${root}/locations/delete">${sent()}<input type="hidden" name="ref" value="${location.ref}"><button class="danger" type="submit">Delete location</button></form></div></details></td>
|
|
1672
|
+
</tr>`;
|
|
1673
|
+
});
|
|
1674
|
+
const groupRows = groups.map((group) => html `<tr>
|
|
1675
|
+
<td data-label="Group" class="strong"><span class="cell"><span class="truncate" title="${group.name}">${group.name}</span><span class="meta">${group.ref}</span></span></td>
|
|
1676
|
+
<td data-label="Description" class="wrap">${group.description ?? ''}</td>
|
|
1677
|
+
<td data-label="Resources">${String(group.members)}</td>
|
|
1678
|
+
<td class="actions"><details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/groups">${sent()}<input type="hidden" name="ref" value="${group.ref}">
|
|
1679
|
+
<label>Name<input name="name" required maxlength="${nameLimit}" value="${group.name}"></label>
|
|
1680
|
+
<label>Description<textarea name="description" rows="2" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${group.description ?? ''}</textarea></label>
|
|
1681
|
+
<button type="submit">Save group</button></form></div></details><details class="act danger"><summary>Delete</summary><div class="pop"><p>Its resources stay as they are, in no group of this name.</p>
|
|
1682
|
+
<form method="post" action="${root}/groups/delete">${sent()}<input type="hidden" name="ref" value="${group.ref}"><button class="danger" type="submit">Delete group</button></form></div></details></td>
|
|
1683
|
+
</tr>`);
|
|
1684
|
+
const content = html `${noticeOf(notice, status)}
|
|
1685
|
+
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1686
|
+
${pageHead('Locations and groups')}
|
|
1687
|
+
<section class="panel" id="locations">
|
|
1688
|
+
<h2>Locations</h2>
|
|
1689
|
+
<form class="create" method="post" action="${root}/locations">${sent()}
|
|
1690
|
+
<label>Reference<input id="location-ref" name="ref" required maxlength="100" placeholder="floor-3"></label>
|
|
1691
|
+
<label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Floor 3"></label>
|
|
1692
|
+
<label>Inside<select name="parent">${insideChoices(null)}</select></label>
|
|
1693
|
+
<label>Time zone<input name="timezone" maxlength="100" placeholder="As where it is"></label>
|
|
1694
|
+
<button class="primary" type="submit">Add location</button>
|
|
1695
|
+
</form>
|
|
1696
|
+
<p class="fine">Where resources are: a site, a building, a floor, up to ${String(SLOTLOCK_MAX_LOCATION_DEPTH)} levels. Agents searching a location search every location inside it too. A resource added at a location over the API without a time zone takes the location's, or the nearest one above it. Adding a reference you have changes that location.</p>
|
|
1697
|
+
${locations.length === 0
|
|
1698
|
+
? html `<p class="empty">No locations yet.</p>`
|
|
1699
|
+
: html `<div class="table"><table><thead><tr><th>Location</th><th>Time zone</th><th>Resources</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${locationRows}</tbody></table></div>`}
|
|
1061
1700
|
</section>
|
|
1062
|
-
<section class="panel">
|
|
1063
|
-
<h2>
|
|
1064
|
-
<
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1701
|
+
<section class="panel" id="groups">
|
|
1702
|
+
<h2>Groups</h2>
|
|
1703
|
+
<form class="create" method="post" action="${root}/groups">${sent()}
|
|
1704
|
+
<label>Reference<input id="group-ref" name="ref" required maxlength="100" placeholder="video-rooms"></label>
|
|
1705
|
+
<label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Rooms with video"></label>
|
|
1706
|
+
<label class="grow">Description<input name="description" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}" placeholder="Optional"></label>
|
|
1707
|
+
<button class="primary" type="submit">Add group</button>
|
|
1708
|
+
</form>
|
|
1709
|
+
<p class="fine">Any set of resources, such as a team or the rooms with video, wherever they are. A resource is in up to ${String(SLOTLOCK_MAX_GROUPS_PER_RESOURCE)} groups; choose them on its page. Adding a reference you have changes that group.</p>
|
|
1710
|
+
${groups.length === 0
|
|
1711
|
+
? html `<p class="empty">No groups yet.</p>`
|
|
1712
|
+
: html `<div class="table"><table><thead><tr><th>Group</th><th>Description</th><th>Resources</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${groupRows}</tbody></table></div>`}
|
|
1068
1713
|
</section>`;
|
|
1714
|
+
return respond(status, layout('Locations and groups', content, session, 'resources'));
|
|
1715
|
+
}
|
|
1716
|
+
/** Settings' sections, each its own page: the assistant's form apart from what is only read. */
|
|
1717
|
+
const SETTINGS_SECTIONS = Object.freeze([
|
|
1718
|
+
{ id: 'assistant', label: 'Assistant', path: '/settings' },
|
|
1719
|
+
{ id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
|
|
1720
|
+
{ id: 'hours', label: 'Hours', path: '/settings/hours' },
|
|
1721
|
+
{ id: 'account', label: 'Account', path: '/settings/account' },
|
|
1722
|
+
]);
|
|
1723
|
+
async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
|
|
1724
|
+
const store = assistantStore();
|
|
1725
|
+
const sections = SETTINGS_SECTIONS.filter((entry) => entry.id !== 'assistant' || store);
|
|
1726
|
+
const shown = section === 'assistant' && !store ? 'endpoints' : section;
|
|
1727
|
+
const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
|
|
1728
|
+
const nav = html `<nav class="subnav" aria-label="Settings">${sections.map((entry) => html `<a href="${root}${entry.path}"${entry.id === shown ? html ` aria-current="page"` : html ``}>${entry.label}${entry.id === 'assistant' && connected ? html `<span class="nav-dot" aria-hidden="true"></span>` : html ``}</a>`)}</nav>`;
|
|
1729
|
+
let body;
|
|
1730
|
+
if (shown === 'assistant') {
|
|
1731
|
+
body = await assistantSettingsSection(session, status, notice, draft);
|
|
1732
|
+
}
|
|
1733
|
+
else if (shown === 'endpoints') {
|
|
1734
|
+
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">MCP</span><div class="row-value">${copyField('mcp-url', mcpUrl)}</div></div><div class="row"><span class="row-label">A2A</span><div class="row-value">${copyField('a2a-url', a2aUrl)}</div></div></div>`;
|
|
1735
|
+
}
|
|
1736
|
+
else if (shown === 'hours') {
|
|
1737
|
+
const rules = options.availability ?? [];
|
|
1738
|
+
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Default hours</span><div class="row-value${rules.length === 0 ? ' muted' : ''}">${rules.length === 0
|
|
1739
|
+
? 'None. Agents find free time only on resources with hours of their own.'
|
|
1740
|
+
: html `${rules.map((rule, index) => html `${index > 0 ? html `<br>` : html ``}${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}`)}`}</div></div></div><p class="fine">In each resource's own time zone, for resources without hours of their own. Set by the server's operator.</p>`;
|
|
1741
|
+
}
|
|
1742
|
+
else {
|
|
1743
|
+
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1744
|
+
const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
|
|
1745
|
+
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : 'Workspace'}</span><div class="row-value">${current ? current.login : 'Personal'}</div></div>${current && others.length > 0
|
|
1746
|
+
? html `<div class="row"><span class="row-label">Also a member of</span><div class="row-value">${others.map((org) => org.login).join(', ')}</div></div>`
|
|
1747
|
+
: html ``}<div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div></div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}`;
|
|
1748
|
+
}
|
|
1749
|
+
const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
|
|
1069
1750
|
return respond(status, layout('Settings', content, session, 'settings'));
|
|
1070
1751
|
}
|
|
1752
|
+
/** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
|
|
1753
|
+
const copyField = (id, value) => html `<dl class="endpoints"><dd><code id="${id}">${value}</code><button class="quiet" type="button" data-copy="${id}">Copy</button></dd></dl>`;
|
|
1071
1754
|
/** The tenant's resource with that id and every resource (for names), or null for anything else. */
|
|
1072
1755
|
async function resourceDetailPage(session, id, status = 200, notice) {
|
|
1073
1756
|
const tenantRef = tenantOf(session);
|
|
@@ -1076,12 +1759,21 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1076
1759
|
return notFound(session);
|
|
1077
1760
|
const start = new Date(now());
|
|
1078
1761
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
1079
|
-
const
|
|
1762
|
+
const grouping = options.resources.grouping;
|
|
1763
|
+
const [agenda, changes, keys, defaults, locations, groups, everyResource] = await Promise.all([
|
|
1080
1764
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
1081
1765
|
options.resources.recentChanges?.(tenantRef, 100),
|
|
1082
1766
|
options.keys.list({ tenantRef }),
|
|
1083
1767
|
options.resources.ruleDefaults?.(tenantRef),
|
|
1768
|
+
grouping?.locations(tenantRef),
|
|
1769
|
+
grouping?.groups(tenantRef),
|
|
1770
|
+
// For a composite: its parts, or the wholes it can be a part of.
|
|
1771
|
+
options.resources.setParent ? options.resources.list(tenantRef) : undefined,
|
|
1084
1772
|
]);
|
|
1773
|
+
const parts = (everyResource?.filter((other) => other.parentId === resource.id) ?? []).sort((one, other) => (one.partIndex ?? 0) - (other.partIndex ?? 0));
|
|
1774
|
+
const wholes = everyResource?.filter((other) => other.id !== resource.id && other.parentId === undefined && other.archivedAt === undefined) ?? [];
|
|
1775
|
+
const paths = locationPaths(locations ?? []);
|
|
1776
|
+
const groupNames = new Map((groups ?? []).map((group) => [group.ref, group.name]));
|
|
1085
1777
|
const byId = new Map([[resource.id, resource]]);
|
|
1086
1778
|
const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
|
|
1087
1779
|
const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
|
|
@@ -1103,10 +1795,11 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1103
1795
|
const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
|
|
1104
1796
|
return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
|
|
1105
1797
|
};
|
|
1106
|
-
const content = html `${noticeOf(notice)}
|
|
1798
|
+
const content = html `${noticeOf(notice, status)}
|
|
1107
1799
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1108
1800
|
${pageHead(resourceName(resource, resource.id))}
|
|
1109
|
-
|
|
1801
|
+
${resource.description ? html `<p>${resource.description}</p>` : html ``}
|
|
1802
|
+
<dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<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>
|
|
1110
1803
|
<div class="split">
|
|
1111
1804
|
<div class="stack">
|
|
1112
1805
|
${options.resources.setRules
|
|
@@ -1131,6 +1824,25 @@ ${inheritedNote(inherited)}
|
|
|
1131
1824
|
<p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
|
|
1132
1825
|
<button class="primary" type="submit">Save rules</button>
|
|
1133
1826
|
</form>
|
|
1827
|
+
</section>`
|
|
1828
|
+
: html ``}
|
|
1829
|
+
${options.resources.setProfile
|
|
1830
|
+
? html `<section class="panel">
|
|
1831
|
+
<h2>Profile</h2>
|
|
1832
|
+
<form method="post" action="${root}/resources/profile">
|
|
1833
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1834
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1835
|
+
<input type="hidden" name="id" value="${resource.id}">
|
|
1836
|
+
<div class="grid-form">
|
|
1837
|
+
<label>Name<input name="name" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name)}" value="${resource.name ?? ''}" placeholder="${resource.externalRef ?? ''}"></label>
|
|
1838
|
+
<label>Kind<select name="kind">${SLOTLOCK_RESOURCE_KINDS.map((kind) => html `<option value="${kind}"${(resource.kind ?? 'other') === kind ? html ` selected` : html ``}>${KIND_NAMES[kind]}</option>`)}</select></label>
|
|
1839
|
+
<label>Capacity<span class="unit-input"><input type="number" inputmode="numeric" min="1" max="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity)}" name="capacity" value="${resource.capacity === undefined ? '' : String(resource.capacity)}"><span>people or seats</span></span></label>
|
|
1840
|
+
<label>Labels<input name="labels" value="${(resource.labels ?? []).join(', ')}" placeholder="video, accessible"></label>
|
|
1841
|
+
</div>
|
|
1842
|
+
<label>Description<textarea name="description" rows="3" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${resource.description ?? ''}</textarea></label>
|
|
1843
|
+
<p class="fine">Agents and people see the name instead of the reference. Kind, capacity and labels help them find it, and change nothing about how it is booked.${resource.attributes ? html ` Its attributes (${Object.keys(resource.attributes).join(', ')}) are set over the API.` : html ``}</p>
|
|
1844
|
+
<button class="primary" type="submit">Save profile</button>
|
|
1845
|
+
</form>
|
|
1134
1846
|
</section>`
|
|
1135
1847
|
: html ``}
|
|
1136
1848
|
${options.resources.setPool
|
|
@@ -1144,6 +1856,39 @@ ${options.resources.setPool
|
|
|
1144
1856
|
<button type="submit">Save pool</button>
|
|
1145
1857
|
</form>
|
|
1146
1858
|
<p class="fine">Agents can book any free member of a pool. Leave it empty for none.</p>
|
|
1859
|
+
</section>`
|
|
1860
|
+
: html ``}
|
|
1861
|
+
${options.resources.setParent && everyResource
|
|
1862
|
+
? html `<section class="panel" id="composite">
|
|
1863
|
+
<h2>Composite</h2>
|
|
1864
|
+
${parts.length > 0
|
|
1865
|
+
? html `<p>A whole of ${String(parts.length)} part${parts.length === 1 ? '' : 's'}: ${parts.map((part, index) => html `${index > 0 ? ', ' : ''}<a href="${root}/resources/${part.id}">${resourceName(part, part.id)}</a>`)}. Booking it takes every part, and a booking of any part keeps it from being booked.</p>`
|
|
1866
|
+
: html `<form class="create" method="post" action="${root}/resources/parent">
|
|
1867
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1868
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1869
|
+
<input type="hidden" name="id" value="${resource.id}">
|
|
1870
|
+
<label>Part of<select name="parent"><option value=""${resource.parentId ? html `` : html ` selected`}>Nothing: it stands alone</option>${wholes.map((whole) => html `<option value="${whole.id}"${resource.parentId === whole.id ? html ` selected` : html ``}>${resourceName(whole, whole.id)}</option>`)}</select></label>
|
|
1871
|
+
<button type="submit">Save</button>
|
|
1872
|
+
</form>
|
|
1873
|
+
<p class="fine">A room of a hall, a half of a court: booking the whole then takes every part, and two parts never get in each other's way. It changes only while neither has a booking or hold to come.</p>`}
|
|
1874
|
+
</section>`
|
|
1875
|
+
: html ``}
|
|
1876
|
+
${grouping && locations && groups
|
|
1877
|
+
? html `<section class="panel" id="place">
|
|
1878
|
+
<h2>Location and groups</h2>
|
|
1879
|
+
${locations.length === 0 && groups.length === 0
|
|
1880
|
+
? html `<p class="empty">No locations or groups yet. <a href="${root}/locations">Add them</a>, then choose them here.</p>`
|
|
1881
|
+
: html `<form method="post" action="${root}/resources/place">
|
|
1882
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1883
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1884
|
+
<input type="hidden" name="id" value="${resource.id}">
|
|
1885
|
+
<label>Location<select name="location"><option value=""${resource.location ? html `` : html ` selected`}>None</option>${locations.map((location) => html `<option value="${location.ref}"${resource.location === location.ref ? html ` selected` : html ``}>${paths.get(location.ref) ?? location.name}</option>`)}</select></label>
|
|
1886
|
+
${groups.length > 0
|
|
1887
|
+
? html `<fieldset class="choices"><legend>Groups</legend><div class="checks">${groups.map((group) => html `<label class="choice"><input type="checkbox" name="group" value="${group.ref}"${resource.groups?.includes(group.ref) ? html ` checked` : html ``}> ${group.name}</label>`)}</div></fieldset>`
|
|
1888
|
+
: html ``}
|
|
1889
|
+
<button class="primary" type="submit">Save location and groups</button>
|
|
1890
|
+
</form>
|
|
1891
|
+
<p class="fine">Agents can search a location, with every location inside it, or a group, in one call. <a href="${root}/locations">Locations and groups</a></p>`}
|
|
1147
1892
|
</section>`
|
|
1148
1893
|
: html ``}
|
|
1149
1894
|
</div>
|
|
@@ -1191,6 +1936,153 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1191
1936
|
}
|
|
1192
1937
|
return redirect(303, `${root}/resources/${id}`);
|
|
1193
1938
|
}
|
|
1939
|
+
async function saveProfile(session, form) {
|
|
1940
|
+
const id = form.get('id') ?? '';
|
|
1941
|
+
const resource = await ownResource(session, id);
|
|
1942
|
+
if (!resource || !options.resources.setProfile)
|
|
1943
|
+
return notFound(session);
|
|
1944
|
+
const text = (key) => optionalField(form, key);
|
|
1945
|
+
const capacity = text('capacity');
|
|
1946
|
+
if (capacity !== null && !/^\d{1,6}$/.test(capacity)) {
|
|
1947
|
+
return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS.capacity);
|
|
1948
|
+
}
|
|
1949
|
+
const kind = form.get('kind') ?? 'other';
|
|
1950
|
+
try {
|
|
1951
|
+
await options.resources.setProfile(tenantOf(session), id, {
|
|
1952
|
+
name: text('name'),
|
|
1953
|
+
description: text('description'),
|
|
1954
|
+
kind: kind,
|
|
1955
|
+
capacity: capacity === null ? null : Number(capacity),
|
|
1956
|
+
// Commas or spaces between them; a person may type them in capitals.
|
|
1957
|
+
labels: (form.get('labels') ?? '')
|
|
1958
|
+
.split(/[\s,]+/)
|
|
1959
|
+
.map((label) => label.trim().toLowerCase())
|
|
1960
|
+
.filter((label) => label !== ''),
|
|
1961
|
+
}, personOf(session));
|
|
1962
|
+
}
|
|
1963
|
+
catch (error) {
|
|
1964
|
+
if (error.code !== 'invalid_resource_profile')
|
|
1965
|
+
throw error;
|
|
1966
|
+
const field = error.field;
|
|
1967
|
+
return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS[typeof field === 'string' && field in PROFILE_PROBLEMS ? field : 'other']);
|
|
1968
|
+
}
|
|
1969
|
+
return redirect(303, `${root}/resources/${id}`);
|
|
1970
|
+
}
|
|
1971
|
+
/** Text from a form, trimmed, or null when empty. */
|
|
1972
|
+
function optionalField(form, key) {
|
|
1973
|
+
const value = (form.get(key) ?? '').trim();
|
|
1974
|
+
return value === '' ? null : value;
|
|
1975
|
+
}
|
|
1976
|
+
async function saveLocation(session, form) {
|
|
1977
|
+
const grouping = options.resources.grouping;
|
|
1978
|
+
if (!grouping)
|
|
1979
|
+
return nothingHere(session);
|
|
1980
|
+
try {
|
|
1981
|
+
await grouping.putLocation(tenantOf(session), {
|
|
1982
|
+
// A person may type a reference in capitals; references are lowercase.
|
|
1983
|
+
ref: (form.get('ref') ?? '').trim().toLowerCase(),
|
|
1984
|
+
name: (form.get('name') ?? '').trim(),
|
|
1985
|
+
parentRef: optionalField(form, 'parent'),
|
|
1986
|
+
timezone: optionalField(form, 'timezone'),
|
|
1987
|
+
}, personOf(session));
|
|
1988
|
+
}
|
|
1989
|
+
catch (error) {
|
|
1990
|
+
const problem = groupingProblem(error);
|
|
1991
|
+
if (!problem)
|
|
1992
|
+
throw error;
|
|
1993
|
+
return locationsPage(session, problem.status, problem.text);
|
|
1994
|
+
}
|
|
1995
|
+
return redirect(303, `${root}/locations`);
|
|
1996
|
+
}
|
|
1997
|
+
async function saveGroup(session, form) {
|
|
1998
|
+
const grouping = options.resources.grouping;
|
|
1999
|
+
if (!grouping)
|
|
2000
|
+
return nothingHere(session);
|
|
2001
|
+
try {
|
|
2002
|
+
await grouping.putGroup(tenantOf(session), {
|
|
2003
|
+
ref: (form.get('ref') ?? '').trim().toLowerCase(),
|
|
2004
|
+
name: (form.get('name') ?? '').trim(),
|
|
2005
|
+
description: optionalField(form, 'description'),
|
|
2006
|
+
}, personOf(session));
|
|
2007
|
+
}
|
|
2008
|
+
catch (error) {
|
|
2009
|
+
const problem = groupingProblem(error);
|
|
2010
|
+
if (!problem)
|
|
2011
|
+
throw error;
|
|
2012
|
+
return locationsPage(session, problem.status, problem.text);
|
|
2013
|
+
}
|
|
2014
|
+
return redirect(303, `${root}/locations#groups`);
|
|
2015
|
+
}
|
|
2016
|
+
async function deleteGrouping(session, form, kind) {
|
|
2017
|
+
const grouping = options.resources.grouping;
|
|
2018
|
+
if (!grouping)
|
|
2019
|
+
return nothingHere(session);
|
|
2020
|
+
const ref = form.get('ref') ?? '';
|
|
2021
|
+
let deleted;
|
|
2022
|
+
try {
|
|
2023
|
+
deleted =
|
|
2024
|
+
kind === 'location'
|
|
2025
|
+
? await grouping.deleteLocation(tenantOf(session), ref, personOf(session))
|
|
2026
|
+
: await grouping.deleteGroup(tenantOf(session), ref, personOf(session));
|
|
2027
|
+
}
|
|
2028
|
+
catch (error) {
|
|
2029
|
+
const problem = groupingProblem(error);
|
|
2030
|
+
if (!problem)
|
|
2031
|
+
throw error;
|
|
2032
|
+
return locationsPage(session, problem.status, problem.text);
|
|
2033
|
+
}
|
|
2034
|
+
if (!deleted) {
|
|
2035
|
+
const problem = GROUPING_PROBLEMS[`${kind}_not_found`];
|
|
2036
|
+
return locationsPage(session, problem.status, problem.text);
|
|
2037
|
+
}
|
|
2038
|
+
return redirect(303, `${root}/locations${kind === 'group' ? '#groups' : ''}`);
|
|
2039
|
+
}
|
|
2040
|
+
async function savePlace(session, form) {
|
|
2041
|
+
const id = form.get('id') ?? '';
|
|
2042
|
+
const resource = await ownResource(session, id);
|
|
2043
|
+
const grouping = options.resources.grouping;
|
|
2044
|
+
if (!resource || !grouping)
|
|
2045
|
+
return notFound(session);
|
|
2046
|
+
const groups = [...new Set(form.getAll('group'))];
|
|
2047
|
+
if (groups.length > SLOTLOCK_MAX_GROUPS_PER_RESOURCE) {
|
|
2048
|
+
const problem = GROUPING_PROBLEMS.resource_group_limit_reached;
|
|
2049
|
+
return resourceDetailPage(session, id, problem.status, problem.text);
|
|
2050
|
+
}
|
|
2051
|
+
try {
|
|
2052
|
+
const placed = await grouping.place(tenantOf(session), id, { location: optionalField(form, 'location'), groups }, personOf(session));
|
|
2053
|
+
if (!placed)
|
|
2054
|
+
return notFound(session);
|
|
2055
|
+
}
|
|
2056
|
+
catch (error) {
|
|
2057
|
+
const problem = groupingProblem(error);
|
|
2058
|
+
if (!problem)
|
|
2059
|
+
throw error;
|
|
2060
|
+
return resourceDetailPage(session, id, problem.status, problem.text);
|
|
2061
|
+
}
|
|
2062
|
+
return redirect(303, `${root}/resources/${id}`);
|
|
2063
|
+
}
|
|
2064
|
+
async function saveParent(session, form) {
|
|
2065
|
+
const id = form.get('id') ?? '';
|
|
2066
|
+
const resource = await ownResource(session, id);
|
|
2067
|
+
if (!resource || !options.resources.setParent)
|
|
2068
|
+
return notFound(session);
|
|
2069
|
+
const wanted = (form.get('parent') ?? '').trim();
|
|
2070
|
+
const parent = wanted === '' ? null : await ownResource(session, wanted);
|
|
2071
|
+
if (wanted !== '' && !parent)
|
|
2072
|
+
return resourceDetailPage(session, id, 400, 'That is not one of your resources.');
|
|
2073
|
+
try {
|
|
2074
|
+
const changed = await options.resources.setParent(tenantOf(session), id, parent?.id ?? null, personOf(session));
|
|
2075
|
+
if (!changed)
|
|
2076
|
+
return notFound(session);
|
|
2077
|
+
}
|
|
2078
|
+
catch (error) {
|
|
2079
|
+
const text = COMPOSITE_PROBLEMS[String(error?.code)];
|
|
2080
|
+
if (!text)
|
|
2081
|
+
throw error;
|
|
2082
|
+
return resourceDetailPage(session, id, 409, text);
|
|
2083
|
+
}
|
|
2084
|
+
return redirect(303, `${root}/resources/${id}`);
|
|
2085
|
+
}
|
|
1194
2086
|
async function savePool(session, form) {
|
|
1195
2087
|
const id = form.get('id') ?? '';
|
|
1196
2088
|
const resource = await ownResource(session, id);
|
|
@@ -1207,6 +2099,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1207
2099
|
}
|
|
1208
2100
|
return redirect(303, `${root}/resources/${id}`);
|
|
1209
2101
|
}
|
|
2102
|
+
/** An endpoint's filter (set over the API), in a few words. */
|
|
2103
|
+
function webhookFilterText(filter) {
|
|
2104
|
+
return [
|
|
2105
|
+
filter.resourceIds ? `${filter.resourceIds.length} resource${filter.resourceIds.length === 1 ? '' : 's'}` : null,
|
|
2106
|
+
filter.pools ? `pools ${filter.pools.join(', ')}` : null,
|
|
2107
|
+
filter.locations ? `locations ${filter.locations.join(', ')}` : null,
|
|
2108
|
+
filter.groups ? `groups ${filter.groups.join(', ')}` : null,
|
|
2109
|
+
]
|
|
2110
|
+
.filter((part) => part !== null)
|
|
2111
|
+
.join('; ');
|
|
2112
|
+
}
|
|
1210
2113
|
function endpointState(endpoint) {
|
|
1211
2114
|
if (endpoint.disabledAt)
|
|
1212
2115
|
return { text: 'Disabled', tone: 'revoked' };
|
|
@@ -1231,7 +2134,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1231
2134
|
: '';
|
|
1232
2135
|
return html `<tr>
|
|
1233
2136
|
<td data-label="URL" class="strong"><code class="truncate url" title="${endpoint.url}">${endpoint.url}</code></td>
|
|
1234
|
-
<td data-label="Events" class="wrap">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}</td>
|
|
2137
|
+
<td data-label="Events" class="wrap">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}${endpoint.filter ? html `<span class="meta">Only ${webhookFilterText(endpoint.filter)}</span>` : html ``}</td>
|
|
1235
2138
|
<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>
|
|
1236
2139
|
<td data-label="Added">${dateCell(endpoint.createdAt)}</td>
|
|
1237
2140
|
<td class="actions">${endpoint.disabledAt
|
|
@@ -1240,7 +2143,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1240
2143
|
<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>
|
|
1241
2144
|
</tr>`;
|
|
1242
2145
|
});
|
|
1243
|
-
const content = html `${noticeOf(notice)}
|
|
2146
|
+
const content = html `${noticeOf(notice, status)}
|
|
1244
2147
|
${pageHead('Webhooks')}
|
|
1245
2148
|
<div class="stats">
|
|
1246
2149
|
<div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
|
|
@@ -1271,7 +2174,7 @@ ${endpoints.length === 0
|
|
|
1271
2174
|
const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
|
|
1272
2175
|
if (!created.ok) {
|
|
1273
2176
|
const message = created.code === 'webhook_limit_reached'
|
|
1274
|
-
? `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
|
|
2177
|
+
? `You have ${String(created.limit)} webhooks, as many as ${plans ? 'your plan' : 'Slotlock'} allows. Delete one first${plans ? ', or see Usage' : ''}.`
|
|
1275
2178
|
: created.code === 'invalid_webhook_url'
|
|
1276
2179
|
? 'Give an https URL, with no user name, password or #fragment.'
|
|
1277
2180
|
: 'Choose events from the list.';
|
|
@@ -1340,7 +2243,7 @@ ${endpoints.length === 0
|
|
|
1340
2243
|
});
|
|
1341
2244
|
const waiting = entries.filter((entry) => entry.status === 'waiting').length;
|
|
1342
2245
|
const offered = entries.filter((entry) => entry.status === 'offered').length;
|
|
1343
|
-
const content = html `${noticeOf(notice)}
|
|
2246
|
+
const content = html `${noticeOf(notice, status)}
|
|
1344
2247
|
${pageHead('Waitlist')}
|
|
1345
2248
|
<div class="stats">
|
|
1346
2249
|
<div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
|
|
@@ -1360,6 +2263,87 @@ ${entries.length === 0
|
|
|
1360
2263
|
}
|
|
1361
2264
|
return redirect(303, `${root}/waitlist`);
|
|
1362
2265
|
}
|
|
2266
|
+
// ---- Approvals (RFC 0004 A4) ----
|
|
2267
|
+
async function approvalsPage(session, status = 200, notice) {
|
|
2268
|
+
const approvals = options.resources.approvals;
|
|
2269
|
+
if (!approvals)
|
|
2270
|
+
return notFound(session);
|
|
2271
|
+
const tenantRef = tenantOf(session);
|
|
2272
|
+
const [entries, resources] = await Promise.all([
|
|
2273
|
+
approvals.list(tenantRef, APPROVALS_SHOWN),
|
|
2274
|
+
options.resources.list(tenantRef),
|
|
2275
|
+
]);
|
|
2276
|
+
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
2277
|
+
const token = csrfToken(session);
|
|
2278
|
+
const pending = entries.filter((entry) => entry.status === 'pending' && entry.expiresAt.getTime() > now());
|
|
2279
|
+
const decided = entries.filter((entry) => !pending.includes(entry));
|
|
2280
|
+
const what = (entry) => entry.resourceIds.map((id) => resourceName(byId.get(id), id)).join(', ');
|
|
2281
|
+
const when = (entry) => {
|
|
2282
|
+
const zone = byId.get(entry.resourceIds[0] ?? '')?.timezone ?? 'UTC';
|
|
2283
|
+
const day = dayLabel(entry.start, zone, now());
|
|
2284
|
+
return `${day.label} ${clock(entry.start, zone)}–${clock(entry.end, zone)}`;
|
|
2285
|
+
};
|
|
2286
|
+
const KINDS = {
|
|
2287
|
+
slotlock_create_event: 'Booking',
|
|
2288
|
+
slotlock_book_together: 'Bundle',
|
|
2289
|
+
slotlock_update_event: 'Move',
|
|
2290
|
+
};
|
|
2291
|
+
const form = (entry, decision, body) => html `<form method="post" action="${root}/approvals/decide"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${entry.id}"><input type="hidden" name="decision" value="${decision}">${body}</form>`;
|
|
2292
|
+
const pendingRows = pending.map((entry) => html `<tr>
|
|
2293
|
+
<td data-label="When" class="strong">${when(entry)}</td>
|
|
2294
|
+
<td data-label="What"><span class="cell"><span class="truncate" title="${what(entry)}">${what(entry)}</span><span class="meta">${KINDS[entry.operation]}, policy ${entry.policyRef}</span></span></td>
|
|
2295
|
+
<td data-label="Asked by"><code class="truncate" title="${entry.requestedBy ?? ''}">${entry.requestedBy ?? 'unknown'}</code></td>
|
|
2296
|
+
<td data-label="Decide by">${clock(entry.expiresAt, 'UTC')} UTC, ${dayLabel(entry.expiresAt, 'UTC', now()).label}</td>
|
|
2297
|
+
<td class="actions">${form(entry, 'grant', html `<button type="submit">Approve</button>`)}<details class="act danger"><summary>Decline</summary><div class="pop"><p>The held time is freed. The agent is told on its next try.</p>${form(entry, 'decline', html `<label>Reason (optional)<input name="reason" maxlength="500" autocomplete="off"></label><button class="danger" type="submit">Decline</button>`)}</div></details></td>
|
|
2298
|
+
</tr>`);
|
|
2299
|
+
const STATUS = {
|
|
2300
|
+
pending: { text: 'Lapsed', tone: 'revoked' },
|
|
2301
|
+
granted: { text: 'Approved', tone: 'active' },
|
|
2302
|
+
declined: { text: 'Declined', tone: 'revoked' },
|
|
2303
|
+
expired: { text: 'Expired', tone: 'revoked' },
|
|
2304
|
+
};
|
|
2305
|
+
const decidedRows = decided.map((entry) => html `<tr>
|
|
2306
|
+
<td data-label="When" class="strong">${when(entry)}</td>
|
|
2307
|
+
<td data-label="What"><span class="truncate" title="${what(entry)}">${what(entry)}</span></td>
|
|
2308
|
+
<td data-label="Status"><span class="cell"><span class="pill ${STATUS[entry.status].tone}">${STATUS[entry.status].text}</span>${entry.reason ? html `<span class="meta" title="${entry.reason}">${entry.reason}</span>` : html ``}</span></td>
|
|
2309
|
+
<td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
|
|
2310
|
+
<td data-label="Asked">${ago(entry.createdAt, now())}</td>
|
|
2311
|
+
</tr>`);
|
|
2312
|
+
const content = html `${noticeOf(notice, status)}
|
|
2313
|
+
${pageHead('Approvals')}
|
|
2314
|
+
<p class="fine">Bookings an approval policy holds for a person. Their time stays held until you decide or the wait ends. Policies are set through the API (slotlock_put_approval_policy).</p>
|
|
2315
|
+
<div class="stats">
|
|
2316
|
+
<div class="stat"><span class="stat-label">Waiting for you</span><span class="stat-value">${String(pending.length)}</span></div>
|
|
2317
|
+
</div>
|
|
2318
|
+
${pending.length === 0
|
|
2319
|
+
? emptyState(ICONS.approvals, 'Nothing is waiting. A policy that holds bookings for a person sends them here.')
|
|
2320
|
+
: html `<div class="table"><table><thead><tr><th>When</th><th>What</th><th>Asked by</th><th>Decide by</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${pendingRows}</tbody></table></div>`}
|
|
2321
|
+
${decided.length === 0
|
|
2322
|
+
? html ``
|
|
2323
|
+
: html `<h2>Decided</h2><div class="table"><table><thead><tr><th>When</th><th>What</th><th>Status</th><th>Decided by</th><th>Asked</th></tr></thead><tbody>${decidedRows}</tbody></table></div>`}`;
|
|
2324
|
+
return respond(status, layout('Approvals', content, session, 'approvals'));
|
|
2325
|
+
}
|
|
2326
|
+
async function decideApproval(session, form) {
|
|
2327
|
+
const approvals = options.resources.approvals;
|
|
2328
|
+
if (!approvals)
|
|
2329
|
+
return notFound(session);
|
|
2330
|
+
const decision = form.get('decision');
|
|
2331
|
+
if (decision !== 'grant' && decision !== 'decline')
|
|
2332
|
+
return approvalsPage(session, 400, 'Choose approve or decline.');
|
|
2333
|
+
const reason = (form.get('reason') ?? '').trim().slice(0, 500);
|
|
2334
|
+
const decided = await approvals.decide(tenantOf(session), form.get('id') ?? '', decision, personOf(session), reason || undefined);
|
|
2335
|
+
if (!decided.ok) {
|
|
2336
|
+
const messages = {
|
|
2337
|
+
approval_not_found: 'That approval is not one of yours.',
|
|
2338
|
+
approval_not_pending: 'That approval was already decided.',
|
|
2339
|
+
approval_expired: 'The wait for that approval ended: its time is no longer held.',
|
|
2340
|
+
overlap: 'That time is taken now, so it cannot be booked. Decline it instead.',
|
|
2341
|
+
revision_conflict: 'The agent changed that event since it asked. Decline this one; it can ask again.',
|
|
2342
|
+
};
|
|
2343
|
+
return approvalsPage(session, 409, messages[decided.code] ?? `That booking cannot be made now (${decided.code}). Decline it instead.`);
|
|
2344
|
+
}
|
|
2345
|
+
return redirect(303, `${root}/approvals`);
|
|
2346
|
+
}
|
|
1363
2347
|
// ---- The assistant ----
|
|
1364
2348
|
const assistantOptions = options.assistant;
|
|
1365
2349
|
/** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
|
|
@@ -1383,6 +2367,23 @@ ${entries.length === 0
|
|
|
1383
2367
|
if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
|
|
1384
2368
|
throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
|
|
1385
2369
|
}
|
|
2370
|
+
const previousSecrets = [...(assistantOptions?.previousSecrets ?? [])];
|
|
2371
|
+
if (previousSecrets.length > SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS) {
|
|
2372
|
+
throw new Error(`Slotlock dashboard assistant takes at most ${String(SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS)} previous secrets`);
|
|
2373
|
+
}
|
|
2374
|
+
for (const previous of previousSecrets) {
|
|
2375
|
+
if (Buffer.byteLength(previous, 'utf8') < 32) {
|
|
2376
|
+
throw new Error('Slotlock dashboard assistant previous secrets must each be at least 32 bytes');
|
|
2377
|
+
}
|
|
2378
|
+
if (previous === assistantOptions?.secret || previous === options.sessionSecret) {
|
|
2379
|
+
throw new Error('Slotlock dashboard assistant previous secrets must differ from the assistant and session secrets');
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
if (new Set(previousSecrets).size !== previousSecrets.length) {
|
|
2383
|
+
throw new Error('Slotlock dashboard assistant previous secrets must differ from each other');
|
|
2384
|
+
}
|
|
2385
|
+
/** The current secret, then the previous ones: a stored key is tried with each in turn. */
|
|
2386
|
+
const assistantSecrets = assistantOptions ? [assistantOptions.secret, ...previousSecrets] : [];
|
|
1386
2387
|
const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
|
|
1387
2388
|
const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
|
|
1388
2389
|
/** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
|
|
@@ -1407,7 +2408,7 @@ ${entries.length === 0
|
|
|
1407
2408
|
* Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
|
|
1408
2409
|
* which a rename does not change. Shown as "Assistant for @login".
|
|
1409
2410
|
*/
|
|
1410
|
-
const assistantActor = (session) => `dashboard-assistant
|
|
2411
|
+
const assistantActor = (session) => `dashboard-assistant:${personOf(session)}`;
|
|
1411
2412
|
const CONFIRMED_WRITES = new Set([
|
|
1412
2413
|
'slotlock_create_event',
|
|
1413
2414
|
'slotlock_update_event',
|
|
@@ -1441,6 +2442,15 @@ ${entries.length === 0
|
|
|
1441
2442
|
else if (operation === 'slotlock_release_slots') {
|
|
1442
2443
|
text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
|
|
1443
2444
|
}
|
|
2445
|
+
else if (operation === 'slotlock_confirm_slot') {
|
|
2446
|
+
text = `Book the time held by ${String(input.hold_id)}${input.title ? `, as "${String(input.title)}"` : ''}, and release the other holds made with it.`;
|
|
2447
|
+
}
|
|
2448
|
+
else if (operation === 'slotlock_reschedule') {
|
|
2449
|
+
text = `Move the event ${String(input.event_id)} to ${String(input.starts_at)} to ${String(input.ends_at)}${input.resource_id ? ` on resource ${String(input.resource_id)}` : ''}.`;
|
|
2450
|
+
}
|
|
2451
|
+
else if (operation === 'slotlock_cancel') {
|
|
2452
|
+
text = `Cancel the event ${String(input.event_id)}.`;
|
|
2453
|
+
}
|
|
1444
2454
|
else {
|
|
1445
2455
|
text = '';
|
|
1446
2456
|
}
|
|
@@ -1448,7 +2458,8 @@ ${entries.length === 0
|
|
|
1448
2458
|
return text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
1449
2459
|
}
|
|
1450
2460
|
/** Owners of the organization in use may change its assistant; a person always may their own. */
|
|
1451
|
-
|
|
2461
|
+
/** An owner of the workspace: the person in their own, an organization's owner in its. */
|
|
2462
|
+
const isAdmin = (session) => session.org === undefined || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
|
|
1452
2463
|
const assistantPrincipal = (session) => ({
|
|
1453
2464
|
subject: assistantActor(session),
|
|
1454
2465
|
tenantRef: tenantOf(session),
|
|
@@ -1486,6 +2497,8 @@ ${entries.length === 0
|
|
|
1486
2497
|
? { consumeRateLimit: assistant.consumeRateLimit }
|
|
1487
2498
|
: {}),
|
|
1488
2499
|
...(confirm ? { confirm } : {}),
|
|
2500
|
+
// A proposal halts at `confirm` and never reaches the backend, so only what runs counts.
|
|
2501
|
+
...(assistant.onUsage ? { onUsage: assistant.onUsage } : {}),
|
|
1489
2502
|
onError: (error) => {
|
|
1490
2503
|
try {
|
|
1491
2504
|
ignoreRejection(options.onError?.(error));
|
|
@@ -1510,49 +2523,153 @@ ${entries.length === 0
|
|
|
1510
2523
|
}
|
|
1511
2524
|
let apiKey = null;
|
|
1512
2525
|
if (settings.sealedKey !== null) {
|
|
1513
|
-
|
|
1514
|
-
|
|
2526
|
+
const tenantRef = tenantOf(session);
|
|
2527
|
+
const opened = await openSlotlockAssistantKeyWithAny(assistantSecrets, tenantRef, settings.sealedKey);
|
|
2528
|
+
if (opened === null) {
|
|
1515
2529
|
return {
|
|
1516
2530
|
problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
|
|
1517
2531
|
};
|
|
1518
2532
|
}
|
|
2533
|
+
apiKey = opened.apiKey;
|
|
2534
|
+
// Sealed under a previous secret: sealed again under the current one, unless an owner saved
|
|
2535
|
+
// another key meanwhile. Best effort: the key opened either way.
|
|
2536
|
+
if (opened.secretIndex > 0 && store?.resealKey) {
|
|
2537
|
+
try {
|
|
2538
|
+
const resealed = await sealSlotlockAssistantKey(assistantOptions.secret, tenantRef, apiKey);
|
|
2539
|
+
await store.resealKey(tenantRef, settings.sealedKey, resealed);
|
|
2540
|
+
}
|
|
2541
|
+
catch (error) {
|
|
2542
|
+
// A suspended tenant reads and writes nothing, this included: it opens the old way.
|
|
2543
|
+
if (error?.code !== 'tenant_suspended') {
|
|
2544
|
+
ignoreRejection(options.onError?.(error));
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
1519
2548
|
}
|
|
1520
2549
|
return {
|
|
1521
2550
|
model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
|
|
1522
2551
|
};
|
|
1523
2552
|
}
|
|
2553
|
+
/** When a change happens, in its resource's time zone when the card knows it, else UTC. */
|
|
2554
|
+
function whenOf(start, end, zone) {
|
|
2555
|
+
if (typeof start !== 'string')
|
|
2556
|
+
return null;
|
|
2557
|
+
const from = new Date(start);
|
|
2558
|
+
if (Number.isNaN(from.getTime()))
|
|
2559
|
+
return null;
|
|
2560
|
+
const timeZone = zone ?? 'UTC';
|
|
2561
|
+
let day;
|
|
2562
|
+
let time;
|
|
2563
|
+
try {
|
|
2564
|
+
day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone });
|
|
2565
|
+
time = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
|
|
2566
|
+
}
|
|
2567
|
+
catch {
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2570
|
+
const to = typeof end === 'string' ? new Date(end) : null;
|
|
2571
|
+
const span = to && !Number.isNaN(to.getTime())
|
|
2572
|
+
? day.format(from) === day.format(to)
|
|
2573
|
+
? `${time.format(from)}–${time.format(to)}`
|
|
2574
|
+
: `${time.format(from)} – ${day.format(to)}, ${time.format(to)}`
|
|
2575
|
+
: time.format(from);
|
|
2576
|
+
return `${day.format(from)}, ${span} ${timeZone}`;
|
|
2577
|
+
}
|
|
2578
|
+
/** What a proposed change's fields are called on its card. */
|
|
2579
|
+
const FACT_NAMES = Object.freeze({
|
|
2580
|
+
resource_id: 'Resource',
|
|
2581
|
+
resource_ids: 'Resources',
|
|
2582
|
+
pool_id: 'Pool',
|
|
2583
|
+
title: 'Title',
|
|
2584
|
+
external_ref: 'Reference',
|
|
2585
|
+
event_id: 'Booking',
|
|
2586
|
+
hold_id: 'Hold',
|
|
2587
|
+
ttl_seconds: 'Held for',
|
|
2588
|
+
notes: 'Note',
|
|
2589
|
+
description: 'Note',
|
|
2590
|
+
metadata: 'Details',
|
|
2591
|
+
slots: 'Times',
|
|
2592
|
+
});
|
|
1524
2593
|
/**
|
|
1525
|
-
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
|
|
1526
|
-
*
|
|
2594
|
+
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the proposal
|
|
2595
|
+
* is bounded instead). A resource is named; its id is the name's title.
|
|
1527
2596
|
*/
|
|
1528
2597
|
function fieldValue(name, value, names) {
|
|
1529
|
-
const named = (id) =>
|
|
1530
|
-
if (typeof value === 'string')
|
|
1531
|
-
return
|
|
2598
|
+
const named = (id) => names.has(id) ? html `<span title="${id}">${names.get(id)}</span>` : html `<code>${id}</code>`;
|
|
2599
|
+
if (name === 'resource_id' && typeof value === 'string')
|
|
2600
|
+
return named(value);
|
|
2601
|
+
if (name === 'resource_ids' && Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
2602
|
+
return html `${value.map((id, index) => html `${index > 0 ? ', ' : ''}${named(id)}`)}`;
|
|
2603
|
+
}
|
|
2604
|
+
if (name === 'ttl_seconds' && typeof value === 'number') {
|
|
2605
|
+
return html `${value >= 60 ? `${Math.round(value / 60)} min` : `${value} s`}`;
|
|
1532
2606
|
}
|
|
2607
|
+
if ((name === 'event_id' || name === 'hold_id') && typeof value === 'string')
|
|
2608
|
+
return html `<code>${value}</code>`;
|
|
2609
|
+
if (typeof value === 'string')
|
|
2610
|
+
return html `<span class="wrap">${value}</span>`;
|
|
1533
2611
|
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
1534
|
-
return html `<span class="wrap">${value.
|
|
2612
|
+
return html `<span class="wrap">${value.join(', ')}</span>`;
|
|
1535
2613
|
}
|
|
1536
|
-
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
2614
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
1537
2615
|
return html `${String(value)}`;
|
|
1538
|
-
}
|
|
1539
|
-
return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
|
|
2616
|
+
return html `<pre>${JSON.stringify(value, null, 2)}</pre>`;
|
|
1540
2617
|
}
|
|
1541
|
-
|
|
2618
|
+
/** Why a change or a step was refused, in words, for the codes a member meets. */
|
|
2619
|
+
const REFUSALS = Object.freeze({
|
|
2620
|
+
overlap: 'that time was just taken',
|
|
2621
|
+
pool_full: 'the pool is full then',
|
|
2622
|
+
not_found: 'it no longer exists',
|
|
2623
|
+
resource_not_found: 'the resource no longer exists',
|
|
2624
|
+
event_not_found: 'the booking no longer exists',
|
|
2625
|
+
invalid_input: 'the request was incomplete',
|
|
2626
|
+
invalid_arguments: 'the request was incomplete',
|
|
2627
|
+
validation_error: 'the request was incomplete',
|
|
2628
|
+
turn_read_budget_exceeded: 'one message may read only so much',
|
|
2629
|
+
too_many_pending_changes: 'too many changes are waiting for you',
|
|
2630
|
+
change_too_large_to_show: 'the change was too large to show',
|
|
2631
|
+
tool_not_available: 'the assistant may not do that',
|
|
2632
|
+
too_many_calls: 'too many at once',
|
|
2633
|
+
quota_exceeded: "this month's allowance is used up",
|
|
2634
|
+
approval_pending: 'it is waiting for an approval',
|
|
2635
|
+
});
|
|
2636
|
+
const refusal = (code) => REFUSALS[code] ?? code.replaceAll('_', ' ');
|
|
2637
|
+
function actionCard(session, action, names, zones) {
|
|
2638
|
+
const verb = slotlockAssistantActionVerb(action.operation);
|
|
2639
|
+
const title = action.summary || `${verb.verb}: ${toolTitles.get(action.operation) ?? action.operation}`;
|
|
2640
|
+
if (action.status !== 'pending') {
|
|
2641
|
+
const [tone, word] = action.status === 'done'
|
|
2642
|
+
? ['ok', verb.done]
|
|
2643
|
+
: action.status === 'dismissed'
|
|
2644
|
+
? ['', 'Declined']
|
|
2645
|
+
: action.status === 'approved'
|
|
2646
|
+
? ['', 'Running…']
|
|
2647
|
+
: ['bad', `Refused: ${refusal(action.outcome ?? 'failed')}`];
|
|
2648
|
+
return html `<li class="turn action" id="action-${action.id}"><p class="receipt ${tone}"><span>${title}</span><span class="why">${word}</span></p></li>`;
|
|
2649
|
+
}
|
|
1542
2650
|
const token = csrfToken(session);
|
|
1543
|
-
const
|
|
1544
|
-
|
|
1545
|
-
const
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
const
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
2651
|
+
const input = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
|
|
2652
|
+
// The change's own time zone when it gives one, else its resource's.
|
|
2653
|
+
const zone = typeof action.input.timezone === 'string'
|
|
2654
|
+
? action.input.timezone
|
|
2655
|
+
: typeof action.input.resource_id === 'string'
|
|
2656
|
+
? zones.get(action.input.resource_id)
|
|
2657
|
+
: undefined;
|
|
2658
|
+
const when = whenOf(action.input.starts_at, action.input.ends_at, zone);
|
|
2659
|
+
const facts = [];
|
|
2660
|
+
const more = [];
|
|
2661
|
+
if (when)
|
|
2662
|
+
facts.push(html `<dt>When</dt><dd>${when}</dd>`);
|
|
2663
|
+
for (const [name, value] of input) {
|
|
2664
|
+
if (when && (name === 'starts_at' || name === 'ends_at' || name === 'timezone'))
|
|
2665
|
+
continue;
|
|
2666
|
+
const label = FACT_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
|
|
2667
|
+
(name in FACT_NAMES || name === 'attendees' ? facts : more).push(html `<dt>${label}</dt><dd>${fieldValue(name, value, names)}</dd>`);
|
|
2668
|
+
}
|
|
2669
|
+
const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${id}">`;
|
|
2670
|
+
return html `<li class="turn action" id="action-${action.id}"><div class="change"><p class="change-title">${title}</p>${facts.length > 0 ? html `<dl class="facts">${facts}</dl>` : html ``}${more.length > 0
|
|
2671
|
+
? html `<details class="more"><summary>All details</summary><dl class="facts">${more}</dl></details>`
|
|
2672
|
+
: html ``}<div class="change-buttons"><form method="post" action="${root}/assistant/approve">${hidden(action.id)}<button class="${verb.danger ? 'danger' : 'primary'}" type="submit" data-busy="${verb.busy}">${verb.verb}</button></form><form method="post" action="${root}/assistant/dismiss">${hidden(action.id)}<button class="ghost" type="submit">Decline</button></form></div></div></li>`;
|
|
1556
2673
|
}
|
|
1557
2674
|
function readAction(id, value) {
|
|
1558
2675
|
if (!isRecord(value) || typeof value.operation !== 'string')
|
|
@@ -1566,6 +2683,100 @@ ${entries.length === 0
|
|
|
1566
2683
|
...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
|
|
1567
2684
|
};
|
|
1568
2685
|
}
|
|
2686
|
+
/** The code a tool result was refused with, from the JSON the model was given. */
|
|
2687
|
+
function refusedCode(content) {
|
|
2688
|
+
try {
|
|
2689
|
+
const parsed = JSON.parse(content);
|
|
2690
|
+
const error = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : null;
|
|
2691
|
+
return error && typeof error.code === 'string' ? error.code : null;
|
|
2692
|
+
}
|
|
2693
|
+
catch {
|
|
2694
|
+
return null;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
/** Notes the turn ends with that are not a failure: a member's Stop, and the step bound. */
|
|
2698
|
+
const quietNote = (text) => text === 'Stopped.' || text.startsWith('The assistant stopped after');
|
|
2699
|
+
/**
|
|
2700
|
+
* The conversation as the thread shows it: the member's messages as bubbles, the replies as
|
|
2701
|
+
* rendered Markdown, each run of tool steps folded into one line, proposed changes as cards, and
|
|
2702
|
+
* a failure that ends it with a way to send the last message again.
|
|
2703
|
+
*/
|
|
2704
|
+
function assistantItems(session, entries, actions, names, zones) {
|
|
2705
|
+
const items = [];
|
|
2706
|
+
let steps = [];
|
|
2707
|
+
let cards = [];
|
|
2708
|
+
const flush = () => {
|
|
2709
|
+
if (steps.length > 0) {
|
|
2710
|
+
const bad = steps.some((step) => !step.ok);
|
|
2711
|
+
const seen = [];
|
|
2712
|
+
for (const step of steps)
|
|
2713
|
+
if (!seen.includes(step.label))
|
|
2714
|
+
seen.push(step.label);
|
|
2715
|
+
const summary = seen.map((label, index) => (index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1))).join(', ');
|
|
2716
|
+
items.push(html `<li class="turn steps"><details class="steps${bad ? ' has-bad' : ''}"><summary>${summary}</summary><ul class="step-list">${steps.map((step) => html `<li class="step ${step.ok ? 'ok' : 'bad'}">${step.label}${step.why ? html ` <span class="why">${step.why}</span>` : html ``}</li>`)}</ul></details></li>`);
|
|
2717
|
+
steps = [];
|
|
2718
|
+
}
|
|
2719
|
+
items.push(...cards);
|
|
2720
|
+
cards = [];
|
|
2721
|
+
};
|
|
2722
|
+
const lastUser = [...entries].reverse().find((entry) => entry.role === 'user');
|
|
2723
|
+
for (const [index, entry] of entries.entries()) {
|
|
2724
|
+
if (entry.role === 'user') {
|
|
2725
|
+
flush();
|
|
2726
|
+
items.push(html `<li class="turn mine"><div class="bubble">${entry.text}</div></li>`);
|
|
2727
|
+
}
|
|
2728
|
+
else if (entry.role === 'assistant') {
|
|
2729
|
+
if (entry.text.trim() === '')
|
|
2730
|
+
continue;
|
|
2731
|
+
flush();
|
|
2732
|
+
items.push(html `<li class="turn bot"><div class="prose">${new Html(renderSlotlockMarkdown(entry.text))}</div></li>`);
|
|
2733
|
+
}
|
|
2734
|
+
else if (entry.role === 'tool') {
|
|
2735
|
+
for (const result of entry.results) {
|
|
2736
|
+
const write = SLOTLOCK_ASSISTANT_TOOL_USE[result.name] === 'propose';
|
|
2737
|
+
const action = result.action ? readAction(result.action, actions[result.action]) : null;
|
|
2738
|
+
if (action) {
|
|
2739
|
+
cards.push(actionCard(session, action, names, zones));
|
|
2740
|
+
continue;
|
|
2741
|
+
}
|
|
2742
|
+
const title = toolTitles.get(result.name) ?? result.name;
|
|
2743
|
+
const code = result.error ? refusedCode(result.content) : null;
|
|
2744
|
+
steps.push({
|
|
2745
|
+
label: slotlockAssistantStepLabel(result.name, title, result.error ? 'bad' : 'ok', write),
|
|
2746
|
+
ok: !result.error,
|
|
2747
|
+
why: code ? refusal(code) : null,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
else if (entry.role === 'note') {
|
|
2752
|
+
flush();
|
|
2753
|
+
const last = index === entries.length - 1;
|
|
2754
|
+
if (last && !quietNote(entry.text) && lastUser?.role === 'user') {
|
|
2755
|
+
items.push(html `<li class="turn error" role="alert"><span>${entry.text}</span><form method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
|
|
2756
|
+
}
|
|
2757
|
+
else {
|
|
2758
|
+
items.push(html `<li class="turn note">${entry.text}</li>`);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
flush();
|
|
2763
|
+
return items;
|
|
2764
|
+
}
|
|
2765
|
+
/** The tenant's resources as a proposed change's card names them, and each one's time zone. */
|
|
2766
|
+
async function resourceLookups(tenantRef) {
|
|
2767
|
+
const resources = await options.resources.list(tenantRef);
|
|
2768
|
+
return {
|
|
2769
|
+
resources,
|
|
2770
|
+
names: new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)])),
|
|
2771
|
+
zones: new Map(resources.flatMap((resource) => (resource.timezone ? [[resource.id, resource.timezone]] : []))),
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
const ASSISTANT_ICONS = {
|
|
2775
|
+
send: new Html('<svg class="arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5"/></svg>'),
|
|
2776
|
+
stop: new Html('<svg class="stop" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/></svg>'),
|
|
2777
|
+
down: new Html('<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M3.5 8.5 8 13l4.5-4.5"/></svg>'),
|
|
2778
|
+
plus: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>'),
|
|
2779
|
+
};
|
|
1569
2780
|
async function assistantPage(session, status = 200, notice) {
|
|
1570
2781
|
const store = assistantStore();
|
|
1571
2782
|
if (!store)
|
|
@@ -1575,83 +2786,100 @@ ${entries.length === 0
|
|
|
1575
2786
|
store.getSettings(tenantRef),
|
|
1576
2787
|
store.getConversation(tenantRef, personOf(session)),
|
|
1577
2788
|
]);
|
|
2789
|
+
if (!settings) {
|
|
2790
|
+
const content = html `${noticeOf(notice, status)}${pageHead('Assistant')}${emptyState(ICONS.assistant, isAdmin(session) ? 'Connect a model to use the assistant.' : 'An admin of this organization connects a model in Settings.', isAdmin(session) ? { href: `${root}/settings#assistant`, label: 'Connect a model' } : undefined)}`;
|
|
2791
|
+
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
2792
|
+
}
|
|
1578
2793
|
const entries = readSlotlockAssistantEntries(conversation?.messages);
|
|
1579
2794
|
const actions = conversation?.actions ?? {};
|
|
1580
|
-
const
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
2795
|
+
const empty = entries.length === 0;
|
|
2796
|
+
const { resources, names, zones } = empty || Object.keys(actions).length > 0
|
|
2797
|
+
? await resourceLookups(tenantRef)
|
|
2798
|
+
: { resources: [], names: new Map(), zones: new Map() };
|
|
1586
2799
|
const token = csrfToken(session);
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
const composer = html `<form class="composer" method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="3" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="Find a free van tomorrow morning and book it for Ana"></textarea><button class="primary" type="submit">Send</button></form>`;
|
|
1608
|
-
const reset = entries.length > 0
|
|
1609
|
-
? html `<form class="inline" method="post" action="${root}/assistant/reset"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">New conversation</button></form>`
|
|
1610
|
-
: undefined;
|
|
1611
|
-
const content = html `${noticeOf(notice)}
|
|
1612
|
-
${pageHead('Assistant', reset)}
|
|
1613
|
-
${settings
|
|
1614
|
-
? html `<section class="panel chat">${items.length === 0
|
|
1615
|
-
? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
|
|
1616
|
-
: html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
|
|
1617
|
-
: html `<p class="empty">${isAdmin(session)
|
|
1618
|
-
? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
|
|
1619
|
-
: 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
|
|
2800
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
2801
|
+
const first = resources.find((resource) => !resource.archivedAt);
|
|
2802
|
+
const named = first ? resourceName(first, first.id) : null;
|
|
2803
|
+
// A read chip asks at once; a write chip fills the box to finish (app.js), or asks (no script).
|
|
2804
|
+
const prompts = [
|
|
2805
|
+
{ label: "What's on today", message: "What's booked today?" },
|
|
2806
|
+
{ label: 'Find free time', message: named ? `When is ${named} free tomorrow morning?` : 'What is free tomorrow morning?' },
|
|
2807
|
+
{ label: 'Book', message: named ? `Book ${named} tomorrow 9:00–10:00` : 'Book ', fill: true },
|
|
2808
|
+
{ label: 'Move a booking', message: 'Move the next booking one hour later', fill: true },
|
|
2809
|
+
{ label: 'Cancel a booking', message: 'Cancel the next booking', fill: true },
|
|
2810
|
+
];
|
|
2811
|
+
const chips = html `<div class="assistant-start"><div class="chips">${prompts.map((prompt) => html `<form method="post" action="${root}/assistant">${hidden()}<button class="chip" type="submit" name="message" value="${prompt.message}"${prompt.fill ? html ` data-fill="${prompt.message}"` : html ``}>${prompt.label}</button></form>`)}</div></div>`;
|
|
2812
|
+
const composer = html `<div class="dock"><button class="jump" type="button" hidden aria-label="Jump to the latest message">${ASSISTANT_ICONS.down}</button><form class="composer" method="post" action="${root}/assistant">${hidden()}<label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="2" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="${empty ? 'Ask about bookings' : 'Reply…'}"></textarea><div class="composer-bar"><span class="count" hidden></span><button class="send" type="submit" aria-label="Send">${ASSISTANT_ICONS.send}${ASSISTANT_ICONS.stop}</button></div></form></div>`;
|
|
2813
|
+
const reset = html `<form class="inline new-chat" method="post" action="${root}/assistant/reset">${hidden()}<button class="ghost" type="submit">${ASSISTANT_ICONS.plus}New conversation</button></form>`;
|
|
2814
|
+
const flag = empty ? new Html(' data-empty') : html ``;
|
|
2815
|
+
const content = html `${noticeOf(notice, status)}<div class="assistant-page"${flag}>${pageHead('Assistant', reset)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones)}</ol><span id="latest"></span>${chips}${composer}</div></div>`;
|
|
1620
2816
|
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
1621
2817
|
}
|
|
1622
|
-
|
|
2818
|
+
/** The named provider a provider and base URL are, or none (Custom). */
|
|
2819
|
+
const presetOf = (provider, baseUrl) => SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id !== 'custom' && preset.provider === provider && preset.baseUrl === baseUrl);
|
|
2820
|
+
/**
|
|
2821
|
+
* The assistant's settings: what is connected and whether it answers (Test, its result in place),
|
|
2822
|
+
* then the form, one provider chosen from tiles, and Disconnect apart. A member sees only what is
|
|
2823
|
+
* connected, and where their messages go.
|
|
2824
|
+
*/
|
|
2825
|
+
async function assistantSettingsSection(session, status, notice, draft) {
|
|
1623
2826
|
const store = assistantStore();
|
|
1624
|
-
if (!store)
|
|
1625
|
-
return html ``;
|
|
1626
2827
|
const settings = await store.getSettings(tenantOf(session));
|
|
1627
|
-
const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
|
|
1628
|
-
if (!isAdmin(session)) {
|
|
1629
|
-
return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
|
|
1630
|
-
? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
|
|
1631
|
-
: html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
|
|
1632
|
-
}
|
|
1633
2828
|
const token = csrfToken(session);
|
|
1634
|
-
const
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2829
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
2830
|
+
const savedName = settings ? (presetOf(settings.provider, settings.baseUrl)?.name ?? 'Custom') : null;
|
|
2831
|
+
// Where messages go, in words: the provider's name, or a custom one's host.
|
|
2832
|
+
const hostOf = (url) => {
|
|
2833
|
+
try {
|
|
2834
|
+
return new URL(url).host;
|
|
2835
|
+
}
|
|
2836
|
+
catch {
|
|
2837
|
+
return url;
|
|
2838
|
+
}
|
|
2839
|
+
};
|
|
2840
|
+
const savedTo = settings ? (savedName === 'Custom' ? hostOf(settings.baseUrl) : savedName) : null;
|
|
2841
|
+
const admin = isAdmin(session);
|
|
2842
|
+
const badge = !settings
|
|
2843
|
+
? html `<span class="badge">Not set up</span>`
|
|
2844
|
+
: notice && status === 200 && notice.startsWith('Connected')
|
|
2845
|
+
? html `<span class="badge ok">Connected</span>`
|
|
2846
|
+
: notice && status === 502
|
|
2847
|
+
? html `<span class="badge bad">Failing</span>`
|
|
2848
|
+
: html `<span class="badge ok">Set up</span>`;
|
|
2849
|
+
const test = settings && admin
|
|
2850
|
+
? html `<form method="post" action="${root}/settings/assistant/test">${hidden()}<button type="submit">Test</button></form>`
|
|
2851
|
+
: html ``;
|
|
2852
|
+
const statusCard = html `<div class="set-card status-card" id="assistant"><div class="status-head"><span class="mono-tile" aria-hidden="true">${savedName ? savedName.charAt(0) : '–'}</span><div class="status-name"><strong>${savedName ?? 'No model connected'}</strong>${settings ? html `<code>${settings.model}</code>` : html ``}</div>${badge}${test}</div>${noticeOf(notice, status)}</div>`;
|
|
2853
|
+
const sentTo = (name) => html `<p class="fine">Members' messages, and the bookings they ask about, are sent to ${name ?? 'the provider you choose'}.</p>`;
|
|
2854
|
+
if (!admin) {
|
|
2855
|
+
return html `${statusCard}${settings ? sentTo(savedTo) : html `<p class="fine">An admin of this organization connects a model here.</p>`}`;
|
|
2856
|
+
}
|
|
2857
|
+
// What the form shows: a draft (a preset, or a model list) over what is saved.
|
|
2858
|
+
const provider = draft?.provider ?? settings?.provider ?? 'anthropic';
|
|
2859
|
+
const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
|
|
2860
|
+
const model = draft?.model ?? settings?.model ?? '';
|
|
2861
|
+
const chosen = settings || draft ? (presetOf(provider, baseUrl)?.id ?? 'custom') : 'anthropic';
|
|
2862
|
+
const chosenName = chosen === 'custom' ? (baseUrl ? hostOf(baseUrl) : null) : (SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id === chosen)?.name ?? null);
|
|
2863
|
+
const models = draft?.models ?? [];
|
|
2864
|
+
const tiles = html `<div class="tiles" role="radiogroup" aria-label="Provider">${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<label class="tile"><input type="radio" name="preset" value="${preset.id}"${preset.id === chosen ? html ` checked` : html ``}><span class="tile-mark" aria-hidden="true">${preset.name.charAt(0)}</span>${preset.name}</label>`)}</div>`;
|
|
2865
|
+
const format = (value, label) => html `<label><input type="radio" name="provider" value="${value}"${provider === value ? html ` checked` : html ``}>${label}</label>`;
|
|
2866
|
+
const keyField = settings?.keyHint
|
|
2867
|
+
? html `<div class="key-set"><code>•••• ${settings.keyHint}</code><details><summary>Replace</summary><input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" aria-label="New API key"></details></div>`
|
|
2868
|
+
: html `<input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="Optional for a local model">`;
|
|
2869
|
+
const form = html `<form class="set-card provider-form" method="post" action="${root}/settings/assistant">${hidden()}<div class="row stack"><span class="row-label">Provider</span>${tiles}</div><div class="row custom-only"><span class="row-label">API format</span><div class="segmented">${format('anthropic', 'Anthropic Messages')}${format('openai', 'OpenAI-compatible')}</div></div><div class="row custom-only"><label class="row-label" for="base-url">Base URL</label><input id="base-url" class="mono" name="base_url" maxlength="2048" value="${baseUrl}" placeholder="https://host/v1"></div><div class="row"><label class="row-label" for="model">Model</label><div class="field-with-button"><input id="model" class="mono" name="model" required maxlength="200" value="${model}" placeholder="Model name"${models.length > 0 ? html ` list="assistant-models"` : html ``}><button type="submit" formaction="${root}/settings/assistant/models" formnovalidate>Load models</button></div>${models.length > 0
|
|
2870
|
+
? html `<datalist id="assistant-models">${models.map((id) => html `<option value="${id}"></option>`)}</datalist>`
|
|
2871
|
+
: html ``}</div><div class="row"><label class="row-label" for="api-key">API key</label>${keyField}</div><div class="card-foot">${sentTo(chosenName)}<button class="primary" type="submit">Save</button></div></form>`;
|
|
2872
|
+
const disconnect = settings
|
|
2873
|
+
? html `<div class="set-card"><div class="danger-row"><strong>Disconnect ${savedName}</strong><details class="act danger"><summary>Disconnect…</summary><div class="pop"><p>The key is deleted. Members can't use the assistant until a model is connected again.</p><form method="post" action="${root}/settings/assistant/remove">${hidden()}<button class="danger" type="submit">Disconnect</button></form></div></details></div></div>`
|
|
2874
|
+
: html ``;
|
|
2875
|
+
return html `${statusCard}${form}${disconnect}`;
|
|
2876
|
+
}
|
|
2877
|
+
/** The provider and base URL a form names: a named provider's own, or Custom's two fields. */
|
|
2878
|
+
function formEndpoint(form) {
|
|
2879
|
+
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === form.get('preset'));
|
|
2880
|
+
if (preset && preset.id !== 'custom')
|
|
2881
|
+
return { provider: preset.provider, baseUrl: preset.baseUrl };
|
|
2882
|
+
return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
|
|
1655
2883
|
}
|
|
1656
2884
|
const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
|
|
1657
2885
|
async function saveAssistantSettings(session, form) {
|
|
@@ -1660,8 +2888,8 @@ ${settings
|
|
|
1660
2888
|
return notFound(session);
|
|
1661
2889
|
if (!isAdmin(session))
|
|
1662
2890
|
return adminOnly(session);
|
|
1663
|
-
const provider = form
|
|
1664
|
-
const checkedUrl = readSlotlockAssistantBaseUrl(
|
|
2891
|
+
const { provider, baseUrl: givenUrl } = formEndpoint(form);
|
|
2892
|
+
const checkedUrl = readSlotlockAssistantBaseUrl(givenUrl, { privateUrls });
|
|
1665
2893
|
const model = (form.get('model') ?? '').trim();
|
|
1666
2894
|
const apiKey = (form.get('api_key') ?? '').trim();
|
|
1667
2895
|
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
@@ -1698,6 +2926,71 @@ ${settings
|
|
|
1698
2926
|
}
|
|
1699
2927
|
return redirect(303, `${root}/settings#assistant`);
|
|
1700
2928
|
}
|
|
2929
|
+
/**
|
|
2930
|
+
* The provider's models for the Model field (Load models): the form's provider and base URL,
|
|
2931
|
+
* checked as saving checks them, with the key given, or the saved one when it is for that same
|
|
2932
|
+
* provider and URL. Saves nothing; counted as a message to the assistant.
|
|
2933
|
+
*/
|
|
2934
|
+
async function loadAssistantModels(session, form) {
|
|
2935
|
+
const store = assistantStore();
|
|
2936
|
+
if (!store || !assistantOptions)
|
|
2937
|
+
return notFound(session);
|
|
2938
|
+
if (!isAdmin(session))
|
|
2939
|
+
return adminOnly(session);
|
|
2940
|
+
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
2941
|
+
return tooManyMessages(session, settingsPage);
|
|
2942
|
+
}
|
|
2943
|
+
const { provider, baseUrl: givenUrl } = formEndpoint(form);
|
|
2944
|
+
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
2945
|
+
return settingsPage(session, 400, 'Choose a provider from the list.');
|
|
2946
|
+
}
|
|
2947
|
+
const typedModel = (form.get('model') ?? '').trim();
|
|
2948
|
+
const draft = {
|
|
2949
|
+
provider,
|
|
2950
|
+
baseUrl: givenUrl,
|
|
2951
|
+
...(typedModel !== '' ? { model: typedModel } : {}),
|
|
2952
|
+
};
|
|
2953
|
+
const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
|
|
2954
|
+
if (!checkedUrl.ok) {
|
|
2955
|
+
return settingsPage(session, 400, checkedUrl.reason === 'private'
|
|
2956
|
+
? PRIVATE_URL_REFUSAL
|
|
2957
|
+
: 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.', draft);
|
|
2958
|
+
}
|
|
2959
|
+
const baseUrl = checkedUrl.url;
|
|
2960
|
+
if (namesOwnHost(baseUrl))
|
|
2961
|
+
return settingsPage(session, 400, OWN_HOST_REFUSAL, draft);
|
|
2962
|
+
if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
2963
|
+
return settingsPage(session, 400, PRIVATE_URL_REFUSAL, draft);
|
|
2964
|
+
}
|
|
2965
|
+
const typedKey = (form.get('api_key') ?? '').trim();
|
|
2966
|
+
if (typedKey !== '' && !SLOTLOCK_ASSISTANT_API_KEY.test(typedKey)) {
|
|
2967
|
+
return settingsPage(session, 400, 'An API key is up to 4,096 characters without spaces.', draft);
|
|
2968
|
+
}
|
|
2969
|
+
// The saved key only for the provider and URL it was entered for, as a model call sends it.
|
|
2970
|
+
let apiKey = typedKey !== '' ? typedKey : null;
|
|
2971
|
+
if (apiKey === null) {
|
|
2972
|
+
const found = await assistantModel(session);
|
|
2973
|
+
if (found && !('problem' in found) && found.model.provider === provider && found.model.baseUrl === baseUrl) {
|
|
2974
|
+
apiKey = found.model.apiKey;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
try {
|
|
2978
|
+
const listed = await listSlotlockAssistantModels({
|
|
2979
|
+
provider,
|
|
2980
|
+
baseUrl,
|
|
2981
|
+
apiKey,
|
|
2982
|
+
fetch: assistantFetch,
|
|
2983
|
+
timeoutMs: assistantTimeout,
|
|
2984
|
+
});
|
|
2985
|
+
return settingsPage(session, 200, listed.length > 0
|
|
2986
|
+
? `${listed.length.toLocaleString('en')} model${listed.length === 1 ? '' : 's'} listed: choose one for Model, then Save.${typedKey !== '' ? ' Enter the API key again when you save.' : ''}`
|
|
2987
|
+
: 'The provider listed no models. Type the model name.', { ...draft, baseUrl, models: listed });
|
|
2988
|
+
}
|
|
2989
|
+
catch (error) {
|
|
2990
|
+
report(error instanceof Error ? error.message : 'assistant model list failed');
|
|
2991
|
+
return settingsPage(session, 502, slotlockAssistantProviderMessage(error), { ...draft, baseUrl });
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
1701
2994
|
async function removeAssistantSettings(session) {
|
|
1702
2995
|
const store = assistantStore();
|
|
1703
2996
|
if (!store)
|
|
@@ -1736,24 +3029,42 @@ ${settings
|
|
|
1736
3029
|
return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
|
|
1737
3030
|
}
|
|
1738
3031
|
}
|
|
1739
|
-
|
|
3032
|
+
/**
|
|
3033
|
+
* One member message: check it, run the turn (streaming its text and steps to `onEvent` when
|
|
3034
|
+
* given, stopping when `signal` aborts), and keep the conversation. Both ways to send share it.
|
|
3035
|
+
*/
|
|
3036
|
+
async function runAssistantMessage(session, form, hooks = {}) {
|
|
1740
3037
|
const store = assistantStore();
|
|
1741
3038
|
if (!store || !assistantOptions)
|
|
1742
|
-
return
|
|
3039
|
+
return { ok: false, status: 404, message: 'Nothing is here.' };
|
|
1743
3040
|
const text = (form.get('message') ?? '').trim();
|
|
1744
3041
|
if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
|
|
1745
|
-
return
|
|
3042
|
+
return {
|
|
3043
|
+
ok: false,
|
|
3044
|
+
status: 400,
|
|
3045
|
+
message: `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`,
|
|
3046
|
+
};
|
|
1746
3047
|
}
|
|
1747
3048
|
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
1748
|
-
return
|
|
3049
|
+
return {
|
|
3050
|
+
ok: false,
|
|
3051
|
+
status: 429,
|
|
3052
|
+
message: 'You have sent the assistant a lot in the last minute. Wait a moment and try again.',
|
|
3053
|
+
};
|
|
1749
3054
|
}
|
|
1750
3055
|
const found = await assistantModel(session);
|
|
1751
3056
|
if (found === null)
|
|
1752
|
-
return
|
|
3057
|
+
return { ok: false, status: 409, message: 'No model is connected.' };
|
|
1753
3058
|
if ('problem' in found)
|
|
1754
|
-
return
|
|
3059
|
+
return { ok: false, status: 409, message: found.problem };
|
|
1755
3060
|
const tenantRef = tenantOf(session);
|
|
1756
3061
|
const member = personOf(session);
|
|
3062
|
+
try {
|
|
3063
|
+
ignoreRejection(assistantOptions?.onMessage?.(tenantRef));
|
|
3064
|
+
}
|
|
3065
|
+
catch {
|
|
3066
|
+
// Metering never changes an answer.
|
|
3067
|
+
}
|
|
1757
3068
|
const stored = await store.getConversation(tenantRef, member);
|
|
1758
3069
|
const entries = boundSlotlockAssistantEntries([
|
|
1759
3070
|
...readSlotlockAssistantEntries(stored?.messages),
|
|
@@ -1777,6 +3088,8 @@ ${settings
|
|
|
1777
3088
|
timeoutMs: assistantTimeout,
|
|
1778
3089
|
turnTimeoutMs: assistantTurnTimeout,
|
|
1779
3090
|
...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
|
|
3091
|
+
...(hooks.onEvent ? { onEvent: hooks.onEvent } : {}),
|
|
3092
|
+
...(hooks.signal ? { signal: hooks.signal } : {}),
|
|
1780
3093
|
read: async (operation, input) => {
|
|
1781
3094
|
// A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
|
|
1782
3095
|
// scan a hundred resources over years.
|
|
@@ -1833,8 +3146,85 @@ ${settings
|
|
|
1833
3146
|
added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
|
|
1834
3147
|
keep: Object.keys(kept),
|
|
1835
3148
|
}, conversationTtl);
|
|
3149
|
+
return { ok: true, messages, actions: kept };
|
|
3150
|
+
}
|
|
3151
|
+
/** The form's way (no script): run the turn, then show the conversation at its latest message. */
|
|
3152
|
+
async function sendToAssistant(session, form) {
|
|
3153
|
+
const run = await runAssistantMessage(session, form);
|
|
3154
|
+
if (!run.ok)
|
|
3155
|
+
return run.status === 404 ? notFound(session) : assistantPage(session, run.status, run.message);
|
|
1836
3156
|
return redirect(303, `${root}/assistant#latest`);
|
|
1837
3157
|
}
|
|
3158
|
+
/**
|
|
3159
|
+
* app.js's way: the same turn, answered as it happens, one JSON object a line. `text` carries a
|
|
3160
|
+
* piece of the reply, `step` a tool step as it starts and ends, and the last line is `done` (the
|
|
3161
|
+
* turn as the page renders it, which replaces what was streamed) or `error`, each with a fresh
|
|
3162
|
+
* single-use value for the next send. The member's Stop aborts the request, and the turn with it.
|
|
3163
|
+
*/
|
|
3164
|
+
async function streamToAssistant(session, form, request) {
|
|
3165
|
+
const encoder = new TextEncoder();
|
|
3166
|
+
const pipe = new TransformStream();
|
|
3167
|
+
const writer = pipe.writable.getWriter();
|
|
3168
|
+
let open = true;
|
|
3169
|
+
const write = (event) => {
|
|
3170
|
+
if (!open)
|
|
3171
|
+
return;
|
|
3172
|
+
writer.write(encoder.encode(`${JSON.stringify(event)}\n`)).catch(() => {
|
|
3173
|
+
open = false;
|
|
3174
|
+
});
|
|
3175
|
+
};
|
|
3176
|
+
const label = (name, state) => slotlockAssistantStepLabel(name, toolTitles.get(name) ?? name, state, SLOTLOCK_ASSISTANT_TOOL_USE[name] === 'propose');
|
|
3177
|
+
const failed = (message) => write({
|
|
3178
|
+
t: 'error',
|
|
3179
|
+
html: html `<li class="turn error" role="alert"><span>${message}</span></li>`.value,
|
|
3180
|
+
once: formNonce(session),
|
|
3181
|
+
});
|
|
3182
|
+
const work = (async () => {
|
|
3183
|
+
try {
|
|
3184
|
+
const run = await runAssistantMessage(session, form, {
|
|
3185
|
+
signal: request.signal,
|
|
3186
|
+
onEvent: (event) => {
|
|
3187
|
+
if (event.type === 'text')
|
|
3188
|
+
write({ t: 'text', d: event.delta });
|
|
3189
|
+
else if (event.type === 'call')
|
|
3190
|
+
write({ t: 'step', id: event.id, s: 'run', label: label(event.name, 'run') });
|
|
3191
|
+
else
|
|
3192
|
+
write({ t: 'step', id: event.id, s: event.ok ? 'ok' : 'bad', label: label(event.name, event.ok ? 'ok' : 'bad') });
|
|
3193
|
+
},
|
|
3194
|
+
});
|
|
3195
|
+
if (!run.ok) {
|
|
3196
|
+
failed(run.message);
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
// The turn from the member's message on, as a reload would show it.
|
|
3200
|
+
const start = run.messages.map((entry) => entry.role).lastIndexOf('user');
|
|
3201
|
+
const turn = run.messages.slice(Math.max(0, start));
|
|
3202
|
+
const lookups = Object.keys(run.actions).length > 0
|
|
3203
|
+
? await resourceLookups(tenantOf(session))
|
|
3204
|
+
: { names: new Map(), zones: new Map() };
|
|
3205
|
+
write({
|
|
3206
|
+
t: 'done',
|
|
3207
|
+
html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones).join(''),
|
|
3208
|
+
once: formNonce(session),
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
catch (error) {
|
|
3212
|
+
report(error instanceof Error ? error.message : 'assistant stream failed');
|
|
3213
|
+
failed('Something went wrong. Reload the page and try again.');
|
|
3214
|
+
}
|
|
3215
|
+
finally {
|
|
3216
|
+
await writer.close().catch(() => undefined);
|
|
3217
|
+
open = false;
|
|
3218
|
+
}
|
|
3219
|
+
})();
|
|
3220
|
+
ignoreRejection(work);
|
|
3221
|
+
const page = respond(200, '');
|
|
3222
|
+
const headers = new Headers(page.headers);
|
|
3223
|
+
headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
3224
|
+
// Proxies that buffer (nginx) send each line as it is written.
|
|
3225
|
+
headers.set('X-Accel-Buffering', 'no');
|
|
3226
|
+
return new Response(pipe.readable, { status: 200, headers });
|
|
3227
|
+
}
|
|
1838
3228
|
async function settleAction(session, form, decision) {
|
|
1839
3229
|
const store = assistantStore();
|
|
1840
3230
|
if (!store || !assistantOptions)
|
|
@@ -1882,6 +3272,256 @@ ${settings
|
|
|
1882
3272
|
await store.deleteConversation(tenantOf(session), personOf(session));
|
|
1883
3273
|
return redirect(303, `${root}/assistant`);
|
|
1884
3274
|
}
|
|
3275
|
+
/**
|
|
3276
|
+
* A workspace's plan as both the Usage page and the overview show it: the plan (or the default it
|
|
3277
|
+
* moves to), what it has against the limits, this month's use against the quotas, and what needs a
|
|
3278
|
+
* person: a failed payment, a quota used up or nearly, more than the coming plan allows.
|
|
3279
|
+
* `here` is the page asking, so an item's action points to the page that resolves it.
|
|
3280
|
+
*/
|
|
3281
|
+
async function planStanding(session, here, given) {
|
|
3282
|
+
const active = plans;
|
|
3283
|
+
const billing = options.billing;
|
|
3284
|
+
const tenantRef = tenantOf(session);
|
|
3285
|
+
const [plan, planDefault, resourceCount, keys, webhooks] = await Promise.all([
|
|
3286
|
+
active.plan(tenantRef),
|
|
3287
|
+
active.planDefault ? active.planDefault() : Promise.resolve(null),
|
|
3288
|
+
// As the limit counts them: archived resources included.
|
|
3289
|
+
options.resources.count
|
|
3290
|
+
? options.resources.count(tenantRef)
|
|
3291
|
+
: options.resources.list(tenantRef).then((listed) => listed.length),
|
|
3292
|
+
given ?? options.keys.list({ tenantRef }),
|
|
3293
|
+
options.resources.webhooks?.list(tenantRef),
|
|
3294
|
+
]);
|
|
3295
|
+
const month = slotlockUsageMonthToDate(now());
|
|
3296
|
+
const used = options.usage && options.resources.usage
|
|
3297
|
+
? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
|
|
3298
|
+
: null;
|
|
3299
|
+
const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
|
|
3300
|
+
const admin = isAdmin(session);
|
|
3301
|
+
const paying = plan !== null && plan.plan !== 'free';
|
|
3302
|
+
const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
|
|
3303
|
+
const limits = plan?.limits ?? upcoming?.limits;
|
|
3304
|
+
const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
|
|
3305
|
+
const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
|
|
3306
|
+
const counted = (value) => value.toLocaleString('en-GB');
|
|
3307
|
+
const usagePage = `${root}/plan`;
|
|
3308
|
+
const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
|
|
3309
|
+
const canUpgrade = !paying && admin && Boolean(billing || active.contact);
|
|
3310
|
+
const upgradeHint = paying ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
|
|
3311
|
+
const attention = [];
|
|
3312
|
+
// Whether anything is near or past a limit: the overview then offers the upgrade.
|
|
3313
|
+
let pressing = false;
|
|
3314
|
+
if (plan?.status === 'past_due') {
|
|
3315
|
+
attention.push(attentionItem('bad', `Your last payment failed. ${admin && billing && plan.stripeCustomer ? 'Update your payment method in Manage billing' : 'An owner updates the payment method'} to keep the ${PLAN_NAMES[plan.plan]} plan.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
|
|
3316
|
+
}
|
|
3317
|
+
if (quotas && used) {
|
|
3318
|
+
for (const metric of ['operations', 'bookings']) {
|
|
3319
|
+
const quota = quotas[metric];
|
|
3320
|
+
const stop = Math.floor((quota * quotas.stopPercent) / 100);
|
|
3321
|
+
const spent = used.totals[metric];
|
|
3322
|
+
const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
|
|
3323
|
+
const upgrade = canUpgrade ? { href: upgradeAt, label: 'Upgrade' } : here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined;
|
|
3324
|
+
if (spent * 100 >= quota * 80)
|
|
3325
|
+
pressing = true;
|
|
3326
|
+
if (spent >= stop) {
|
|
3327
|
+
attention.push(attentionItem('bad', `This month's ${metric} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${upgradeHint}`, upgrade));
|
|
3328
|
+
}
|
|
3329
|
+
else if (spent >= quota) {
|
|
3330
|
+
const billed = metric === 'operations' && billing?.overage && plan?.plan === 'developer' && paying;
|
|
3331
|
+
attention.push(attentionItem('warn', `You have used this month's ${counted(quota)} ${metric}.${billed ? ` Those past it are billed at ${billing?.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.${billed ? '' : upgradeHint}`, billed ? undefined : upgrade));
|
|
3332
|
+
}
|
|
3333
|
+
else if (spent * 100 >= quota * 80) {
|
|
3334
|
+
attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
if (upcoming) {
|
|
3339
|
+
// What the workspace has that its coming plan will not allow: nothing is removed; adding stops.
|
|
3340
|
+
const over = [
|
|
3341
|
+
['resources', resourceCount, upcoming.limits.resources],
|
|
3342
|
+
['active API keys', keyCount, upcoming.limits.apiKeys],
|
|
3343
|
+
['webhooks', webhooks?.length ?? 0, upcoming.limits.webhookEndpoints],
|
|
3344
|
+
].filter(([, have, allowed]) => have > allowed);
|
|
3345
|
+
if (over.length > 0)
|
|
3346
|
+
pressing = true;
|
|
3347
|
+
attention.push(attentionItem(over.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${over.length > 0
|
|
3348
|
+
? ` You have ${over.map(([what, have]) => `${String(have)} ${what}`).join(', ')}: nothing is removed, but you cannot add more until you have fewer${paying || !admin ? '' : ', or upgrade'}.`
|
|
3349
|
+
: ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
|
|
3350
|
+
}
|
|
3351
|
+
/** One use against its allowance: a bar, amber from 80%, red at the limit. */
|
|
3352
|
+
const meter = (label, value, limit) => {
|
|
3353
|
+
if (limit === undefined) {
|
|
3354
|
+
return html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
|
|
3355
|
+
}
|
|
3356
|
+
const tone = value >= limit ? ' bad' : value * 100 >= limit * 80 ? ' warn' : '';
|
|
3357
|
+
return html `<div class="meter${tone}"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}<span class="meta"> of ${counted(limit)}</span></span></div><progress value="${String(Math.min(value, limit))}" max="${String(limit)}" aria-label="${label}: ${counted(value)} of ${counted(limit)}"></progress></div>`;
|
|
3358
|
+
};
|
|
3359
|
+
const limitMeters = html `${meter('Resources', resourceCount, limits?.resources)}${meter('Active API keys', keyCount, limits?.apiKeys)}${webhooks ? meter('Webhooks', webhooks.length, limits?.webhookEndpoints) : html ``}`;
|
|
3360
|
+
// Under a "This month" heading, or beside the limits with the month in each label.
|
|
3361
|
+
const monthMeters = (suffix) => used
|
|
3362
|
+
? html `${meter(`Operations${suffix}`, used.totals.operations, quotas?.operations)}${meter(`Bookings${suffix}`, used.totals.bookings, quotas?.bookings)}${meter(`Holds${suffix}`, used.totals.holds, undefined)}`
|
|
3363
|
+
: html ``;
|
|
3364
|
+
const name = plan
|
|
3365
|
+
? PLAN_NAMES[plan.plan]
|
|
3366
|
+
: upcoming
|
|
3367
|
+
? `${PLAN_NAMES[upcoming.plan]} from ${dayOf(upcoming.appliesFrom)}`
|
|
3368
|
+
: 'No plan';
|
|
3369
|
+
return {
|
|
3370
|
+
plan,
|
|
3371
|
+
upcoming,
|
|
3372
|
+
paying,
|
|
3373
|
+
admin,
|
|
3374
|
+
used,
|
|
3375
|
+
month,
|
|
3376
|
+
resets,
|
|
3377
|
+
keys,
|
|
3378
|
+
name,
|
|
3379
|
+
limitMeters,
|
|
3380
|
+
monthMeters,
|
|
3381
|
+
meters: html `${limitMeters}${monthMeters(' this month')}`,
|
|
3382
|
+
attention,
|
|
3383
|
+
canUpgrade,
|
|
3384
|
+
pressing,
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
/** One thing that needs a person: a sentence, its tone, and what resolves it. */
|
|
3388
|
+
const attentionItem = (tone, text, action) => html `<li class="attention ${tone}"><span class="attention-text">${text}</span>${action ? html `<a class="attention-action" href="${action.href}">${action.label}</a>` : html ``}</li>`;
|
|
3389
|
+
/** The list of things that need a person, or nothing. */
|
|
3390
|
+
const attentionList = (items) => items.length > 0 ? html `<ul class="attention-list" aria-label="Needs attention">${items}</ul>` : html ``;
|
|
3391
|
+
async function planPage(session, status = 200, notice, thanks = false) {
|
|
3392
|
+
if (!plans)
|
|
3393
|
+
return notFound(session);
|
|
3394
|
+
const billing = options.billing;
|
|
3395
|
+
const counted = (value) => value.toLocaleString('en-GB');
|
|
3396
|
+
if (session.env === 'test') {
|
|
3397
|
+
// Its own use, counted apart from Live's and never billed.
|
|
3398
|
+
const range = slotlockUsageMonthToDate(now());
|
|
3399
|
+
const tested = options.usage && options.resources.usage
|
|
3400
|
+
? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
|
|
3401
|
+
: null;
|
|
3402
|
+
const meter = (label, value) => html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
|
|
3403
|
+
return respond(status, layout('Usage', html `${pageHead('Usage')}${noticeOf(notice, status)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
|
|
3404
|
+
? html `<section class="panel" id="usage"><h2>This month, in Test</h2><div class="meters">${meter('Operations', tested.totals.operations)}${meter('Bookings', tested.totals.bookings)}${meter('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
|
|
3405
|
+
: html ``}`, session, 'plan'));
|
|
3406
|
+
}
|
|
3407
|
+
const standing = await planStanding(session, 'usage');
|
|
3408
|
+
const { plan, paying, admin, used, month, resets, keys } = standing;
|
|
3409
|
+
const token = csrfToken(session);
|
|
3410
|
+
const keyName = new Map(keys.map((key) => [key.id, key.name]));
|
|
3411
|
+
// An organization is offered Team (where billing sells it, or by asking); a person, Developer.
|
|
3412
|
+
const offered = session.org !== undefined && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
|
|
3413
|
+
const contact = plans.contact ?? billing?.contact;
|
|
3414
|
+
const manage = billing && plan?.stripeCustomer && admin
|
|
3415
|
+
? html `<form class="inline" method="post" action="${root}/plan/manage"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Manage billing</button></form>`
|
|
3416
|
+
: html ``;
|
|
3417
|
+
const renews = paying && plan?.currentPeriodEnd
|
|
3418
|
+
? html `<span class="meta">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}</span>`
|
|
3419
|
+
: html ``;
|
|
3420
|
+
const planCard = html `<section class="panel plan-card" id="plan"><div class="plan-head"><h2>${standing.name}</h2>${renews}</div><div class="meters">${standing.limitMeters}</div>${used ? html `` : html `<p class="fine">This server does not count use yet.</p>`}</section>${used
|
|
3421
|
+
? html `<section class="panel" id="month"><h2>This month</h2><div class="meters">${standing.monthMeters('')}</div><p class="fine">Since ${month.from} (UTC); the month starts again on ${resets}. An operation is a call that reached Slotlock, whatever it answered.</p></section>`
|
|
3422
|
+
: html ``}`;
|
|
3423
|
+
const byKey = used && used.keys.length > 0
|
|
3424
|
+
? html `<section class="panel" id="usage"><h2>Used by</h2><div class="table"><table><thead><tr><th>Key</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Key">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${counted(row.operations)}</td><td data-label="Bookings">${counted(row.bookings)}</td><td data-label="Holds">${counted(row.holds)}</td></tr>`)}</tbody></table></div><p class="fine">Webhook deliveries ${counted(used.totals.webhook_deliveries)} · assistant messages ${counted(used.totals.assistant_messages)}.</p></section>`
|
|
3425
|
+
: html ``;
|
|
3426
|
+
const offeredLimits = SLOTLOCK_PLAN_LIMITS[offered];
|
|
3427
|
+
const offeredText = html `${String(offeredLimits.resources)} resources, ${String(offeredLimits.apiKeys)} API keys and ${String(offeredLimits.webhookEndpoints)} webhook endpoints${options.quotas
|
|
3428
|
+
? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS[offered].operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS[offered].bookings)} bookings a month${offered === 'team' ? ' shared by the organization' : ''}`
|
|
3429
|
+
: html ``}.`;
|
|
3430
|
+
let upgrade = html ``;
|
|
3431
|
+
if (!paying) {
|
|
3432
|
+
if (!admin) {
|
|
3433
|
+
upgrade = html `<p class="fine">An owner of this organization can change its plan.</p>`;
|
|
3434
|
+
}
|
|
3435
|
+
else if (billing) {
|
|
3436
|
+
const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
|
|
3437
|
+
upgrade = html `<section class="panel" id="upgrade">
|
|
3438
|
+
<h2>${PLAN_NAMES[offered]}</h2>
|
|
3439
|
+
<p>${offeredText}${offered === 'developer' && billing.overage && options.quotas
|
|
3440
|
+
? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
|
|
3441
|
+
: ''}</p>
|
|
3442
|
+
<form method="post" action="${root}/plan/upgrade">
|
|
3443
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
3444
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
3445
|
+
<fieldset class="modes"><legend>Billed</legend>
|
|
3446
|
+
<label class="choice"><input type="radio" name="interval" value="month" checked> Monthly, ${offeredPrices.month} a month</label>
|
|
3447
|
+
<label class="choice"><input type="radio" name="interval" value="year"> Yearly, ${offeredPrices.year} a year</label>
|
|
3448
|
+
</fieldset>
|
|
3449
|
+
<button class="primary" type="submit">Upgrade to ${PLAN_NAMES[offered]}</button>
|
|
3450
|
+
</form>
|
|
3451
|
+
<p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
|
|
3452
|
+
</section>`;
|
|
3453
|
+
}
|
|
3454
|
+
else if (contact) {
|
|
3455
|
+
// No payment here yet: the plan is arranged by asking.
|
|
3456
|
+
upgrade = html `<section class="panel" id="upgrade"><h2>${PLAN_NAMES[offered]}</h2><p>${offeredText}</p><a class="button primary" href="${contact}">Talk to us about ${PLAN_NAMES[offered]}</a></section>`;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
const content = html `${thanks ? html `<p class="notice done" role="status">${notice ?? ''}</p>` : noticeOf(notice, status)}
|
|
3460
|
+
${pageHead('Usage', manage)}
|
|
3461
|
+
${attentionList(standing.attention)}
|
|
3462
|
+
${planCard}
|
|
3463
|
+
${byKey}
|
|
3464
|
+
${upgrade}
|
|
3465
|
+
${contact
|
|
3466
|
+
? html `<section class="panel" id="custom"><h2>Custom</h2><p>Slotlock on your own servers, with support from the people who build it.</p><a class="button" href="${contact}">Talk to the founder</a></section>`
|
|
3467
|
+
: html ``}`;
|
|
3468
|
+
return respond(status, layout('Usage', content, session, 'plan'));
|
|
3469
|
+
}
|
|
3470
|
+
async function upgradePlan(session, form) {
|
|
3471
|
+
const billing = options.billing;
|
|
3472
|
+
if (!billing)
|
|
3473
|
+
return notFound(session);
|
|
3474
|
+
if (session.env === 'test')
|
|
3475
|
+
return planPage(session, 400, 'Plans are changed in Live.');
|
|
3476
|
+
if (!isAdmin(session))
|
|
3477
|
+
return planPage(session, 403, 'Only owners of the organization can change its plan.');
|
|
3478
|
+
const interval = form.get('interval') === 'year' ? 'year' : 'month';
|
|
3479
|
+
const tenantRef = tenantOf(session);
|
|
3480
|
+
const plan = await billing.plan(tenantRef);
|
|
3481
|
+
if (plan && plan.plan !== 'free') {
|
|
3482
|
+
return planPage(session, 409, `You are on the ${PLAN_NAMES[plan.plan]} plan already.`);
|
|
3483
|
+
}
|
|
3484
|
+
let url;
|
|
3485
|
+
try {
|
|
3486
|
+
url = await billing.checkout({
|
|
3487
|
+
tenantRef,
|
|
3488
|
+
interval,
|
|
3489
|
+
...(session.org !== undefined && billing.teamPrices ? { plan: 'team' } : {}),
|
|
3490
|
+
customer: plan?.stripeCustomer ?? null,
|
|
3491
|
+
successUrl: `${publicRoot}/plan?checkout=done`,
|
|
3492
|
+
cancelUrl: `${publicRoot}/plan`,
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
catch (error) {
|
|
3496
|
+
if (error?.code === 'subscription_exists') {
|
|
3497
|
+
return planPage(session, 409, 'You pay for a subscription already. Its plan shows here once Stripe confirms it; to change it, use Manage billing.');
|
|
3498
|
+
}
|
|
3499
|
+
ignoreRejection(options.onError?.(error));
|
|
3500
|
+
return planPage(session, 502, 'The payment page did not open. Try again in a minute.');
|
|
3501
|
+
}
|
|
3502
|
+
return redirect(303, url);
|
|
3503
|
+
}
|
|
3504
|
+
async function manageBilling(session) {
|
|
3505
|
+
const billing = options.billing;
|
|
3506
|
+
if (!billing)
|
|
3507
|
+
return notFound(session);
|
|
3508
|
+
if (session.env === 'test')
|
|
3509
|
+
return planPage(session, 400, 'Billing is managed in Live.');
|
|
3510
|
+
if (!isAdmin(session))
|
|
3511
|
+
return planPage(session, 403, 'Only owners of the organization can manage its billing.');
|
|
3512
|
+
const plan = await billing.plan(tenantOf(session));
|
|
3513
|
+
if (!plan?.stripeCustomer)
|
|
3514
|
+
return planPage(session, 409, 'There is no billing to manage yet.');
|
|
3515
|
+
let url;
|
|
3516
|
+
try {
|
|
3517
|
+
url = await billing.portal({ customer: plan.stripeCustomer, returnUrl: `${publicRoot}/plan` });
|
|
3518
|
+
}
|
|
3519
|
+
catch (error) {
|
|
3520
|
+
ignoreRejection(options.onError?.(error));
|
|
3521
|
+
return planPage(session, 502, 'Billing did not open. Try again in a minute.');
|
|
3522
|
+
}
|
|
3523
|
+
return redirect(303, url);
|
|
3524
|
+
}
|
|
1885
3525
|
/** The page a form was sent from, to show what went wrong with it. */
|
|
1886
3526
|
function pageFor(route) {
|
|
1887
3527
|
if (route.startsWith('/keys'))
|
|
@@ -1890,12 +3530,21 @@ ${settings
|
|
|
1890
3530
|
return webhooksPage;
|
|
1891
3531
|
if (route.startsWith('/waitlist') && options.resources.waitlist)
|
|
1892
3532
|
return waitlistPage;
|
|
3533
|
+
if (route.startsWith('/approvals') && options.resources.approvals)
|
|
3534
|
+
return approvalsPage;
|
|
3535
|
+
if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
|
|
3536
|
+
return locationsPage;
|
|
3537
|
+
}
|
|
1893
3538
|
if (route.startsWith('/resources') || route === '/hours')
|
|
1894
3539
|
return resourcesPage;
|
|
1895
3540
|
if (route.startsWith('/assistant') && assistantStore())
|
|
1896
3541
|
return assistantPage;
|
|
1897
3542
|
if (route.startsWith('/settings'))
|
|
1898
3543
|
return settingsPage;
|
|
3544
|
+
if (route.startsWith('/plan') && plans)
|
|
3545
|
+
return planPage;
|
|
3546
|
+
if (route.startsWith('/teams') && teams)
|
|
3547
|
+
return teamsPage;
|
|
1899
3548
|
return overviewPage;
|
|
1900
3549
|
}
|
|
1901
3550
|
function hoursPage(session, state) {
|
|
@@ -1950,6 +3599,7 @@ ${state.unshown
|
|
|
1950
3599
|
return resources.find((resource) => resource.id === id) ?? null;
|
|
1951
3600
|
}
|
|
1952
3601
|
const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
|
|
3602
|
+
const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
1953
3603
|
async function hoursEditor(session, url) {
|
|
1954
3604
|
const id = url.searchParams.get('resource');
|
|
1955
3605
|
if (id === null) {
|
|
@@ -2037,8 +3687,83 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2037
3687
|
return respond(200, layout('Your new key', content, session, 'keys'));
|
|
2038
3688
|
}
|
|
2039
3689
|
/** Act in another of the session's organizations: the same session, sealed again. */
|
|
2040
|
-
function
|
|
3690
|
+
function switchEnvironment(session, form) {
|
|
3691
|
+
const wanted = form.get('environment');
|
|
3692
|
+
if (wanted !== 'live' && wanted !== 'test') {
|
|
3693
|
+
return respond(400, layout('Refused', html `<p class="notice">Choose Live or Test.</p>`, session));
|
|
3694
|
+
}
|
|
3695
|
+
const { env: _, ...rest } = session;
|
|
3696
|
+
const sealed = seal('slotlock-dashboard-session-v1', wanted === 'test' ? { ...rest, env: 'test' } : rest);
|
|
3697
|
+
const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
|
|
3698
|
+
return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Teams on Cloud: the person's GitHub organizations, as read at sign-in, each on or off. An owner
|
|
3702
|
+
* turns one on or off; a member asks an owner; one that is on is a workspace to switch to.
|
|
3703
|
+
*/
|
|
3704
|
+
async function teamsPage(session, status = 200, notice) {
|
|
3705
|
+
if (!teams)
|
|
3706
|
+
return nothingHere(session);
|
|
3707
|
+
const orgs = session.orgs ?? [];
|
|
3708
|
+
const enabled = orgs.length > 0 ? await teams.enabled(orgs.map((org) => org.id)) : new Set();
|
|
3709
|
+
enabledOf.set(session, enabled);
|
|
3710
|
+
const token = csrfToken(session);
|
|
3711
|
+
const hidden = (org) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="organization" value="${org.id}">`;
|
|
3712
|
+
const rows = orgs.map((org) => {
|
|
3713
|
+
const on = enabled.has(org.id);
|
|
3714
|
+
const owner = org.role === 'admin';
|
|
3715
|
+
const actions = on
|
|
3716
|
+
? html `${org.id === session.org ? html `<span class="muted">In use</span>` : html `<form class="inline" method="post" action="${root}/organization">${hidden(org)}<button type="submit">Switch to it</button></form>`}${owner
|
|
3717
|
+
? html `<details class="act danger"><summary>Turn off</summary><div class="pop"><p>Its keys stop at once and no member can open it. What it holds stays until an owner turns it on again or erases it.</p>
|
|
3718
|
+
<form method="post" action="${root}/teams/disable">${hidden(org)}<label for="confirm-${org.id}">Type ${org.login} to confirm</label><input id="confirm-${org.id}" name="confirm" autocomplete="off" required><button class="danger" type="submit">Turn off</button></form></div></details>`
|
|
3719
|
+
: html ``}`
|
|
3720
|
+
: owner
|
|
3721
|
+
? html `<form class="inline" method="post" action="${root}/teams/enable">${hidden(org)}<button class="primary" type="submit">Turn on</button></form>`
|
|
3722
|
+
: html `<span class="muted">An owner can turn it on</span>`;
|
|
3723
|
+
return html `<tr><td data-label="Organization" class="strong">${org.login}</td><td data-label="You are">${owner ? 'Owner' : 'Member'}</td><td data-label="Slotlock"><span class="pill ${on ? 'active' : 'revoked'}">${on ? 'On' : 'Off'}</span></td><td class="actions">${actions}</td></tr>`;
|
|
3724
|
+
});
|
|
3725
|
+
const content = html `${noticeOf(notice, status)}${pageHead('Teams')}
|
|
3726
|
+
<p>Work with the people in a GitHub organization: once an owner turns Slotlock on for it, every owner and member shares its resources, keys and history, and picks it from the workspace menu. Your own workspace stays yours.</p>
|
|
3727
|
+
${rows.length === 0
|
|
3728
|
+
? emptyState(ICONS.teams, 'Slotlock sees none of your GitHub organizations. It reads them as you sign in: after joining one, sign out and in again. An organization that restricts OAuth apps needs an owner to approve this one.')
|
|
3729
|
+
: html `<div class="table"><table><thead><tr><th>Organization</th><th>You are</th><th>Slotlock</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
3730
|
+
<p class="fine">Who owns an organization is read from GitHub as you sign in. Leaving one ends your access at your next sign-in.</p>`}`;
|
|
3731
|
+
return respond(status, layout('Teams', content, session, 'teams'));
|
|
3732
|
+
}
|
|
3733
|
+
async function changeTeam(session, form, on) {
|
|
3734
|
+
if (!teams)
|
|
3735
|
+
return nothingHere(session);
|
|
3736
|
+
const org = session.orgs?.find((candidate) => candidate.id === form.get('organization'));
|
|
3737
|
+
if (!org)
|
|
3738
|
+
return teamsPage(session, 400, 'That is not one of your organizations.');
|
|
3739
|
+
if (org.role !== 'admin')
|
|
3740
|
+
return teamsPage(session, 403, `Only an owner of ${org.login} can turn Slotlock ${on ? 'on' : 'off'} for it.`);
|
|
3741
|
+
// Who owns it was read as the person signed in: only a recent sign-in is trusted to say so.
|
|
3742
|
+
if (now() - (session.exp - SESSION_TTL_MS) > FRESH_SIGN_IN_MS) {
|
|
3743
|
+
return teamsPage(session, 403, `To turn Slotlock ${on ? 'on' : 'off'} for ${org.login}, sign out and in again: who owns it is read from GitHub as you sign in.`);
|
|
3744
|
+
}
|
|
3745
|
+
if (on) {
|
|
3746
|
+
await teams.enable({ id: org.id, login: org.login }, personOf(session));
|
|
3747
|
+
return teamsPage(session, 200, `Slotlock is on for ${org.login}. Its members pick it from the workspace menu.`);
|
|
3748
|
+
}
|
|
3749
|
+
if (form.get('confirm') !== org.login)
|
|
3750
|
+
return teamsPage(session, 400, `Type ${org.login} to turn it off.`);
|
|
3751
|
+
await teams.disable(org.id, personOf(session));
|
|
3752
|
+
return teamsPage(session, 200, `Slotlock is off for ${org.login}: its keys stopped. What it holds stays until it is turned on again or erased.`);
|
|
3753
|
+
}
|
|
3754
|
+
async function switchOrganization(session, form) {
|
|
2041
3755
|
const wanted = form.get('organization') ?? '';
|
|
3756
|
+
if (teams) {
|
|
3757
|
+
// Their own tenant, or an organization of theirs that is turned on now.
|
|
3758
|
+
const org = session.orgs?.find((candidate) => candidate.id === wanted);
|
|
3759
|
+
if (wanted !== '' && (!org || !(await teams.enabled([wanted])).has(wanted))) {
|
|
3760
|
+
return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization. An owner turns it on from Teams.</p>`, session));
|
|
3761
|
+
}
|
|
3762
|
+
const { org: _, ...personal } = session;
|
|
3763
|
+
const sealed = seal('slotlock-dashboard-session-v1', wanted === '' ? personal : { ...personal, org: wanted });
|
|
3764
|
+
const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
|
|
3765
|
+
return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
|
|
3766
|
+
}
|
|
2042
3767
|
const org = session.orgs?.find((candidate) => candidate.id === wanted && served?.has(candidate.id));
|
|
2043
3768
|
if (!served || !org) {
|
|
2044
3769
|
return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization.</p>`, session));
|
|
@@ -2059,7 +3784,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2059
3784
|
code_challenge: challenge,
|
|
2060
3785
|
code_challenge_method: 'S256',
|
|
2061
3786
|
// Reading organization membership needs read:org; a personal tenant needs no scope at all.
|
|
2062
|
-
...(
|
|
3787
|
+
...(readsOrganizations ? { scope: 'read:org' } : {}),
|
|
2063
3788
|
}).toString();
|
|
2064
3789
|
const sealed = seal('slotlock-dashboard-sign-in-v1', {
|
|
2065
3790
|
state,
|
|
@@ -2165,7 +3890,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2165
3890
|
continue;
|
|
2166
3891
|
}
|
|
2167
3892
|
const id = String(organization.id);
|
|
2168
|
-
if (served?.has(id)) {
|
|
3893
|
+
if (teams || served?.has(id)) {
|
|
2169
3894
|
found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
|
|
2170
3895
|
}
|
|
2171
3896
|
}
|
|
@@ -2173,13 +3898,146 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2173
3898
|
.sort((a, b) => a.login.localeCompare(b.login))
|
|
2174
3899
|
.slice(0, MAX_SESSION_ORGANIZATIONS);
|
|
2175
3900
|
}
|
|
3901
|
+
/** Google's sign-in (OpenID Connect, code flow with PKCE and a nonce), back to the same callback. */
|
|
3902
|
+
async function startGoogleSignIn(provider) {
|
|
3903
|
+
const state = randomBytes(32).toString('base64url');
|
|
3904
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
3905
|
+
const nonce = randomBytes(24).toString('base64url');
|
|
3906
|
+
const target = new URL(provider.authorizeUrl ?? 'https://accounts.google.com/o/oauth2/v2/auth');
|
|
3907
|
+
target.search = new URLSearchParams({
|
|
3908
|
+
client_id: provider.clientId,
|
|
3909
|
+
redirect_uri: callbackUrl,
|
|
3910
|
+
response_type: 'code',
|
|
3911
|
+
scope: 'openid email',
|
|
3912
|
+
state,
|
|
3913
|
+
nonce,
|
|
3914
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
3915
|
+
code_challenge_method: 'S256',
|
|
3916
|
+
}).toString();
|
|
3917
|
+
const sealed = seal('slotlock-dashboard-sign-in-v1', {
|
|
3918
|
+
state,
|
|
3919
|
+
verifier,
|
|
3920
|
+
nonce,
|
|
3921
|
+
idp: 'google',
|
|
3922
|
+
exp: now() + SIGN_IN_TTL_MS,
|
|
3923
|
+
});
|
|
3924
|
+
return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
|
|
3925
|
+
}
|
|
3926
|
+
/** Google's signing keys by key id, kept for as long as its answer says (an hour by default). */
|
|
3927
|
+
let googleKeys = null;
|
|
3928
|
+
async function readGoogleKeys(provider, refresh) {
|
|
3929
|
+
if (googleKeys && !refresh && googleKeys.until > now())
|
|
3930
|
+
return googleKeys.keys;
|
|
3931
|
+
const response = await fetchImpl(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs', {
|
|
3932
|
+
headers: { Accept: 'application/json' },
|
|
3933
|
+
redirect: 'manual',
|
|
3934
|
+
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
|
|
3935
|
+
});
|
|
3936
|
+
if (!response.ok)
|
|
3937
|
+
throw new Error(`Google keys answered HTTP ${response.status}`);
|
|
3938
|
+
const body = await response.json();
|
|
3939
|
+
const keys = new Map();
|
|
3940
|
+
for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
|
|
3941
|
+
if (!isRecord(jwk) || jwk.kty !== 'RSA' || typeof jwk.kid !== 'string' || (jwk.alg !== undefined && jwk.alg !== 'RS256'))
|
|
3942
|
+
continue;
|
|
3943
|
+
const key = await globalThis.crypto.subtle
|
|
3944
|
+
.importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e, alg: 'RS256', ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'])
|
|
3945
|
+
.catch(() => null);
|
|
3946
|
+
if (key)
|
|
3947
|
+
keys.set(jwk.kid, key);
|
|
3948
|
+
}
|
|
3949
|
+
const maxAge = Number(/max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]);
|
|
3950
|
+
googleKeys = { keys, until: now() + (Number.isFinite(maxAge) && maxAge > 0 ? Math.min(maxAge * 1000, 86_400_000) : GOOGLE_KEYS_DEFAULT_TTL_MS) };
|
|
3951
|
+
return keys;
|
|
3952
|
+
}
|
|
3953
|
+
/** Whether an ID token's signature is Google's: RS256, by a key id Google publishes (read again once when unknown, as Google rotates them). */
|
|
3954
|
+
async function googleSigned(provider, header, payload, signature) {
|
|
3955
|
+
let parsed;
|
|
3956
|
+
try {
|
|
3957
|
+
parsed = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
|
|
3958
|
+
}
|
|
3959
|
+
catch {
|
|
3960
|
+
return false;
|
|
3961
|
+
}
|
|
3962
|
+
if (!isRecord(parsed) || parsed.alg !== 'RS256' || typeof parsed.kid !== 'string')
|
|
3963
|
+
return false;
|
|
3964
|
+
let key = (await readGoogleKeys(provider, false)).get(parsed.kid);
|
|
3965
|
+
if (!key)
|
|
3966
|
+
key = (await readGoogleKeys(provider, true)).get(parsed.kid);
|
|
3967
|
+
if (!key)
|
|
3968
|
+
return false;
|
|
3969
|
+
return globalThis.crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, Buffer.from(signature, 'base64url'), Buffer.from(`${header}.${payload}`, 'ascii'));
|
|
3970
|
+
}
|
|
3971
|
+
/**
|
|
3972
|
+
* The person Google signed in, from the ID token its token endpoint returned for this code. The
|
|
3973
|
+
* token's signature is checked against Google's published keys, and its issuer, audience (and
|
|
3974
|
+
* azp), expiry and nonce (OpenID Connect Core 1.0, 3.1.3.7).
|
|
3975
|
+
*/
|
|
3976
|
+
async function askGoogle(provider, code, verifier, nonce) {
|
|
3977
|
+
try {
|
|
3978
|
+
const response = await fetchImpl(provider.tokenUrl ?? 'https://oauth2.googleapis.com/token', {
|
|
3979
|
+
method: 'POST',
|
|
3980
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
3981
|
+
body: new URLSearchParams({
|
|
3982
|
+
code,
|
|
3983
|
+
client_id: provider.clientId,
|
|
3984
|
+
client_secret: provider.clientSecret,
|
|
3985
|
+
redirect_uri: callbackUrl,
|
|
3986
|
+
grant_type: 'authorization_code',
|
|
3987
|
+
code_verifier: verifier,
|
|
3988
|
+
}).toString(),
|
|
3989
|
+
redirect: 'manual',
|
|
3990
|
+
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
|
|
3991
|
+
});
|
|
3992
|
+
if (!response.ok) {
|
|
3993
|
+
report(`Google token exchange failed: HTTP ${response.status}`);
|
|
3994
|
+
return null;
|
|
3995
|
+
}
|
|
3996
|
+
const body = await response.json().catch(() => null);
|
|
3997
|
+
const token = isRecord(body) && typeof body.id_token === 'string' ? body.id_token : null;
|
|
3998
|
+
const [header, payload, signature, ...extra] = token?.split('.') ?? [];
|
|
3999
|
+
if (!header || !payload || !signature || extra.length > 0 || ![header, payload, signature].every((part) => BASE64URL.test(part))) {
|
|
4000
|
+
return null;
|
|
4001
|
+
}
|
|
4002
|
+
// Signed by Google, with one of the keys it publishes (RS256), whichever way the token came.
|
|
4003
|
+
if (!(await googleSigned(provider, header, payload, signature))) {
|
|
4004
|
+
report('Google sign-in refused: the ID token is not signed by Google');
|
|
4005
|
+
return null;
|
|
4006
|
+
}
|
|
4007
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
4008
|
+
if (!isRecord(claims))
|
|
4009
|
+
return null;
|
|
4010
|
+
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
4011
|
+
if ((claims.iss !== 'https://accounts.google.com' && claims.iss !== 'accounts.google.com') ||
|
|
4012
|
+
!audience.includes(provider.clientId) ||
|
|
4013
|
+
(audience.length > 1 && claims.azp !== provider.clientId) ||
|
|
4014
|
+
typeof claims.exp !== 'number' ||
|
|
4015
|
+
claims.exp * 1000 <= now() ||
|
|
4016
|
+
typeof claims.nonce !== 'string' ||
|
|
4017
|
+
!safeEqual(claims.nonce, nonce) ||
|
|
4018
|
+
typeof claims.sub !== 'string' ||
|
|
4019
|
+
!GOOGLE_SUBJECT.test(claims.sub)) {
|
|
4020
|
+
report('Google sign-in refused: the ID token did not check out');
|
|
4021
|
+
return null;
|
|
4022
|
+
}
|
|
4023
|
+
// The email names the person on the page only, and only once Google has verified it.
|
|
4024
|
+
const email = claims.email_verified === true && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
|
|
4025
|
+
? claims.email
|
|
4026
|
+
: 'Google account';
|
|
4027
|
+
return { uid: claims.sub, login: email, idp: 'google' };
|
|
4028
|
+
}
|
|
4029
|
+
catch (error) {
|
|
4030
|
+
report(`Google sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
|
|
4031
|
+
return null;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
2176
4034
|
async function askGitHub(code, verifier) {
|
|
2177
4035
|
try {
|
|
2178
4036
|
const token = await exchangeCode(code, verifier);
|
|
2179
4037
|
if (token === null)
|
|
2180
4038
|
return null;
|
|
2181
4039
|
const user = await readGitHubUser(token);
|
|
2182
|
-
if (!user || !
|
|
4040
|
+
if (!user || !readsOrganizations)
|
|
2183
4041
|
return user;
|
|
2184
4042
|
const orgs = await readMemberships(token);
|
|
2185
4043
|
return orgs === null ? null : { ...user, orgs };
|
|
@@ -2221,7 +4079,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2221
4079
|
if (!(await options.state.use('sign_in', pending.state, pending.exp))) {
|
|
2222
4080
|
return signedOutPage(400, SIGN_IN_AGAIN, [clearSignIn]);
|
|
2223
4081
|
}
|
|
2224
|
-
user =
|
|
4082
|
+
user =
|
|
4083
|
+
pending.idp === 'google'
|
|
4084
|
+
? google && typeof pending.nonce === 'string'
|
|
4085
|
+
? await askGoogle(google, code, verifier, pending.nonce)
|
|
4086
|
+
: null
|
|
4087
|
+
: await askGitHub(code, verifier);
|
|
2225
4088
|
}
|
|
2226
4089
|
finally {
|
|
2227
4090
|
signInsInFlight -= 1;
|
|
@@ -2234,12 +4097,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2234
4097
|
}
|
|
2235
4098
|
}
|
|
2236
4099
|
if (!user) {
|
|
2237
|
-
return signedOutPage(502, 'GitHub did not complete the sign-in. Try again
|
|
4100
|
+
return signedOutPage(502, `${pending.idp === 'google' ? 'Google' : 'GitHub'} did not complete the sign-in. Try again.`, [clearSignIn]);
|
|
2238
4101
|
}
|
|
2239
|
-
if (!allowed(user.uid)) {
|
|
2240
|
-
return signedOutPage(403, `This Slotlock server does not let
|
|
2241
|
-
clearSignIn,
|
|
2242
|
-
]);
|
|
4102
|
+
if (!allowed(user.uid, user.idp)) {
|
|
4103
|
+
return signedOutPage(403, `This Slotlock server does not let ${user.idp === 'google' ? user.login : `@${user.login}`} sign in.`, [clearSignIn]);
|
|
2243
4104
|
}
|
|
2244
4105
|
const orgs = user.orgs ?? [];
|
|
2245
4106
|
if (served && orgs.length === 0) {
|
|
@@ -2250,7 +4111,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2250
4111
|
login: user.login,
|
|
2251
4112
|
sid: randomBytes(24).toString('base64url'),
|
|
2252
4113
|
exp: now() + SESSION_TTL_MS,
|
|
2253
|
-
...(
|
|
4114
|
+
...(user.idp === 'google' ? { idp: 'google' } : {}),
|
|
4115
|
+
...(served ? { orgs, org: orgs[0].id } : teams && user.idp !== 'google' ? { orgs } : {}),
|
|
2254
4116
|
});
|
|
2255
4117
|
return redirect(303, root, [
|
|
2256
4118
|
clearSignIn,
|
|
@@ -2290,6 +4152,25 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2290
4152
|
return keysPage(session, 400, 'Choose an access level and an expiry from the lists.');
|
|
2291
4153
|
}
|
|
2292
4154
|
const days = KEY_EXPIRY_DAYS[expiry];
|
|
4155
|
+
const limited = {
|
|
4156
|
+
locations: [...new Set(form.getAll('limit_location'))],
|
|
4157
|
+
groups: [...new Set(form.getAll('limit_group'))],
|
|
4158
|
+
};
|
|
4159
|
+
const resourceScope = limited.locations.length + limited.groups.length > 0 ? limited : undefined;
|
|
4160
|
+
const limits = keyLimitsOf(form);
|
|
4161
|
+
if (typeof limits === 'string')
|
|
4162
|
+
return keysPage(session, 400, limits);
|
|
4163
|
+
if (resourceScope) {
|
|
4164
|
+
if (scopes.includes('admin')) {
|
|
4165
|
+
return keysPage(session, 400, 'A key limited to locations or groups reads and writes only: choose that access.');
|
|
4166
|
+
}
|
|
4167
|
+
// Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
|
|
4168
|
+
// plans allows them.
|
|
4169
|
+
const plan = plans ? await plans.plan(tenantOf(session)) : null;
|
|
4170
|
+
if (plan?.plan === 'free') {
|
|
4171
|
+
return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Usage.');
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
2293
4174
|
try {
|
|
2294
4175
|
const created = await options.keys.create({
|
|
2295
4176
|
tenantRef: tenantOf(session),
|
|
@@ -2297,6 +4178,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2297
4178
|
scopes,
|
|
2298
4179
|
createdBy: personOf(session),
|
|
2299
4180
|
origin: 'dashboard',
|
|
4181
|
+
...(resourceScope ? { resourceScope } : {}),
|
|
4182
|
+
...(Object.keys(limits).length > 0 ? { limits } : {}),
|
|
2300
4183
|
...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
|
|
2301
4184
|
});
|
|
2302
4185
|
return shownOncePage(session, created.key, created.apiKey, false);
|
|
@@ -2306,8 +4189,13 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2306
4189
|
if (code === 'invalid_api_key_name') {
|
|
2307
4190
|
return keysPage(session, 400, 'Give the key a name of 1 to 100 characters.');
|
|
2308
4191
|
}
|
|
4192
|
+
if (code === 'invalid_api_key_resource_scope') {
|
|
4193
|
+
return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
|
|
4194
|
+
}
|
|
2309
4195
|
if (code === 'api_key_limit_reached') {
|
|
2310
|
-
return keysPage(session, 409,
|
|
4196
|
+
return keysPage(session, 409, plans
|
|
4197
|
+
? 'You have as many active keys as your plan allows. Revoke one first, or see Usage.'
|
|
4198
|
+
: 'You have as many keys as Slotlock allows. Revoke one first.');
|
|
2311
4199
|
}
|
|
2312
4200
|
throw error;
|
|
2313
4201
|
}
|
|
@@ -2318,6 +4206,40 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2318
4206
|
actor: personOf(session),
|
|
2319
4207
|
origin: 'dashboard',
|
|
2320
4208
|
});
|
|
4209
|
+
/** A key's limits from its form (RFC 0004 A5): each empty field the server's; else what is wrong. */
|
|
4210
|
+
function keyLimitsOf(form) {
|
|
4211
|
+
const whole = (name, most) => {
|
|
4212
|
+
const text = (form.get(name) ?? '').trim();
|
|
4213
|
+
if (text === '')
|
|
4214
|
+
return undefined;
|
|
4215
|
+
const value = /^[0-9]{1,7}$/.test(text) ? Number(text) : Number.NaN;
|
|
4216
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= most ? value : null;
|
|
4217
|
+
};
|
|
4218
|
+
const maxHolds = whole('max_holds', 10_000);
|
|
4219
|
+
const maxHeldHours = whole('max_held_hours', 876_000);
|
|
4220
|
+
const requestsPerMinute = whole('requests_per_minute', 100_000);
|
|
4221
|
+
if (maxHolds === null || maxHeldHours === null || requestsPerMinute === null) {
|
|
4222
|
+
return 'Give each limit as a whole number: live holds 1 to 10,000, hours held 1 to 876,000, requests a minute 1 to 100,000. Leave one empty for the server’s.';
|
|
4223
|
+
}
|
|
4224
|
+
return {
|
|
4225
|
+
...(maxHolds !== undefined ? { maxHolds } : {}),
|
|
4226
|
+
...(maxHeldHours !== undefined ? { maxHeldHours } : {}),
|
|
4227
|
+
...(requestsPerMinute !== undefined ? { requestsPerMinute } : {}),
|
|
4228
|
+
...(form.get('requires_approval') === 'on' ? { requiresApproval: true } : {}),
|
|
4229
|
+
};
|
|
4230
|
+
}
|
|
4231
|
+
async function saveKeyLimits(session, form) {
|
|
4232
|
+
const id = form.get('id') ?? '';
|
|
4233
|
+
if (!UUID_PATTERN.test(id))
|
|
4234
|
+
return keysPage(session, 404, 'That key is not one of yours.');
|
|
4235
|
+
const limits = keyLimitsOf(form);
|
|
4236
|
+
if (typeof limits === 'string')
|
|
4237
|
+
return keysPage(session, 400, limits);
|
|
4238
|
+
if (!(await options.keys.setLimits({ tenantRef: tenantOf(session), id, limits }))) {
|
|
4239
|
+
return keysPage(session, 404, 'That key is not an active key of yours.');
|
|
4240
|
+
}
|
|
4241
|
+
return redirect(303, `${root}/keys`);
|
|
4242
|
+
}
|
|
2321
4243
|
async function changeKey(session, form, change) {
|
|
2322
4244
|
const id = form.get('id') ?? '';
|
|
2323
4245
|
if (!UUID_PATTERN.test(id))
|
|
@@ -2345,7 +4267,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2345
4267
|
catch (error) {
|
|
2346
4268
|
const code = error.code;
|
|
2347
4269
|
if (code === 'resource_limit_reached') {
|
|
2348
|
-
|
|
4270
|
+
const limit = error.limit;
|
|
4271
|
+
return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
|
|
4272
|
+
? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Usage.`
|
|
4273
|
+
: `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
|
|
2349
4274
|
}
|
|
2350
4275
|
if (code === 'invalid_timezone') {
|
|
2351
4276
|
return resourcesPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
|
|
@@ -2365,7 +4290,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2365
4290
|
const session = await readSession(request);
|
|
2366
4291
|
if (!session)
|
|
2367
4292
|
return redirect(303, root);
|
|
2368
|
-
const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
|
|
4293
|
+
const form = await readForm(request, route === '/assistant' || route === '/assistant/stream' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
|
|
2369
4294
|
if (form === 'too_large') {
|
|
2370
4295
|
return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
|
|
2371
4296
|
}
|
|
@@ -2395,7 +4320,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2395
4320
|
try {
|
|
2396
4321
|
first =
|
|
2397
4322
|
checked === 'ok' &&
|
|
2398
|
-
(await options.state.use('form', once, session.exp, session.uid));
|
|
4323
|
+
(await options.state.use('form', once, session.exp, session.idp === 'google' ? personOf(session) : session.uid));
|
|
2399
4324
|
}
|
|
2400
4325
|
catch (error) {
|
|
2401
4326
|
if (error.code !== 'dashboard_form_limit')
|
|
@@ -2403,9 +4328,16 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2403
4328
|
return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
|
|
2404
4329
|
}
|
|
2405
4330
|
if (!first) {
|
|
4331
|
+
// The first one was taken: a second click, a reload or a resend shows what it did.
|
|
4332
|
+
if (route.startsWith('/assistant'))
|
|
4333
|
+
return redirect(303, `${root}/assistant#latest`);
|
|
2406
4334
|
return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
|
|
2407
4335
|
}
|
|
2408
4336
|
}
|
|
4337
|
+
// A tenant new to plans is put on the default plan before it adds what plans limit.
|
|
4338
|
+
if (route === '/keys' || route === '/resources' || route === '/webhooks') {
|
|
4339
|
+
await plans?.enrol?.(tenantOf(session));
|
|
4340
|
+
}
|
|
2409
4341
|
switch (route) {
|
|
2410
4342
|
case '/sign-out':
|
|
2411
4343
|
await options.state.endSession(session.sid, session.exp);
|
|
@@ -2416,16 +4348,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2416
4348
|
return changeKey(session, form, 'rotate');
|
|
2417
4349
|
case '/keys/revoke':
|
|
2418
4350
|
return changeKey(session, form, 'revoke');
|
|
4351
|
+
case '/keys/limits':
|
|
4352
|
+
return saveKeyLimits(session, form);
|
|
2419
4353
|
case '/resources':
|
|
2420
4354
|
return addResource(session, form);
|
|
2421
4355
|
case '/hours':
|
|
2422
4356
|
return saveHours(session, form);
|
|
2423
4357
|
case '/organization':
|
|
2424
4358
|
return switchOrganization(session, form);
|
|
4359
|
+
case '/environment':
|
|
4360
|
+
return switchEnvironment(session, form);
|
|
2425
4361
|
case '/resources/rules':
|
|
2426
4362
|
return saveRules(session, form);
|
|
2427
4363
|
case '/resources/pool':
|
|
2428
4364
|
return savePool(session, form);
|
|
4365
|
+
case '/resources/profile':
|
|
4366
|
+
return saveProfile(session, form);
|
|
4367
|
+
case '/resources/place':
|
|
4368
|
+
return savePlace(session, form);
|
|
4369
|
+
case '/resources/parent':
|
|
4370
|
+
return saveParent(session, form);
|
|
4371
|
+
case '/locations':
|
|
4372
|
+
return saveLocation(session, form);
|
|
4373
|
+
case '/locations/delete':
|
|
4374
|
+
return deleteGrouping(session, form, 'location');
|
|
4375
|
+
case '/groups':
|
|
4376
|
+
return saveGroup(session, form);
|
|
4377
|
+
case '/groups/delete':
|
|
4378
|
+
return deleteGrouping(session, form, 'group');
|
|
2429
4379
|
case '/webhooks':
|
|
2430
4380
|
return addWebhook(session, form);
|
|
2431
4381
|
case '/webhooks/delete':
|
|
@@ -2434,8 +4384,18 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2434
4384
|
return changeWebhook(session, form, 'enable');
|
|
2435
4385
|
case '/waitlist/leave':
|
|
2436
4386
|
return leaveWaitlist(session, form);
|
|
4387
|
+
case '/approvals/decide':
|
|
4388
|
+
return decideApproval(session, form);
|
|
4389
|
+
case '/sandbox/seed':
|
|
4390
|
+
return sandboxAction(session, form, 'seed');
|
|
4391
|
+
case '/sandbox/reset':
|
|
4392
|
+
return sandboxAction(session, form, 'reset');
|
|
4393
|
+
case '/sandbox/fast-forward':
|
|
4394
|
+
return sandboxAction(session, form, 'fast-forward');
|
|
2437
4395
|
case '/assistant':
|
|
2438
4396
|
return sendToAssistant(session, form);
|
|
4397
|
+
case '/assistant/stream':
|
|
4398
|
+
return streamToAssistant(session, form, request);
|
|
2439
4399
|
case '/assistant/approve':
|
|
2440
4400
|
return settleAction(session, form, 'approve');
|
|
2441
4401
|
case '/assistant/dismiss':
|
|
@@ -2444,10 +4404,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2444
4404
|
return resetAssistant(session);
|
|
2445
4405
|
case '/settings/assistant':
|
|
2446
4406
|
return saveAssistantSettings(session, form);
|
|
4407
|
+
case '/settings/assistant/models':
|
|
4408
|
+
return loadAssistantModels(session, form);
|
|
2447
4409
|
case '/settings/assistant/test':
|
|
2448
4410
|
return testAssistant(session);
|
|
2449
4411
|
case '/settings/assistant/remove':
|
|
2450
4412
|
return removeAssistantSettings(session);
|
|
4413
|
+
case '/plan/upgrade':
|
|
4414
|
+
return upgradePlan(session, form);
|
|
4415
|
+
case '/plan/manage':
|
|
4416
|
+
return manageBilling(session);
|
|
4417
|
+
case '/teams/enable':
|
|
4418
|
+
return changeTeam(session, form, true);
|
|
4419
|
+
case '/teams/disable':
|
|
4420
|
+
return changeTeam(session, form, false);
|
|
2451
4421
|
default:
|
|
2452
4422
|
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
2453
4423
|
}
|
|
@@ -2471,11 +4441,24 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2471
4441
|
case '/resources':
|
|
2472
4442
|
case '/webhooks':
|
|
2473
4443
|
case '/waitlist':
|
|
4444
|
+
case '/approvals':
|
|
2474
4445
|
case '/assistant':
|
|
4446
|
+
case '/plan':
|
|
4447
|
+
case '/teams':
|
|
4448
|
+
case '/settings/endpoints':
|
|
4449
|
+
case '/settings/hours':
|
|
4450
|
+
case '/settings/account':
|
|
2475
4451
|
case '/settings': {
|
|
2476
4452
|
const session = await readSession(request);
|
|
2477
4453
|
if (!session)
|
|
2478
4454
|
return redirect(303, root);
|
|
4455
|
+
if (route === '/teams')
|
|
4456
|
+
return await teamsPage(session);
|
|
4457
|
+
if (route === '/plan') {
|
|
4458
|
+
return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
|
|
4459
|
+
? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
|
|
4460
|
+
: undefined, url.searchParams.get('checkout') === 'done');
|
|
4461
|
+
}
|
|
2479
4462
|
if (route === '/keys')
|
|
2480
4463
|
return await keysPage(session);
|
|
2481
4464
|
if (route === '/resources')
|
|
@@ -2484,22 +4467,40 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2484
4467
|
return await webhooksPage(session);
|
|
2485
4468
|
if (route === '/waitlist')
|
|
2486
4469
|
return await waitlistPage(session);
|
|
4470
|
+
if (route === '/approvals')
|
|
4471
|
+
return await approvalsPage(session);
|
|
2487
4472
|
if (route === '/assistant')
|
|
2488
4473
|
return await assistantPage(session);
|
|
2489
|
-
|
|
4474
|
+
if (route === '/settings/endpoints')
|
|
4475
|
+
return await settingsPage(session, 200, undefined, undefined, 'endpoints');
|
|
4476
|
+
if (route === '/settings/hours')
|
|
4477
|
+
return await settingsPage(session, 200, undefined, undefined, 'hours');
|
|
4478
|
+
if (route === '/settings/account')
|
|
4479
|
+
return await settingsPage(session, 200, undefined, undefined, 'account');
|
|
4480
|
+
// A provider preset fills the assistant's form; nothing is saved until Save.
|
|
4481
|
+
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
|
|
4482
|
+
return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
|
|
2490
4483
|
}
|
|
2491
4484
|
case '/style.css':
|
|
2492
|
-
return asset(
|
|
4485
|
+
return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
2493
4486
|
case '/app.js':
|
|
2494
|
-
return asset(
|
|
4487
|
+
return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
|
|
4488
|
+
case '/favicon.svg':
|
|
4489
|
+
return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
|
|
2495
4490
|
case '/sign-in':
|
|
2496
|
-
return
|
|
4491
|
+
return google && url.searchParams.get('with') === 'google'
|
|
4492
|
+
? await startGoogleSignIn(google)
|
|
4493
|
+
: await startSignIn();
|
|
2497
4494
|
case '/callback':
|
|
2498
4495
|
return await finishSignIn(request, url);
|
|
2499
4496
|
case '/hours': {
|
|
2500
4497
|
const session = await readSession(request);
|
|
2501
4498
|
return session ? await hoursEditor(session, url) : redirect(303, root);
|
|
2502
4499
|
}
|
|
4500
|
+
case '/locations': {
|
|
4501
|
+
const session = await readSession(request);
|
|
4502
|
+
return session ? await locationsPage(session) : redirect(303, root);
|
|
4503
|
+
}
|
|
2503
4504
|
default: {
|
|
2504
4505
|
const resourceId = RESOURCE_PAGE.exec(route)?.[1];
|
|
2505
4506
|
if (resourceId !== undefined) {
|
|
@@ -2644,6 +4645,12 @@ code, pre, kbd, input[readonly] { font-family: var(--mono); font-size: 12.5px; }
|
|
|
2644
4645
|
.workspace select { flex: 1; min-width: 0; height: 26px; border: 0; padding: 0 18px 0 2px; font-weight: 600; font-size: var(--text-md); background-color: transparent; box-shadow: none; text-overflow: ellipsis; }
|
|
2645
4646
|
.workspace select:focus { box-shadow: none; }
|
|
2646
4647
|
.workspace:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
|
|
4648
|
+
.environment { display: flex; gap: 2px; margin: calc(-1 * var(--space-2)) 0 0; padding: 2px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
|
4649
|
+
.environment button { flex: 1; height: 26px; padding: 0 var(--space-2); border: 0; border-radius: calc(var(--radius) - 2px); background: none; box-shadow: none; color: var(--muted); font-size: var(--text-sm); font-weight: 600; }
|
|
4650
|
+
.environment button[aria-pressed="true"] { background: var(--surface-2); color: var(--ink); box-shadow: 0 0 0 1px var(--line); }
|
|
4651
|
+
.environment button[aria-pressed="true"][value="test"] { background: var(--warn-wash); color: var(--warn); box-shadow: 0 0 0 1px var(--warn-line); }
|
|
4652
|
+
.test-banner { margin: 0 0 var(--space-4); padding: var(--space-2) var(--space-3); border: 1px dashed var(--warn-line); border-radius: var(--radius); background: var(--warn-wash); color: var(--warn); font-size: var(--text-md); }
|
|
4653
|
+
.test-banner code { background: none; padding: 0; }
|
|
2647
4654
|
.avatar { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: var(--radius-sm); background: var(--ink); color: var(--surface); font-size: 11px; font-weight: 700; }
|
|
2648
4655
|
.nav { display: grid; gap: 1px; }
|
|
2649
4656
|
.nav a { position: relative; display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 10px; border-radius: var(--radius); color: var(--ink-2); font-weight: 500; font-size: var(--text-md); transition: background-color var(--fast) var(--ease), color var(--fast) var(--ease); }
|
|
@@ -2681,13 +4688,44 @@ a.stat:hover { background: var(--surface-2); text-decoration: none; }
|
|
|
2681
4688
|
a.stat::after { content: ""; position: absolute; top: 18px; right: 18px; width: 6px; height: 6px; border-top: 1.5px solid var(--ink-3); border-right: 1.5px solid var(--ink-3); transform: rotate(45deg); opacity: 0; transition: opacity var(--fast) var(--ease); }
|
|
2682
4689
|
a.stat:hover::after, a.stat:focus-visible::after { opacity: 1; }
|
|
2683
4690
|
a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
|
|
2684
|
-
.stat-label { font:
|
|
4691
|
+
.stat-label { font-size: var(--text-sm); line-height: 1.4; color: var(--ink-3); }
|
|
4692
|
+
.attention-stat .stat-value { color: var(--accent); }
|
|
2685
4693
|
.stat-value { font-size: var(--text-2xl); line-height: 1.1; font-weight: 500; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
|
|
2686
4694
|
|
|
2687
|
-
/*
|
|
2688
|
-
.
|
|
2689
|
-
.
|
|
2690
|
-
.
|
|
4695
|
+
/* Overview: what needs a person, getting started, the side column */
|
|
4696
|
+
.column { display: grid; gap: var(--space-5); align-content: start; min-width: 0; }
|
|
4697
|
+
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
|
|
4698
|
+
.panel-head h2 { margin: 0; }
|
|
4699
|
+
.panel-head .meta { flex: none; }
|
|
4700
|
+
.attention-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
|
|
4701
|
+
.attention { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: var(--text-md); color: var(--ink); box-shadow: inset 3px 0 0 var(--ink-3); }
|
|
4702
|
+
.attention.bad { border-color: var(--bad-line); background: var(--bad-wash); box-shadow: inset 3px 0 0 var(--bad); }
|
|
4703
|
+
.attention.warn { border-color: var(--warn-line); background: var(--warn-wash); box-shadow: inset 3px 0 0 var(--warn); }
|
|
4704
|
+
.attention.info { box-shadow: inset 3px 0 0 var(--accent); }
|
|
4705
|
+
.attention-text { flex: 1; min-width: 0; }
|
|
4706
|
+
.attention-action { flex: none; font-weight: 600; white-space: nowrap; }
|
|
4707
|
+
.segments { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; }
|
|
4708
|
+
.segments span { height: 4px; border-radius: var(--radius-full); background: var(--sunken); }
|
|
4709
|
+
.segments span.done { background: var(--good); }
|
|
4710
|
+
.setup-card .segments { margin-bottom: var(--space-3); }
|
|
4711
|
+
.setup-steps { list-style: none; margin: 0; padding: 0; display: grid; }
|
|
4712
|
+
.setup-steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 44px; border-top: 1px solid var(--line-soft); color: var(--ink-3); }
|
|
4713
|
+
.setup-steps li:first-child { border-top: 0; }
|
|
4714
|
+
.setup-steps li.next { color: var(--ink); font-weight: 500; }
|
|
4715
|
+
.setup-steps li.done .step-title { text-decoration: line-through; text-decoration-color: var(--line-strong); }
|
|
4716
|
+
.setup-steps .step-title { flex: 1; min-width: 0; }
|
|
4717
|
+
.setup-row { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); color: var(--ink-2); font-size: var(--text-md); }
|
|
4718
|
+
.setup-row:hover { background: var(--surface-2); text-decoration: none; }
|
|
4719
|
+
.setup-row .segments { width: 96px; flex: none; }
|
|
4720
|
+
.plan-card .panel-head h2 { font-size: var(--text-lg); }
|
|
4721
|
+
.meters.stacked { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); margin: 0 0 var(--space-4); }
|
|
4722
|
+
.plan-card > .button { width: 100%; justify-content: center; }
|
|
4723
|
+
details.command { margin-top: var(--space-4); }
|
|
4724
|
+
details.command > summary { cursor: pointer; font-size: var(--text-md); color: var(--ink-2); }
|
|
4725
|
+
details.command[open] > summary { margin-bottom: var(--space-2); }
|
|
4726
|
+
.copy-block { position: relative; }
|
|
4727
|
+
.copy-block pre { padding-right: 72px; font-size: var(--text-sm); }
|
|
4728
|
+
.copy-block button { position: absolute; top: 6px; right: 6px; }
|
|
2691
4729
|
.check { display: inline-grid; place-items: center; width: 20px; height: 20px; flex: none; border-radius: var(--radius-full); border: 1.5px dashed var(--line-strong); color: var(--surface); }
|
|
2692
4730
|
.done .check { background: var(--good); border: 0; }
|
|
2693
4731
|
.check .icon { width: 12px; height: 12px; stroke-width: 3; }
|
|
@@ -2705,7 +4743,11 @@ a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
|
|
|
2705
4743
|
.on { color: var(--ink-3); font-weight: 400; }
|
|
2706
4744
|
.meta { font-size: var(--text-sm); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2707
4745
|
.feed { position: relative; }
|
|
2708
|
-
.event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr); gap: var(--space-3); align-items: start; padding: 7px 0; }
|
|
4746
|
+
.event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; padding: 7px 0; }
|
|
4747
|
+
.event .title { font-weight: 400; color: var(--ink-2); white-space: normal; overflow-wrap: anywhere; }
|
|
4748
|
+
.event .title strong { font-weight: 600; color: var(--ink); }
|
|
4749
|
+
.event .when { font-size: var(--text-sm); font-weight: 400; color: var(--ink-3); white-space: nowrap; padding-top: 1px; }
|
|
4750
|
+
.times { font-size: var(--text-sm); color: var(--ink-3); }
|
|
2709
4751
|
.event:not(:last-child)::before { content: ""; position: absolute; left: 5.5px; top: 22px; bottom: -8px; width: 1px; background: var(--line); }
|
|
2710
4752
|
.dot { position: relative; width: 12px; height: 12px; margin-top: 4px; border-radius: var(--radius-full); background: var(--surface); box-shadow: inset 0 0 0 3px var(--ink-3); }
|
|
2711
4753
|
.dot.good { box-shadow: inset 0 0 0 3px var(--good); }
|
|
@@ -2744,9 +4786,11 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
|
|
|
2744
4786
|
.create { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin: 0; }
|
|
2745
4787
|
.create .grow { flex: 1 1 220px; }
|
|
2746
4788
|
label > input, label > select, label > .unit-input { width: 100%; }
|
|
4789
|
+
label > textarea { width: 100%; padding: var(--space-2) var(--space-3); resize: vertical; }
|
|
2747
4790
|
label.choice > input { width: 16px; }
|
|
2748
4791
|
.create label:not(.grow) { flex: 0 1 auto; }
|
|
2749
4792
|
form > label { margin-bottom: var(--space-3); }
|
|
4793
|
+
.create > label { margin-bottom: 0; }
|
|
2750
4794
|
.grid-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4) var(--space-3); margin-bottom: var(--space-4); align-items: end; }
|
|
2751
4795
|
.unit-input { display: flex; align-items: center; gap: var(--space-2); }
|
|
2752
4796
|
.unit-input input { width: 100%; }
|
|
@@ -2761,6 +4805,12 @@ form .choice { margin: var(--space-1) 0 var(--space-3); }
|
|
|
2761
4805
|
.types summary { cursor: pointer; color: var(--ink-2); font-size: var(--text-md); width: fit-content; }
|
|
2762
4806
|
.checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-2) var(--space-4); margin-top: var(--space-3); padding: var(--space-4); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-2); }
|
|
2763
4807
|
.checks .choice { font-size: var(--text-md); margin: 0; }
|
|
4808
|
+
.choices { border: 0; padding: 0; margin: var(--space-4) 0; min-width: 0; }
|
|
4809
|
+
.choices legend { font-size: var(--text-sm); font-weight: 500; color: var(--ink-2); padding: 0; }
|
|
4810
|
+
.choices .checks { margin-top: 6px; }
|
|
4811
|
+
.fine + .table, .fine + .empty { margin-top: var(--space-4); }
|
|
4812
|
+
.pop label { margin-bottom: var(--space-3); }
|
|
4813
|
+
.pop textarea { width: 100%; }
|
|
2764
4814
|
|
|
2765
4815
|
/* Buttons */
|
|
2766
4816
|
button, .button { font: inherit; font-size: var(--text-md); font-weight: 500; height: 36px; padding: 0 var(--space-4); border-radius: var(--radius); border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); white-space: nowrap; transition: background-color var(--fast) var(--ease), border-color var(--fast) var(--ease), color var(--fast) var(--ease), transform var(--fast) var(--ease); }
|
|
@@ -2826,6 +4876,22 @@ form > .table { margin-bottom: var(--space-3); }
|
|
|
2826
4876
|
/* Notices and empty states */
|
|
2827
4877
|
.notice { display: block; border: 1px solid var(--line); border-radius: var(--radius); padding: var(--space-3) var(--space-4); background: var(--surface); color: var(--ink); margin: 0; font-size: var(--text-md); }
|
|
2828
4878
|
.notice[role="alert"] { border-color: var(--bad-line); background: var(--bad-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--bad); }
|
|
4879
|
+
.meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-5) var(--space-6); margin: var(--space-4) 0; }
|
|
4880
|
+
.meter { display: grid; gap: 6px; min-width: 0; }
|
|
4881
|
+
.meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }
|
|
4882
|
+
.meter-value { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
4883
|
+
.meter progress { appearance: none; -webkit-appearance: none; width: 100%; height: 6px; border: 0; border-radius: var(--radius-full); background: var(--sunken); overflow: hidden; }
|
|
4884
|
+
.meter progress::-webkit-progress-bar { background: var(--sunken); border-radius: var(--radius-full); }
|
|
4885
|
+
.meter progress::-webkit-progress-value { background: var(--ink-2); border-radius: var(--radius-full); }
|
|
4886
|
+
.meter progress::-moz-progress-bar { background: var(--ink-2); border-radius: var(--radius-full); }
|
|
4887
|
+
.meter.warn progress::-webkit-progress-value { background: var(--warn); }
|
|
4888
|
+
.meter.warn progress::-moz-progress-bar { background: var(--warn); }
|
|
4889
|
+
.meter.bad progress::-webkit-progress-value { background: var(--bad); }
|
|
4890
|
+
.meter.bad progress::-moz-progress-bar { background: var(--bad); }
|
|
4891
|
+
.meter.bad .meter-value { color: var(--bad); }
|
|
4892
|
+
.plan-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
|
|
4893
|
+
.plan-head h2 { margin: 0; }
|
|
4894
|
+
.notice.done { border-color: color-mix(in srgb, var(--good) 35%, var(--line)); background: var(--good-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--good); }
|
|
2829
4895
|
.notice.warn, .notice.warn[role="alert"] { border-color: var(--warn-line); background: var(--warn-wash); color: var(--warn); box-shadow: inset 3px 0 0 var(--warn); }
|
|
2830
4896
|
.notice p { color: inherit; margin: 0 0 var(--space-2); }
|
|
2831
4897
|
.empty { color: var(--ink-3); margin: 0; }
|
|
@@ -2862,23 +4928,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2862
4928
|
.bare-main > .notice { max-width: 480px; }
|
|
2863
4929
|
|
|
2864
4930
|
/* Assistant */
|
|
2865
|
-
.chat { display: grid; gap: var(--space-5); }
|
|
2866
|
-
.thread { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
|
|
2867
|
-
.msg { max-width: 78%; padding: 10px 14px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line); }
|
|
2868
|
-
.msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
2869
|
-
.msg.mine { justify-self: end; background: var(--button); border-color: var(--button); border-bottom-right-radius: 4px; }
|
|
2870
|
-
.msg.mine p { color: var(--button-ink); }
|
|
2871
|
-
.msg.bot { justify-self: start; border-bottom-left-radius: 4px; }
|
|
2872
|
-
.tool-line { font: 400 var(--text-sm)/1.5 var(--mono); color: var(--ink-3); padding-left: var(--space-1); }
|
|
2873
|
-
.thread > .notice { justify-self: start; }
|
|
2874
|
-
.action-card { justify-self: start; width: min(100%, 540px); padding: var(--space-4) var(--space-5); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--pop-shadow); display: grid; gap: var(--space-3); }
|
|
2875
|
-
.action-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
|
2876
|
-
.action-card .facts { font-size: var(--text-md); gap: 6px var(--space-4); }
|
|
2877
|
-
.action-card .facts dt::first-letter { text-transform: uppercase; }
|
|
2878
|
-
.action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: var(--space-2); font-size: 12px; }
|
|
2879
|
-
.action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
|
|
2880
|
-
.action-card .action-buttons { margin-top: var(--space-1); }
|
|
2881
|
-
.composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: end; margin: 0; padding-top: var(--space-4); border-top: 1px solid var(--line-soft); }
|
|
2882
4931
|
|
|
2883
4932
|
@media (max-width: 1080px) {
|
|
2884
4933
|
.split { grid-template-columns: minmax(0, 1fr); }
|
|
@@ -2891,6 +4940,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2891
4940
|
.side-top .brand .name { display: none; }
|
|
2892
4941
|
.workspace { flex: 1; min-width: 0; padding: 3px 4px; }
|
|
2893
4942
|
.workspace .avatar { display: none; }
|
|
4943
|
+
.environment { margin: 0; flex: none; }
|
|
2894
4944
|
.me { grid-column: 2; grid-row: 1; margin: 0; padding: 0; border: 0; }
|
|
2895
4945
|
.me-avatar, .me .login { display: none; }
|
|
2896
4946
|
.sign-out { width: 36px; height: 36px; padding: 0; }
|
|
@@ -2907,6 +4957,8 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2907
4957
|
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
2908
4958
|
.stat { padding: 14px var(--space-4); box-shadow: -1px 0 0 var(--line), 0 -1px 0 var(--line); }
|
|
2909
4959
|
.stat-value { font-size: 22px; }
|
|
4960
|
+
.attention { flex-wrap: wrap; gap: var(--space-1) var(--space-4); }
|
|
4961
|
+
.setup-row .segments { width: 56px; }
|
|
2910
4962
|
.create > * { flex: 1 1 100%; }
|
|
2911
4963
|
.create button { width: 100%; }
|
|
2912
4964
|
.endpoints { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
|
|
@@ -2929,13 +4981,12 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2929
4981
|
.week th[scope="row"] { display: block; padding: var(--space-2) var(--space-4) 0; }
|
|
2930
4982
|
.actions { text-align: left; width: auto; padding-top: var(--space-2); }
|
|
2931
4983
|
.act { margin: 0 var(--space-2) 0 0; }
|
|
4984
|
+
.actions form.inline { margin-right: var(--space-2); }
|
|
2932
4985
|
.act:first-child, form.inline + .act { margin-left: 0; }
|
|
2933
4986
|
.act summary { border: 1px solid var(--line-strong); height: 32px; }
|
|
2934
4987
|
.act.danger summary { border-color: var(--bad-line); }
|
|
2935
4988
|
.pop { left: 0; right: auto; width: min(300px, calc(100vw - 48px)); transform-origin: top left; }
|
|
2936
4989
|
.table .pop { margin-left: 0; }
|
|
2937
|
-
.msg { max-width: 92%; }
|
|
2938
|
-
.composer { grid-template-columns: minmax(0, 1fr); }
|
|
2939
4990
|
.empty-state { padding: var(--space-8) var(--space-4); }
|
|
2940
4991
|
.bare-main { padding-top: 6vh; }
|
|
2941
4992
|
.sign-in { padding: var(--space-6); }
|
|
@@ -2947,7 +4998,22 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2947
4998
|
.pill, .chip, .nav a[aria-current="page"] { border: 1px solid CanvasText; }
|
|
2948
4999
|
}
|
|
2949
5000
|
`;
|
|
5001
|
+
/** A composite the store refused, in a person's words. */
|
|
5002
|
+
const COMPOSITE_PROBLEMS = Object.freeze({
|
|
5003
|
+
parent_not_found: 'That is not one of your resources.',
|
|
5004
|
+
composite_too_deep: 'A part has no parts of its own, and a whole is no part: choose a resource that stands alone.',
|
|
5005
|
+
composite_parts_limit: 'A whole has at most 50 parts.',
|
|
5006
|
+
composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
|
|
5007
|
+
});
|
|
5008
|
+
/** What the Usage page calls each plan. */
|
|
5009
|
+
const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
|
|
5010
|
+
const DASHBOARD_ASSETS = Object.freeze({
|
|
5011
|
+
css: DASHBOARD_CSS + SLOTLOCK_ASSISTANT_CSS + SLOTLOCK_SETTINGS_CSS,
|
|
5012
|
+
// The chat's script renders a reply with the server's own Markdown renderer, embedded.
|
|
5013
|
+
js: DASHBOARD_JS + slotlockAssistantScript(renderSlotlockMarkdown.toString()),
|
|
5014
|
+
});
|
|
2950
5015
|
const ASSET_VERSIONS = Object.freeze({
|
|
2951
|
-
css: createHash('sha256').update(
|
|
2952
|
-
js: createHash('sha256').update(
|
|
5016
|
+
css: createHash('sha256').update(DASHBOARD_ASSETS.css).digest('base64url').slice(0, 12),
|
|
5017
|
+
js: createHash('sha256').update(DASHBOARD_ASSETS.js).digest('base64url').slice(0, 12),
|
|
5018
|
+
icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
|
|
2953
5019
|
});
|