slotlock 0.6.3 → 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 +467 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +40 -0
- package/assistant.js +115 -0
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1545 -64
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
package/dashboard.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// keeps these in Postgres, so they hold on every server that shares the database; the resource cap
|
|
15
15
|
// is held in the database too.
|
|
16
16
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
|
|
17
18
|
import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
|
|
18
|
-
import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore,
|
|
19
|
+
import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
|
|
19
20
|
import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
|
|
20
21
|
import { createSlotlockRateLimiter } from './rate-limit.js';
|
|
21
22
|
import { createSlotlockGuardedFetch } from './webhooks.js';
|
|
@@ -23,12 +24,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,8 @@ 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);
|
|
648
905
|
// Its cookies are Secure, but a first visit over http:// could still be answered by someone else:
|
|
649
906
|
// a browser that has seen this goes straight to https:// for a year. Not for a local http one.
|
|
650
907
|
const hsts = publicUrl.protocol === 'https:' ? { 'Strict-Transport-Security': 'max-age=31536000' } : {};
|
|
@@ -657,14 +914,31 @@ export function createSlotlockDashboard(options) {
|
|
|
657
914
|
const userUrl = options.github.userUrl ?? 'https://api.github.com/user';
|
|
658
915
|
const membershipsUrl = options.github.membershipsUrl ??
|
|
659
916
|
'https://api.github.com/user/memberships/orgs?state=active&per_page=100';
|
|
660
|
-
const served = options.organizations ? new Set(options.organizations) : null;
|
|
917
|
+
const served = Array.isArray(options.organizations) ? new Set(options.organizations) : null;
|
|
661
918
|
if (served && (served.size === 0 || [...served].some((id) => !ORGANIZATION_ID.test(id)))) {
|
|
662
919
|
throw new Error('Slotlock dashboard organizations must be GitHub organization ids');
|
|
663
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();
|
|
664
930
|
const brandName = options.name ?? 'Slotlock';
|
|
665
931
|
if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
|
|
666
932
|
throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
|
|
667
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
|
+
}
|
|
668
942
|
const fetchImpl = options.fetch ?? fetch;
|
|
669
943
|
const now = options.now ?? Date.now;
|
|
670
944
|
if (Buffer.byteLength(options.sessionSecret, 'utf8') < 32) {
|
|
@@ -672,8 +946,10 @@ export function createSlotlockDashboard(options) {
|
|
|
672
946
|
}
|
|
673
947
|
const secret = Buffer.from(options.sessionSecret, 'utf8');
|
|
674
948
|
// A list, or exactly '*': any other string would match ids by substring.
|
|
675
|
-
|
|
676
|
-
|
|
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);
|
|
677
953
|
let signInsInFlight = 0;
|
|
678
954
|
/** Sign-ins in flight by client address. */
|
|
679
955
|
const signInsByClient = new Map();
|
|
@@ -741,18 +1017,39 @@ export function createSlotlockDashboard(options) {
|
|
|
741
1017
|
typeof payload.uid !== 'string' ||
|
|
742
1018
|
typeof payload.login !== 'string' ||
|
|
743
1019
|
typeof payload.sid !== 'string' ||
|
|
744
|
-
|
|
1020
|
+
(payload.idp !== undefined && (payload.idp !== 'google' || !google)) ||
|
|
1021
|
+
!allowed(payload.uid, payload.idp) ||
|
|
745
1022
|
!organizationsHold(payload) ||
|
|
1023
|
+
(payload.env !== undefined && payload.env !== 'test') ||
|
|
746
1024
|
(await options.state.sessionEnded(payload.sid, payload.exp))) {
|
|
747
1025
|
return null;
|
|
748
1026
|
}
|
|
749
|
-
|
|
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;
|
|
750
1039
|
}
|
|
751
1040
|
/**
|
|
752
1041
|
* With organizations, the session names one the server still serves, from its own list; without
|
|
753
1042
|
* them, it names none. A session from the other mode is signed out.
|
|
754
1043
|
*/
|
|
755
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
|
+
}
|
|
756
1053
|
if (!served)
|
|
757
1054
|
return payload.org === undefined && payload.orgs === undefined;
|
|
758
1055
|
if (typeof payload.org !== 'string' || !served.has(payload.org) || !Array.isArray(payload.orgs)) {
|
|
@@ -763,15 +1060,20 @@ export function createSlotlockDashboard(options) {
|
|
|
763
1060
|
const csrfToken = (session) => createHmac('sha256', secret)
|
|
764
1061
|
.update(`slotlock-dashboard-csrf-v1.${session.sid}`)
|
|
765
1062
|
.digest('base64url');
|
|
766
|
-
|
|
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 : ''}`;
|
|
767
1065
|
/** Who did something, for the record: the person, whatever tenant they act in. */
|
|
768
|
-
|
|
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}`);
|
|
769
1071
|
function respond(status, body, headers = {}, cookies = []) {
|
|
770
1072
|
const response = new Response(String(body), {
|
|
771
1073
|
status,
|
|
772
1074
|
headers: {
|
|
773
1075
|
'Content-Type': 'text/html; charset=utf-8',
|
|
774
|
-
'Content-Security-Policy':
|
|
1076
|
+
'Content-Security-Policy': contentSecurityPolicy,
|
|
775
1077
|
'Cache-Control': 'no-store',
|
|
776
1078
|
'X-Frame-Options': 'DENY',
|
|
777
1079
|
'X-Content-Type-Options': 'nosniff',
|
|
@@ -794,6 +1096,9 @@ export function createSlotlockDashboard(options) {
|
|
|
794
1096
|
...(options.resources.waitlist
|
|
795
1097
|
? [{ section: 'waitlist', label: 'Waitlist', path: '/waitlist', icon: ICONS.waitlist }]
|
|
796
1098
|
: []),
|
|
1099
|
+
...(options.resources.approvals
|
|
1100
|
+
? [{ section: 'approvals', label: 'Approvals', path: '/approvals', icon: ICONS.approvals }]
|
|
1101
|
+
: []),
|
|
797
1102
|
{ section: 'keys', label: 'API keys', path: '/keys', icon: ICONS.keys },
|
|
798
1103
|
...(options.resources.webhooks
|
|
799
1104
|
? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
|
|
@@ -801,14 +1106,27 @@ export function createSlotlockDashboard(options) {
|
|
|
801
1106
|
...(assistantStore()
|
|
802
1107
|
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
803
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 }] : []),
|
|
804
1113
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
805
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
|
+
}
|
|
806
1120
|
/** The workspace the session acts in: its organization, or the person themselves. */
|
|
807
1121
|
function workspace(session) {
|
|
808
1122
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
809
1123
|
const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
|
|
810
1124
|
const name = current?.login ?? session.login;
|
|
811
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
|
+
}
|
|
812
1130
|
if (current && choices.length > 1) {
|
|
813
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>`;
|
|
814
1132
|
}
|
|
@@ -823,6 +1141,7 @@ export function createSlotlockDashboard(options) {
|
|
|
823
1141
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
824
1142
|
<meta name="referrer" content="${REFERRER_POLICY}">
|
|
825
1143
|
<title>${title} · ${brandName}</title>
|
|
1144
|
+
<link rel="icon" href="${root}/favicon.svg?v=${ASSET_VERSIONS.icon}" type="image/svg+xml">
|
|
826
1145
|
<link rel="stylesheet" href="${root}/style.css?v=${ASSET_VERSIONS.css}">
|
|
827
1146
|
<script src="${root}/app.js?v=${ASSET_VERSIONS.js}" defer></script>
|
|
828
1147
|
</head>`;
|
|
@@ -846,11 +1165,13 @@ ${live}
|
|
|
846
1165
|
${skip}
|
|
847
1166
|
<div class="app">
|
|
848
1167
|
<header class="side">
|
|
849
|
-
<div class="side-top">${brand}${workspace(session)}</div>
|
|
1168
|
+
<div class="side-top">${brand}${workspace(session)}${environmentSwitch(session)}</div>
|
|
850
1169
|
<nav class="nav" aria-label="Dashboard">${links}</nav>
|
|
851
|
-
<div class="me"><span class="avatar me-avatar" aria-hidden="true">${session.login.slice(0, 1).toUpperCase()}</span><span class="login" title="
|
|
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>
|
|
852
1171
|
</header>
|
|
853
|
-
<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>
|
|
854
1175
|
</div>
|
|
855
1176
|
${live}
|
|
856
1177
|
</body>
|
|
@@ -863,27 +1184,52 @@ ${live}
|
|
|
863
1184
|
<h1>Sign in to ${brandName}</h1>
|
|
864
1185
|
<p>Create keys for your agents, add what they book, and watch what they do.</p>
|
|
865
1186
|
${noticeOf(notice)}
|
|
866
|
-
<a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a
|
|
867
|
-
|
|
868
|
-
?
|
|
869
|
-
|
|
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>
|
|
870
1198
|
</section>`;
|
|
871
1199
|
return respond(status, layout('Sign in', content, null), {}, cookies);
|
|
872
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(', ');
|
|
873
1215
|
function keyRows(keys, token, session) {
|
|
874
1216
|
return keys.map((key) => {
|
|
875
1217
|
const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
|
|
876
1218
|
const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
|
|
877
1219
|
const actions = status === 'Active'
|
|
878
|
-
? 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>
|
|
879
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>
|
|
880
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>
|
|
881
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>`
|
|
882
1226
|
: html ``;
|
|
883
1227
|
return html `<tr>
|
|
884
|
-
<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>
|
|
885
1229
|
<td data-label="Key"><code class="chip">${key.prefix}…</code></td>
|
|
886
|
-
<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>
|
|
887
1233
|
<td data-label="Created">${dateCell(key.createdAt)}</td>
|
|
888
1234
|
<td data-label="Last used">${key.lastUsedAt ? dateCell(key.lastUsedAt) : html `<span class="muted">Never</span>`}</td>
|
|
889
1235
|
<td data-label="Expires">${key.expiresAt ? dateCell(key.expiresAt) : html `<span class="muted">Never</span>`}</td>
|
|
@@ -896,7 +1242,8 @@ ${noticeOf(notice)}
|
|
|
896
1242
|
/** A list with nothing in it yet: what goes here, and the one thing to do next. */
|
|
897
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>`;
|
|
898
1244
|
/** A resource as people name it: its reference, else the start of its id. */
|
|
899
|
-
|
|
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)}…`;
|
|
900
1247
|
function agendaPanel(entries, byId, more) {
|
|
901
1248
|
if (entries.length === 0) {
|
|
902
1249
|
return html `<p class="empty">Nothing booked in the next ${String(AGENDA_DAYS)} days.</p>`;
|
|
@@ -942,11 +1289,15 @@ ${noticeOf(notice)}
|
|
|
942
1289
|
? 'A deleted key'
|
|
943
1290
|
: /^github:\d+$/.test(change.actor)
|
|
944
1291
|
? `GitHub user ${change.actor.slice(7)}`
|
|
945
|
-
: /^
|
|
946
|
-
?
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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)));
|
|
950
1301
|
const where = change.resourceId
|
|
951
1302
|
? resourceName(byId.get(change.resourceId), change.resourceId)
|
|
952
1303
|
: null;
|
|
@@ -985,18 +1336,74 @@ ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
|
|
|
985
1336
|
${stat('Resources', String(resources.length), `${root}/resources`)}
|
|
986
1337
|
${stat('Active keys', String(active.length), `${root}/keys`)}
|
|
987
1338
|
</div>
|
|
1339
|
+
${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
988
1340
|
<div class="split">
|
|
989
1341
|
${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
|
|
990
1342
|
${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
|
|
991
1343
|
</div>`;
|
|
992
1344
|
return respond(status, layout('Overview', content, session, 'overview'));
|
|
993
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
|
+
}
|
|
994
1389
|
function endpoints() {
|
|
995
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>`;
|
|
996
1391
|
}
|
|
1392
|
+
const accessOf = (key) => key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only';
|
|
997
1393
|
async function keysPage(session, status = 200, notice) {
|
|
998
|
-
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
|
+
]);
|
|
999
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 ``;
|
|
1000
1407
|
const content = html `${noticeOf(notice)}
|
|
1001
1408
|
${pageHead('API keys')}
|
|
1002
1409
|
<section class="panel">
|
|
@@ -1013,19 +1420,39 @@ ${endpoints()}
|
|
|
1013
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>
|
|
1014
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>
|
|
1015
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>
|
|
1016
1425
|
</form>
|
|
1017
1426
|
</section>
|
|
1018
1427
|
${keys.length === 0
|
|
1019
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' })
|
|
1020
|
-
: 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>
|
|
1021
1430
|
<tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
|
|
1022
1431
|
return respond(status, layout('API keys', content, session, 'keys'));
|
|
1023
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
|
+
}
|
|
1024
1450
|
async function resourcesPage(session, status = 200, notice) {
|
|
1025
1451
|
const resources = await options.resources.list(tenantOf(session));
|
|
1452
|
+
const allowance = await resourceAllowance(tenantOf(session), resources);
|
|
1026
1453
|
const token = csrfToken(session);
|
|
1027
1454
|
const content = html `${noticeOf(notice)}
|
|
1028
|
-
${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>`)}
|
|
1029
1456
|
<section class="panel" id="add-resource">
|
|
1030
1457
|
<h2>Add a resource</h2>
|
|
1031
1458
|
<form class="create" method="post" action="${root}/resources">
|
|
@@ -1035,19 +1462,92 @@ ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${r
|
|
|
1035
1462
|
<label>Time zone<input name="timezone" required maxlength="64" value="UTC" placeholder="Europe/London"></label>
|
|
1036
1463
|
<button class="primary" type="submit">Add resource</button>
|
|
1037
1464
|
</form>
|
|
1038
|
-
<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>
|
|
1039
1466
|
</section>
|
|
1040
1467
|
${resources.length === 0
|
|
1041
1468
|
? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
|
|
1042
|
-
: html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${hoursSummary(resource.availabilityRules)}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
|
|
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>`}`;
|
|
1043
1470
|
return respond(status, layout('Resources', content, session, 'resources'));
|
|
1044
1471
|
}
|
|
1045
|
-
|
|
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) {
|
|
1046
1546
|
const rules = options.availability ?? [];
|
|
1047
1547
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1048
1548
|
const content = html `${noticeOf(notice)}
|
|
1049
1549
|
${pageHead('Settings')}
|
|
1050
|
-
${await assistantSettingsPanel(session)}
|
|
1550
|
+
${await assistantSettingsPanel(session, draft)}
|
|
1051
1551
|
<section class="panel">
|
|
1052
1552
|
<h2>Endpoints</h2>
|
|
1053
1553
|
${endpoints()}
|
|
@@ -1061,7 +1561,7 @@ ${rules.length === 0
|
|
|
1061
1561
|
</section>
|
|
1062
1562
|
<section class="panel">
|
|
1063
1563
|
<h2>Account</h2>
|
|
1064
|
-
<dl class="facts"><dt>Signed in as</dt><dd
|
|
1564
|
+
<dl class="facts"><dt>Signed in as</dt><dd>${shownAs(session)}</dd>${current
|
|
1065
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 ``}`
|
|
1066
1566
|
: html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
|
|
1067
1567
|
${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
|
|
@@ -1076,12 +1576,21 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1076
1576
|
return notFound(session);
|
|
1077
1577
|
const start = new Date(now());
|
|
1078
1578
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
1079
|
-
const
|
|
1579
|
+
const grouping = options.resources.grouping;
|
|
1580
|
+
const [agenda, changes, keys, defaults, locations, groups, everyResource] = await Promise.all([
|
|
1080
1581
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
1081
1582
|
options.resources.recentChanges?.(tenantRef, 100),
|
|
1082
1583
|
options.keys.list({ tenantRef }),
|
|
1083
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,
|
|
1084
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]));
|
|
1085
1594
|
const byId = new Map([[resource.id, resource]]);
|
|
1086
1595
|
const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
|
|
1087
1596
|
const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
|
|
@@ -1106,7 +1615,8 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1106
1615
|
const content = html `${noticeOf(notice)}
|
|
1107
1616
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1108
1617
|
${pageHead(resourceName(resource, resource.id))}
|
|
1109
|
-
|
|
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>
|
|
1110
1620
|
<div class="split">
|
|
1111
1621
|
<div class="stack">
|
|
1112
1622
|
${options.resources.setRules
|
|
@@ -1131,6 +1641,25 @@ ${inheritedNote(inherited)}
|
|
|
1131
1641
|
<p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
|
|
1132
1642
|
<button class="primary" type="submit">Save rules</button>
|
|
1133
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>
|
|
1134
1663
|
</section>`
|
|
1135
1664
|
: html ``}
|
|
1136
1665
|
${options.resources.setPool
|
|
@@ -1144,6 +1673,39 @@ ${options.resources.setPool
|
|
|
1144
1673
|
<button type="submit">Save pool</button>
|
|
1145
1674
|
</form>
|
|
1146
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>`}
|
|
1147
1709
|
</section>`
|
|
1148
1710
|
: html ``}
|
|
1149
1711
|
</div>
|
|
@@ -1191,6 +1753,153 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1191
1753
|
}
|
|
1192
1754
|
return redirect(303, `${root}/resources/${id}`);
|
|
1193
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
|
+
}
|
|
1194
1903
|
async function savePool(session, form) {
|
|
1195
1904
|
const id = form.get('id') ?? '';
|
|
1196
1905
|
const resource = await ownResource(session, id);
|
|
@@ -1207,6 +1916,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1207
1916
|
}
|
|
1208
1917
|
return redirect(303, `${root}/resources/${id}`);
|
|
1209
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
|
+
}
|
|
1210
1930
|
function endpointState(endpoint) {
|
|
1211
1931
|
if (endpoint.disabledAt)
|
|
1212
1932
|
return { text: 'Disabled', tone: 'revoked' };
|
|
@@ -1231,7 +1951,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1231
1951
|
: '';
|
|
1232
1952
|
return html `<tr>
|
|
1233
1953
|
<td data-label="URL" class="strong"><code class="truncate url" title="${endpoint.url}">${endpoint.url}</code></td>
|
|
1234
|
-
<td data-label="Events" class="wrap">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}</td>
|
|
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>
|
|
1235
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>
|
|
1236
1956
|
<td data-label="Added">${dateCell(endpoint.createdAt)}</td>
|
|
1237
1957
|
<td class="actions">${endpoint.disabledAt
|
|
@@ -1271,7 +1991,7 @@ ${endpoints.length === 0
|
|
|
1271
1991
|
const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
|
|
1272
1992
|
if (!created.ok) {
|
|
1273
1993
|
const message = created.code === 'webhook_limit_reached'
|
|
1274
|
-
? `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' : ''}.`
|
|
1275
1995
|
: created.code === 'invalid_webhook_url'
|
|
1276
1996
|
? 'Give an https URL, with no user name, password or #fragment.'
|
|
1277
1997
|
: 'Choose events from the list.';
|
|
@@ -1360,6 +2080,87 @@ ${entries.length === 0
|
|
|
1360
2080
|
}
|
|
1361
2081
|
return redirect(303, `${root}/waitlist`);
|
|
1362
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
|
+
}
|
|
1363
2164
|
// ---- The assistant ----
|
|
1364
2165
|
const assistantOptions = options.assistant;
|
|
1365
2166
|
/** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
|
|
@@ -1383,6 +2184,23 @@ ${entries.length === 0
|
|
|
1383
2184
|
if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
|
|
1384
2185
|
throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
|
|
1385
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] : [];
|
|
1386
2204
|
const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
|
|
1387
2205
|
const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
|
|
1388
2206
|
/** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
|
|
@@ -1407,7 +2225,7 @@ ${entries.length === 0
|
|
|
1407
2225
|
* Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
|
|
1408
2226
|
* which a rename does not change. Shown as "Assistant for @login".
|
|
1409
2227
|
*/
|
|
1410
|
-
const assistantActor = (session) => `dashboard-assistant
|
|
2228
|
+
const assistantActor = (session) => `dashboard-assistant:${personOf(session)}`;
|
|
1411
2229
|
const CONFIRMED_WRITES = new Set([
|
|
1412
2230
|
'slotlock_create_event',
|
|
1413
2231
|
'slotlock_update_event',
|
|
@@ -1441,6 +2259,15 @@ ${entries.length === 0
|
|
|
1441
2259
|
else if (operation === 'slotlock_release_slots') {
|
|
1442
2260
|
text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
|
|
1443
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
|
+
}
|
|
1444
2271
|
else {
|
|
1445
2272
|
text = '';
|
|
1446
2273
|
}
|
|
@@ -1448,7 +2275,8 @@ ${entries.length === 0
|
|
|
1448
2275
|
return text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
1449
2276
|
}
|
|
1450
2277
|
/** Owners of the organization in use may change its assistant; a person always may their own. */
|
|
1451
|
-
|
|
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';
|
|
1452
2280
|
const assistantPrincipal = (session) => ({
|
|
1453
2281
|
subject: assistantActor(session),
|
|
1454
2282
|
tenantRef: tenantOf(session),
|
|
@@ -1486,6 +2314,8 @@ ${entries.length === 0
|
|
|
1486
2314
|
? { consumeRateLimit: assistant.consumeRateLimit }
|
|
1487
2315
|
: {}),
|
|
1488
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 } : {}),
|
|
1489
2319
|
onError: (error) => {
|
|
1490
2320
|
try {
|
|
1491
2321
|
ignoreRejection(options.onError?.(error));
|
|
@@ -1510,12 +2340,28 @@ ${entries.length === 0
|
|
|
1510
2340
|
}
|
|
1511
2341
|
let apiKey = null;
|
|
1512
2342
|
if (settings.sealedKey !== null) {
|
|
1513
|
-
|
|
1514
|
-
|
|
2343
|
+
const tenantRef = tenantOf(session);
|
|
2344
|
+
const opened = await openSlotlockAssistantKeyWithAny(assistantSecrets, tenantRef, settings.sealedKey);
|
|
2345
|
+
if (opened === null) {
|
|
1515
2346
|
return {
|
|
1516
2347
|
problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
|
|
1517
2348
|
};
|
|
1518
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
|
+
}
|
|
1519
2365
|
}
|
|
1520
2366
|
return {
|
|
1521
2367
|
model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
|
|
@@ -1619,7 +2465,7 @@ ${settings
|
|
|
1619
2465
|
: 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
|
|
1620
2466
|
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
1621
2467
|
}
|
|
1622
|
-
async function assistantSettingsPanel(session) {
|
|
2468
|
+
async function assistantSettingsPanel(session, draft) {
|
|
1623
2469
|
const store = assistantStore();
|
|
1624
2470
|
if (!store)
|
|
1625
2471
|
return html ``;
|
|
@@ -1631,21 +2477,29 @@ ${settings
|
|
|
1631
2477
|
: html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
|
|
1632
2478
|
}
|
|
1633
2479
|
const token = csrfToken(session);
|
|
1634
|
-
|
|
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 ?? [];
|
|
1635
2486
|
return html `<section class="panel" id="assistant">
|
|
1636
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>
|
|
1637
2489
|
<form method="post" action="${root}/settings/assistant">
|
|
1638
2490
|
<input type="hidden" name="csrf" value="${token}">
|
|
1639
2491
|
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1640
2492
|
<div class="grid-form">
|
|
1641
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>
|
|
1642
|
-
<label>Base URL<input name="base_url" required maxlength="2048" value="${
|
|
1643
|
-
<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 ``}
|
|
1644
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>
|
|
1645
2499
|
</div>
|
|
1646
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>
|
|
1647
2501
|
${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
|
|
1648
|
-
<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>
|
|
1649
2503
|
</form>
|
|
1650
2504
|
${sentTo}
|
|
1651
2505
|
${settings
|
|
@@ -1698,6 +2552,71 @@ ${settings
|
|
|
1698
2552
|
}
|
|
1699
2553
|
return redirect(303, `${root}/settings#assistant`);
|
|
1700
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
|
+
}
|
|
1701
2620
|
async function removeAssistantSettings(session) {
|
|
1702
2621
|
const store = assistantStore();
|
|
1703
2622
|
if (!store)
|
|
@@ -1754,6 +2673,12 @@ ${settings
|
|
|
1754
2673
|
return assistantPage(session, 409, found.problem);
|
|
1755
2674
|
const tenantRef = tenantOf(session);
|
|
1756
2675
|
const member = personOf(session);
|
|
2676
|
+
try {
|
|
2677
|
+
ignoreRejection(assistantOptions?.onMessage?.(tenantRef));
|
|
2678
|
+
}
|
|
2679
|
+
catch {
|
|
2680
|
+
// Metering never changes an answer.
|
|
2681
|
+
}
|
|
1757
2682
|
const stored = await store.getConversation(tenantRef, member);
|
|
1758
2683
|
const entries = boundSlotlockAssistantEntries([
|
|
1759
2684
|
...readSlotlockAssistantEntries(stored?.messages),
|
|
@@ -1882,6 +2807,187 @@ ${settings
|
|
|
1882
2807
|
await store.deleteConversation(tenantOf(session), personOf(session));
|
|
1883
2808
|
return redirect(303, `${root}/assistant`);
|
|
1884
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
|
+
}
|
|
1885
2991
|
/** The page a form was sent from, to show what went wrong with it. */
|
|
1886
2992
|
function pageFor(route) {
|
|
1887
2993
|
if (route.startsWith('/keys'))
|
|
@@ -1890,12 +2996,21 @@ ${settings
|
|
|
1890
2996
|
return webhooksPage;
|
|
1891
2997
|
if (route.startsWith('/waitlist') && options.resources.waitlist)
|
|
1892
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
|
+
}
|
|
1893
3004
|
if (route.startsWith('/resources') || route === '/hours')
|
|
1894
3005
|
return resourcesPage;
|
|
1895
3006
|
if (route.startsWith('/assistant') && assistantStore())
|
|
1896
3007
|
return assistantPage;
|
|
1897
3008
|
if (route.startsWith('/settings'))
|
|
1898
3009
|
return settingsPage;
|
|
3010
|
+
if (route.startsWith('/plan') && options.billing)
|
|
3011
|
+
return planPage;
|
|
3012
|
+
if (route.startsWith('/teams') && teams)
|
|
3013
|
+
return teamsPage;
|
|
1899
3014
|
return overviewPage;
|
|
1900
3015
|
}
|
|
1901
3016
|
function hoursPage(session, state) {
|
|
@@ -1950,6 +3065,7 @@ ${state.unshown
|
|
|
1950
3065
|
return resources.find((resource) => resource.id === id) ?? null;
|
|
1951
3066
|
}
|
|
1952
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));
|
|
1953
3069
|
async function hoursEditor(session, url) {
|
|
1954
3070
|
const id = url.searchParams.get('resource');
|
|
1955
3071
|
if (id === null) {
|
|
@@ -2037,8 +3153,83 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2037
3153
|
return respond(200, layout('Your new key', content, session, 'keys'));
|
|
2038
3154
|
}
|
|
2039
3155
|
/** Act in another of the session's organizations: the same session, sealed again. */
|
|
2040
|
-
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) {
|
|
2041
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
|
+
}
|
|
2042
3233
|
const org = session.orgs?.find((candidate) => candidate.id === wanted && served?.has(candidate.id));
|
|
2043
3234
|
if (!served || !org) {
|
|
2044
3235
|
return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization.</p>`, session));
|
|
@@ -2059,7 +3250,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2059
3250
|
code_challenge: challenge,
|
|
2060
3251
|
code_challenge_method: 'S256',
|
|
2061
3252
|
// Reading organization membership needs read:org; a personal tenant needs no scope at all.
|
|
2062
|
-
...(
|
|
3253
|
+
...(readsOrganizations ? { scope: 'read:org' } : {}),
|
|
2063
3254
|
}).toString();
|
|
2064
3255
|
const sealed = seal('slotlock-dashboard-sign-in-v1', {
|
|
2065
3256
|
state,
|
|
@@ -2165,7 +3356,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2165
3356
|
continue;
|
|
2166
3357
|
}
|
|
2167
3358
|
const id = String(organization.id);
|
|
2168
|
-
if (served?.has(id)) {
|
|
3359
|
+
if (teams || served?.has(id)) {
|
|
2169
3360
|
found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
|
|
2170
3361
|
}
|
|
2171
3362
|
}
|
|
@@ -2173,13 +3364,146 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2173
3364
|
.sort((a, b) => a.login.localeCompare(b.login))
|
|
2174
3365
|
.slice(0, MAX_SESSION_ORGANIZATIONS);
|
|
2175
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
|
+
}
|
|
2176
3500
|
async function askGitHub(code, verifier) {
|
|
2177
3501
|
try {
|
|
2178
3502
|
const token = await exchangeCode(code, verifier);
|
|
2179
3503
|
if (token === null)
|
|
2180
3504
|
return null;
|
|
2181
3505
|
const user = await readGitHubUser(token);
|
|
2182
|
-
if (!user || !
|
|
3506
|
+
if (!user || !readsOrganizations)
|
|
2183
3507
|
return user;
|
|
2184
3508
|
const orgs = await readMemberships(token);
|
|
2185
3509
|
return orgs === null ? null : { ...user, orgs };
|
|
@@ -2221,7 +3545,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2221
3545
|
if (!(await options.state.use('sign_in', pending.state, pending.exp))) {
|
|
2222
3546
|
return signedOutPage(400, SIGN_IN_AGAIN, [clearSignIn]);
|
|
2223
3547
|
}
|
|
2224
|
-
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);
|
|
2225
3554
|
}
|
|
2226
3555
|
finally {
|
|
2227
3556
|
signInsInFlight -= 1;
|
|
@@ -2234,12 +3563,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2234
3563
|
}
|
|
2235
3564
|
}
|
|
2236
3565
|
if (!user) {
|
|
2237
|
-
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]);
|
|
2238
3567
|
}
|
|
2239
|
-
if (!allowed(user.uid)) {
|
|
2240
|
-
return signedOutPage(403, `This Slotlock server does not let
|
|
2241
|
-
clearSignIn,
|
|
2242
|
-
]);
|
|
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]);
|
|
2243
3570
|
}
|
|
2244
3571
|
const orgs = user.orgs ?? [];
|
|
2245
3572
|
if (served && orgs.length === 0) {
|
|
@@ -2250,7 +3577,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2250
3577
|
login: user.login,
|
|
2251
3578
|
sid: randomBytes(24).toString('base64url'),
|
|
2252
3579
|
exp: now() + SESSION_TTL_MS,
|
|
2253
|
-
...(
|
|
3580
|
+
...(user.idp === 'google' ? { idp: 'google' } : {}),
|
|
3581
|
+
...(served ? { orgs, org: orgs[0].id } : teams && user.idp !== 'google' ? { orgs } : {}),
|
|
2254
3582
|
});
|
|
2255
3583
|
return redirect(303, root, [
|
|
2256
3584
|
clearSignIn,
|
|
@@ -2290,6 +3618,25 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2290
3618
|
return keysPage(session, 400, 'Choose an access level and an expiry from the lists.');
|
|
2291
3619
|
}
|
|
2292
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
|
+
}
|
|
2293
3640
|
try {
|
|
2294
3641
|
const created = await options.keys.create({
|
|
2295
3642
|
tenantRef: tenantOf(session),
|
|
@@ -2297,6 +3644,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2297
3644
|
scopes,
|
|
2298
3645
|
createdBy: personOf(session),
|
|
2299
3646
|
origin: 'dashboard',
|
|
3647
|
+
...(resourceScope ? { resourceScope } : {}),
|
|
3648
|
+
...(Object.keys(limits).length > 0 ? { limits } : {}),
|
|
2300
3649
|
...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
|
|
2301
3650
|
});
|
|
2302
3651
|
return shownOncePage(session, created.key, created.apiKey, false);
|
|
@@ -2306,8 +3655,13 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2306
3655
|
if (code === 'invalid_api_key_name') {
|
|
2307
3656
|
return keysPage(session, 400, 'Give the key a name of 1 to 100 characters.');
|
|
2308
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
|
+
}
|
|
2309
3661
|
if (code === 'api_key_limit_reached') {
|
|
2310
|
-
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.');
|
|
2311
3665
|
}
|
|
2312
3666
|
throw error;
|
|
2313
3667
|
}
|
|
@@ -2318,6 +3672,40 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2318
3672
|
actor: personOf(session),
|
|
2319
3673
|
origin: 'dashboard',
|
|
2320
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
|
+
}
|
|
2321
3709
|
async function changeKey(session, form, change) {
|
|
2322
3710
|
const id = form.get('id') ?? '';
|
|
2323
3711
|
if (!UUID_PATTERN.test(id))
|
|
@@ -2345,7 +3733,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2345
3733
|
catch (error) {
|
|
2346
3734
|
const code = error.code;
|
|
2347
3735
|
if (code === 'resource_limit_reached') {
|
|
2348
|
-
|
|
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.`);
|
|
2349
3740
|
}
|
|
2350
3741
|
if (code === 'invalid_timezone') {
|
|
2351
3742
|
return resourcesPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
|
|
@@ -2395,7 +3786,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2395
3786
|
try {
|
|
2396
3787
|
first =
|
|
2397
3788
|
checked === 'ok' &&
|
|
2398
|
-
(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));
|
|
2399
3790
|
}
|
|
2400
3791
|
catch (error) {
|
|
2401
3792
|
if (error.code !== 'dashboard_form_limit')
|
|
@@ -2406,6 +3797,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2406
3797
|
return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
|
|
2407
3798
|
}
|
|
2408
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
|
+
}
|
|
2409
3804
|
switch (route) {
|
|
2410
3805
|
case '/sign-out':
|
|
2411
3806
|
await options.state.endSession(session.sid, session.exp);
|
|
@@ -2416,16 +3811,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2416
3811
|
return changeKey(session, form, 'rotate');
|
|
2417
3812
|
case '/keys/revoke':
|
|
2418
3813
|
return changeKey(session, form, 'revoke');
|
|
3814
|
+
case '/keys/limits':
|
|
3815
|
+
return saveKeyLimits(session, form);
|
|
2419
3816
|
case '/resources':
|
|
2420
3817
|
return addResource(session, form);
|
|
2421
3818
|
case '/hours':
|
|
2422
3819
|
return saveHours(session, form);
|
|
2423
3820
|
case '/organization':
|
|
2424
3821
|
return switchOrganization(session, form);
|
|
3822
|
+
case '/environment':
|
|
3823
|
+
return switchEnvironment(session, form);
|
|
2425
3824
|
case '/resources/rules':
|
|
2426
3825
|
return saveRules(session, form);
|
|
2427
3826
|
case '/resources/pool':
|
|
2428
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');
|
|
2429
3842
|
case '/webhooks':
|
|
2430
3843
|
return addWebhook(session, form);
|
|
2431
3844
|
case '/webhooks/delete':
|
|
@@ -2434,6 +3847,14 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2434
3847
|
return changeWebhook(session, form, 'enable');
|
|
2435
3848
|
case '/waitlist/leave':
|
|
2436
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');
|
|
2437
3858
|
case '/assistant':
|
|
2438
3859
|
return sendToAssistant(session, form);
|
|
2439
3860
|
case '/assistant/approve':
|
|
@@ -2444,10 +3865,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2444
3865
|
return resetAssistant(session);
|
|
2445
3866
|
case '/settings/assistant':
|
|
2446
3867
|
return saveAssistantSettings(session, form);
|
|
3868
|
+
case '/settings/assistant/models':
|
|
3869
|
+
return loadAssistantModels(session, form);
|
|
2447
3870
|
case '/settings/assistant/test':
|
|
2448
3871
|
return testAssistant(session);
|
|
2449
3872
|
case '/settings/assistant/remove':
|
|
2450
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);
|
|
2451
3882
|
default:
|
|
2452
3883
|
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
2453
3884
|
}
|
|
@@ -2471,11 +3902,21 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2471
3902
|
case '/resources':
|
|
2472
3903
|
case '/webhooks':
|
|
2473
3904
|
case '/waitlist':
|
|
3905
|
+
case '/approvals':
|
|
2474
3906
|
case '/assistant':
|
|
3907
|
+
case '/plan':
|
|
3908
|
+
case '/teams':
|
|
2475
3909
|
case '/settings': {
|
|
2476
3910
|
const session = await readSession(request);
|
|
2477
3911
|
if (!session)
|
|
2478
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
|
+
}
|
|
2479
3920
|
if (route === '/keys')
|
|
2480
3921
|
return await keysPage(session);
|
|
2481
3922
|
if (route === '/resources')
|
|
@@ -2484,22 +3925,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2484
3925
|
return await webhooksPage(session);
|
|
2485
3926
|
if (route === '/waitlist')
|
|
2486
3927
|
return await waitlistPage(session);
|
|
3928
|
+
if (route === '/approvals')
|
|
3929
|
+
return await approvalsPage(session);
|
|
2487
3930
|
if (route === '/assistant')
|
|
2488
3931
|
return await assistantPage(session);
|
|
2489
|
-
|
|
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);
|
|
2490
3935
|
}
|
|
2491
3936
|
case '/style.css':
|
|
2492
3937
|
return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
2493
3938
|
case '/app.js':
|
|
2494
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);
|
|
2495
3942
|
case '/sign-in':
|
|
2496
|
-
return
|
|
3943
|
+
return google && url.searchParams.get('with') === 'google'
|
|
3944
|
+
? await startGoogleSignIn(google)
|
|
3945
|
+
: await startSignIn();
|
|
2497
3946
|
case '/callback':
|
|
2498
3947
|
return await finishSignIn(request, url);
|
|
2499
3948
|
case '/hours': {
|
|
2500
3949
|
const session = await readSession(request);
|
|
2501
3950
|
return session ? await hoursEditor(session, url) : redirect(303, root);
|
|
2502
3951
|
}
|
|
3952
|
+
case '/locations': {
|
|
3953
|
+
const session = await readSession(request);
|
|
3954
|
+
return session ? await locationsPage(session) : redirect(303, root);
|
|
3955
|
+
}
|
|
2503
3956
|
default: {
|
|
2504
3957
|
const resourceId = RESOURCE_PAGE.exec(route)?.[1];
|
|
2505
3958
|
if (resourceId !== undefined) {
|
|
@@ -2644,6 +4097,12 @@ code, pre, kbd, input[readonly] { font-family: var(--mono); font-size: 12.5px; }
|
|
|
2644
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; }
|
|
2645
4098
|
.workspace select:focus { box-shadow: none; }
|
|
2646
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; }
|
|
2647
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; }
|
|
2648
4107
|
.nav { display: grid; gap: 1px; }
|
|
2649
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); }
|
|
@@ -2744,9 +4203,13 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
|
|
|
2744
4203
|
.create { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin: 0; }
|
|
2745
4204
|
.create .grow { flex: 1 1 220px; }
|
|
2746
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); }
|
|
2747
4209
|
label.choice > input { width: 16px; }
|
|
2748
4210
|
.create label:not(.grow) { flex: 0 1 auto; }
|
|
2749
4211
|
form > label { margin-bottom: var(--space-3); }
|
|
4212
|
+
.create > label { margin-bottom: 0; }
|
|
2750
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; }
|
|
2751
4214
|
.unit-input { display: flex; align-items: center; gap: var(--space-2); }
|
|
2752
4215
|
.unit-input input { width: 100%; }
|
|
@@ -2761,6 +4224,12 @@ form .choice { margin: var(--space-1) 0 var(--space-3); }
|
|
|
2761
4224
|
.types summary { cursor: pointer; color: var(--ink-2); font-size: var(--text-md); width: fit-content; }
|
|
2762
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); }
|
|
2763
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%; }
|
|
2764
4233
|
|
|
2765
4234
|
/* Buttons */
|
|
2766
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); }
|
|
@@ -2891,6 +4360,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2891
4360
|
.side-top .brand .name { display: none; }
|
|
2892
4361
|
.workspace { flex: 1; min-width: 0; padding: 3px 4px; }
|
|
2893
4362
|
.workspace .avatar { display: none; }
|
|
4363
|
+
.environment { margin: 0; flex: none; }
|
|
2894
4364
|
.me { grid-column: 2; grid-row: 1; margin: 0; padding: 0; border: 0; }
|
|
2895
4365
|
.me-avatar, .me .login { display: none; }
|
|
2896
4366
|
.sign-out { width: 36px; height: 36px; padding: 0; }
|
|
@@ -2929,6 +4399,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2929
4399
|
.week th[scope="row"] { display: block; padding: var(--space-2) var(--space-4) 0; }
|
|
2930
4400
|
.actions { text-align: left; width: auto; padding-top: var(--space-2); }
|
|
2931
4401
|
.act { margin: 0 var(--space-2) 0 0; }
|
|
4402
|
+
.actions form.inline { margin-right: var(--space-2); }
|
|
2932
4403
|
.act:first-child, form.inline + .act { margin-left: 0; }
|
|
2933
4404
|
.act summary { border: 1px solid var(--line-strong); height: 32px; }
|
|
2934
4405
|
.act.danger summary { border-color: var(--bad-line); }
|
|
@@ -2947,7 +4418,17 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
2947
4418
|
.pill, .chip, .nav a[aria-current="page"] { border: 1px solid CanvasText; }
|
|
2948
4419
|
}
|
|
2949
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' });
|
|
2950
4430
|
const ASSET_VERSIONS = Object.freeze({
|
|
2951
4431
|
css: createHash('sha256').update(DASHBOARD_CSS).digest('base64url').slice(0, 12),
|
|
2952
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),
|
|
2953
4434
|
});
|