slotlock 0.6.2 → 0.7.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 +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- 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-version.js +2 -4
- 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 +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
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,27 @@ 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_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
|
|
29
|
+
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';
|
|
30
|
+
import { slotlockQuotaPeriod } from './quotas.js';
|
|
31
|
+
import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
|
|
26
32
|
import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
|
|
33
|
+
import { SlotlockAgentOperationError } from './agent-server.js';
|
|
34
|
+
import { runSlotlockApprovalGrant } from './agent-store-backend.js';
|
|
35
|
+
import { SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
27
36
|
import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
|
|
28
37
|
/** Resources one dashboard tenant may hold. */
|
|
29
|
-
export const SLOTLOCK_DASHBOARD_MAX_RESOURCES =
|
|
38
|
+
export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
|
|
30
39
|
const SESSION_TTL_MS = 12 * 3_600_000;
|
|
31
40
|
const SIGN_IN_TTL_MS = 10 * 60_000;
|
|
41
|
+
/**
|
|
42
|
+
* How recent a sign-in must be to turn an organization on or off: who owns it is read from GitHub
|
|
43
|
+
* as one signs in, so a change of owners reaches the dashboard by signing in again.
|
|
44
|
+
*/
|
|
45
|
+
const FRESH_SIGN_IN_MS = 15 * 60_000;
|
|
46
|
+
/** How long Google's signing keys are kept when its answer does not say (Cache-Control max-age). */
|
|
47
|
+
const GOOGLE_KEYS_DEFAULT_TTL_MS = 3_600_000;
|
|
32
48
|
const MAX_FORM_BYTES = 8_192;
|
|
33
49
|
/** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
|
|
34
50
|
const MAX_ASSISTANT_FORM_BYTES = 24_576;
|
|
@@ -48,27 +64,47 @@ const POST_ROUTES = new Set([
|
|
|
48
64
|
'/keys',
|
|
49
65
|
'/keys/rotate',
|
|
50
66
|
'/keys/revoke',
|
|
67
|
+
'/keys/limits',
|
|
51
68
|
'/resources',
|
|
52
69
|
'/resources/rules',
|
|
53
70
|
'/resources/pool',
|
|
71
|
+
'/resources/profile',
|
|
72
|
+
'/resources/place',
|
|
73
|
+
'/resources/parent',
|
|
74
|
+
'/locations',
|
|
75
|
+
'/locations/delete',
|
|
76
|
+
'/groups',
|
|
77
|
+
'/groups/delete',
|
|
54
78
|
'/webhooks',
|
|
55
79
|
'/webhooks/delete',
|
|
56
80
|
'/webhooks/enable',
|
|
57
81
|
'/waitlist/leave',
|
|
82
|
+
'/approvals/decide',
|
|
83
|
+
'/sandbox/seed',
|
|
84
|
+
'/sandbox/reset',
|
|
85
|
+
'/sandbox/fast-forward',
|
|
58
86
|
'/hours',
|
|
59
87
|
'/organization',
|
|
88
|
+
'/environment',
|
|
89
|
+
'/teams/enable',
|
|
90
|
+
'/teams/disable',
|
|
60
91
|
'/assistant',
|
|
61
92
|
'/assistant/approve',
|
|
62
93
|
'/assistant/dismiss',
|
|
63
94
|
'/assistant/reset',
|
|
64
95
|
'/settings/assistant',
|
|
65
96
|
'/settings/assistant/test',
|
|
97
|
+
'/settings/assistant/models',
|
|
66
98
|
'/settings/assistant/remove',
|
|
99
|
+
'/plan/upgrade',
|
|
100
|
+
'/plan/manage',
|
|
67
101
|
]);
|
|
68
102
|
const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
|
|
69
103
|
const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
|
|
70
104
|
const MAX_COOKIE_CHARACTERS = 4_096;
|
|
71
105
|
const GITHUB_TIMEOUT_MS = 10_000;
|
|
106
|
+
/** A Google account's subject: ASCII, at most 255 characters (OpenID Connect); Google's are digits. */
|
|
107
|
+
const GOOGLE_SUBJECT = /^[A-Za-z0-9._-]{1,255}$/;
|
|
72
108
|
/** The REST API version GitHub documents for GET /user (docs.github.com, read 2026-10-10). */
|
|
73
109
|
const GITHUB_API_VERSION = '2026-03-10';
|
|
74
110
|
const USER_AGENT = 'slotlock-dashboard';
|
|
@@ -97,15 +133,84 @@ const KEY_EXPIRY_DAYS = Object.freeze({
|
|
|
97
133
|
*/
|
|
98
134
|
const REFERRER_POLICY = 'same-origin';
|
|
99
135
|
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'";
|
|
136
|
+
/** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
|
|
137
|
+
function policyFor(billing) {
|
|
138
|
+
if (!billing)
|
|
139
|
+
return CONTENT_SECURITY_POLICY;
|
|
140
|
+
const contact = new URL(billing.contact);
|
|
141
|
+
if (contact.protocol !== 'https:' && contact.protocol !== 'mailto:') {
|
|
142
|
+
throw new Error('billing.contact must be an https or mailto URL');
|
|
143
|
+
}
|
|
144
|
+
const origins = billing.formOrigins.map((value) => {
|
|
145
|
+
const parsed = new URL(value);
|
|
146
|
+
if (parsed.protocol !== 'https:' || parsed.origin !== value.replace(/\/$/, '')) {
|
|
147
|
+
throw new Error(`billing.formOrigins must be https origins: ${value}`);
|
|
148
|
+
}
|
|
149
|
+
return parsed.origin;
|
|
150
|
+
});
|
|
151
|
+
return CONTENT_SECURITY_POLICY.replace("form-action 'self'", ["form-action 'self'", ...origins].join(' '));
|
|
152
|
+
}
|
|
100
153
|
/**
|
|
101
154
|
* The dashboard's resources, in the store under each tenant's row-level security. Adds hold the cap
|
|
102
155
|
* in the database (a READ COMMITTED transaction, under a per-tenant lock).
|
|
103
156
|
*/
|
|
104
|
-
export function createSlotlockDashboardResources(store) {
|
|
157
|
+
export function createSlotlockDashboardResources(store, options = {}) {
|
|
105
158
|
// Every change here is the dashboard's, made by the person signed in (actor) when known.
|
|
106
159
|
const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
|
|
160
|
+
const agentBackend = options.agentBackend;
|
|
107
161
|
return {
|
|
162
|
+
sandbox: {
|
|
163
|
+
seed: (tenantRef, scenario) => seedSlotlockSandbox(store, tenantRef, scenario),
|
|
164
|
+
async reset(tenantRef) {
|
|
165
|
+
await resetSlotlockSandbox(store, tenantRef);
|
|
166
|
+
},
|
|
167
|
+
async fastForward(tenantRef, minutes) {
|
|
168
|
+
const forward = await fastForwardSlotlockSandbox(store, tenantRef, minutes);
|
|
169
|
+
return { holds: forward.holds, approvals: forward.approvals };
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
...(agentBackend
|
|
173
|
+
? {
|
|
174
|
+
approvals: {
|
|
175
|
+
list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listApprovals({ tenantRef, limit })),
|
|
176
|
+
async decide(tenantRef, id, decision, actor, reason) {
|
|
177
|
+
const decidedBy = actor ?? 'dashboard';
|
|
178
|
+
if (decision === 'decline') {
|
|
179
|
+
const declined = await store.withTenant(tenantRef, (tenant) => tenant.declineApproval({ tenantRef, id, decidedBy, ...(reason ? { reason } : {}) }), by(actor));
|
|
180
|
+
if (!declined.ok)
|
|
181
|
+
return declined;
|
|
182
|
+
// Its held time is free: offer it to the waitlist, as a release does.
|
|
183
|
+
await store
|
|
184
|
+
.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), { origin: 'maintenance' })
|
|
185
|
+
.catch(() => undefined);
|
|
186
|
+
return { ok: true };
|
|
187
|
+
}
|
|
188
|
+
const approval = await store.withTenant(tenantRef, (tenant) => tenant.getApproval({ tenantRef, id }));
|
|
189
|
+
if (!approval)
|
|
190
|
+
return { ok: false, code: 'approval_not_found' };
|
|
191
|
+
if (approval.status !== 'pending')
|
|
192
|
+
return { ok: false, code: 'approval_not_pending' };
|
|
193
|
+
try {
|
|
194
|
+
await runSlotlockApprovalGrant(agentBackend, approval, {
|
|
195
|
+
tenantRef,
|
|
196
|
+
decidedBy,
|
|
197
|
+
signal: new AbortController().signal,
|
|
198
|
+
origin: 'dashboard',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error instanceof SlotlockAgentOperationError)
|
|
203
|
+
return { ok: false, code: error.code };
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
return { ok: true };
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
: {}),
|
|
108
211
|
list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
|
|
212
|
+
count: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.countResources({ tenantRef })),
|
|
213
|
+
usage: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.readUsage({ tenantRef, from, to })),
|
|
109
214
|
add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
|
|
110
215
|
tenantRef,
|
|
111
216
|
externalRef,
|
|
@@ -123,6 +228,39 @@ export function createSlotlockDashboardResources(store) {
|
|
|
123
228
|
setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
|
|
124
229
|
ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
|
|
125
230
|
setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
|
|
231
|
+
setParent: (tenantRef, id, parentId, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceParent({ tenantRef, id, parentId }), by(actor)),
|
|
232
|
+
setProfile: (tenantRef, id, profile, actor) => store.withTenant(tenantRef, (tenant) => tenant.updateResourceProfile({ tenantRef, id, profile }), by(actor)),
|
|
233
|
+
grouping: {
|
|
234
|
+
locations: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listLocations({ tenantRef })),
|
|
235
|
+
putLocation: (tenantRef, location, actor) => store.withTenant(tenantRef, (tenant) => tenant.putLocation({ tenantRef, ...location }), by(actor)),
|
|
236
|
+
deleteLocation: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteLocation({ tenantRef, ref }), by(actor)),
|
|
237
|
+
groups: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listGroups({ tenantRef })),
|
|
238
|
+
putGroup: (tenantRef, group, actor) => store.withTenant(tenantRef, (tenant) => tenant.putGroup({ tenantRef, ...group }), by(actor)),
|
|
239
|
+
deleteGroup: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteGroup({ tenantRef, ref }), by(actor)),
|
|
240
|
+
place: (tenantRef, id, place, actor) => store.withTenant(tenantRef, async (tenant) => {
|
|
241
|
+
// The location first, which finds the resource (null when it is not the tenant's);
|
|
242
|
+
// then its groups, in the same transaction, so a refused group moves nothing.
|
|
243
|
+
const moved = await tenant.updateResourceProfile({
|
|
244
|
+
tenantRef,
|
|
245
|
+
id,
|
|
246
|
+
profile: {},
|
|
247
|
+
location: place.location,
|
|
248
|
+
});
|
|
249
|
+
if (!moved)
|
|
250
|
+
return null;
|
|
251
|
+
const wanted = new Set(place.groups);
|
|
252
|
+
const current = new Set(moved.groups ?? []);
|
|
253
|
+
for (const group of current) {
|
|
254
|
+
if (!wanted.has(group))
|
|
255
|
+
await tenant.removeGroupMember({ tenantRef, group, resourceId: id });
|
|
256
|
+
}
|
|
257
|
+
for (const group of wanted) {
|
|
258
|
+
if (!current.has(group))
|
|
259
|
+
await tenant.addGroupMember({ tenantRef, group, resourceId: id });
|
|
260
|
+
}
|
|
261
|
+
return tenant.getResource({ tenantRef, id });
|
|
262
|
+
}, by(actor)),
|
|
263
|
+
},
|
|
126
264
|
waitlist: {
|
|
127
265
|
list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
|
|
128
266
|
async leave(tenantRef, id, actor) {
|
|
@@ -308,6 +446,8 @@ const AGENDA_LIMIT = 200;
|
|
|
308
446
|
const AGENDA_SHOWN = 30;
|
|
309
447
|
const ACTIVITY_LIMIT = 20;
|
|
310
448
|
const WAITLIST_SHOWN = 200;
|
|
449
|
+
/** Approvals the dashboard lists, pending and recent decided together, newest first. */
|
|
450
|
+
const APPROVALS_SHOWN = 100;
|
|
311
451
|
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
452
|
/** How the booking rules form names each rule. */
|
|
313
453
|
const RULE_LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
|
|
@@ -378,12 +518,122 @@ const ICONS = Object.freeze({
|
|
|
378
518
|
waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
|
|
379
519
|
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
520
|
assistant: icon('<path d="M4 5h16v11H9l-5 4z"/><path d="M8 9h8M8 12h5"/>'),
|
|
521
|
+
plan: icon('<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h4"/>'),
|
|
522
|
+
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
523
|
settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
|
|
382
524
|
check: icon('<path d="M5 12l5 5L20 7"/>'),
|
|
525
|
+
approvals: icon('<circle cx="12" cy="12" r="8"/><path d="M8.5 12.5l2.5 2.5 4.5-5"/>'),
|
|
383
526
|
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
527
|
plus: icon('<path d="M12 5v14M5 12h14"/>'),
|
|
528
|
+
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
529
|
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
530
|
});
|
|
531
|
+
/** How the dashboard names each kind of resource. */
|
|
532
|
+
const KIND_NAMES = Object.freeze({
|
|
533
|
+
person: 'Person',
|
|
534
|
+
room: 'Room',
|
|
535
|
+
desk: 'Desk',
|
|
536
|
+
space: 'Space',
|
|
537
|
+
vehicle: 'Vehicle',
|
|
538
|
+
machine: 'Machine',
|
|
539
|
+
equipment: 'Equipment',
|
|
540
|
+
service: 'Service',
|
|
541
|
+
other: 'Not said',
|
|
542
|
+
});
|
|
543
|
+
/** What a profile field it could not keep must be, in a person's words. */
|
|
544
|
+
const PROFILE_PROBLEMS = Object.freeze({
|
|
545
|
+
name: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.`,
|
|
546
|
+
description: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
|
|
547
|
+
kind: 'Choose a kind from the list.',
|
|
548
|
+
capacity: `Capacity is a whole number from 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity.toLocaleString('en')}. Leave it empty for none.`,
|
|
549
|
+
labels: `Up to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.labels} labels, each 1 to 64 letters, digits, dots, dashes or underscores.`,
|
|
550
|
+
attributes: 'Those attributes are not ones Slotlock can keep.',
|
|
551
|
+
other: 'That profile is not one Slotlock can keep.',
|
|
552
|
+
});
|
|
553
|
+
const GROUPING_REF_RULE = 'A reference is 1 to 100 lowercase letters, digits, dots, dashes or underscores, starting with a letter or digit.';
|
|
554
|
+
/** What a person is told when a location or group change is refused, by the store's code. */
|
|
555
|
+
const GROUPING_PROBLEMS = Object.freeze({
|
|
556
|
+
location_ref: { status: 400, text: GROUPING_REF_RULE },
|
|
557
|
+
location_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
|
|
558
|
+
location_parent: { status: 400, text: 'Choose where it is from the list, or nowhere.' },
|
|
559
|
+
location_timezone: {
|
|
560
|
+
status: 400,
|
|
561
|
+
text: 'A time zone is a name such as Europe/London. Leave it empty to take the one of the location it is in.',
|
|
562
|
+
},
|
|
563
|
+
location_other: { status: 400, text: 'That location is not one Slotlock can keep.' },
|
|
564
|
+
location_not_found: { status: 404, text: 'That location is not one of yours.' },
|
|
565
|
+
location_too_deep: { status: 409, text: `Locations go ${SLOTLOCK_MAX_LOCATION_DEPTH} levels deep at most.` },
|
|
566
|
+
location_cycle: { status: 409, text: 'A location cannot be inside itself, or inside a location that is inside it.' },
|
|
567
|
+
location_limit_reached: {
|
|
568
|
+
status: 409,
|
|
569
|
+
text: `You have ${SLOTLOCK_MAX_LOCATIONS.toLocaleString('en')} locations, the most there can be.`,
|
|
570
|
+
},
|
|
571
|
+
location_in_use: {
|
|
572
|
+
status: 409,
|
|
573
|
+
text: 'Move its resources, and the locations inside it, somewhere else first.',
|
|
574
|
+
},
|
|
575
|
+
group_ref: { status: 400, text: GROUPING_REF_RULE },
|
|
576
|
+
group_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
|
|
577
|
+
group_description: {
|
|
578
|
+
status: 400,
|
|
579
|
+
text: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
|
|
580
|
+
},
|
|
581
|
+
group_other: { status: 400, text: 'That group is not one Slotlock can keep.' },
|
|
582
|
+
group_not_found: { status: 404, text: 'That group is not one of yours.' },
|
|
583
|
+
group_limit_reached: {
|
|
584
|
+
status: 409,
|
|
585
|
+
text: `You have ${SLOTLOCK_MAX_RESOURCE_GROUPS.toLocaleString('en')} groups, the most there can be.`,
|
|
586
|
+
},
|
|
587
|
+
resource_group_limit_reached: {
|
|
588
|
+
status: 409,
|
|
589
|
+
text: `A resource is in ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} groups at most.`,
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
/** The sentence for a refused location or group change, or null when it is not one. */
|
|
593
|
+
function groupingProblem(error) {
|
|
594
|
+
const code = error?.code;
|
|
595
|
+
if (code === 'invalid_location' || code === 'invalid_group') {
|
|
596
|
+
const kind = code === 'invalid_location' ? 'location' : 'group';
|
|
597
|
+
const field = error.field;
|
|
598
|
+
return GROUPING_PROBLEMS[`${kind}_${typeof field === 'string' ? field : 'other'}`] ?? GROUPING_PROBLEMS[`${kind}_other`] ?? null;
|
|
599
|
+
}
|
|
600
|
+
return typeof code === 'string' ? (GROUPING_PROBLEMS[code] ?? null) : null;
|
|
601
|
+
}
|
|
602
|
+
/** Each location's place from the top, as "London › Floor 3", by reference. */
|
|
603
|
+
function locationPaths(locations) {
|
|
604
|
+
const byRef = new Map(locations.map((location) => [location.ref, location]));
|
|
605
|
+
const paths = new Map();
|
|
606
|
+
for (const location of locations) {
|
|
607
|
+
const names = [];
|
|
608
|
+
let at = location;
|
|
609
|
+
for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
|
|
610
|
+
names.unshift(at.name);
|
|
611
|
+
at = at.parentRef === null ? undefined : byRef.get(at.parentRef);
|
|
612
|
+
}
|
|
613
|
+
paths.set(location.ref, names.join(' › '));
|
|
614
|
+
}
|
|
615
|
+
return paths;
|
|
616
|
+
}
|
|
617
|
+
/** Whether `ref` is `ancestor`, or a location inside it. */
|
|
618
|
+
function locationWithin(locations, ref, ancestor) {
|
|
619
|
+
let at = ref;
|
|
620
|
+
for (let steps = 0; at !== null && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
|
|
621
|
+
if (at === ancestor)
|
|
622
|
+
return true;
|
|
623
|
+
at = locations.get(at)?.parentRef ?? null;
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
/** The time zone a location gives a resource: its own, or the nearest one above it, and where from. */
|
|
628
|
+
function locationZone(locations, location) {
|
|
629
|
+
let at = location;
|
|
630
|
+
for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
|
|
631
|
+
if (at.timezone !== null)
|
|
632
|
+
return { timezone: at.timezone, from: at };
|
|
633
|
+
at = at.parentRef === null ? undefined : locations.get(at.parentRef);
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
387
637
|
/** How the overview names each change the feed records. */
|
|
388
638
|
const CHANGE_LABELS = Object.freeze({
|
|
389
639
|
'hold.created': { text: 'Hold placed', tone: 'neutral' },
|
|
@@ -404,7 +654,12 @@ const CHANGE_LABELS = Object.freeze({
|
|
|
404
654
|
'coverage.stale': { text: 'Calendar sync is behind', tone: 'bad' },
|
|
405
655
|
'waitlist.offered': { text: 'Waitlist offer sent', tone: 'good' },
|
|
406
656
|
'waitlist.expired': { text: 'Waitlist offer lapsed', tone: 'neutral' },
|
|
657
|
+
'approval.requested': { text: 'Waiting for approval', tone: 'neutral' },
|
|
658
|
+
'approval.granted': { text: 'Approved and booked', tone: 'good' },
|
|
659
|
+
'approval.declined': { text: 'Approval declined', tone: 'bad' },
|
|
660
|
+
'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
|
|
407
661
|
'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
|
|
662
|
+
'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
|
|
408
663
|
});
|
|
409
664
|
const zoned = (zone, parts) => {
|
|
410
665
|
try {
|
|
@@ -645,6 +900,11 @@ export function createSlotlockDashboard(options) {
|
|
|
645
900
|
const base = publicUrl.pathname.replace(/\/$/, '');
|
|
646
901
|
const root = `${base}/dashboard`;
|
|
647
902
|
const callbackUrl = `${origin}${root}/callback`;
|
|
903
|
+
const publicRoot = `${origin}${root}`;
|
|
904
|
+
const contentSecurityPolicy = policyFor(options.billing);
|
|
905
|
+
// Its cookies are Secure, but a first visit over http:// could still be answered by someone else:
|
|
906
|
+
// a browser that has seen this goes straight to https:// for a year. Not for a local http one.
|
|
907
|
+
const hsts = publicUrl.protocol === 'https:' ? { 'Strict-Transport-Security': 'max-age=31536000' } : {};
|
|
648
908
|
const service = new URL(options.serviceUrl ?? options.publicUrl);
|
|
649
909
|
const serviceRoot = `${service.origin}${service.pathname.replace(/\/$/, '')}`;
|
|
650
910
|
const mcpUrl = `${serviceRoot}/mcp`;
|
|
@@ -654,14 +914,31 @@ export function createSlotlockDashboard(options) {
|
|
|
654
914
|
const userUrl = options.github.userUrl ?? 'https://api.github.com/user';
|
|
655
915
|
const membershipsUrl = options.github.membershipsUrl ??
|
|
656
916
|
'https://api.github.com/user/memberships/orgs?state=active&per_page=100';
|
|
657
|
-
const served = options.organizations ? new Set(options.organizations) : null;
|
|
917
|
+
const served = Array.isArray(options.organizations) ? new Set(options.organizations) : null;
|
|
658
918
|
if (served && (served.size === 0 || [...served].some((id) => !ORGANIZATION_ID.test(id)))) {
|
|
659
919
|
throw new Error('Slotlock dashboard organizations must be GitHub organization ids');
|
|
660
920
|
}
|
|
921
|
+
// Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
|
|
922
|
+
const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
|
|
923
|
+
if (options.organizations === 'enabled' && !teams) {
|
|
924
|
+
throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
|
|
925
|
+
}
|
|
926
|
+
/** Sign-in reads the person's organizations: a fixed list, or those turned on. */
|
|
927
|
+
const readsOrganizations = served !== null || teams !== null;
|
|
928
|
+
/** Each session's organizations turned on, read with it (teams mode). */
|
|
929
|
+
const enabledOf = new WeakMap();
|
|
661
930
|
const brandName = options.name ?? 'Slotlock';
|
|
662
931
|
if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
|
|
663
932
|
throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
|
|
664
933
|
}
|
|
934
|
+
for (const [label, url] of [
|
|
935
|
+
['termsUrl', options.termsUrl],
|
|
936
|
+
['privacyUrl', options.privacyUrl],
|
|
937
|
+
]) {
|
|
938
|
+
if (url !== undefined && !/^https:\/\/[^\s"'<>`]+$/.test(url)) {
|
|
939
|
+
throw new Error(`Slotlock dashboard ${label} must be an https URL`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
665
942
|
const fetchImpl = options.fetch ?? fetch;
|
|
666
943
|
const now = options.now ?? Date.now;
|
|
667
944
|
if (Buffer.byteLength(options.sessionSecret, 'utf8') < 32) {
|
|
@@ -669,8 +946,10 @@ export function createSlotlockDashboard(options) {
|
|
|
669
946
|
}
|
|
670
947
|
const secret = Buffer.from(options.sessionSecret, 'utf8');
|
|
671
948
|
// A list, or exactly '*': any other string would match ids by substring.
|
|
672
|
-
|
|
673
|
-
|
|
949
|
+
// A GitHub person by their bare id, a Google one as google:<subject>.
|
|
950
|
+
const allowed = (uid, idp) => options.allowedUsers === '*' ||
|
|
951
|
+
(Array.isArray(options.allowedUsers) && options.allowedUsers.includes(idp === 'google' ? `google:${uid}` : uid));
|
|
952
|
+
const google = served ? null : (options.google ?? null);
|
|
674
953
|
let signInsInFlight = 0;
|
|
675
954
|
/** Sign-ins in flight by client address. */
|
|
676
955
|
const signInsByClient = new Map();
|
|
@@ -738,18 +1017,39 @@ export function createSlotlockDashboard(options) {
|
|
|
738
1017
|
typeof payload.uid !== 'string' ||
|
|
739
1018
|
typeof payload.login !== 'string' ||
|
|
740
1019
|
typeof payload.sid !== 'string' ||
|
|
741
|
-
|
|
1020
|
+
(payload.idp !== undefined && (payload.idp !== 'google' || !google)) ||
|
|
1021
|
+
!allowed(payload.uid, payload.idp) ||
|
|
742
1022
|
!organizationsHold(payload) ||
|
|
1023
|
+
(payload.env !== undefined && payload.env !== 'test') ||
|
|
743
1024
|
(await options.state.sessionEnded(payload.sid, payload.exp))) {
|
|
744
1025
|
return null;
|
|
745
1026
|
}
|
|
746
|
-
|
|
1027
|
+
const session = payload;
|
|
1028
|
+
if (teams) {
|
|
1029
|
+
// Which of the person's organizations are on now: one turned off since is left at once.
|
|
1030
|
+
const enabled = session.orgs?.length ? await teams.enabled(session.orgs.map((org) => org.id)) : new Set();
|
|
1031
|
+
if (session.org !== undefined && !enabled.has(session.org)) {
|
|
1032
|
+
const { org: _, ...personal } = session;
|
|
1033
|
+
enabledOf.set(personal, enabled);
|
|
1034
|
+
return personal;
|
|
1035
|
+
}
|
|
1036
|
+
enabledOf.set(session, enabled);
|
|
1037
|
+
}
|
|
1038
|
+
return session;
|
|
747
1039
|
}
|
|
748
1040
|
/**
|
|
749
1041
|
* With organizations, the session names one the server still serves, from its own list; without
|
|
750
1042
|
* them, it names none. A session from the other mode is signed out.
|
|
751
1043
|
*/
|
|
752
1044
|
function organizationsHold(payload) {
|
|
1045
|
+
if (teams) {
|
|
1046
|
+
if (payload.orgs === undefined)
|
|
1047
|
+
return payload.org === undefined;
|
|
1048
|
+
if (!Array.isArray(payload.orgs))
|
|
1049
|
+
return false;
|
|
1050
|
+
const listed = payload.orgs.filter((org) => isRecord(org) && typeof org.id === 'string' && typeof org.login === 'string');
|
|
1051
|
+
return payload.org === undefined || (typeof payload.org === 'string' && listed.some((org) => org.id === payload.org));
|
|
1052
|
+
}
|
|
753
1053
|
if (!served)
|
|
754
1054
|
return payload.org === undefined && payload.orgs === undefined;
|
|
755
1055
|
if (typeof payload.org !== 'string' || !served.has(payload.org) || !Array.isArray(payload.orgs)) {
|
|
@@ -760,20 +1060,26 @@ export function createSlotlockDashboard(options) {
|
|
|
760
1060
|
const csrfToken = (session) => createHmac('sha256', secret)
|
|
761
1061
|
.update(`slotlock-dashboard-csrf-v1.${session.sid}`)
|
|
762
1062
|
.digest('base64url');
|
|
763
|
-
|
|
1063
|
+
/** The tenant the session acts in: the workspace's, or its test environment's (`~test`). */
|
|
1064
|
+
const tenantOf = (session) => `${session.org !== undefined ? `github-org:${session.org}` : personOf(session)}${session.env === 'test' ? SLOTLOCK_TEST_TENANT_SUFFIX : ''}`;
|
|
764
1065
|
/** Who did something, for the record: the person, whatever tenant they act in. */
|
|
765
|
-
|
|
1066
|
+
function personOf(session) {
|
|
1067
|
+
return session.idp === 'google' ? `google:${session.uid}` : `github:${session.uid}`;
|
|
1068
|
+
}
|
|
1069
|
+
/** How the dashboard shows the person: their GitHub login, or their Google account's email. */
|
|
1070
|
+
const shownAs = (session) => (session.idp === 'google' ? session.login : `@${session.login}`);
|
|
766
1071
|
function respond(status, body, headers = {}, cookies = []) {
|
|
767
1072
|
const response = new Response(String(body), {
|
|
768
1073
|
status,
|
|
769
1074
|
headers: {
|
|
770
1075
|
'Content-Type': 'text/html; charset=utf-8',
|
|
771
|
-
'Content-Security-Policy':
|
|
1076
|
+
'Content-Security-Policy': contentSecurityPolicy,
|
|
772
1077
|
'Cache-Control': 'no-store',
|
|
773
1078
|
'X-Frame-Options': 'DENY',
|
|
774
1079
|
'X-Content-Type-Options': 'nosniff',
|
|
775
1080
|
'Referrer-Policy': REFERRER_POLICY,
|
|
776
1081
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
1082
|
+
...hsts,
|
|
777
1083
|
...headers,
|
|
778
1084
|
},
|
|
779
1085
|
});
|
|
@@ -790,6 +1096,9 @@ export function createSlotlockDashboard(options) {
|
|
|
790
1096
|
...(options.resources.waitlist
|
|
791
1097
|
? [{ section: 'waitlist', label: 'Waitlist', path: '/waitlist', icon: ICONS.waitlist }]
|
|
792
1098
|
: []),
|
|
1099
|
+
...(options.resources.approvals
|
|
1100
|
+
? [{ section: 'approvals', label: 'Approvals', path: '/approvals', icon: ICONS.approvals }]
|
|
1101
|
+
: []),
|
|
793
1102
|
{ section: 'keys', label: 'API keys', path: '/keys', icon: ICONS.keys },
|
|
794
1103
|
...(options.resources.webhooks
|
|
795
1104
|
? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
|
|
@@ -797,14 +1106,27 @@ export function createSlotlockDashboard(options) {
|
|
|
797
1106
|
...(assistantStore()
|
|
798
1107
|
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
799
1108
|
: []),
|
|
1109
|
+
...(options.billing
|
|
1110
|
+
? [{ section: 'plan', label: 'Plan', path: '/plan', icon: ICONS.plan }]
|
|
1111
|
+
: []),
|
|
1112
|
+
...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
|
|
800
1113
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
801
1114
|
];
|
|
1115
|
+
/** Live or Test (RFC 0003 G9): the same workspace, a tenant of its own for trying things. */
|
|
1116
|
+
function environmentSwitch(session) {
|
|
1117
|
+
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>`;
|
|
1118
|
+
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>`;
|
|
1119
|
+
}
|
|
802
1120
|
/** The workspace the session acts in: its organization, or the person themselves. */
|
|
803
1121
|
function workspace(session) {
|
|
804
1122
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
805
1123
|
const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
|
|
806
1124
|
const name = current?.login ?? session.login;
|
|
807
1125
|
const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
|
|
1126
|
+
const enabled = teams ? (session.orgs ?? []).filter((org) => enabledOf.get(session)?.has(org.id)) : [];
|
|
1127
|
+
if (teams && enabled.length > 0) {
|
|
1128
|
+
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>`;
|
|
1129
|
+
}
|
|
808
1130
|
if (current && choices.length > 1) {
|
|
809
1131
|
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>`;
|
|
810
1132
|
}
|
|
@@ -819,6 +1141,7 @@ export function createSlotlockDashboard(options) {
|
|
|
819
1141
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
820
1142
|
<meta name="referrer" content="${REFERRER_POLICY}">
|
|
821
1143
|
<title>${title} · ${brandName}</title>
|
|
1144
|
+
<link rel="icon" href="${root}/favicon.svg?v=${ASSET_VERSIONS.icon}" type="image/svg+xml">
|
|
822
1145
|
<link rel="stylesheet" href="${root}/style.css?v=${ASSET_VERSIONS.css}">
|
|
823
1146
|
<script src="${root}/app.js?v=${ASSET_VERSIONS.js}" defer></script>
|
|
824
1147
|
</head>`;
|
|
@@ -842,11 +1165,13 @@ ${live}
|
|
|
842
1165
|
${skip}
|
|
843
1166
|
<div class="app">
|
|
844
1167
|
<header class="side">
|
|
845
|
-
<div class="side-top">${brand}${workspace(session)}</div>
|
|
1168
|
+
<div class="side-top">${brand}${workspace(session)}${environmentSwitch(session)}</div>
|
|
846
1169
|
<nav class="nav" aria-label="Dashboard">${links}</nav>
|
|
847
|
-
<div class="me"><span class="avatar me-avatar" aria-hidden="true">${session.login.slice(0, 1).toUpperCase()}</span><span class="login" title="
|
|
1170
|
+
<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>
|
|
848
1171
|
</header>
|
|
849
|
-
<main class="page" id="main">${
|
|
1172
|
+
<main class="page" id="main">${session.env === 'test'
|
|
1173
|
+
? 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>`
|
|
1174
|
+
: html ``}${content}</main>
|
|
850
1175
|
</div>
|
|
851
1176
|
${live}
|
|
852
1177
|
</body>
|
|
@@ -859,27 +1184,52 @@ ${live}
|
|
|
859
1184
|
<h1>Sign in to ${brandName}</h1>
|
|
860
1185
|
<p>Create keys for your agents, add what they book, and watch what they do.</p>
|
|
861
1186
|
${noticeOf(notice)}
|
|
862
|
-
<a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a
|
|
863
|
-
|
|
864
|
-
?
|
|
865
|
-
|
|
1187
|
+
<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 ``}
|
|
1188
|
+
${options.termsUrl
|
|
1189
|
+
? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
|
|
1190
|
+
? html ` The <a href="${options.privacyUrl}">Privacy notice</a> says what is kept.`
|
|
1191
|
+
: html ``}</p>`
|
|
1192
|
+
: html ``}
|
|
1193
|
+
<p class="fine">${teams
|
|
1194
|
+
? `${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.`
|
|
1195
|
+
: served
|
|
1196
|
+
? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
|
|
1197
|
+
: `${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>
|
|
866
1198
|
</section>`;
|
|
867
1199
|
return respond(status, layout('Sign in', content, null), {}, cookies);
|
|
868
1200
|
}
|
|
1201
|
+
/** A key's own limits (RFC 0004 A5) as fields, filled with what it has. */
|
|
1202
|
+
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>
|
|
1203
|
+
<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>
|
|
1204
|
+
<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>
|
|
1205
|
+
<label class="choice"><input type="checkbox" name="requires_approval" value="on"${limits?.requiresApproval ? html ` checked` : html ``}> Its bookings wait for approval</label>`;
|
|
1206
|
+
/** A key's limits in words, for its row. */
|
|
1207
|
+
const keyLimitsText = (limits) => [
|
|
1208
|
+
limits.maxHolds !== undefined ? `${String(limits.maxHolds)} live holds` : '',
|
|
1209
|
+
limits.maxHeldHours !== undefined ? `${String(limits.maxHeldHours)} hours held` : '',
|
|
1210
|
+
limits.requestsPerMinute !== undefined ? `${String(limits.requestsPerMinute)} requests a minute` : '',
|
|
1211
|
+
limits.requiresApproval ? 'bookings need approval' : '',
|
|
1212
|
+
]
|
|
1213
|
+
.filter(Boolean)
|
|
1214
|
+
.join(', ');
|
|
869
1215
|
function keyRows(keys, token, session) {
|
|
870
1216
|
return keys.map((key) => {
|
|
871
1217
|
const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
|
|
872
1218
|
const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
|
|
873
1219
|
const actions = status === 'Active'
|
|
874
|
-
? html `<details class="act"><summary>
|
|
1220
|
+
? 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>
|
|
1221
|
+
<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>
|
|
1222
|
+
<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>
|
|
875
1223
|
<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>
|
|
876
1224
|
<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>
|
|
877
1225
|
<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>`
|
|
878
1226
|
: html ``;
|
|
879
1227
|
return html `<tr>
|
|
880
|
-
<td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${
|
|
1228
|
+
<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>
|
|
881
1229
|
<td data-label="Key"><code class="chip">${key.prefix}…</code></td>
|
|
882
|
-
<td data-label="Access">${key.
|
|
1230
|
+
<td data-label="Access">${key.resourceScope || key.limits
|
|
1231
|
+
? 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>`
|
|
1232
|
+
: accessOf(key)}</td>
|
|
883
1233
|
<td data-label="Created">${dateCell(key.createdAt)}</td>
|
|
884
1234
|
<td data-label="Last used">${key.lastUsedAt ? dateCell(key.lastUsedAt) : html `<span class="muted">Never</span>`}</td>
|
|
885
1235
|
<td data-label="Expires">${key.expiresAt ? dateCell(key.expiresAt) : html `<span class="muted">Never</span>`}</td>
|
|
@@ -892,7 +1242,8 @@ ${noticeOf(notice)}
|
|
|
892
1242
|
/** A list with nothing in it yet: what goes here, and the one thing to do next. */
|
|
893
1243
|
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>`;
|
|
894
1244
|
/** A resource as people name it: its reference, else the start of its id. */
|
|
895
|
-
|
|
1245
|
+
// Its profile's name, else its reference, else the start of its id.
|
|
1246
|
+
const resourceName = (resource, id) => resource?.name ?? resource?.externalRef ?? `${id.slice(0, 8)}…`;
|
|
896
1247
|
function agendaPanel(entries, byId, more) {
|
|
897
1248
|
if (entries.length === 0) {
|
|
898
1249
|
return html `<p class="empty">Nothing booked in the next ${String(AGENDA_DAYS)} days.</p>`;
|
|
@@ -938,11 +1289,15 @@ ${noticeOf(notice)}
|
|
|
938
1289
|
? 'A deleted key'
|
|
939
1290
|
: /^github:\d+$/.test(change.actor)
|
|
940
1291
|
? `GitHub user ${change.actor.slice(7)}`
|
|
941
|
-
: /^
|
|
942
|
-
?
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1292
|
+
: /^google:/.test(change.actor)
|
|
1293
|
+
? 'A Google user'
|
|
1294
|
+
: /^dashboard-assistant:(github|google):/.test(change.actor)
|
|
1295
|
+
? change.actor === assistantActor(session)
|
|
1296
|
+
? `Assistant for ${shownAs(session)}`
|
|
1297
|
+
: change.actor.startsWith('dashboard-assistant:google:')
|
|
1298
|
+
? 'Assistant for a Google user'
|
|
1299
|
+
: `Assistant for GitHub user ${change.actor.slice(27)}`
|
|
1300
|
+
: change.actor.slice(0, 60)));
|
|
946
1301
|
const where = change.resourceId
|
|
947
1302
|
? resourceName(byId.get(change.resourceId), change.resourceId)
|
|
948
1303
|
: null;
|
|
@@ -981,18 +1336,74 @@ ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
|
|
|
981
1336
|
${stat('Resources', String(resources.length), `${root}/resources`)}
|
|
982
1337
|
${stat('Active keys', String(active.length), `${root}/keys`)}
|
|
983
1338
|
</div>
|
|
1339
|
+
${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
984
1340
|
<div class="split">
|
|
985
1341
|
${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
|
|
986
1342
|
${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
|
|
987
1343
|
</div>`;
|
|
988
1344
|
return respond(status, layout('Overview', content, session, 'overview'));
|
|
989
1345
|
}
|
|
1346
|
+
/** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
|
|
1347
|
+
function sandboxPanel(session, resourceCount) {
|
|
1348
|
+
const token = csrfToken(session);
|
|
1349
|
+
const hidden = html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
1350
|
+
const names = { 'car-rental': 'Car rental', clinic: 'Clinic', desks: 'Desks and a hall' };
|
|
1351
|
+
return html `<section class="panel" id="sandbox"><h2>Sandbox</h2>
|
|
1352
|
+
<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>
|
|
1353
|
+
${resourceCount === 0
|
|
1354
|
+
? 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>`
|
|
1355
|
+
: 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>
|
|
1356
|
+
<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>`}
|
|
1357
|
+
</section>`;
|
|
1358
|
+
}
|
|
1359
|
+
async function sandboxAction(session, form, action) {
|
|
1360
|
+
const sandbox = options.resources.sandbox;
|
|
1361
|
+
if (!sandbox || session.env !== 'test')
|
|
1362
|
+
return overviewPage(session, 400, 'The sandbox is in Test only.');
|
|
1363
|
+
const tenantRef = tenantOf(session);
|
|
1364
|
+
try {
|
|
1365
|
+
if (action === 'seed') {
|
|
1366
|
+
const scenario = form.get('scenario');
|
|
1367
|
+
if (!SLOTLOCK_SANDBOX_SCENARIOS.includes(scenario))
|
|
1368
|
+
return overviewPage(session, 400, 'Choose a sample from the list.');
|
|
1369
|
+
const seeded = await sandbox.seed(tenantRef, scenario);
|
|
1370
|
+
return overviewPage(session, 200, `Loaded ${String(seeded.resources)} resources and ${String(seeded.bookings)} bookings for tomorrow.`);
|
|
1371
|
+
}
|
|
1372
|
+
if (action === 'reset') {
|
|
1373
|
+
await sandbox.reset(tenantRef);
|
|
1374
|
+
return redirect(303, `${root}#sandbox`);
|
|
1375
|
+
}
|
|
1376
|
+
const minutes = Number(form.get('minutes'));
|
|
1377
|
+
if (![15, 60, 1440].includes(minutes))
|
|
1378
|
+
return overviewPage(session, 400, 'Choose how far from the list.');
|
|
1379
|
+
const moved = await sandbox.fastForward(tenantRef, minutes);
|
|
1380
|
+
return overviewPage(session, 200, `${String(moved.holds)} holds and ${String(moved.approvals)} approvals lapsed.`);
|
|
1381
|
+
}
|
|
1382
|
+
catch (error) {
|
|
1383
|
+
if (error.code === 'sandbox_not_empty') {
|
|
1384
|
+
return overviewPage(session, 409, 'Test has resources already. Start again first.');
|
|
1385
|
+
}
|
|
1386
|
+
throw error;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
990
1389
|
function endpoints() {
|
|
991
1390
|
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>`;
|
|
992
1391
|
}
|
|
1392
|
+
const accessOf = (key) => key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only';
|
|
993
1393
|
async function keysPage(session, status = 200, notice) {
|
|
994
|
-
const
|
|
1394
|
+
const tenantRef = tenantOf(session);
|
|
1395
|
+
const grouping = options.resources.grouping;
|
|
1396
|
+
const [keys, locations, groups] = await Promise.all([
|
|
1397
|
+
options.keys.list({ tenantRef }),
|
|
1398
|
+
grouping?.locations(tenantRef),
|
|
1399
|
+
grouping?.groups(tenantRef),
|
|
1400
|
+
]);
|
|
995
1401
|
const token = csrfToken(session);
|
|
1402
|
+
const paths = locationPaths(locations ?? []);
|
|
1403
|
+
// A key limited to locations or groups (RFC 0003 G6), when the tenant has any to choose.
|
|
1404
|
+
const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
|
|
1405
|
+
? 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>`
|
|
1406
|
+
: html ``;
|
|
996
1407
|
const content = html `${noticeOf(notice)}
|
|
997
1408
|
${pageHead('API keys')}
|
|
998
1409
|
<section class="panel">
|
|
@@ -1009,19 +1420,39 @@ ${endpoints()}
|
|
|
1009
1420
|
<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>
|
|
1010
1421
|
<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>
|
|
1011
1422
|
<button class="primary" type="submit">Create key</button>
|
|
1423
|
+
${limitChoices}
|
|
1424
|
+
<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>
|
|
1012
1425
|
</form>
|
|
1013
1426
|
</section>
|
|
1014
1427
|
${keys.length === 0
|
|
1015
1428
|
? emptyState(ICONS.keys, 'No keys yet. Give every agent its own, with the least access it needs.', { href: '#key-name', label: 'Create a key' })
|
|
1016
|
-
: html `${
|
|
1429
|
+
: 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>
|
|
1017
1430
|
<tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
|
|
1018
1431
|
return respond(status, layout('API keys', content, session, 'keys'));
|
|
1019
1432
|
}
|
|
1433
|
+
/**
|
|
1434
|
+
* How many resources the tenant may have here, and how many it has as that limit counts them: the
|
|
1435
|
+
* dashboard's own limit, or its plan's when lower (archived resources count against a plan).
|
|
1436
|
+
*/
|
|
1437
|
+
async function resourceAllowance(tenantRef, resources) {
|
|
1438
|
+
const plan = options.billing ? await options.billing.plan(tenantRef) : null;
|
|
1439
|
+
if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
|
|
1440
|
+
return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
|
|
1441
|
+
}
|
|
1442
|
+
const counted = options.resources.count ? await options.resources.count(tenantRef) : resources.length;
|
|
1443
|
+
return {
|
|
1444
|
+
used: counted,
|
|
1445
|
+
limit: plan.limits.resources,
|
|
1446
|
+
planned: true,
|
|
1447
|
+
archived: Math.max(0, counted - resources.length),
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1020
1450
|
async function resourcesPage(session, status = 200, notice) {
|
|
1021
1451
|
const resources = await options.resources.list(tenantOf(session));
|
|
1452
|
+
const allowance = await resourceAllowance(tenantOf(session), resources);
|
|
1022
1453
|
const token = csrfToken(session);
|
|
1023
1454
|
const content = html `${noticeOf(notice)}
|
|
1024
|
-
${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` :
|
|
1455
|
+
${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>`)}
|
|
1025
1456
|
<section class="panel" id="add-resource">
|
|
1026
1457
|
<h2>Add a resource</h2>
|
|
1027
1458
|
<form class="create" method="post" action="${root}/resources">
|
|
@@ -1031,19 +1462,92 @@ ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${r
|
|
|
1031
1462
|
<label>Time zone<input name="timezone" required maxlength="64" value="UTC" placeholder="Europe/London"></label>
|
|
1032
1463
|
<button class="primary" type="submit">Add resource</button>
|
|
1033
1464
|
</form>
|
|
1034
|
-
<p class="fine">${String(
|
|
1465
|
+
<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>
|
|
1035
1466
|
</section>
|
|
1036
1467
|
${resources.length === 0
|
|
1037
1468
|
? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
|
|
1038
|
-
: 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>`}`;
|
|
1469
|
+
: 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>`}`;
|
|
1039
1470
|
return respond(status, layout('Resources', content, session, 'resources'));
|
|
1040
1471
|
}
|
|
1041
|
-
|
|
1472
|
+
/** The tenant's locations and groups (RFC 0003 G2, G3): what they are, and adding and changing them. */
|
|
1473
|
+
async function locationsPage(session, status = 200, notice) {
|
|
1474
|
+
const grouping = options.resources.grouping;
|
|
1475
|
+
if (!grouping)
|
|
1476
|
+
return nothingHere(session);
|
|
1477
|
+
const tenantRef = tenantOf(session);
|
|
1478
|
+
const [locations, groups] = await Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]);
|
|
1479
|
+
const token = csrfToken(session);
|
|
1480
|
+
const byRef = new Map(locations.map((location) => [location.ref, location]));
|
|
1481
|
+
const paths = locationPaths(locations);
|
|
1482
|
+
const sent = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
1483
|
+
// Where a location may be: nowhere, or any location but itself and those inside it.
|
|
1484
|
+
const insideChoices = (selected, except) => html `<option value=""${selected === null ? html ` selected` : html ``}>Nowhere (at the top)</option>${locations
|
|
1485
|
+
.filter((location) => except === undefined || !locationWithin(byRef, location.ref, except))
|
|
1486
|
+
.map((location) => html `<option value="${location.ref}"${location.ref === selected ? html ` selected` : html ``}>${paths.get(location.ref) ?? location.name}</option>`)}`;
|
|
1487
|
+
const nameLimit = String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name);
|
|
1488
|
+
const locationRows = locations.map((location) => {
|
|
1489
|
+
const parent = location.parentRef === null ? undefined : paths.get(location.parentRef);
|
|
1490
|
+
const zone = locationZone(byRef, location);
|
|
1491
|
+
return html `<tr>
|
|
1492
|
+
<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>
|
|
1493
|
+
<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>
|
|
1494
|
+
<td data-label="Resources">${String(location.resources)}</td>
|
|
1495
|
+
<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}">
|
|
1496
|
+
<label>Name<input name="name" required maxlength="${nameLimit}" value="${location.name}"></label>
|
|
1497
|
+
<label>Inside<select name="parent">${insideChoices(location.parentRef, location.ref)}</select></label>
|
|
1498
|
+
<label>Time zone<input name="timezone" maxlength="100" value="${location.timezone ?? ''}" placeholder="As where it is"></label>
|
|
1499
|
+
<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>
|
|
1500
|
+
<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>
|
|
1501
|
+
</tr>`;
|
|
1502
|
+
});
|
|
1503
|
+
const groupRows = groups.map((group) => html `<tr>
|
|
1504
|
+
<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>
|
|
1505
|
+
<td data-label="Description" class="wrap">${group.description ?? ''}</td>
|
|
1506
|
+
<td data-label="Resources">${String(group.members)}</td>
|
|
1507
|
+
<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}">
|
|
1508
|
+
<label>Name<input name="name" required maxlength="${nameLimit}" value="${group.name}"></label>
|
|
1509
|
+
<label>Description<textarea name="description" rows="2" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${group.description ?? ''}</textarea></label>
|
|
1510
|
+
<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>
|
|
1511
|
+
<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>
|
|
1512
|
+
</tr>`);
|
|
1513
|
+
const content = html `${noticeOf(notice)}
|
|
1514
|
+
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1515
|
+
${pageHead('Locations and groups')}
|
|
1516
|
+
<section class="panel" id="locations">
|
|
1517
|
+
<h2>Locations</h2>
|
|
1518
|
+
<form class="create" method="post" action="${root}/locations">${sent()}
|
|
1519
|
+
<label>Reference<input id="location-ref" name="ref" required maxlength="100" placeholder="floor-3"></label>
|
|
1520
|
+
<label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Floor 3"></label>
|
|
1521
|
+
<label>Inside<select name="parent">${insideChoices(null)}</select></label>
|
|
1522
|
+
<label>Time zone<input name="timezone" maxlength="100" placeholder="As where it is"></label>
|
|
1523
|
+
<button class="primary" type="submit">Add location</button>
|
|
1524
|
+
</form>
|
|
1525
|
+
<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>
|
|
1526
|
+
${locations.length === 0
|
|
1527
|
+
? html `<p class="empty">No locations yet.</p>`
|
|
1528
|
+
: 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>`}
|
|
1529
|
+
</section>
|
|
1530
|
+
<section class="panel" id="groups">
|
|
1531
|
+
<h2>Groups</h2>
|
|
1532
|
+
<form class="create" method="post" action="${root}/groups">${sent()}
|
|
1533
|
+
<label>Reference<input id="group-ref" name="ref" required maxlength="100" placeholder="video-rooms"></label>
|
|
1534
|
+
<label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Rooms with video"></label>
|
|
1535
|
+
<label class="grow">Description<input name="description" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}" placeholder="Optional"></label>
|
|
1536
|
+
<button class="primary" type="submit">Add group</button>
|
|
1537
|
+
</form>
|
|
1538
|
+
<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>
|
|
1539
|
+
${groups.length === 0
|
|
1540
|
+
? html `<p class="empty">No groups yet.</p>`
|
|
1541
|
+
: 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>`}
|
|
1542
|
+
</section>`;
|
|
1543
|
+
return respond(status, layout('Locations and groups', content, session, 'resources'));
|
|
1544
|
+
}
|
|
1545
|
+
async function settingsPage(session, status = 200, notice, draft) {
|
|
1042
1546
|
const rules = options.availability ?? [];
|
|
1043
1547
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1044
1548
|
const content = html `${noticeOf(notice)}
|
|
1045
1549
|
${pageHead('Settings')}
|
|
1046
|
-
${await assistantSettingsPanel(session)}
|
|
1550
|
+
${await assistantSettingsPanel(session, draft)}
|
|
1047
1551
|
<section class="panel">
|
|
1048
1552
|
<h2>Endpoints</h2>
|
|
1049
1553
|
${endpoints()}
|
|
@@ -1057,7 +1561,7 @@ ${rules.length === 0
|
|
|
1057
1561
|
</section>
|
|
1058
1562
|
<section class="panel">
|
|
1059
1563
|
<h2>Account</h2>
|
|
1060
|
-
<dl class="facts"><dt>Signed in as</dt><dd
|
|
1564
|
+
<dl class="facts"><dt>Signed in as</dt><dd>${shownAs(session)}</dd>${current
|
|
1061
1565
|
? html `<dt>Organization</dt><dd>${current.login}</dd>${(session.orgs?.length ?? 0) > 1 ? html `<dt>Also a member of</dt><dd>${(session.orgs ?? []).filter((org) => org.id !== current.id).map((org) => org.login).join(', ')}</dd>` : html ``}`
|
|
1062
1566
|
: html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
|
|
1063
1567
|
${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
|
|
@@ -1072,12 +1576,21 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1072
1576
|
return notFound(session);
|
|
1073
1577
|
const start = new Date(now());
|
|
1074
1578
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
1075
|
-
const
|
|
1579
|
+
const grouping = options.resources.grouping;
|
|
1580
|
+
const [agenda, changes, keys, defaults, locations, groups, everyResource] = await Promise.all([
|
|
1076
1581
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
1077
1582
|
options.resources.recentChanges?.(tenantRef, 100),
|
|
1078
1583
|
options.keys.list({ tenantRef }),
|
|
1079
1584
|
options.resources.ruleDefaults?.(tenantRef),
|
|
1585
|
+
grouping?.locations(tenantRef),
|
|
1586
|
+
grouping?.groups(tenantRef),
|
|
1587
|
+
// For a composite: its parts, or the wholes it can be a part of.
|
|
1588
|
+
options.resources.setParent ? options.resources.list(tenantRef) : undefined,
|
|
1080
1589
|
]);
|
|
1590
|
+
const parts = (everyResource?.filter((other) => other.parentId === resource.id) ?? []).sort((one, other) => (one.partIndex ?? 0) - (other.partIndex ?? 0));
|
|
1591
|
+
const wholes = everyResource?.filter((other) => other.id !== resource.id && other.parentId === undefined && other.archivedAt === undefined) ?? [];
|
|
1592
|
+
const paths = locationPaths(locations ?? []);
|
|
1593
|
+
const groupNames = new Map((groups ?? []).map((group) => [group.ref, group.name]));
|
|
1081
1594
|
const byId = new Map([[resource.id, resource]]);
|
|
1082
1595
|
const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
|
|
1083
1596
|
const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
|
|
@@ -1102,7 +1615,8 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1102
1615
|
const content = html `${noticeOf(notice)}
|
|
1103
1616
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1104
1617
|
${pageHead(resourceName(resource, resource.id))}
|
|
1105
|
-
|
|
1618
|
+
${resource.description ? html `<p>${resource.description}</p>` : html ``}
|
|
1619
|
+
<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>
|
|
1106
1620
|
<div class="split">
|
|
1107
1621
|
<div class="stack">
|
|
1108
1622
|
${options.resources.setRules
|
|
@@ -1127,6 +1641,25 @@ ${inheritedNote(inherited)}
|
|
|
1127
1641
|
<p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
|
|
1128
1642
|
<button class="primary" type="submit">Save rules</button>
|
|
1129
1643
|
</form>
|
|
1644
|
+
</section>`
|
|
1645
|
+
: html ``}
|
|
1646
|
+
${options.resources.setProfile
|
|
1647
|
+
? html `<section class="panel">
|
|
1648
|
+
<h2>Profile</h2>
|
|
1649
|
+
<form method="post" action="${root}/resources/profile">
|
|
1650
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1651
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1652
|
+
<input type="hidden" name="id" value="${resource.id}">
|
|
1653
|
+
<div class="grid-form">
|
|
1654
|
+
<label>Name<input name="name" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name)}" value="${resource.name ?? ''}" placeholder="${resource.externalRef ?? ''}"></label>
|
|
1655
|
+
<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>
|
|
1656
|
+
<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>
|
|
1657
|
+
<label>Labels<input name="labels" value="${(resource.labels ?? []).join(', ')}" placeholder="video, accessible"></label>
|
|
1658
|
+
</div>
|
|
1659
|
+
<label>Description<textarea name="description" rows="3" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${resource.description ?? ''}</textarea></label>
|
|
1660
|
+
<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>
|
|
1661
|
+
<button class="primary" type="submit">Save profile</button>
|
|
1662
|
+
</form>
|
|
1130
1663
|
</section>`
|
|
1131
1664
|
: html ``}
|
|
1132
1665
|
${options.resources.setPool
|
|
@@ -1140,6 +1673,39 @@ ${options.resources.setPool
|
|
|
1140
1673
|
<button type="submit">Save pool</button>
|
|
1141
1674
|
</form>
|
|
1142
1675
|
<p class="fine">Agents can book any free member of a pool. Leave it empty for none.</p>
|
|
1676
|
+
</section>`
|
|
1677
|
+
: html ``}
|
|
1678
|
+
${options.resources.setParent && everyResource
|
|
1679
|
+
? html `<section class="panel" id="composite">
|
|
1680
|
+
<h2>Composite</h2>
|
|
1681
|
+
${parts.length > 0
|
|
1682
|
+
? 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>`
|
|
1683
|
+
: html `<form class="create" method="post" action="${root}/resources/parent">
|
|
1684
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1685
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1686
|
+
<input type="hidden" name="id" value="${resource.id}">
|
|
1687
|
+
<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>
|
|
1688
|
+
<button type="submit">Save</button>
|
|
1689
|
+
</form>
|
|
1690
|
+
<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>`}
|
|
1691
|
+
</section>`
|
|
1692
|
+
: html ``}
|
|
1693
|
+
${grouping && locations && groups
|
|
1694
|
+
? html `<section class="panel" id="place">
|
|
1695
|
+
<h2>Location and groups</h2>
|
|
1696
|
+
${locations.length === 0 && groups.length === 0
|
|
1697
|
+
? html `<p class="empty">No locations or groups yet. <a href="${root}/locations">Add them</a>, then choose them here.</p>`
|
|
1698
|
+
: html `<form method="post" action="${root}/resources/place">
|
|
1699
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1700
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1701
|
+
<input type="hidden" name="id" value="${resource.id}">
|
|
1702
|
+
<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>
|
|
1703
|
+
${groups.length > 0
|
|
1704
|
+
? 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>`
|
|
1705
|
+
: html ``}
|
|
1706
|
+
<button class="primary" type="submit">Save location and groups</button>
|
|
1707
|
+
</form>
|
|
1708
|
+
<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>`}
|
|
1143
1709
|
</section>`
|
|
1144
1710
|
: html ``}
|
|
1145
1711
|
</div>
|
|
@@ -1187,6 +1753,153 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1187
1753
|
}
|
|
1188
1754
|
return redirect(303, `${root}/resources/${id}`);
|
|
1189
1755
|
}
|
|
1756
|
+
async function saveProfile(session, form) {
|
|
1757
|
+
const id = form.get('id') ?? '';
|
|
1758
|
+
const resource = await ownResource(session, id);
|
|
1759
|
+
if (!resource || !options.resources.setProfile)
|
|
1760
|
+
return notFound(session);
|
|
1761
|
+
const text = (key) => optionalField(form, key);
|
|
1762
|
+
const capacity = text('capacity');
|
|
1763
|
+
if (capacity !== null && !/^\d{1,6}$/.test(capacity)) {
|
|
1764
|
+
return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS.capacity);
|
|
1765
|
+
}
|
|
1766
|
+
const kind = form.get('kind') ?? 'other';
|
|
1767
|
+
try {
|
|
1768
|
+
await options.resources.setProfile(tenantOf(session), id, {
|
|
1769
|
+
name: text('name'),
|
|
1770
|
+
description: text('description'),
|
|
1771
|
+
kind: kind,
|
|
1772
|
+
capacity: capacity === null ? null : Number(capacity),
|
|
1773
|
+
// Commas or spaces between them; a person may type them in capitals.
|
|
1774
|
+
labels: (form.get('labels') ?? '')
|
|
1775
|
+
.split(/[\s,]+/)
|
|
1776
|
+
.map((label) => label.trim().toLowerCase())
|
|
1777
|
+
.filter((label) => label !== ''),
|
|
1778
|
+
}, personOf(session));
|
|
1779
|
+
}
|
|
1780
|
+
catch (error) {
|
|
1781
|
+
if (error.code !== 'invalid_resource_profile')
|
|
1782
|
+
throw error;
|
|
1783
|
+
const field = error.field;
|
|
1784
|
+
return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS[typeof field === 'string' && field in PROFILE_PROBLEMS ? field : 'other']);
|
|
1785
|
+
}
|
|
1786
|
+
return redirect(303, `${root}/resources/${id}`);
|
|
1787
|
+
}
|
|
1788
|
+
/** Text from a form, trimmed, or null when empty. */
|
|
1789
|
+
function optionalField(form, key) {
|
|
1790
|
+
const value = (form.get(key) ?? '').trim();
|
|
1791
|
+
return value === '' ? null : value;
|
|
1792
|
+
}
|
|
1793
|
+
async function saveLocation(session, form) {
|
|
1794
|
+
const grouping = options.resources.grouping;
|
|
1795
|
+
if (!grouping)
|
|
1796
|
+
return nothingHere(session);
|
|
1797
|
+
try {
|
|
1798
|
+
await grouping.putLocation(tenantOf(session), {
|
|
1799
|
+
// A person may type a reference in capitals; references are lowercase.
|
|
1800
|
+
ref: (form.get('ref') ?? '').trim().toLowerCase(),
|
|
1801
|
+
name: (form.get('name') ?? '').trim(),
|
|
1802
|
+
parentRef: optionalField(form, 'parent'),
|
|
1803
|
+
timezone: optionalField(form, 'timezone'),
|
|
1804
|
+
}, personOf(session));
|
|
1805
|
+
}
|
|
1806
|
+
catch (error) {
|
|
1807
|
+
const problem = groupingProblem(error);
|
|
1808
|
+
if (!problem)
|
|
1809
|
+
throw error;
|
|
1810
|
+
return locationsPage(session, problem.status, problem.text);
|
|
1811
|
+
}
|
|
1812
|
+
return redirect(303, `${root}/locations`);
|
|
1813
|
+
}
|
|
1814
|
+
async function saveGroup(session, form) {
|
|
1815
|
+
const grouping = options.resources.grouping;
|
|
1816
|
+
if (!grouping)
|
|
1817
|
+
return nothingHere(session);
|
|
1818
|
+
try {
|
|
1819
|
+
await grouping.putGroup(tenantOf(session), {
|
|
1820
|
+
ref: (form.get('ref') ?? '').trim().toLowerCase(),
|
|
1821
|
+
name: (form.get('name') ?? '').trim(),
|
|
1822
|
+
description: optionalField(form, 'description'),
|
|
1823
|
+
}, personOf(session));
|
|
1824
|
+
}
|
|
1825
|
+
catch (error) {
|
|
1826
|
+
const problem = groupingProblem(error);
|
|
1827
|
+
if (!problem)
|
|
1828
|
+
throw error;
|
|
1829
|
+
return locationsPage(session, problem.status, problem.text);
|
|
1830
|
+
}
|
|
1831
|
+
return redirect(303, `${root}/locations#groups`);
|
|
1832
|
+
}
|
|
1833
|
+
async function deleteGrouping(session, form, kind) {
|
|
1834
|
+
const grouping = options.resources.grouping;
|
|
1835
|
+
if (!grouping)
|
|
1836
|
+
return nothingHere(session);
|
|
1837
|
+
const ref = form.get('ref') ?? '';
|
|
1838
|
+
let deleted;
|
|
1839
|
+
try {
|
|
1840
|
+
deleted =
|
|
1841
|
+
kind === 'location'
|
|
1842
|
+
? await grouping.deleteLocation(tenantOf(session), ref, personOf(session))
|
|
1843
|
+
: await grouping.deleteGroup(tenantOf(session), ref, personOf(session));
|
|
1844
|
+
}
|
|
1845
|
+
catch (error) {
|
|
1846
|
+
const problem = groupingProblem(error);
|
|
1847
|
+
if (!problem)
|
|
1848
|
+
throw error;
|
|
1849
|
+
return locationsPage(session, problem.status, problem.text);
|
|
1850
|
+
}
|
|
1851
|
+
if (!deleted) {
|
|
1852
|
+
const problem = GROUPING_PROBLEMS[`${kind}_not_found`];
|
|
1853
|
+
return locationsPage(session, problem.status, problem.text);
|
|
1854
|
+
}
|
|
1855
|
+
return redirect(303, `${root}/locations${kind === 'group' ? '#groups' : ''}`);
|
|
1856
|
+
}
|
|
1857
|
+
async function savePlace(session, form) {
|
|
1858
|
+
const id = form.get('id') ?? '';
|
|
1859
|
+
const resource = await ownResource(session, id);
|
|
1860
|
+
const grouping = options.resources.grouping;
|
|
1861
|
+
if (!resource || !grouping)
|
|
1862
|
+
return notFound(session);
|
|
1863
|
+
const groups = [...new Set(form.getAll('group'))];
|
|
1864
|
+
if (groups.length > SLOTLOCK_MAX_GROUPS_PER_RESOURCE) {
|
|
1865
|
+
const problem = GROUPING_PROBLEMS.resource_group_limit_reached;
|
|
1866
|
+
return resourceDetailPage(session, id, problem.status, problem.text);
|
|
1867
|
+
}
|
|
1868
|
+
try {
|
|
1869
|
+
const placed = await grouping.place(tenantOf(session), id, { location: optionalField(form, 'location'), groups }, personOf(session));
|
|
1870
|
+
if (!placed)
|
|
1871
|
+
return notFound(session);
|
|
1872
|
+
}
|
|
1873
|
+
catch (error) {
|
|
1874
|
+
const problem = groupingProblem(error);
|
|
1875
|
+
if (!problem)
|
|
1876
|
+
throw error;
|
|
1877
|
+
return resourceDetailPage(session, id, problem.status, problem.text);
|
|
1878
|
+
}
|
|
1879
|
+
return redirect(303, `${root}/resources/${id}`);
|
|
1880
|
+
}
|
|
1881
|
+
async function saveParent(session, form) {
|
|
1882
|
+
const id = form.get('id') ?? '';
|
|
1883
|
+
const resource = await ownResource(session, id);
|
|
1884
|
+
if (!resource || !options.resources.setParent)
|
|
1885
|
+
return notFound(session);
|
|
1886
|
+
const wanted = (form.get('parent') ?? '').trim();
|
|
1887
|
+
const parent = wanted === '' ? null : await ownResource(session, wanted);
|
|
1888
|
+
if (wanted !== '' && !parent)
|
|
1889
|
+
return resourceDetailPage(session, id, 400, 'That is not one of your resources.');
|
|
1890
|
+
try {
|
|
1891
|
+
const changed = await options.resources.setParent(tenantOf(session), id, parent?.id ?? null, personOf(session));
|
|
1892
|
+
if (!changed)
|
|
1893
|
+
return notFound(session);
|
|
1894
|
+
}
|
|
1895
|
+
catch (error) {
|
|
1896
|
+
const text = COMPOSITE_PROBLEMS[String(error?.code)];
|
|
1897
|
+
if (!text)
|
|
1898
|
+
throw error;
|
|
1899
|
+
return resourceDetailPage(session, id, 409, text);
|
|
1900
|
+
}
|
|
1901
|
+
return redirect(303, `${root}/resources/${id}`);
|
|
1902
|
+
}
|
|
1190
1903
|
async function savePool(session, form) {
|
|
1191
1904
|
const id = form.get('id') ?? '';
|
|
1192
1905
|
const resource = await ownResource(session, id);
|
|
@@ -1203,6 +1916,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1203
1916
|
}
|
|
1204
1917
|
return redirect(303, `${root}/resources/${id}`);
|
|
1205
1918
|
}
|
|
1919
|
+
/** An endpoint's filter (set over the API), in a few words. */
|
|
1920
|
+
function webhookFilterText(filter) {
|
|
1921
|
+
return [
|
|
1922
|
+
filter.resourceIds ? `${filter.resourceIds.length} resource${filter.resourceIds.length === 1 ? '' : 's'}` : null,
|
|
1923
|
+
filter.pools ? `pools ${filter.pools.join(', ')}` : null,
|
|
1924
|
+
filter.locations ? `locations ${filter.locations.join(', ')}` : null,
|
|
1925
|
+
filter.groups ? `groups ${filter.groups.join(', ')}` : null,
|
|
1926
|
+
]
|
|
1927
|
+
.filter((part) => part !== null)
|
|
1928
|
+
.join('; ');
|
|
1929
|
+
}
|
|
1206
1930
|
function endpointState(endpoint) {
|
|
1207
1931
|
if (endpoint.disabledAt)
|
|
1208
1932
|
return { text: 'Disabled', tone: 'revoked' };
|
|
@@ -1227,7 +1951,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1227
1951
|
: '';
|
|
1228
1952
|
return html `<tr>
|
|
1229
1953
|
<td data-label="URL" class="strong"><code class="truncate url" title="${endpoint.url}">${endpoint.url}</code></td>
|
|
1230
|
-
<td data-label="Events" class="wrap">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}</td>
|
|
1954
|
+
<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>
|
|
1231
1955
|
<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>
|
|
1232
1956
|
<td data-label="Added">${dateCell(endpoint.createdAt)}</td>
|
|
1233
1957
|
<td class="actions">${endpoint.disabledAt
|
|
@@ -1267,7 +1991,7 @@ ${endpoints.length === 0
|
|
|
1267
1991
|
const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
|
|
1268
1992
|
if (!created.ok) {
|
|
1269
1993
|
const message = created.code === 'webhook_limit_reached'
|
|
1270
|
-
? `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
|
|
1994
|
+
? `You have ${String(created.limit)} webhooks, as many as ${options.billing ? 'your plan' : 'Slotlock'} allows. Delete one first${options.billing ? ', or see Plan' : ''}.`
|
|
1271
1995
|
: created.code === 'invalid_webhook_url'
|
|
1272
1996
|
? 'Give an https URL, with no user name, password or #fragment.'
|
|
1273
1997
|
: 'Choose events from the list.';
|
|
@@ -1356,6 +2080,87 @@ ${entries.length === 0
|
|
|
1356
2080
|
}
|
|
1357
2081
|
return redirect(303, `${root}/waitlist`);
|
|
1358
2082
|
}
|
|
2083
|
+
// ---- Approvals (RFC 0004 A4) ----
|
|
2084
|
+
async function approvalsPage(session, status = 200, notice) {
|
|
2085
|
+
const approvals = options.resources.approvals;
|
|
2086
|
+
if (!approvals)
|
|
2087
|
+
return notFound(session);
|
|
2088
|
+
const tenantRef = tenantOf(session);
|
|
2089
|
+
const [entries, resources] = await Promise.all([
|
|
2090
|
+
approvals.list(tenantRef, APPROVALS_SHOWN),
|
|
2091
|
+
options.resources.list(tenantRef),
|
|
2092
|
+
]);
|
|
2093
|
+
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
2094
|
+
const token = csrfToken(session);
|
|
2095
|
+
const pending = entries.filter((entry) => entry.status === 'pending' && entry.expiresAt.getTime() > now());
|
|
2096
|
+
const decided = entries.filter((entry) => !pending.includes(entry));
|
|
2097
|
+
const what = (entry) => entry.resourceIds.map((id) => resourceName(byId.get(id), id)).join(', ');
|
|
2098
|
+
const when = (entry) => {
|
|
2099
|
+
const zone = byId.get(entry.resourceIds[0] ?? '')?.timezone ?? 'UTC';
|
|
2100
|
+
const day = dayLabel(entry.start, zone, now());
|
|
2101
|
+
return `${day.label} ${clock(entry.start, zone)}–${clock(entry.end, zone)}`;
|
|
2102
|
+
};
|
|
2103
|
+
const KINDS = {
|
|
2104
|
+
slotlock_create_event: 'Booking',
|
|
2105
|
+
slotlock_book_together: 'Bundle',
|
|
2106
|
+
slotlock_update_event: 'Move',
|
|
2107
|
+
};
|
|
2108
|
+
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>`;
|
|
2109
|
+
const pendingRows = pending.map((entry) => html `<tr>
|
|
2110
|
+
<td data-label="When" class="strong">${when(entry)}</td>
|
|
2111
|
+
<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>
|
|
2112
|
+
<td data-label="Asked by"><code class="truncate" title="${entry.requestedBy ?? ''}">${entry.requestedBy ?? 'unknown'}</code></td>
|
|
2113
|
+
<td data-label="Decide by">${clock(entry.expiresAt, 'UTC')} UTC, ${dayLabel(entry.expiresAt, 'UTC', now()).label}</td>
|
|
2114
|
+
<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>
|
|
2115
|
+
</tr>`);
|
|
2116
|
+
const STATUS = {
|
|
2117
|
+
pending: { text: 'Lapsed', tone: 'revoked' },
|
|
2118
|
+
granted: { text: 'Approved', tone: 'active' },
|
|
2119
|
+
declined: { text: 'Declined', tone: 'revoked' },
|
|
2120
|
+
expired: { text: 'Expired', tone: 'revoked' },
|
|
2121
|
+
};
|
|
2122
|
+
const decidedRows = decided.map((entry) => html `<tr>
|
|
2123
|
+
<td data-label="When" class="strong">${when(entry)}</td>
|
|
2124
|
+
<td data-label="What"><span class="truncate" title="${what(entry)}">${what(entry)}</span></td>
|
|
2125
|
+
<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>
|
|
2126
|
+
<td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
|
|
2127
|
+
<td data-label="Asked">${ago(entry.createdAt, now())}</td>
|
|
2128
|
+
</tr>`);
|
|
2129
|
+
const content = html `${noticeOf(notice)}
|
|
2130
|
+
${pageHead('Approvals')}
|
|
2131
|
+
<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>
|
|
2132
|
+
<div class="stats">
|
|
2133
|
+
<div class="stat"><span class="stat-label">Waiting for you</span><span class="stat-value">${String(pending.length)}</span></div>
|
|
2134
|
+
</div>
|
|
2135
|
+
${pending.length === 0
|
|
2136
|
+
? emptyState(ICONS.approvals, 'Nothing is waiting. A policy that holds bookings for a person sends them here.')
|
|
2137
|
+
: 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>`}
|
|
2138
|
+
${decided.length === 0
|
|
2139
|
+
? html ``
|
|
2140
|
+
: 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>`}`;
|
|
2141
|
+
return respond(status, layout('Approvals', content, session, 'approvals'));
|
|
2142
|
+
}
|
|
2143
|
+
async function decideApproval(session, form) {
|
|
2144
|
+
const approvals = options.resources.approvals;
|
|
2145
|
+
if (!approvals)
|
|
2146
|
+
return notFound(session);
|
|
2147
|
+
const decision = form.get('decision');
|
|
2148
|
+
if (decision !== 'grant' && decision !== 'decline')
|
|
2149
|
+
return approvalsPage(session, 400, 'Choose approve or decline.');
|
|
2150
|
+
const reason = (form.get('reason') ?? '').trim().slice(0, 500);
|
|
2151
|
+
const decided = await approvals.decide(tenantOf(session), form.get('id') ?? '', decision, personOf(session), reason || undefined);
|
|
2152
|
+
if (!decided.ok) {
|
|
2153
|
+
const messages = {
|
|
2154
|
+
approval_not_found: 'That approval is not one of yours.',
|
|
2155
|
+
approval_not_pending: 'That approval was already decided.',
|
|
2156
|
+
approval_expired: 'The wait for that approval ended: its time is no longer held.',
|
|
2157
|
+
overlap: 'That time is taken now, so it cannot be booked. Decline it instead.',
|
|
2158
|
+
revision_conflict: 'The agent changed that event since it asked. Decline this one; it can ask again.',
|
|
2159
|
+
};
|
|
2160
|
+
return approvalsPage(session, 409, messages[decided.code] ?? `That booking cannot be made now (${decided.code}). Decline it instead.`);
|
|
2161
|
+
}
|
|
2162
|
+
return redirect(303, `${root}/approvals`);
|
|
2163
|
+
}
|
|
1359
2164
|
// ---- The assistant ----
|
|
1360
2165
|
const assistantOptions = options.assistant;
|
|
1361
2166
|
/** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
|
|
@@ -1365,6 +2170,11 @@ ${entries.length === 0
|
|
|
1365
2170
|
const resolveHost = assistantOptions?.resolve ??
|
|
1366
2171
|
(async (hostname) => (await dnsLookup(hostname, { all: true })).map(({ address }) => address));
|
|
1367
2172
|
const PRIVATE_URL_REFUSAL = 'That URL is, or resolves to, a local or private network address (loopback, private, link-local, shared, unique-local or cloud metadata), which this server does not reach. For a model on its own machine or network, the server’s operator can allow them with SLOTLOCK_ASSISTANT_PRIVATE_URLS=on.';
|
|
2173
|
+
// The dashboard's own host and the server's: a model there would be sent the organization's key
|
|
2174
|
+
// and the conversation by this deployment itself, on a public name the private-address check passes.
|
|
2175
|
+
const ownHosts = new Set([publicUrl.hostname, service.hostname]);
|
|
2176
|
+
const namesOwnHost = (baseUrl) => ownHosts.has(new URL(baseUrl).hostname);
|
|
2177
|
+
const OWN_HOST_REFUSAL = 'That URL is this Slotlock deployment’s own address. Give the address of the model’s API.';
|
|
1368
2178
|
const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
|
|
1369
2179
|
const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
|
|
1370
2180
|
const conversationTtl = assistantOptions?.conversationTtlMs ?? 2 * 3_600_000;
|
|
@@ -1374,6 +2184,23 @@ ${entries.length === 0
|
|
|
1374
2184
|
if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
|
|
1375
2185
|
throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
|
|
1376
2186
|
}
|
|
2187
|
+
const previousSecrets = [...(assistantOptions?.previousSecrets ?? [])];
|
|
2188
|
+
if (previousSecrets.length > SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS) {
|
|
2189
|
+
throw new Error(`Slotlock dashboard assistant takes at most ${String(SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS)} previous secrets`);
|
|
2190
|
+
}
|
|
2191
|
+
for (const previous of previousSecrets) {
|
|
2192
|
+
if (Buffer.byteLength(previous, 'utf8') < 32) {
|
|
2193
|
+
throw new Error('Slotlock dashboard assistant previous secrets must each be at least 32 bytes');
|
|
2194
|
+
}
|
|
2195
|
+
if (previous === assistantOptions?.secret || previous === options.sessionSecret) {
|
|
2196
|
+
throw new Error('Slotlock dashboard assistant previous secrets must differ from the assistant and session secrets');
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
if (new Set(previousSecrets).size !== previousSecrets.length) {
|
|
2200
|
+
throw new Error('Slotlock dashboard assistant previous secrets must differ from each other');
|
|
2201
|
+
}
|
|
2202
|
+
/** The current secret, then the previous ones: a stored key is tried with each in turn. */
|
|
2203
|
+
const assistantSecrets = assistantOptions ? [assistantOptions.secret, ...previousSecrets] : [];
|
|
1377
2204
|
const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
|
|
1378
2205
|
const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
|
|
1379
2206
|
/** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
|
|
@@ -1398,7 +2225,7 @@ ${entries.length === 0
|
|
|
1398
2225
|
* Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
|
|
1399
2226
|
* which a rename does not change. Shown as "Assistant for @login".
|
|
1400
2227
|
*/
|
|
1401
|
-
const assistantActor = (session) => `dashboard-assistant
|
|
2228
|
+
const assistantActor = (session) => `dashboard-assistant:${personOf(session)}`;
|
|
1402
2229
|
const CONFIRMED_WRITES = new Set([
|
|
1403
2230
|
'slotlock_create_event',
|
|
1404
2231
|
'slotlock_update_event',
|
|
@@ -1432,6 +2259,15 @@ ${entries.length === 0
|
|
|
1432
2259
|
else if (operation === 'slotlock_release_slots') {
|
|
1433
2260
|
text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
|
|
1434
2261
|
}
|
|
2262
|
+
else if (operation === 'slotlock_confirm_slot') {
|
|
2263
|
+
text = `Book the time held by ${String(input.hold_id)}${input.title ? `, as "${String(input.title)}"` : ''}, and release the other holds made with it.`;
|
|
2264
|
+
}
|
|
2265
|
+
else if (operation === 'slotlock_reschedule') {
|
|
2266
|
+
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)}` : ''}.`;
|
|
2267
|
+
}
|
|
2268
|
+
else if (operation === 'slotlock_cancel') {
|
|
2269
|
+
text = `Cancel the event ${String(input.event_id)}.`;
|
|
2270
|
+
}
|
|
1435
2271
|
else {
|
|
1436
2272
|
text = '';
|
|
1437
2273
|
}
|
|
@@ -1439,7 +2275,8 @@ ${entries.length === 0
|
|
|
1439
2275
|
return text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
1440
2276
|
}
|
|
1441
2277
|
/** Owners of the organization in use may change its assistant; a person always may their own. */
|
|
1442
|
-
|
|
2278
|
+
/** An owner of the workspace: the person in their own, an organization's owner in its. */
|
|
2279
|
+
const isAdmin = (session) => session.org === undefined || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
|
|
1443
2280
|
const assistantPrincipal = (session) => ({
|
|
1444
2281
|
subject: assistantActor(session),
|
|
1445
2282
|
tenantRef: tenantOf(session),
|
|
@@ -1477,6 +2314,8 @@ ${entries.length === 0
|
|
|
1477
2314
|
? { consumeRateLimit: assistant.consumeRateLimit }
|
|
1478
2315
|
: {}),
|
|
1479
2316
|
...(confirm ? { confirm } : {}),
|
|
2317
|
+
// A proposal halts at `confirm` and never reaches the backend, so only what runs counts.
|
|
2318
|
+
...(assistant.onUsage ? { onUsage: assistant.onUsage } : {}),
|
|
1480
2319
|
onError: (error) => {
|
|
1481
2320
|
try {
|
|
1482
2321
|
ignoreRejection(options.onError?.(error));
|
|
@@ -1493,18 +2332,36 @@ ${entries.length === 0
|
|
|
1493
2332
|
const settings = store ? await store.getSettings(tenantOf(session)) : null;
|
|
1494
2333
|
if (!settings || !assistantOptions)
|
|
1495
2334
|
return null;
|
|
2335
|
+
if (namesOwnHost(settings.baseUrl))
|
|
2336
|
+
return { problem: OWN_HOST_REFUSAL };
|
|
1496
2337
|
if (!slotlockAssistantUrlAllowed(settings.baseUrl, privateUrls) ||
|
|
1497
2338
|
!(await checkSlotlockAssistantHost(settings.baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
1498
2339
|
return { problem: PRIVATE_URL_REFUSAL };
|
|
1499
2340
|
}
|
|
1500
2341
|
let apiKey = null;
|
|
1501
2342
|
if (settings.sealedKey !== null) {
|
|
1502
|
-
|
|
1503
|
-
|
|
2343
|
+
const tenantRef = tenantOf(session);
|
|
2344
|
+
const opened = await openSlotlockAssistantKeyWithAny(assistantSecrets, tenantRef, settings.sealedKey);
|
|
2345
|
+
if (opened === null) {
|
|
1504
2346
|
return {
|
|
1505
2347
|
problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
|
|
1506
2348
|
};
|
|
1507
2349
|
}
|
|
2350
|
+
apiKey = opened.apiKey;
|
|
2351
|
+
// Sealed under a previous secret: sealed again under the current one, unless an owner saved
|
|
2352
|
+
// another key meanwhile. Best effort: the key opened either way.
|
|
2353
|
+
if (opened.secretIndex > 0 && store?.resealKey) {
|
|
2354
|
+
try {
|
|
2355
|
+
const resealed = await sealSlotlockAssistantKey(assistantOptions.secret, tenantRef, apiKey);
|
|
2356
|
+
await store.resealKey(tenantRef, settings.sealedKey, resealed);
|
|
2357
|
+
}
|
|
2358
|
+
catch (error) {
|
|
2359
|
+
// A suspended tenant reads and writes nothing, this included: it opens the old way.
|
|
2360
|
+
if (error?.code !== 'tenant_suspended') {
|
|
2361
|
+
ignoreRejection(options.onError?.(error));
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
1508
2365
|
}
|
|
1509
2366
|
return {
|
|
1510
2367
|
model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
|
|
@@ -1608,7 +2465,7 @@ ${settings
|
|
|
1608
2465
|
: 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
|
|
1609
2466
|
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
1610
2467
|
}
|
|
1611
|
-
async function assistantSettingsPanel(session) {
|
|
2468
|
+
async function assistantSettingsPanel(session, draft) {
|
|
1612
2469
|
const store = assistantStore();
|
|
1613
2470
|
if (!store)
|
|
1614
2471
|
return html ``;
|
|
@@ -1620,21 +2477,29 @@ ${settings
|
|
|
1620
2477
|
: html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
|
|
1621
2478
|
}
|
|
1622
2479
|
const token = csrfToken(session);
|
|
1623
|
-
|
|
2480
|
+
// What the form shows: a draft (a preset, or a model list) over what is saved.
|
|
2481
|
+
const provider = draft?.provider ?? settings?.provider;
|
|
2482
|
+
const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
|
|
2483
|
+
const model = draft?.model ?? settings?.model ?? '';
|
|
2484
|
+
const selected = (value) => (provider === value ? html ` selected` : html ``);
|
|
2485
|
+
const models = draft?.models ?? [];
|
|
1624
2486
|
return html `<section class="panel" id="assistant">
|
|
1625
2487
|
<h2>Assistant</h2>
|
|
2488
|
+
<form class="presets" method="get" action="${root}/settings"><span class="fine">Fill in for</span>${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<button type="submit" name="preset" value="${preset.id}">${preset.name}</button>`)}</form>
|
|
1626
2489
|
<form method="post" action="${root}/settings/assistant">
|
|
1627
2490
|
<input type="hidden" name="csrf" value="${token}">
|
|
1628
2491
|
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1629
2492
|
<div class="grid-form">
|
|
1630
2493
|
<label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
|
|
1631
|
-
<label>Base URL<input name="base_url" required maxlength="2048" value="${
|
|
1632
|
-
<label>Model<input name="model" required maxlength="200" value="${
|
|
2494
|
+
<label>Base URL<input name="base_url" required maxlength="2048" value="${baseUrl}" placeholder="https://api.anthropic.com"></label>
|
|
2495
|
+
<label>Model<input name="model" required maxlength="200" value="${model}" placeholder="your-model"${models.length > 0 ? html ` list="assistant-models"` : html ``}></label>${models.length > 0
|
|
2496
|
+
? html `<datalist id="assistant-models">${models.map((id) => html `<option value="${id}"></option>`)}</datalist>`
|
|
2497
|
+
: html ``}
|
|
1633
2498
|
<label>API key<input name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="${settings?.keyHint ? 'Leave empty to keep it' : 'Optional for a local model'}"></label>
|
|
1634
2499
|
</div>
|
|
1635
2500
|
<p class="fine">Key: ${settings?.keyHint ? html `set, ending in <code>${settings.keyHint}</code>` : 'none'}. OpenAI-compatible covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio; give the URL that ends in <code>/v1</code>.</p>
|
|
1636
2501
|
${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
|
|
1637
|
-
<button class="primary" type="submit">Save</button>
|
|
2502
|
+
<div class="row-actions"><button class="primary" type="submit">Save</button><button type="submit" formaction="${root}/settings/assistant/models">Load models</button></div>
|
|
1638
2503
|
</form>
|
|
1639
2504
|
${sentTo}
|
|
1640
2505
|
${settings
|
|
@@ -1662,6 +2527,8 @@ ${settings
|
|
|
1662
2527
|
: 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.');
|
|
1663
2528
|
}
|
|
1664
2529
|
const baseUrl = checkedUrl.url;
|
|
2530
|
+
if (namesOwnHost(baseUrl))
|
|
2531
|
+
return settingsPage(session, 400, OWN_HOST_REFUSAL);
|
|
1665
2532
|
if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
1666
2533
|
return settingsPage(session, 400, PRIVATE_URL_REFUSAL);
|
|
1667
2534
|
}
|
|
@@ -1685,6 +2552,71 @@ ${settings
|
|
|
1685
2552
|
}
|
|
1686
2553
|
return redirect(303, `${root}/settings#assistant`);
|
|
1687
2554
|
}
|
|
2555
|
+
/**
|
|
2556
|
+
* The provider's models for the Model field (Load models): the form's provider and base URL,
|
|
2557
|
+
* checked as saving checks them, with the key given, or the saved one when it is for that same
|
|
2558
|
+
* provider and URL. Saves nothing; counted as a message to the assistant.
|
|
2559
|
+
*/
|
|
2560
|
+
async function loadAssistantModels(session, form) {
|
|
2561
|
+
const store = assistantStore();
|
|
2562
|
+
if (!store || !assistantOptions)
|
|
2563
|
+
return notFound(session);
|
|
2564
|
+
if (!isAdmin(session))
|
|
2565
|
+
return adminOnly(session);
|
|
2566
|
+
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
2567
|
+
return tooManyMessages(session, settingsPage);
|
|
2568
|
+
}
|
|
2569
|
+
const provider = form.get('provider');
|
|
2570
|
+
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
2571
|
+
return settingsPage(session, 400, 'Choose a provider from the list.');
|
|
2572
|
+
}
|
|
2573
|
+
const typedModel = (form.get('model') ?? '').trim();
|
|
2574
|
+
const draft = {
|
|
2575
|
+
provider,
|
|
2576
|
+
baseUrl: (form.get('base_url') ?? '').trim(),
|
|
2577
|
+
...(typedModel !== '' ? { model: typedModel } : {}),
|
|
2578
|
+
};
|
|
2579
|
+
const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
|
|
2580
|
+
if (!checkedUrl.ok) {
|
|
2581
|
+
return settingsPage(session, 400, checkedUrl.reason === 'private'
|
|
2582
|
+
? PRIVATE_URL_REFUSAL
|
|
2583
|
+
: 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.', draft);
|
|
2584
|
+
}
|
|
2585
|
+
const baseUrl = checkedUrl.url;
|
|
2586
|
+
if (namesOwnHost(baseUrl))
|
|
2587
|
+
return settingsPage(session, 400, OWN_HOST_REFUSAL, draft);
|
|
2588
|
+
if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
2589
|
+
return settingsPage(session, 400, PRIVATE_URL_REFUSAL, draft);
|
|
2590
|
+
}
|
|
2591
|
+
const typedKey = (form.get('api_key') ?? '').trim();
|
|
2592
|
+
if (typedKey !== '' && !SLOTLOCK_ASSISTANT_API_KEY.test(typedKey)) {
|
|
2593
|
+
return settingsPage(session, 400, 'An API key is up to 4,096 characters without spaces.', draft);
|
|
2594
|
+
}
|
|
2595
|
+
// The saved key only for the provider and URL it was entered for, as a model call sends it.
|
|
2596
|
+
let apiKey = typedKey !== '' ? typedKey : null;
|
|
2597
|
+
if (apiKey === null) {
|
|
2598
|
+
const found = await assistantModel(session);
|
|
2599
|
+
if (found && !('problem' in found) && found.model.provider === provider && found.model.baseUrl === baseUrl) {
|
|
2600
|
+
apiKey = found.model.apiKey;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
try {
|
|
2604
|
+
const listed = await listSlotlockAssistantModels({
|
|
2605
|
+
provider,
|
|
2606
|
+
baseUrl,
|
|
2607
|
+
apiKey,
|
|
2608
|
+
fetch: assistantFetch,
|
|
2609
|
+
timeoutMs: assistantTimeout,
|
|
2610
|
+
});
|
|
2611
|
+
return settingsPage(session, 200, listed.length > 0
|
|
2612
|
+
? `${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.' : ''}`
|
|
2613
|
+
: 'The provider listed no models. Type the model name.', { ...draft, baseUrl, models: listed });
|
|
2614
|
+
}
|
|
2615
|
+
catch (error) {
|
|
2616
|
+
report(error instanceof Error ? error.message : 'assistant model list failed');
|
|
2617
|
+
return settingsPage(session, 502, slotlockAssistantProviderMessage(error), { ...draft, baseUrl });
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
1688
2620
|
async function removeAssistantSettings(session) {
|
|
1689
2621
|
const store = assistantStore();
|
|
1690
2622
|
if (!store)
|
|
@@ -1741,6 +2673,12 @@ ${settings
|
|
|
1741
2673
|
return assistantPage(session, 409, found.problem);
|
|
1742
2674
|
const tenantRef = tenantOf(session);
|
|
1743
2675
|
const member = personOf(session);
|
|
2676
|
+
try {
|
|
2677
|
+
ignoreRejection(assistantOptions?.onMessage?.(tenantRef));
|
|
2678
|
+
}
|
|
2679
|
+
catch {
|
|
2680
|
+
// Metering never changes an answer.
|
|
2681
|
+
}
|
|
1744
2682
|
const stored = await store.getConversation(tenantRef, member);
|
|
1745
2683
|
const entries = boundSlotlockAssistantEntries([
|
|
1746
2684
|
...readSlotlockAssistantEntries(stored?.messages),
|
|
@@ -1869,6 +2807,187 @@ ${settings
|
|
|
1869
2807
|
await store.deleteConversation(tenantOf(session), personOf(session));
|
|
1870
2808
|
return redirect(303, `${root}/assistant`);
|
|
1871
2809
|
}
|
|
2810
|
+
async function planPage(session, status = 200, notice, thanks = false) {
|
|
2811
|
+
const billing = options.billing;
|
|
2812
|
+
if (!billing)
|
|
2813
|
+
return notFound(session);
|
|
2814
|
+
if (session.env === 'test') {
|
|
2815
|
+
// Its own use, counted apart from Live's and never billed.
|
|
2816
|
+
const range = slotlockUsageMonthToDate(now());
|
|
2817
|
+
const tested = options.usage && options.resources.usage
|
|
2818
|
+
? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
|
|
2819
|
+
: null;
|
|
2820
|
+
const stat = (label, value) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${value.toLocaleString('en-GB')}</span></div>`;
|
|
2821
|
+
return respond(status, layout('Plan', html `${pageHead('Plan')}${noticeOf(notice)}<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
|
|
2822
|
+
? html `<section class="panel" id="usage"><h2>Test usage this month</h2><div class="stats">${stat('Operations', tested.totals.operations)}${stat('Bookings', tested.totals.bookings)}${stat('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
|
|
2823
|
+
: html ``}`, session, 'plan'));
|
|
2824
|
+
}
|
|
2825
|
+
const tenantRef = tenantOf(session);
|
|
2826
|
+
const [plan, resourceCount, keys, webhooks] = await Promise.all([
|
|
2827
|
+
billing.plan(tenantRef),
|
|
2828
|
+
// As the limit counts them: archived resources included.
|
|
2829
|
+
options.resources.count
|
|
2830
|
+
? options.resources.count(tenantRef)
|
|
2831
|
+
: options.resources.list(tenantRef).then((listed) => listed.length),
|
|
2832
|
+
options.keys.list({ tenantRef }),
|
|
2833
|
+
options.resources.webhooks?.list(tenantRef),
|
|
2834
|
+
]);
|
|
2835
|
+
const month = slotlockUsageMonthToDate(now());
|
|
2836
|
+
const used = options.usage && options.resources.usage
|
|
2837
|
+
? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
|
|
2838
|
+
: null;
|
|
2839
|
+
const keyName = new Map(keys.map((key) => [key.id, key.name]));
|
|
2840
|
+
const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
|
|
2841
|
+
const token = csrfToken(session);
|
|
2842
|
+
const paying = plan !== null && plan.plan !== 'free';
|
|
2843
|
+
const name = plan ? PLAN_NAMES[plan.plan] : 'No plan';
|
|
2844
|
+
// An organization is offered Team where the deployment sells it; a person, Developer.
|
|
2845
|
+
const offered = session.org !== undefined && billing.teamPrices ? 'team' : 'developer';
|
|
2846
|
+
const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
|
|
2847
|
+
const counted = (value) => value.toLocaleString('en-GB');
|
|
2848
|
+
const use = (label, used, limit) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${counted(used)}${limit === undefined ? html `` : html `<span class="meta"> of ${counted(limit)}</span>`}</span></div>`;
|
|
2849
|
+
// This month's quotas, when the deployment holds tenants to them.
|
|
2850
|
+
const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
|
|
2851
|
+
const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
|
|
2852
|
+
const quotaWarnings = quotas && used
|
|
2853
|
+
? ['operations', 'bookings'].flatMap((metric) => {
|
|
2854
|
+
const quota = quotas[metric];
|
|
2855
|
+
const stop = Math.floor((quota * quotas.stopPercent) / 100);
|
|
2856
|
+
const spent = used.totals[metric];
|
|
2857
|
+
const what = metric === 'operations' ? 'operations' : 'bookings';
|
|
2858
|
+
const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
|
|
2859
|
+
if (spent >= stop) {
|
|
2860
|
+
return [html `<p class="notice warn" role="alert">This month's ${what} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${paying ? html `` : html ` Upgrade for more.`}</p>`];
|
|
2861
|
+
}
|
|
2862
|
+
if (spent >= quota) {
|
|
2863
|
+
const billed = metric === 'operations' && billing.overage && plan?.plan === 'developer' && paying;
|
|
2864
|
+
return [html `<p class="notice warn" role="alert">You have used this month's ${counted(quota)} ${what}.${billed ? ` Those past it are billed at ${billing.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.</p>`];
|
|
2865
|
+
}
|
|
2866
|
+
if (spent * 100 >= quota * 80) {
|
|
2867
|
+
return [html `<p class="notice warn" role="status">You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${what}. They start again on ${resets}.</p>`];
|
|
2868
|
+
}
|
|
2869
|
+
return [];
|
|
2870
|
+
})
|
|
2871
|
+
: [];
|
|
2872
|
+
const warning = html `${plan?.status === 'past_due'
|
|
2873
|
+
? html `<p class="notice warn" role="alert">Your last payment failed. Update your payment method to keep the ${name} plan.</p>`
|
|
2874
|
+
: html ``}${quotaWarnings}`;
|
|
2875
|
+
const free = SLOTLOCK_PLAN_LIMITS.free;
|
|
2876
|
+
const developer = SLOTLOCK_PLAN_LIMITS[offered];
|
|
2877
|
+
const upgrade = paying || !isAdmin(session)
|
|
2878
|
+
? html ``
|
|
2879
|
+
: html `<section class="panel" id="upgrade">
|
|
2880
|
+
<h2>${PLAN_NAMES[offered]}</h2>
|
|
2881
|
+
<p>${String(developer.resources)} resources, ${String(developer.apiKeys)} API keys and ${String(developer.webhookEndpoints)} webhook endpoints${options.quotas ? 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' : ''}` : html ``}.${offered === 'developer' && billing.overage && options.quotas
|
|
2882
|
+
? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
|
|
2883
|
+
: ''}</p>
|
|
2884
|
+
<form method="post" action="${root}/plan/upgrade">
|
|
2885
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
2886
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
2887
|
+
<fieldset class="modes"><legend>Billed</legend>
|
|
2888
|
+
<label class="choice"><input type="radio" name="interval" value="month" checked> Monthly, ${offeredPrices.month} a month</label>
|
|
2889
|
+
<label class="choice"><input type="radio" name="interval" value="year"> Yearly, ${offeredPrices.year} a year</label>
|
|
2890
|
+
</fieldset>
|
|
2891
|
+
<button class="primary" type="submit">Upgrade to ${PLAN_NAMES[offered]}</button>
|
|
2892
|
+
</form>
|
|
2893
|
+
<p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
|
|
2894
|
+
</section>`;
|
|
2895
|
+
const manage = plan?.stripeCustomer && isAdmin(session)
|
|
2896
|
+
? 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>`
|
|
2897
|
+
: html ``;
|
|
2898
|
+
const renews = paying && plan?.currentPeriodEnd
|
|
2899
|
+
? html `<p class="fine">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}.</p>`
|
|
2900
|
+
: html ``;
|
|
2901
|
+
const usageSection = used
|
|
2902
|
+
? html `<section class="panel" id="usage">
|
|
2903
|
+
<h2>Usage this month</h2>
|
|
2904
|
+
<div class="stats">
|
|
2905
|
+
${use('Operations', used.totals.operations, quotas?.operations)}
|
|
2906
|
+
${use('Bookings', used.totals.bookings, quotas?.bookings)}
|
|
2907
|
+
${use('Holds', used.totals.holds, undefined)}
|
|
2908
|
+
${use('Webhook deliveries', used.totals.webhook_deliveries, undefined)}
|
|
2909
|
+
${use('Assistant messages', used.totals.assistant_messages, undefined)}
|
|
2910
|
+
</div>
|
|
2911
|
+
<p class="fine">Since ${month.from}, in UTC. An operation is a call that reached Slotlock, whatever it answered; it shows here within 15 seconds. A booking is a reservation, a calendar event or a confirmed hold, and with holds shows within the hour.</p>
|
|
2912
|
+
${used.keys.length === 0
|
|
2913
|
+
? html ``
|
|
2914
|
+
: html `<div class="table"><table><thead><tr><th>Used by</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Used by">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${String(row.operations)}</td><td data-label="Bookings">${String(row.bookings)}</td><td data-label="Holds">${String(row.holds)}</td></tr>`)}</tbody></table></div>`}
|
|
2915
|
+
</section>`
|
|
2916
|
+
: html ``;
|
|
2917
|
+
const content = html `${thanks ? html `<p class="notice">${notice ?? ''}</p>` : noticeOf(notice)}
|
|
2918
|
+
${warning}
|
|
2919
|
+
${pageHead(`Plan: ${name}`, manage)}
|
|
2920
|
+
<div class="stats">
|
|
2921
|
+
${use('Resources', resourceCount, plan?.limits.resources)}
|
|
2922
|
+
${use('Active API keys', active.length, plan?.limits.apiKeys)}
|
|
2923
|
+
${webhooks ? use('Webhooks', webhooks.length, plan?.limits.webhookEndpoints) : html ``}
|
|
2924
|
+
</div>
|
|
2925
|
+
${renews}
|
|
2926
|
+
${usageSection}
|
|
2927
|
+
${plan ? html `` : html `<p class="fine">Free allows ${String(free.resources)} resources, ${String(free.apiKeys)} API keys and ${String(free.webhookEndpoints)} webhook endpoints${options.quotas ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS.free.operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS.free.bookings)} bookings a month` : html ``}.</p>`}
|
|
2928
|
+
${upgrade}
|
|
2929
|
+
<section class="panel" id="custom">
|
|
2930
|
+
<h2>Custom</h2>
|
|
2931
|
+
<p>Slotlock on your own servers, with support from the people who build it.</p>
|
|
2932
|
+
<a class="button" href="${billing.contact}">Talk to the founder</a>
|
|
2933
|
+
</section>`;
|
|
2934
|
+
return respond(status, layout('Plan', content, session, 'plan'));
|
|
2935
|
+
}
|
|
2936
|
+
async function upgradePlan(session, form) {
|
|
2937
|
+
const billing = options.billing;
|
|
2938
|
+
if (!billing)
|
|
2939
|
+
return notFound(session);
|
|
2940
|
+
if (session.env === 'test')
|
|
2941
|
+
return planPage(session, 400, 'Plans are changed in Live.');
|
|
2942
|
+
if (!isAdmin(session))
|
|
2943
|
+
return planPage(session, 403, 'Only owners of the organization can change its plan.');
|
|
2944
|
+
const interval = form.get('interval') === 'year' ? 'year' : 'month';
|
|
2945
|
+
const tenantRef = tenantOf(session);
|
|
2946
|
+
const plan = await billing.plan(tenantRef);
|
|
2947
|
+
if (plan && plan.plan !== 'free') {
|
|
2948
|
+
return planPage(session, 409, `You are on the ${PLAN_NAMES[plan.plan]} plan already.`);
|
|
2949
|
+
}
|
|
2950
|
+
let url;
|
|
2951
|
+
try {
|
|
2952
|
+
url = await billing.checkout({
|
|
2953
|
+
tenantRef,
|
|
2954
|
+
interval,
|
|
2955
|
+
...(session.org !== undefined && billing.teamPrices ? { plan: 'team' } : {}),
|
|
2956
|
+
customer: plan?.stripeCustomer ?? null,
|
|
2957
|
+
successUrl: `${publicRoot}/plan?checkout=done`,
|
|
2958
|
+
cancelUrl: `${publicRoot}/plan`,
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
catch (error) {
|
|
2962
|
+
if (error?.code === 'subscription_exists') {
|
|
2963
|
+
return planPage(session, 409, 'You pay for a subscription already. Its plan shows here once Stripe confirms it; to change it, use Manage billing.');
|
|
2964
|
+
}
|
|
2965
|
+
ignoreRejection(options.onError?.(error));
|
|
2966
|
+
return planPage(session, 502, 'The payment page did not open. Try again in a minute.');
|
|
2967
|
+
}
|
|
2968
|
+
return redirect(303, url);
|
|
2969
|
+
}
|
|
2970
|
+
async function manageBilling(session) {
|
|
2971
|
+
const billing = options.billing;
|
|
2972
|
+
if (!billing)
|
|
2973
|
+
return notFound(session);
|
|
2974
|
+
if (session.env === 'test')
|
|
2975
|
+
return planPage(session, 400, 'Billing is managed in Live.');
|
|
2976
|
+
if (!isAdmin(session))
|
|
2977
|
+
return planPage(session, 403, 'Only owners of the organization can manage its billing.');
|
|
2978
|
+
const plan = await billing.plan(tenantOf(session));
|
|
2979
|
+
if (!plan?.stripeCustomer)
|
|
2980
|
+
return planPage(session, 409, 'There is no billing to manage yet.');
|
|
2981
|
+
let url;
|
|
2982
|
+
try {
|
|
2983
|
+
url = await billing.portal({ customer: plan.stripeCustomer, returnUrl: `${publicRoot}/plan` });
|
|
2984
|
+
}
|
|
2985
|
+
catch (error) {
|
|
2986
|
+
ignoreRejection(options.onError?.(error));
|
|
2987
|
+
return planPage(session, 502, 'Billing did not open. Try again in a minute.');
|
|
2988
|
+
}
|
|
2989
|
+
return redirect(303, url);
|
|
2990
|
+
}
|
|
1872
2991
|
/** The page a form was sent from, to show what went wrong with it. */
|
|
1873
2992
|
function pageFor(route) {
|
|
1874
2993
|
if (route.startsWith('/keys'))
|
|
@@ -1877,12 +2996,21 @@ ${settings
|
|
|
1877
2996
|
return webhooksPage;
|
|
1878
2997
|
if (route.startsWith('/waitlist') && options.resources.waitlist)
|
|
1879
2998
|
return waitlistPage;
|
|
2999
|
+
if (route.startsWith('/approvals') && options.resources.approvals)
|
|
3000
|
+
return approvalsPage;
|
|
3001
|
+
if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
|
|
3002
|
+
return locationsPage;
|
|
3003
|
+
}
|
|
1880
3004
|
if (route.startsWith('/resources') || route === '/hours')
|
|
1881
3005
|
return resourcesPage;
|
|
1882
3006
|
if (route.startsWith('/assistant') && assistantStore())
|
|
1883
3007
|
return assistantPage;
|
|
1884
3008
|
if (route.startsWith('/settings'))
|
|
1885
3009
|
return settingsPage;
|
|
3010
|
+
if (route.startsWith('/plan') && options.billing)
|
|
3011
|
+
return planPage;
|
|
3012
|
+
if (route.startsWith('/teams') && teams)
|
|
3013
|
+
return teamsPage;
|
|
1886
3014
|
return overviewPage;
|
|
1887
3015
|
}
|
|
1888
3016
|
function hoursPage(session, state) {
|
|
@@ -1937,6 +3065,7 @@ ${state.unshown
|
|
|
1937
3065
|
return resources.find((resource) => resource.id === id) ?? null;
|
|
1938
3066
|
}
|
|
1939
3067
|
const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
|
|
3068
|
+
const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
1940
3069
|
async function hoursEditor(session, url) {
|
|
1941
3070
|
const id = url.searchParams.get('resource');
|
|
1942
3071
|
if (id === null) {
|
|
@@ -2024,8 +3153,83 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2024
3153
|
return respond(200, layout('Your new key', content, session, 'keys'));
|
|
2025
3154
|
}
|
|
2026
3155
|
/** Act in another of the session's organizations: the same session, sealed again. */
|
|
2027
|
-
function
|
|
3156
|
+
function switchEnvironment(session, form) {
|
|
3157
|
+
const wanted = form.get('environment');
|
|
3158
|
+
if (wanted !== 'live' && wanted !== 'test') {
|
|
3159
|
+
return respond(400, layout('Refused', html `<p class="notice">Choose Live or Test.</p>`, session));
|
|
3160
|
+
}
|
|
3161
|
+
const { env: _, ...rest } = session;
|
|
3162
|
+
const sealed = seal('slotlock-dashboard-session-v1', wanted === 'test' ? { ...rest, env: 'test' } : rest);
|
|
3163
|
+
const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
|
|
3164
|
+
return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Teams on Cloud: the person's GitHub organizations, as read at sign-in, each on or off. An owner
|
|
3168
|
+
* turns one on or off; a member asks an owner; one that is on is a workspace to switch to.
|
|
3169
|
+
*/
|
|
3170
|
+
async function teamsPage(session, status = 200, notice) {
|
|
3171
|
+
if (!teams)
|
|
3172
|
+
return nothingHere(session);
|
|
3173
|
+
const orgs = session.orgs ?? [];
|
|
3174
|
+
const enabled = orgs.length > 0 ? await teams.enabled(orgs.map((org) => org.id)) : new Set();
|
|
3175
|
+
enabledOf.set(session, enabled);
|
|
3176
|
+
const token = csrfToken(session);
|
|
3177
|
+
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}">`;
|
|
3178
|
+
const rows = orgs.map((org) => {
|
|
3179
|
+
const on = enabled.has(org.id);
|
|
3180
|
+
const owner = org.role === 'admin';
|
|
3181
|
+
const actions = on
|
|
3182
|
+
? 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
|
|
3183
|
+
? 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>
|
|
3184
|
+
<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>`
|
|
3185
|
+
: html ``}`
|
|
3186
|
+
: owner
|
|
3187
|
+
? html `<form class="inline" method="post" action="${root}/teams/enable">${hidden(org)}<button class="primary" type="submit">Turn on</button></form>`
|
|
3188
|
+
: html `<span class="muted">An owner can turn it on</span>`;
|
|
3189
|
+
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>`;
|
|
3190
|
+
});
|
|
3191
|
+
const content = html `${noticeOf(notice)}${pageHead('Teams')}
|
|
3192
|
+
<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>
|
|
3193
|
+
${rows.length === 0
|
|
3194
|
+
? 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.')
|
|
3195
|
+
: 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>
|
|
3196
|
+
<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>`}`;
|
|
3197
|
+
return respond(status, layout('Teams', content, session, 'teams'));
|
|
3198
|
+
}
|
|
3199
|
+
async function changeTeam(session, form, on) {
|
|
3200
|
+
if (!teams)
|
|
3201
|
+
return nothingHere(session);
|
|
3202
|
+
const org = session.orgs?.find((candidate) => candidate.id === form.get('organization'));
|
|
3203
|
+
if (!org)
|
|
3204
|
+
return teamsPage(session, 400, 'That is not one of your organizations.');
|
|
3205
|
+
if (org.role !== 'admin')
|
|
3206
|
+
return teamsPage(session, 403, `Only an owner of ${org.login} can turn Slotlock ${on ? 'on' : 'off'} for it.`);
|
|
3207
|
+
// Who owns it was read as the person signed in: only a recent sign-in is trusted to say so.
|
|
3208
|
+
if (now() - (session.exp - SESSION_TTL_MS) > FRESH_SIGN_IN_MS) {
|
|
3209
|
+
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.`);
|
|
3210
|
+
}
|
|
3211
|
+
if (on) {
|
|
3212
|
+
await teams.enable({ id: org.id, login: org.login }, personOf(session));
|
|
3213
|
+
return teamsPage(session, 200, `Slotlock is on for ${org.login}. Its members pick it from the workspace menu.`);
|
|
3214
|
+
}
|
|
3215
|
+
if (form.get('confirm') !== org.login)
|
|
3216
|
+
return teamsPage(session, 400, `Type ${org.login} to turn it off.`);
|
|
3217
|
+
await teams.disable(org.id, personOf(session));
|
|
3218
|
+
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.`);
|
|
3219
|
+
}
|
|
3220
|
+
async function switchOrganization(session, form) {
|
|
2028
3221
|
const wanted = form.get('organization') ?? '';
|
|
3222
|
+
if (teams) {
|
|
3223
|
+
// Their own tenant, or an organization of theirs that is turned on now.
|
|
3224
|
+
const org = session.orgs?.find((candidate) => candidate.id === wanted);
|
|
3225
|
+
if (wanted !== '' && (!org || !(await teams.enabled([wanted])).has(wanted))) {
|
|
3226
|
+
return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization. An owner turns it on from Teams.</p>`, session));
|
|
3227
|
+
}
|
|
3228
|
+
const { org: _, ...personal } = session;
|
|
3229
|
+
const sealed = seal('slotlock-dashboard-session-v1', wanted === '' ? personal : { ...personal, org: wanted });
|
|
3230
|
+
const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
|
|
3231
|
+
return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
|
|
3232
|
+
}
|
|
2029
3233
|
const org = session.orgs?.find((candidate) => candidate.id === wanted && served?.has(candidate.id));
|
|
2030
3234
|
if (!served || !org) {
|
|
2031
3235
|
return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization.</p>`, session));
|
|
@@ -2046,7 +3250,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2046
3250
|
code_challenge: challenge,
|
|
2047
3251
|
code_challenge_method: 'S256',
|
|
2048
3252
|
// Reading organization membership needs read:org; a personal tenant needs no scope at all.
|
|
2049
|
-
...(
|
|
3253
|
+
...(readsOrganizations ? { scope: 'read:org' } : {}),
|
|
2050
3254
|
}).toString();
|
|
2051
3255
|
const sealed = seal('slotlock-dashboard-sign-in-v1', {
|
|
2052
3256
|
state,
|
|
@@ -2152,7 +3356,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2152
3356
|
continue;
|
|
2153
3357
|
}
|
|
2154
3358
|
const id = String(organization.id);
|
|
2155
|
-
if (served?.has(id)) {
|
|
3359
|
+
if (teams || served?.has(id)) {
|
|
2156
3360
|
found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
|
|
2157
3361
|
}
|
|
2158
3362
|
}
|
|
@@ -2160,13 +3364,146 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2160
3364
|
.sort((a, b) => a.login.localeCompare(b.login))
|
|
2161
3365
|
.slice(0, MAX_SESSION_ORGANIZATIONS);
|
|
2162
3366
|
}
|
|
3367
|
+
/** Google's sign-in (OpenID Connect, code flow with PKCE and a nonce), back to the same callback. */
|
|
3368
|
+
async function startGoogleSignIn(provider) {
|
|
3369
|
+
const state = randomBytes(32).toString('base64url');
|
|
3370
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
3371
|
+
const nonce = randomBytes(24).toString('base64url');
|
|
3372
|
+
const target = new URL(provider.authorizeUrl ?? 'https://accounts.google.com/o/oauth2/v2/auth');
|
|
3373
|
+
target.search = new URLSearchParams({
|
|
3374
|
+
client_id: provider.clientId,
|
|
3375
|
+
redirect_uri: callbackUrl,
|
|
3376
|
+
response_type: 'code',
|
|
3377
|
+
scope: 'openid email',
|
|
3378
|
+
state,
|
|
3379
|
+
nonce,
|
|
3380
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
3381
|
+
code_challenge_method: 'S256',
|
|
3382
|
+
}).toString();
|
|
3383
|
+
const sealed = seal('slotlock-dashboard-sign-in-v1', {
|
|
3384
|
+
state,
|
|
3385
|
+
verifier,
|
|
3386
|
+
nonce,
|
|
3387
|
+
idp: 'google',
|
|
3388
|
+
exp: now() + SIGN_IN_TTL_MS,
|
|
3389
|
+
});
|
|
3390
|
+
return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
|
|
3391
|
+
}
|
|
3392
|
+
/** Google's signing keys by key id, kept for as long as its answer says (an hour by default). */
|
|
3393
|
+
let googleKeys = null;
|
|
3394
|
+
async function readGoogleKeys(provider, refresh) {
|
|
3395
|
+
if (googleKeys && !refresh && googleKeys.until > now())
|
|
3396
|
+
return googleKeys.keys;
|
|
3397
|
+
const response = await fetchImpl(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs', {
|
|
3398
|
+
headers: { Accept: 'application/json' },
|
|
3399
|
+
redirect: 'manual',
|
|
3400
|
+
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
|
|
3401
|
+
});
|
|
3402
|
+
if (!response.ok)
|
|
3403
|
+
throw new Error(`Google keys answered HTTP ${response.status}`);
|
|
3404
|
+
const body = await response.json();
|
|
3405
|
+
const keys = new Map();
|
|
3406
|
+
for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
|
|
3407
|
+
if (!isRecord(jwk) || jwk.kty !== 'RSA' || typeof jwk.kid !== 'string' || (jwk.alg !== undefined && jwk.alg !== 'RS256'))
|
|
3408
|
+
continue;
|
|
3409
|
+
const key = await globalThis.crypto.subtle
|
|
3410
|
+
.importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e, alg: 'RS256', ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'])
|
|
3411
|
+
.catch(() => null);
|
|
3412
|
+
if (key)
|
|
3413
|
+
keys.set(jwk.kid, key);
|
|
3414
|
+
}
|
|
3415
|
+
const maxAge = Number(/max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]);
|
|
3416
|
+
googleKeys = { keys, until: now() + (Number.isFinite(maxAge) && maxAge > 0 ? Math.min(maxAge * 1000, 86_400_000) : GOOGLE_KEYS_DEFAULT_TTL_MS) };
|
|
3417
|
+
return keys;
|
|
3418
|
+
}
|
|
3419
|
+
/** 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). */
|
|
3420
|
+
async function googleSigned(provider, header, payload, signature) {
|
|
3421
|
+
let parsed;
|
|
3422
|
+
try {
|
|
3423
|
+
parsed = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
|
|
3424
|
+
}
|
|
3425
|
+
catch {
|
|
3426
|
+
return false;
|
|
3427
|
+
}
|
|
3428
|
+
if (!isRecord(parsed) || parsed.alg !== 'RS256' || typeof parsed.kid !== 'string')
|
|
3429
|
+
return false;
|
|
3430
|
+
let key = (await readGoogleKeys(provider, false)).get(parsed.kid);
|
|
3431
|
+
if (!key)
|
|
3432
|
+
key = (await readGoogleKeys(provider, true)).get(parsed.kid);
|
|
3433
|
+
if (!key)
|
|
3434
|
+
return false;
|
|
3435
|
+
return globalThis.crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, Buffer.from(signature, 'base64url'), Buffer.from(`${header}.${payload}`, 'ascii'));
|
|
3436
|
+
}
|
|
3437
|
+
/**
|
|
3438
|
+
* The person Google signed in, from the ID token its token endpoint returned for this code. The
|
|
3439
|
+
* token's signature is checked against Google's published keys, and its issuer, audience (and
|
|
3440
|
+
* azp), expiry and nonce (OpenID Connect Core 1.0, 3.1.3.7).
|
|
3441
|
+
*/
|
|
3442
|
+
async function askGoogle(provider, code, verifier, nonce) {
|
|
3443
|
+
try {
|
|
3444
|
+
const response = await fetchImpl(provider.tokenUrl ?? 'https://oauth2.googleapis.com/token', {
|
|
3445
|
+
method: 'POST',
|
|
3446
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
3447
|
+
body: new URLSearchParams({
|
|
3448
|
+
code,
|
|
3449
|
+
client_id: provider.clientId,
|
|
3450
|
+
client_secret: provider.clientSecret,
|
|
3451
|
+
redirect_uri: callbackUrl,
|
|
3452
|
+
grant_type: 'authorization_code',
|
|
3453
|
+
code_verifier: verifier,
|
|
3454
|
+
}).toString(),
|
|
3455
|
+
redirect: 'manual',
|
|
3456
|
+
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
|
|
3457
|
+
});
|
|
3458
|
+
if (!response.ok) {
|
|
3459
|
+
report(`Google token exchange failed: HTTP ${response.status}`);
|
|
3460
|
+
return null;
|
|
3461
|
+
}
|
|
3462
|
+
const body = await response.json().catch(() => null);
|
|
3463
|
+
const token = isRecord(body) && typeof body.id_token === 'string' ? body.id_token : null;
|
|
3464
|
+
const [header, payload, signature, ...extra] = token?.split('.') ?? [];
|
|
3465
|
+
if (!header || !payload || !signature || extra.length > 0 || ![header, payload, signature].every((part) => BASE64URL.test(part))) {
|
|
3466
|
+
return null;
|
|
3467
|
+
}
|
|
3468
|
+
// Signed by Google, with one of the keys it publishes (RS256), whichever way the token came.
|
|
3469
|
+
if (!(await googleSigned(provider, header, payload, signature))) {
|
|
3470
|
+
report('Google sign-in refused: the ID token is not signed by Google');
|
|
3471
|
+
return null;
|
|
3472
|
+
}
|
|
3473
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
3474
|
+
if (!isRecord(claims))
|
|
3475
|
+
return null;
|
|
3476
|
+
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
3477
|
+
if ((claims.iss !== 'https://accounts.google.com' && claims.iss !== 'accounts.google.com') ||
|
|
3478
|
+
!audience.includes(provider.clientId) ||
|
|
3479
|
+
(audience.length > 1 && claims.azp !== provider.clientId) ||
|
|
3480
|
+
typeof claims.exp !== 'number' ||
|
|
3481
|
+
claims.exp * 1000 <= now() ||
|
|
3482
|
+
typeof claims.nonce !== 'string' ||
|
|
3483
|
+
!safeEqual(claims.nonce, nonce) ||
|
|
3484
|
+
typeof claims.sub !== 'string' ||
|
|
3485
|
+
!GOOGLE_SUBJECT.test(claims.sub)) {
|
|
3486
|
+
report('Google sign-in refused: the ID token did not check out');
|
|
3487
|
+
return null;
|
|
3488
|
+
}
|
|
3489
|
+
// The email names the person on the page only, and only once Google has verified it.
|
|
3490
|
+
const email = claims.email_verified === true && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
|
|
3491
|
+
? claims.email
|
|
3492
|
+
: 'Google account';
|
|
3493
|
+
return { uid: claims.sub, login: email, idp: 'google' };
|
|
3494
|
+
}
|
|
3495
|
+
catch (error) {
|
|
3496
|
+
report(`Google sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
|
|
3497
|
+
return null;
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
2163
3500
|
async function askGitHub(code, verifier) {
|
|
2164
3501
|
try {
|
|
2165
3502
|
const token = await exchangeCode(code, verifier);
|
|
2166
3503
|
if (token === null)
|
|
2167
3504
|
return null;
|
|
2168
3505
|
const user = await readGitHubUser(token);
|
|
2169
|
-
if (!user || !
|
|
3506
|
+
if (!user || !readsOrganizations)
|
|
2170
3507
|
return user;
|
|
2171
3508
|
const orgs = await readMemberships(token);
|
|
2172
3509
|
return orgs === null ? null : { ...user, orgs };
|
|
@@ -2208,7 +3545,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2208
3545
|
if (!(await options.state.use('sign_in', pending.state, pending.exp))) {
|
|
2209
3546
|
return signedOutPage(400, SIGN_IN_AGAIN, [clearSignIn]);
|
|
2210
3547
|
}
|
|
2211
|
-
user =
|
|
3548
|
+
user =
|
|
3549
|
+
pending.idp === 'google'
|
|
3550
|
+
? google && typeof pending.nonce === 'string'
|
|
3551
|
+
? await askGoogle(google, code, verifier, pending.nonce)
|
|
3552
|
+
: null
|
|
3553
|
+
: await askGitHub(code, verifier);
|
|
2212
3554
|
}
|
|
2213
3555
|
finally {
|
|
2214
3556
|
signInsInFlight -= 1;
|
|
@@ -2221,12 +3563,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2221
3563
|
}
|
|
2222
3564
|
}
|
|
2223
3565
|
if (!user) {
|
|
2224
|
-
return signedOutPage(502, 'GitHub did not complete the sign-in. Try again
|
|
3566
|
+
return signedOutPage(502, `${pending.idp === 'google' ? 'Google' : 'GitHub'} did not complete the sign-in. Try again.`, [clearSignIn]);
|
|
2225
3567
|
}
|
|
2226
|
-
if (!allowed(user.uid)) {
|
|
2227
|
-
return signedOutPage(403, `This Slotlock server does not let
|
|
2228
|
-
clearSignIn,
|
|
2229
|
-
]);
|
|
3568
|
+
if (!allowed(user.uid, user.idp)) {
|
|
3569
|
+
return signedOutPage(403, `This Slotlock server does not let ${user.idp === 'google' ? user.login : `@${user.login}`} sign in.`, [clearSignIn]);
|
|
2230
3570
|
}
|
|
2231
3571
|
const orgs = user.orgs ?? [];
|
|
2232
3572
|
if (served && orgs.length === 0) {
|
|
@@ -2237,7 +3577,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2237
3577
|
login: user.login,
|
|
2238
3578
|
sid: randomBytes(24).toString('base64url'),
|
|
2239
3579
|
exp: now() + SESSION_TTL_MS,
|
|
2240
|
-
...(
|
|
3580
|
+
...(user.idp === 'google' ? { idp: 'google' } : {}),
|
|
3581
|
+
...(served ? { orgs, org: orgs[0].id } : teams && user.idp !== 'google' ? { orgs } : {}),
|
|
2241
3582
|
});
|
|
2242
3583
|
return redirect(303, root, [
|
|
2243
3584
|
clearSignIn,
|
|
@@ -2277,6 +3618,25 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2277
3618
|
return keysPage(session, 400, 'Choose an access level and an expiry from the lists.');
|
|
2278
3619
|
}
|
|
2279
3620
|
const days = KEY_EXPIRY_DAYS[expiry];
|
|
3621
|
+
const limited = {
|
|
3622
|
+
locations: [...new Set(form.getAll('limit_location'))],
|
|
3623
|
+
groups: [...new Set(form.getAll('limit_group'))],
|
|
3624
|
+
};
|
|
3625
|
+
const resourceScope = limited.locations.length + limited.groups.length > 0 ? limited : undefined;
|
|
3626
|
+
const limits = keyLimitsOf(form);
|
|
3627
|
+
if (typeof limits === 'string')
|
|
3628
|
+
return keysPage(session, 400, limits);
|
|
3629
|
+
if (resourceScope) {
|
|
3630
|
+
if (scopes.includes('admin')) {
|
|
3631
|
+
return keysPage(session, 400, 'A key limited to locations or groups reads and writes only: choose that access.');
|
|
3632
|
+
}
|
|
3633
|
+
// Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
|
|
3634
|
+
// plans allows them.
|
|
3635
|
+
const plan = options.billing ? await options.billing.plan(tenantOf(session)) : null;
|
|
3636
|
+
if (plan?.plan === 'free') {
|
|
3637
|
+
return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Plan.');
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
2280
3640
|
try {
|
|
2281
3641
|
const created = await options.keys.create({
|
|
2282
3642
|
tenantRef: tenantOf(session),
|
|
@@ -2284,6 +3644,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2284
3644
|
scopes,
|
|
2285
3645
|
createdBy: personOf(session),
|
|
2286
3646
|
origin: 'dashboard',
|
|
3647
|
+
...(resourceScope ? { resourceScope } : {}),
|
|
3648
|
+
...(Object.keys(limits).length > 0 ? { limits } : {}),
|
|
2287
3649
|
...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
|
|
2288
3650
|
});
|
|
2289
3651
|
return shownOncePage(session, created.key, created.apiKey, false);
|
|
@@ -2293,8 +3655,13 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2293
3655
|
if (code === 'invalid_api_key_name') {
|
|
2294
3656
|
return keysPage(session, 400, 'Give the key a name of 1 to 100 characters.');
|
|
2295
3657
|
}
|
|
3658
|
+
if (code === 'invalid_api_key_resource_scope') {
|
|
3659
|
+
return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
|
|
3660
|
+
}
|
|
2296
3661
|
if (code === 'api_key_limit_reached') {
|
|
2297
|
-
return keysPage(session, 409,
|
|
3662
|
+
return keysPage(session, 409, options.billing
|
|
3663
|
+
? 'You have as many active keys as your plan allows. Revoke one first, or see Plan.'
|
|
3664
|
+
: 'You have as many keys as Slotlock allows. Revoke one first.');
|
|
2298
3665
|
}
|
|
2299
3666
|
throw error;
|
|
2300
3667
|
}
|
|
@@ -2305,6 +3672,40 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2305
3672
|
actor: personOf(session),
|
|
2306
3673
|
origin: 'dashboard',
|
|
2307
3674
|
});
|
|
3675
|
+
/** A key's limits from its form (RFC 0004 A5): each empty field the server's; else what is wrong. */
|
|
3676
|
+
function keyLimitsOf(form) {
|
|
3677
|
+
const whole = (name, most) => {
|
|
3678
|
+
const text = (form.get(name) ?? '').trim();
|
|
3679
|
+
if (text === '')
|
|
3680
|
+
return undefined;
|
|
3681
|
+
const value = /^[0-9]{1,7}$/.test(text) ? Number(text) : Number.NaN;
|
|
3682
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= most ? value : null;
|
|
3683
|
+
};
|
|
3684
|
+
const maxHolds = whole('max_holds', 10_000);
|
|
3685
|
+
const maxHeldHours = whole('max_held_hours', 876_000);
|
|
3686
|
+
const requestsPerMinute = whole('requests_per_minute', 100_000);
|
|
3687
|
+
if (maxHolds === null || maxHeldHours === null || requestsPerMinute === null) {
|
|
3688
|
+
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.';
|
|
3689
|
+
}
|
|
3690
|
+
return {
|
|
3691
|
+
...(maxHolds !== undefined ? { maxHolds } : {}),
|
|
3692
|
+
...(maxHeldHours !== undefined ? { maxHeldHours } : {}),
|
|
3693
|
+
...(requestsPerMinute !== undefined ? { requestsPerMinute } : {}),
|
|
3694
|
+
...(form.get('requires_approval') === 'on' ? { requiresApproval: true } : {}),
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
async function saveKeyLimits(session, form) {
|
|
3698
|
+
const id = form.get('id') ?? '';
|
|
3699
|
+
if (!UUID_PATTERN.test(id))
|
|
3700
|
+
return keysPage(session, 404, 'That key is not one of yours.');
|
|
3701
|
+
const limits = keyLimitsOf(form);
|
|
3702
|
+
if (typeof limits === 'string')
|
|
3703
|
+
return keysPage(session, 400, limits);
|
|
3704
|
+
if (!(await options.keys.setLimits({ tenantRef: tenantOf(session), id, limits }))) {
|
|
3705
|
+
return keysPage(session, 404, 'That key is not an active key of yours.');
|
|
3706
|
+
}
|
|
3707
|
+
return redirect(303, `${root}/keys`);
|
|
3708
|
+
}
|
|
2308
3709
|
async function changeKey(session, form, change) {
|
|
2309
3710
|
const id = form.get('id') ?? '';
|
|
2310
3711
|
if (!UUID_PATTERN.test(id))
|
|
@@ -2332,7 +3733,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2332
3733
|
catch (error) {
|
|
2333
3734
|
const code = error.code;
|
|
2334
3735
|
if (code === 'resource_limit_reached') {
|
|
2335
|
-
|
|
3736
|
+
const limit = error.limit;
|
|
3737
|
+
return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
|
|
3738
|
+
? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Plan.`
|
|
3739
|
+
: `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
|
|
2336
3740
|
}
|
|
2337
3741
|
if (code === 'invalid_timezone') {
|
|
2338
3742
|
return resourcesPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
|
|
@@ -2382,7 +3786,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2382
3786
|
try {
|
|
2383
3787
|
first =
|
|
2384
3788
|
checked === 'ok' &&
|
|
2385
|
-
(await options.state.use('form', once, session.exp, session.uid));
|
|
3789
|
+
(await options.state.use('form', once, session.exp, session.idp === 'google' ? personOf(session) : session.uid));
|
|
2386
3790
|
}
|
|
2387
3791
|
catch (error) {
|
|
2388
3792
|
if (error.code !== 'dashboard_form_limit')
|
|
@@ -2393,6 +3797,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2393
3797
|
return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
|
|
2394
3798
|
}
|
|
2395
3799
|
}
|
|
3800
|
+
// A tenant new to plans is put on the default plan before it adds what plans limit.
|
|
3801
|
+
if (route === '/keys' || route === '/resources' || route === '/webhooks') {
|
|
3802
|
+
await options.billing?.enrol?.(tenantOf(session));
|
|
3803
|
+
}
|
|
2396
3804
|
switch (route) {
|
|
2397
3805
|
case '/sign-out':
|
|
2398
3806
|
await options.state.endSession(session.sid, session.exp);
|
|
@@ -2403,16 +3811,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2403
3811
|
return changeKey(session, form, 'rotate');
|
|
2404
3812
|
case '/keys/revoke':
|
|
2405
3813
|
return changeKey(session, form, 'revoke');
|
|
3814
|
+
case '/keys/limits':
|
|
3815
|
+
return saveKeyLimits(session, form);
|
|
2406
3816
|
case '/resources':
|
|
2407
3817
|
return addResource(session, form);
|
|
2408
3818
|
case '/hours':
|
|
2409
3819
|
return saveHours(session, form);
|
|
2410
3820
|
case '/organization':
|
|
2411
3821
|
return switchOrganization(session, form);
|
|
3822
|
+
case '/environment':
|
|
3823
|
+
return switchEnvironment(session, form);
|
|
2412
3824
|
case '/resources/rules':
|
|
2413
3825
|
return saveRules(session, form);
|
|
2414
3826
|
case '/resources/pool':
|
|
2415
3827
|
return savePool(session, form);
|
|
3828
|
+
case '/resources/profile':
|
|
3829
|
+
return saveProfile(session, form);
|
|
3830
|
+
case '/resources/place':
|
|
3831
|
+
return savePlace(session, form);
|
|
3832
|
+
case '/resources/parent':
|
|
3833
|
+
return saveParent(session, form);
|
|
3834
|
+
case '/locations':
|
|
3835
|
+
return saveLocation(session, form);
|
|
3836
|
+
case '/locations/delete':
|
|
3837
|
+
return deleteGrouping(session, form, 'location');
|
|
3838
|
+
case '/groups':
|
|
3839
|
+
return saveGroup(session, form);
|
|
3840
|
+
case '/groups/delete':
|
|
3841
|
+
return deleteGrouping(session, form, 'group');
|
|
2416
3842
|
case '/webhooks':
|
|
2417
3843
|
return addWebhook(session, form);
|
|
2418
3844
|
case '/webhooks/delete':
|
|
@@ -2421,6 +3847,14 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2421
3847
|
return changeWebhook(session, form, 'enable');
|
|
2422
3848
|
case '/waitlist/leave':
|
|
2423
3849
|
return leaveWaitlist(session, form);
|
|
3850
|
+
case '/approvals/decide':
|
|
3851
|
+
return decideApproval(session, form);
|
|
3852
|
+
case '/sandbox/seed':
|
|
3853
|
+
return sandboxAction(session, form, 'seed');
|
|
3854
|
+
case '/sandbox/reset':
|
|
3855
|
+
return sandboxAction(session, form, 'reset');
|
|
3856
|
+
case '/sandbox/fast-forward':
|
|
3857
|
+
return sandboxAction(session, form, 'fast-forward');
|
|
2424
3858
|
case '/assistant':
|
|
2425
3859
|
return sendToAssistant(session, form);
|
|
2426
3860
|
case '/assistant/approve':
|
|
@@ -2431,10 +3865,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2431
3865
|
return resetAssistant(session);
|
|
2432
3866
|
case '/settings/assistant':
|
|
2433
3867
|
return saveAssistantSettings(session, form);
|
|
3868
|
+
case '/settings/assistant/models':
|
|
3869
|
+
return loadAssistantModels(session, form);
|
|
2434
3870
|
case '/settings/assistant/test':
|
|
2435
3871
|
return testAssistant(session);
|
|
2436
3872
|
case '/settings/assistant/remove':
|
|
2437
3873
|
return removeAssistantSettings(session);
|
|
3874
|
+
case '/plan/upgrade':
|
|
3875
|
+
return upgradePlan(session, form);
|
|
3876
|
+
case '/plan/manage':
|
|
3877
|
+
return manageBilling(session);
|
|
3878
|
+
case '/teams/enable':
|
|
3879
|
+
return changeTeam(session, form, true);
|
|
3880
|
+
case '/teams/disable':
|
|
3881
|
+
return changeTeam(session, form, false);
|
|
2438
3882
|
default:
|
|
2439
3883
|
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
2440
3884
|
}
|
|
@@ -2458,11 +3902,21 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2458
3902
|
case '/resources':
|
|
2459
3903
|
case '/webhooks':
|
|
2460
3904
|
case '/waitlist':
|
|
3905
|
+
case '/approvals':
|
|
2461
3906
|
case '/assistant':
|
|
3907
|
+
case '/plan':
|
|
3908
|
+
case '/teams':
|
|
2462
3909
|
case '/settings': {
|
|
2463
3910
|
const session = await readSession(request);
|
|
2464
3911
|
if (!session)
|
|
2465
3912
|
return redirect(303, root);
|
|
3913
|
+
if (route === '/teams')
|
|
3914
|
+
return await teamsPage(session);
|
|
3915
|
+
if (route === '/plan') {
|
|
3916
|
+
return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
|
|
3917
|
+
? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
|
|
3918
|
+
: undefined, url.searchParams.get('checkout') === 'done');
|
|
3919
|
+
}
|
|
2466
3920
|
if (route === '/keys')
|
|
2467
3921
|
return await keysPage(session);
|
|
2468
3922
|
if (route === '/resources')
|
|
@@ -2471,22 +3925,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2471
3925
|
return await webhooksPage(session);
|
|
2472
3926
|
if (route === '/waitlist')
|
|
2473
3927
|
return await waitlistPage(session);
|
|
3928
|
+
if (route === '/approvals')
|
|
3929
|
+
return await approvalsPage(session);
|
|
2474
3930
|
if (route === '/assistant')
|
|
2475
3931
|
return await assistantPage(session);
|
|
2476
|
-
|
|
3932
|
+
// A provider preset fills the assistant's form; nothing is saved until Save.
|
|
3933
|
+
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
|
|
3934
|
+
return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
|
|
2477
3935
|
}
|
|
2478
3936
|
case '/style.css':
|
|
2479
3937
|
return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
2480
3938
|
case '/app.js':
|
|
2481
3939
|
return asset(DASHBOARD_JS, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
|
|
3940
|
+
case '/favicon.svg':
|
|
3941
|
+
return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
|
|
2482
3942
|
case '/sign-in':
|
|
2483
|
-
return
|
|
3943
|
+
return google && url.searchParams.get('with') === 'google'
|
|
3944
|
+
? await startGoogleSignIn(google)
|
|
3945
|
+
: await startSignIn();
|
|
2484
3946
|
case '/callback':
|
|
2485
3947
|
return await finishSignIn(request, url);
|
|
2486
3948
|
case '/hours': {
|
|
2487
3949
|
const session = await readSession(request);
|
|
2488
3950
|
return session ? await hoursEditor(session, url) : redirect(303, root);
|
|
2489
3951
|
}
|
|
3952
|
+
case '/locations': {
|
|
3953
|
+
const session = await readSession(request);
|
|
3954
|
+
return session ? await locationsPage(session) : redirect(303, root);
|
|
3955
|
+
}
|
|
2490
3956
|
default: {
|
|
2491
3957
|
const resourceId = RESOURCE_PAGE.exec(route)?.[1];
|
|
2492
3958
|
if (resourceId !== undefined) {
|
|
@@ -2631,6 +4097,12 @@ code, pre, kbd, input[readonly] { font-family: var(--mono); font-size: 12.5px; }
|
|
|
2631
4097
|
.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; }
|
|
2632
4098
|
.workspace select:focus { box-shadow: none; }
|
|
2633
4099
|
.workspace:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
|
|
4100
|
+
.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); }
|
|
4101
|
+
.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; }
|
|
4102
|
+
.environment button[aria-pressed="true"] { background: var(--surface-2); color: var(--ink); box-shadow: 0 0 0 1px var(--line); }
|
|
4103
|
+
.environment button[aria-pressed="true"][value="test"] { background: var(--warn-wash); color: var(--warn); box-shadow: 0 0 0 1px var(--warn-line); }
|
|
4104
|
+
.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); }
|
|
4105
|
+
.test-banner code { background: none; padding: 0; }
|
|
2634
4106
|
.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; }
|
|
2635
4107
|
.nav { display: grid; gap: 1px; }
|
|
2636
4108
|
.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); }
|
|
@@ -2731,9 +4203,13 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
|
|
|
2731
4203
|
.create { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin: 0; }
|
|
2732
4204
|
.create .grow { flex: 1 1 220px; }
|
|
2733
4205
|
label > input, label > select, label > .unit-input { width: 100%; }
|
|
4206
|
+
label > textarea { width: 100%; padding: var(--space-2) var(--space-3); resize: vertical; }
|
|
4207
|
+
.presets { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-3); }
|
|
4208
|
+
.presets button { height: 28px; padding: 0 var(--space-3); font-size: var(--text-sm); }
|
|
2734
4209
|
label.choice > input { width: 16px; }
|
|
2735
4210
|
.create label:not(.grow) { flex: 0 1 auto; }
|
|
2736
4211
|
form > label { margin-bottom: var(--space-3); }
|
|
4212
|
+
.create > label { margin-bottom: 0; }
|
|
2737
4213
|
.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; }
|
|
2738
4214
|
.unit-input { display: flex; align-items: center; gap: var(--space-2); }
|
|
2739
4215
|
.unit-input input { width: 100%; }
|
|
@@ -2748,6 +4224,12 @@ form .choice { margin: var(--space-1) 0 var(--space-3); }
|
|
|
2748
4224
|
.types summary { cursor: pointer; color: var(--ink-2); font-size: var(--text-md); width: fit-content; }
|
|
2749
4225
|
.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); }
|
|
2750
4226
|
.checks .choice { font-size: var(--text-md); margin: 0; }
|
|
4227
|
+
.choices { border: 0; padding: 0; margin: var(--space-4) 0; min-width: 0; }
|
|
4228
|
+
.choices legend { font-size: var(--text-sm); font-weight: 500; color: var(--ink-2); padding: 0; }
|
|
4229
|
+
.choices .checks { margin-top: 6px; }
|
|
4230
|
+
.fine + .table, .fine + .empty { margin-top: var(--space-4); }
|
|
4231
|
+
.pop label { margin-bottom: var(--space-3); }
|
|
4232
|
+
.pop textarea { width: 100%; }
|
|
2751
4233
|
|
|
2752
4234
|
/* Buttons */
|
|
2753
4235
|
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); }
|
|
@@ -2878,6 +4360,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2878
4360
|
.side-top .brand .name { display: none; }
|
|
2879
4361
|
.workspace { flex: 1; min-width: 0; padding: 3px 4px; }
|
|
2880
4362
|
.workspace .avatar { display: none; }
|
|
4363
|
+
.environment { margin: 0; flex: none; }
|
|
2881
4364
|
.me { grid-column: 2; grid-row: 1; margin: 0; padding: 0; border: 0; }
|
|
2882
4365
|
.me-avatar, .me .login { display: none; }
|
|
2883
4366
|
.sign-out { width: 36px; height: 36px; padding: 0; }
|
|
@@ -2916,6 +4399,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2916
4399
|
.week th[scope="row"] { display: block; padding: var(--space-2) var(--space-4) 0; }
|
|
2917
4400
|
.actions { text-align: left; width: auto; padding-top: var(--space-2); }
|
|
2918
4401
|
.act { margin: 0 var(--space-2) 0 0; }
|
|
4402
|
+
.actions form.inline { margin-right: var(--space-2); }
|
|
2919
4403
|
.act:first-child, form.inline + .act { margin-left: 0; }
|
|
2920
4404
|
.act summary { border: 1px solid var(--line-strong); height: 32px; }
|
|
2921
4405
|
.act.danger summary { border-color: var(--bad-line); }
|
|
@@ -2934,7 +4418,17 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2934
4418
|
.pill, .chip, .nav a[aria-current="page"] { border: 1px solid CanvasText; }
|
|
2935
4419
|
}
|
|
2936
4420
|
`;
|
|
4421
|
+
/** A composite the store refused, in a person's words. */
|
|
4422
|
+
const COMPOSITE_PROBLEMS = Object.freeze({
|
|
4423
|
+
parent_not_found: 'That is not one of your resources.',
|
|
4424
|
+
composite_too_deep: 'A part has no parts of its own, and a whole is no part: choose a resource that stands alone.',
|
|
4425
|
+
composite_parts_limit: 'A whole has at most 50 parts.',
|
|
4426
|
+
composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
|
|
4427
|
+
});
|
|
4428
|
+
/** What the Plan page calls each plan. */
|
|
4429
|
+
const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
|
|
2937
4430
|
const ASSET_VERSIONS = Object.freeze({
|
|
2938
4431
|
css: createHash('sha256').update(DASHBOARD_CSS).digest('base64url').slice(0, 12),
|
|
2939
4432
|
js: createHash('sha256').update(DASHBOARD_JS).digest('base64url').slice(0, 12),
|
|
4433
|
+
icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
|
|
2940
4434
|
});
|