slotlock 0.9.0 → 0.10.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 +305 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-store-backend.js +209 -15
- package/application-api.d.ts +10 -1
- package/application-api.js +213 -2
- package/availability-store.js +13 -5
- package/booking-rules.d.ts +11 -0
- package/booking-rules.js +21 -1
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +97 -2
- package/dashboard.js +3068 -284
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +40 -9
- package/ddl.js +2123 -49
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +1 -1
- package/event-feed.js +6 -34
- package/holiday-import.d.ts +1 -1
- package/holiday-import.js +2 -3
- package/holidays.js +70 -11
- package/index.d.ts +12 -4
- package/index.js +8 -2
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/maintenance.d.ts +7 -2
- package/maintenance.js +15 -1
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +1886 -6
- package/package.json +2 -1
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +262 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +273 -2
- package/store.js +984 -72
- package/types.d.ts +33 -1
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/dashboard.js
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
// once) and signed-out sessions (a copied cookie stays signed out). `createSlotlockDashboardState`
|
|
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
|
-
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES, SLOTLOCK_DASHBOARD_POST_CAPABILITIES, SLOTLOCK_FRESH_CAPABILITIES, SLOTLOCK_SCOPED_DASHBOARD_PAGES, SLOTLOCK_SCOPED_DASHBOARD_POSTS, SLOTLOCK_WORKSPACE_ROLE_NAMES, SLOTLOCK_WORKSPACE_ROLES, isSlotlockWorkspaceRole, slotlockMemberCan, slotlockRoleCan, slotlockRoleMayGrant, } from './workspace-roles.js';
|
|
18
|
+
import { slotlockDomainRecord, slotlockEmailDomain } from './domains.js';
|
|
19
|
+
import { SLOTLOCK_GOOGLE_ISSUERS, createSlotlockJwksCache, discoverSlotlockOidc, slotlockEntraIssuer, slotlockOidcUrlAllowed, slotlockReadJsonBody, verifySlotlockIdToken, } from './oidc.js';
|
|
20
|
+
import { handleSlotlockScimRequest } from './scim-server.js';
|
|
21
|
+
import { SLOTLOCK_DOMAIN_AUDIT_ACTIONS, slotlockDomainCheckEvent, slotlockJoinRole, } from './workspace-domains.js';
|
|
22
|
+
import { SLOTLOCK_WORKSPACE_DOMAIN_LIMIT } from './ddl-identity.js';
|
|
17
23
|
import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
|
|
18
24
|
import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
|
|
19
25
|
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, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
|
|
@@ -21,18 +27,20 @@ import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
|
|
|
21
27
|
import { createSlotlockRateLimiter } from './rate-limit.js';
|
|
22
28
|
import { createSlotlockGuardedFetch } from './webhooks.js';
|
|
23
29
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
24
|
-
import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
|
|
30
|
+
import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_DASHBOARD_FORM_LIMIT, SLOTLOCK_INVITATIONS_PER_ADDRESS } from './ddl.js';
|
|
25
31
|
import { ignoreRejection } from './hooks.js';
|
|
26
32
|
import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
|
|
27
33
|
import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
|
|
34
|
+
import { runSlotlockDashboardRequest, setSlotlockDashboardRequestScope, slotlockDashboardRequestScope, } from './dashboard-scope.js';
|
|
28
35
|
import { SLOTLOCK_SETTINGS_CSS } from './dashboard-settings-ui.js';
|
|
29
36
|
import { renderSlotlockMarkdown } from './dashboard-markdown.js';
|
|
30
37
|
import { SLOTLOCK_ASSISTANT_CSS, slotlockAssistantActionVerb, slotlockAssistantScript, slotlockAssistantStepLabel, } from './dashboard-assistant-ui.js';
|
|
31
38
|
import { SLOTLOCK_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
|
|
32
|
-
import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
|
|
39
|
+
import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, SLOTLOCK_MAX_KEY_SCOPE_REFS, isSlotlockResourceScope, } from './resource-profile.js';
|
|
33
40
|
import { slotlockQuotaPeriod } from './quotas.js';
|
|
34
41
|
import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
|
|
35
42
|
import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
|
|
43
|
+
import { buildSlotlockTenantExport, } from './store.js';
|
|
36
44
|
import { dayToDate } from './hours.js';
|
|
37
45
|
import { SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES } from './holidays.js';
|
|
38
46
|
import { readSlotlockHolidayCalendar } from './holiday-import.js';
|
|
@@ -73,6 +81,8 @@ const MAX_REMEMBERED = 10_000;
|
|
|
73
81
|
const FORM_NONCE = /^([A-Za-z0-9_-]{24})\.([A-Za-z0-9_-]{43})$/;
|
|
74
82
|
const POST_ROUTES = new Set([
|
|
75
83
|
'/sign-out',
|
|
84
|
+
'/sign-out-everywhere',
|
|
85
|
+
'/sessions/end',
|
|
76
86
|
'/keys',
|
|
77
87
|
'/keys/rotate',
|
|
78
88
|
'/keys/revoke',
|
|
@@ -105,6 +115,38 @@ const POST_ROUTES = new Set([
|
|
|
105
115
|
'/environment',
|
|
106
116
|
'/teams/enable',
|
|
107
117
|
'/teams/disable',
|
|
118
|
+
'/teams/create',
|
|
119
|
+
'/teams/delete',
|
|
120
|
+
'/teams/restore',
|
|
121
|
+
'/invite/accept',
|
|
122
|
+
'/members/invite',
|
|
123
|
+
'/members/resend',
|
|
124
|
+
'/members/revoke',
|
|
125
|
+
'/members/role',
|
|
126
|
+
'/members/scope',
|
|
127
|
+
'/members/remove',
|
|
128
|
+
'/members/leave',
|
|
129
|
+
'/members/requests',
|
|
130
|
+
'/join/request',
|
|
131
|
+
'/identity/domains',
|
|
132
|
+
'/identity/domains/verify',
|
|
133
|
+
'/identity/domains/remove',
|
|
134
|
+
'/identity/domains/policy',
|
|
135
|
+
'/identity/invites',
|
|
136
|
+
'/identity/sso',
|
|
137
|
+
'/identity/sso/test',
|
|
138
|
+
'/identity/sso/status',
|
|
139
|
+
'/identity/sso/delete',
|
|
140
|
+
'/identity/sso/provisioning',
|
|
141
|
+
'/identity/sso/mapping',
|
|
142
|
+
'/identity/sso/require',
|
|
143
|
+
'/identity/scim/enable',
|
|
144
|
+
'/identity/scim/disable',
|
|
145
|
+
'/identity/scim/token',
|
|
146
|
+
'/identity/scim/token/revoke',
|
|
147
|
+
'/identity/scim/group',
|
|
148
|
+
'/sso/proof',
|
|
149
|
+
'/sso/bypass',
|
|
108
150
|
'/assistant',
|
|
109
151
|
'/assistant/stream',
|
|
110
152
|
'/assistant/approve',
|
|
@@ -118,11 +160,87 @@ const POST_ROUTES = new Set([
|
|
|
118
160
|
'/settings/assistant/remove',
|
|
119
161
|
'/plan/upgrade',
|
|
120
162
|
'/plan/manage',
|
|
163
|
+
'/settings/privacy/export',
|
|
164
|
+
'/settings/privacy/export/delete',
|
|
165
|
+
'/settings/privacy/person',
|
|
166
|
+
'/settings/privacy/person/export',
|
|
167
|
+
'/settings/privacy/person/erase',
|
|
168
|
+
]);
|
|
169
|
+
/** The forms whose success the audit log records, by the action it records. */
|
|
170
|
+
const AUDITED_ROUTES = Object.freeze({
|
|
171
|
+
'/keys': 'api_key.created',
|
|
172
|
+
'/keys/rotate': 'api_key.rotated',
|
|
173
|
+
'/keys/revoke': 'api_key.revoked',
|
|
174
|
+
'/keys/limits': 'api_key.limits_changed',
|
|
175
|
+
'/webhooks': 'webhook.created',
|
|
176
|
+
'/webhooks/delete': 'webhook.deleted',
|
|
177
|
+
'/webhooks/enable': 'webhook.enabled',
|
|
178
|
+
'/contacts/forget': 'contact.forgotten',
|
|
179
|
+
'/settings/assistant': 'assistant.settings_saved',
|
|
180
|
+
'/settings/assistant/remove': 'assistant.settings_removed',
|
|
181
|
+
'/plan/upgrade': 'billing.checkout_started',
|
|
182
|
+
'/plan/manage': 'billing.portal_opened',
|
|
183
|
+
'/teams/enable': 'organization.enabled',
|
|
184
|
+
'/teams/disable': 'organization.disabled',
|
|
185
|
+
'/sandbox/reset': 'sandbox.reset',
|
|
186
|
+
'/sign-out-everywhere': 'session.signed_out_everywhere',
|
|
187
|
+
'/sessions/end': 'session.ended',
|
|
188
|
+
});
|
|
189
|
+
/** What each capability lets a person do, as a refusal says it. */
|
|
190
|
+
const CAPABILITY_WORDS = Object.freeze({
|
|
191
|
+
'account.use': 'use this account',
|
|
192
|
+
'workspace.read': 'see this workspace',
|
|
193
|
+
'usage.read': 'see its usage',
|
|
194
|
+
'assistant.chat': 'chat with the assistant',
|
|
195
|
+
'operations.run': 'change bookings, hours or closures',
|
|
196
|
+
'resources.manage': 'change resources',
|
|
197
|
+
'sandbox.manage': 'change the test environment',
|
|
198
|
+
'keys.manage_own': 'create or change API keys',
|
|
199
|
+
'keys.full': 'create or change Full-access keys',
|
|
200
|
+
'keys.manage_any': "change a key someone else created",
|
|
201
|
+
'webhooks.manage': 'change webhooks',
|
|
202
|
+
'contacts.forget': 'forget contacts',
|
|
203
|
+
'assistant.settings': "change the assistant's model",
|
|
204
|
+
'members.manage': 'manage members',
|
|
205
|
+
'audit.read': 'read the audit log',
|
|
206
|
+
'privacy.manage': "export the workspace's data or erase a person",
|
|
207
|
+
'billing.manage': 'change the plan or billing',
|
|
208
|
+
'workspace.own': 'change who owns this workspace',
|
|
209
|
+
'identity.manage': "change the team's domains",
|
|
210
|
+
});
|
|
211
|
+
/** What a session signed in through a team's provider alone may not do: anything beyond its team. */
|
|
212
|
+
const SSO_SCOPED_REFUSED = new Set([
|
|
213
|
+
'/organization',
|
|
214
|
+
'/teams/create',
|
|
215
|
+
'/teams/enable',
|
|
216
|
+
'/teams/disable',
|
|
217
|
+
'/invite/accept',
|
|
218
|
+
'/join/request',
|
|
219
|
+
'/sso/proof',
|
|
220
|
+
'/sso/bypass',
|
|
221
|
+
'/sessions/end',
|
|
222
|
+
'/sign-out-everywhere',
|
|
223
|
+
]);
|
|
224
|
+
/** The pages a sign-in may come back to: one made to confirm it's you, or to open an invitation. */
|
|
225
|
+
const STEP_UP_PAGES = new Set([
|
|
226
|
+
'/settings/privacy',
|
|
227
|
+
'/keys',
|
|
228
|
+
'/webhooks',
|
|
229
|
+
'/plan',
|
|
230
|
+
'/settings',
|
|
231
|
+
'/settings/members',
|
|
232
|
+
'/settings/team',
|
|
233
|
+
'/settings/identity',
|
|
234
|
+
'/teams',
|
|
235
|
+
'/contacts',
|
|
236
|
+
'/invite',
|
|
121
237
|
]);
|
|
122
238
|
const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
|
|
123
239
|
const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
|
|
124
240
|
const MAX_COOKIE_CHARACTERS = 4_096;
|
|
125
241
|
const GITHUB_TIMEOUT_MS = 10_000;
|
|
242
|
+
/** The most a team's token endpoint may answer: an ID token and its companions fit well within. */
|
|
243
|
+
const SSO_TOKEN_ANSWER_BYTES = 65_536;
|
|
126
244
|
/** A Google account's subject: ASCII, at most 255 characters (OpenID Connect); Google's are digits. */
|
|
127
245
|
const GOOGLE_SUBJECT = /^[A-Za-z0-9._-]{1,255}$/;
|
|
128
246
|
/** The REST API version GitHub documents for GET /user (docs.github.com, read 2026-10-10). */
|
|
@@ -135,6 +253,196 @@ const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
|
135
253
|
const GITHUB_LOGIN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
136
254
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
137
255
|
const DAY_MS = 86_400_000;
|
|
256
|
+
/** The invitation link being opened, for the sign-in that accepts it: sealed, for 30 minutes. */
|
|
257
|
+
const INVITE_COOKIE = '__Host-slotlock-invite';
|
|
258
|
+
/** A sign-in through a team's identity provider, until its callback. */
|
|
259
|
+
const SSO_COOKIE = '__Host-slotlock-sso';
|
|
260
|
+
const INVITE_LINK_TTL_MS = 30 * 60_000;
|
|
261
|
+
/** An invitation's token: 32 random bytes, base64url. Only its SHA-256 digest is stored. */
|
|
262
|
+
const INVITE_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
263
|
+
/** How long an invitation stays open, and how many a team workspace may send in a day. */
|
|
264
|
+
const INVITATION_DAYS = 7;
|
|
265
|
+
const INVITATION_DAILY_LIMIT = 50;
|
|
266
|
+
/** People in a team workspace on the Free plan, its Owner and open invitations included. */
|
|
267
|
+
const FREE_TEAM_MEMBERS = 3;
|
|
268
|
+
/** Team workspaces on the Free plan each person may create, where plans apply. */
|
|
269
|
+
const FREE_TEAMS_PER_PERSON = 1;
|
|
270
|
+
/** A team workspace's id, as its tenant names it (`team:<id>`). */
|
|
271
|
+
const TEAM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
272
|
+
const EMAIL_ADDRESS = /^[^@\s<>"]{1,64}@[^@\s<>"]+\.[^@\s<>"]+$/;
|
|
273
|
+
/** Audit events a page of the audit log shows, and an export holds at most. */
|
|
274
|
+
const AUDIT_PAGE = 50;
|
|
275
|
+
const AUDIT_EXPORT_MOST = 10_000;
|
|
276
|
+
const AUDIT_EXPORT_EMPTY = Object.freeze({
|
|
277
|
+
occurred_at: '', action: '', outcome: '', actor_type: '', actor_ref: '', actor_role: '', target_type: '',
|
|
278
|
+
target_ref: '', reason: '', ip_address: '', user_agent: '', metadata: '', chain_index: '', hash: '',
|
|
279
|
+
});
|
|
280
|
+
/** A CSV cell, quoted; one a spreadsheet would read as a formula starts with an apostrophe. */
|
|
281
|
+
function csvCell(value) {
|
|
282
|
+
const safe = /^[\s]*[=+\-@]|^[\t\r\n]/.test(value) ? `'${value}` : value;
|
|
283
|
+
return `"${safe.replaceAll('"', '""')}"`;
|
|
284
|
+
}
|
|
285
|
+
/** What each audited action did, as the audit log says it. */
|
|
286
|
+
const AUDIT_ACTION_WORDS = Object.freeze({
|
|
287
|
+
'api_key.created': 'created an API key',
|
|
288
|
+
'api_key.rotated': 'rotated an API key',
|
|
289
|
+
'api_key.revoked': 'revoked an API key',
|
|
290
|
+
'api_key.limits_changed': "changed an API key's limits",
|
|
291
|
+
'webhook.created': 'added a webhook',
|
|
292
|
+
'webhook.deleted': 'removed a webhook',
|
|
293
|
+
'webhook.enabled': 'turned a webhook back on',
|
|
294
|
+
'contact.forgotten': 'forgot a contact',
|
|
295
|
+
'subject.erased': 'erased a person',
|
|
296
|
+
'export.created': 'exported the account',
|
|
297
|
+
'export.downloaded': 'downloaded an export of the account',
|
|
298
|
+
'subject.exported': "exported a person's data",
|
|
299
|
+
'assistant.settings_saved': "changed the assistant's model",
|
|
300
|
+
'assistant.settings_removed': "removed the assistant's model",
|
|
301
|
+
'billing.checkout_started': 'started a checkout',
|
|
302
|
+
'billing.portal_opened': 'opened billing',
|
|
303
|
+
'organization.enabled': 'turned the organization on',
|
|
304
|
+
'organization.disabled': 'turned the organization off',
|
|
305
|
+
'sandbox.reset': 'reset the test environment',
|
|
306
|
+
'session.signed_in': 'signed in',
|
|
307
|
+
'session.signed_out_everywhere': 'signed out everywhere',
|
|
308
|
+
'session.ended': 'signed a device out',
|
|
309
|
+
'audit.exported': 'exported the audit log',
|
|
310
|
+
'workspace.created': 'created the team',
|
|
311
|
+
'workspace.deletion_scheduled': 'asked for the team to be deleted',
|
|
312
|
+
'workspace.deletion_cancelled': 'kept the team',
|
|
313
|
+
'member.invited': 'invited someone',
|
|
314
|
+
'invitation.resent': 'sent an invitation again',
|
|
315
|
+
'invitation.revoked': 'revoked an invitation',
|
|
316
|
+
'member.joined': 'joined',
|
|
317
|
+
'member.role_changed': "changed a member's role",
|
|
318
|
+
'member.scope_changed': 'changed what a member reaches',
|
|
319
|
+
'member.removed': 'removed a member',
|
|
320
|
+
'member.left': 'left',
|
|
321
|
+
'member.join_requested': 'asked to join',
|
|
322
|
+
'member.join_approved': 'let someone join',
|
|
323
|
+
'member.join_declined': 'declined a request to join',
|
|
324
|
+
'domain.claimed': 'added a domain',
|
|
325
|
+
'domain.verified': 'verified a domain',
|
|
326
|
+
'domain.record_missing': "found a domain's record gone",
|
|
327
|
+
'domain.lapsing': 'found a domain lapsing',
|
|
328
|
+
'domain.restored': 'verified a domain again',
|
|
329
|
+
'domain.lapsed': 'found a domain lapsed',
|
|
330
|
+
'domain.released': 'removed a domain',
|
|
331
|
+
'domain.join_policy_changed': 'changed who may join through a domain',
|
|
332
|
+
'workspace.invites_restricted': 'held invitations to verified domains',
|
|
333
|
+
'workspace.invites_unrestricted': 'let invitations go to any address',
|
|
334
|
+
'sso.connection_saved': 'saved the single sign-on connection',
|
|
335
|
+
'sso.connection_tested': 'tested single sign-on',
|
|
336
|
+
'sso.connection_activated': 'turned single sign-on on',
|
|
337
|
+
'sso.connection_disabled': 'turned single sign-on off',
|
|
338
|
+
'sso.connection_deleted': 'deleted the single sign-on connection',
|
|
339
|
+
'sso.provisioning_changed': 'changed who single sign-on adds',
|
|
340
|
+
'sso.mapping_changed': 'changed a group’s role',
|
|
341
|
+
'sso.required': 'required single sign-on',
|
|
342
|
+
'sso.not_required': 'stopped requiring single sign-on',
|
|
343
|
+
'sso.bypass_used': 'bypassed single sign-on',
|
|
344
|
+
'sso.signed_in': 'signed in with single sign-on',
|
|
345
|
+
'sso.linked': 'linked a single sign-on account',
|
|
346
|
+
'authz.denied': 'was refused',
|
|
347
|
+
});
|
|
348
|
+
const AUDIT_ACTION_GROUPS = Object.freeze([
|
|
349
|
+
['API keys', ['api_key.created', 'api_key.rotated', 'api_key.revoked', 'api_key.limits_changed']],
|
|
350
|
+
['Webhooks', ['webhook.created', 'webhook.deleted', 'webhook.enabled']],
|
|
351
|
+
['People', ['workspace.created', 'workspace.deletion_scheduled', 'workspace.deletion_cancelled', 'member.invited', 'invitation.resent', 'invitation.revoked', 'member.joined', 'member.role_changed', 'member.scope_changed', 'member.removed', 'member.left', 'member.join_requested', 'member.join_approved', 'member.join_declined', 'organization.enabled', 'organization.disabled']],
|
|
352
|
+
['Identity', [
|
|
353
|
+
...SLOTLOCK_DOMAIN_AUDIT_ACTIONS,
|
|
354
|
+
'sso.connection_saved', 'sso.connection_tested', 'sso.connection_activated', 'sso.connection_disabled',
|
|
355
|
+
'sso.connection_deleted', 'sso.provisioning_changed', 'sso.mapping_changed', 'sso.required', 'sso.not_required',
|
|
356
|
+
'sso.bypass_used', 'sso.signed_in', 'sso.linked',
|
|
357
|
+
]],
|
|
358
|
+
['Billing', ['billing.checkout_started', 'billing.portal_opened']],
|
|
359
|
+
['Sessions', ['session.signed_in', 'session.signed_out_everywhere', 'session.ended']],
|
|
360
|
+
['Other', ['contact.forgotten', 'subject.erased', 'subject.exported', 'export.created', 'export.downloaded', 'assistant.settings_saved', 'assistant.settings_removed', 'sandbox.reset', 'audit.exported', 'authz.denied']],
|
|
361
|
+
]);
|
|
362
|
+
const AUDIT_ACTION_NAMES = new Set(AUDIT_ACTION_GROUPS.flatMap(([, actions]) => actions));
|
|
363
|
+
/** What a team change refused says, and its status. */
|
|
364
|
+
const TEAM_REFUSALS = Object.freeze({
|
|
365
|
+
invalid_handle: { status: 400, text: 'That name gives no usable handle. Try another.' },
|
|
366
|
+
invalid_name: { status: 400, text: 'Name the team: 1 to 80 characters.' },
|
|
367
|
+
handle_taken: { status: 409, text: 'That name is taken. Try another.' },
|
|
368
|
+
team_limit: {
|
|
369
|
+
status: 403,
|
|
370
|
+
text: `Each person may create ${String(FREE_TEAMS_PER_PERSON)} team on the Free plan. Upgrade yours to create another.`,
|
|
371
|
+
},
|
|
372
|
+
not_found: { status: 404, text: 'That is no longer here.' },
|
|
373
|
+
not_allowed: { status: 403, text: 'Your role can’t give that role, or change that member.' },
|
|
374
|
+
already_member: { status: 409, text: 'They are already a member.' },
|
|
375
|
+
invitation_limit: {
|
|
376
|
+
status: 429,
|
|
377
|
+
text: `This team sent ${String(INVITATION_DAILY_LIMIT)} invitations in the last day. Try again tomorrow.`,
|
|
378
|
+
},
|
|
379
|
+
recipient_limit: {
|
|
380
|
+
status: 429,
|
|
381
|
+
text: `That address was sent ${String(SLOTLOCK_INVITATIONS_PER_ADDRESS)} invitations in the last day. Try again tomorrow.`,
|
|
382
|
+
},
|
|
383
|
+
member_cap: {
|
|
384
|
+
status: 403,
|
|
385
|
+
text: `A team on the Free plan has up to ${String(FREE_TEAM_MEMBERS)} people, open invitations included. Upgrade it to add more.`,
|
|
386
|
+
},
|
|
387
|
+
revoked: { status: 409, text: 'This invitation was withdrawn.' },
|
|
388
|
+
accepted: { status: 409, text: 'This invitation was already used.' },
|
|
389
|
+
expired: { status: 409, text: 'This invitation expired. Ask for a new one.' },
|
|
390
|
+
not_for_you: { status: 403, text: 'This invitation is for another account.' },
|
|
391
|
+
last_owner: { status: 409, text: 'A team keeps at least one owner. Make someone else an owner first.' },
|
|
392
|
+
sso_owner: { status: 409, text: 'An owner signs in with GitHub or Google too, for when the team’s provider is down. Ask them to link it first.' },
|
|
393
|
+
confirm: { status: 400, text: "Type the team's handle to confirm." },
|
|
394
|
+
subscription_live: { status: 409, text: 'Cancel its Team plan on Usage first.' },
|
|
395
|
+
owner: { status: 409, text: 'An owner reaches the whole workspace. Change their role first.' },
|
|
396
|
+
domain_restricted: { status: 403, text: 'Invitations go only to addresses at this team’s verified domains.' },
|
|
397
|
+
});
|
|
398
|
+
/** A handle from a team's name: lower-case letters, digits and dashes; later attempts add a random end. */
|
|
399
|
+
function teamHandleFrom(name, attempt) {
|
|
400
|
+
const base = name
|
|
401
|
+
.normalize('NFKD')
|
|
402
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
403
|
+
.toLowerCase()
|
|
404
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
405
|
+
.replace(/^-+/, '')
|
|
406
|
+
.slice(0, 30)
|
|
407
|
+
.replace(/-+$/, '') || 'team';
|
|
408
|
+
return attempt === 0 ? base : `${base}-${randomBytes(3).toString('hex')}`;
|
|
409
|
+
}
|
|
410
|
+
/** A browser as people name it, from its User-Agent: `Firefox on macOS`. */
|
|
411
|
+
function deviceOf(agent) {
|
|
412
|
+
if (!agent)
|
|
413
|
+
return 'A browser';
|
|
414
|
+
const browser = /Edg\//.test(agent)
|
|
415
|
+
? 'Edge'
|
|
416
|
+
: /OPR\//.test(agent)
|
|
417
|
+
? 'Opera'
|
|
418
|
+
: /Firefox\//.test(agent)
|
|
419
|
+
? 'Firefox'
|
|
420
|
+
: /Chrome\//.test(agent)
|
|
421
|
+
? 'Chrome'
|
|
422
|
+
: /Safari\//.test(agent)
|
|
423
|
+
? 'Safari'
|
|
424
|
+
: 'A browser';
|
|
425
|
+
const system = /iPhone|iPad/.test(agent)
|
|
426
|
+
? 'iOS'
|
|
427
|
+
: /Android/.test(agent)
|
|
428
|
+
? 'Android'
|
|
429
|
+
: /Mac OS X|Macintosh/.test(agent)
|
|
430
|
+
? 'macOS'
|
|
431
|
+
: /Windows/.test(agent)
|
|
432
|
+
? 'Windows'
|
|
433
|
+
: /Linux/.test(agent)
|
|
434
|
+
? 'Linux'
|
|
435
|
+
: '';
|
|
436
|
+
return system ? `${browser} on ${system}` : browser;
|
|
437
|
+
}
|
|
438
|
+
/** An address as an invitation shows it before anyone signs in: its first letter and domain. */
|
|
439
|
+
function maskedEmail(email) {
|
|
440
|
+
const at = email.lastIndexOf('@');
|
|
441
|
+
if (at < 1)
|
|
442
|
+
return email;
|
|
443
|
+
return `${email.slice(0, 1)}${'•'.repeat(Math.min(3, at - 1))}${email.slice(at)}`;
|
|
444
|
+
}
|
|
445
|
+
const tokenDigest = (raw) => createHash('sha256').update(raw).digest();
|
|
138
446
|
const KEY_ACCESS = Object.freeze({
|
|
139
447
|
read: ['read'],
|
|
140
448
|
read_write: ['read', 'write'],
|
|
@@ -174,11 +482,34 @@ function policyFor(billing) {
|
|
|
174
482
|
* The dashboard's resources, in the store under each tenant's row-level security. Adds hold the cap
|
|
175
483
|
* in the database (a READ COMMITTED transaction, under a per-tenant lock).
|
|
176
484
|
*/
|
|
177
|
-
export function createSlotlockDashboardResources(
|
|
485
|
+
export function createSlotlockDashboardResources(given, options = {}) {
|
|
486
|
+
// Every transaction keeps to the resources the request's member is limited to, if any.
|
|
487
|
+
const store = {
|
|
488
|
+
...given,
|
|
489
|
+
withTenant(tenantRef, callback, options) {
|
|
490
|
+
const scope = slotlockDashboardRequestScope();
|
|
491
|
+
return given.withTenant(tenantRef, callback, scope ? { ...options, resourceScope: scope } : options);
|
|
492
|
+
},
|
|
493
|
+
};
|
|
178
494
|
// Every change here is the dashboard's, made by the person signed in (actor) when known.
|
|
179
495
|
const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
|
|
180
496
|
const agentBackend = options.agentBackend;
|
|
497
|
+
/**
|
|
498
|
+
* Approvals the request's member may see: every one, or under a scope those whose every resource
|
|
499
|
+
* the scope reaches (approvals are the tenant's, not a resource's, so the database cannot tell).
|
|
500
|
+
*/
|
|
501
|
+
const reachable = async (tenant, tenantRef, approvals) => {
|
|
502
|
+
if (!slotlockDashboardRequestScope())
|
|
503
|
+
return approvals;
|
|
504
|
+
const reached = new Set();
|
|
505
|
+
for (const id of new Set(approvals.flatMap((approval) => approval.resourceIds))) {
|
|
506
|
+
if (await tenant.getResource({ tenantRef, id }))
|
|
507
|
+
reached.add(id);
|
|
508
|
+
}
|
|
509
|
+
return approvals.filter((approval) => approval.resourceIds.every((id) => reached.has(id)));
|
|
510
|
+
};
|
|
181
511
|
return {
|
|
512
|
+
keepsToScope: true,
|
|
182
513
|
sandbox: {
|
|
183
514
|
seed: (tenantRef, scenario) => seedSlotlockSandbox(store, tenantRef, scenario),
|
|
184
515
|
async reset(tenantRef) {
|
|
@@ -192,9 +523,18 @@ export function createSlotlockDashboardResources(store, options = {}) {
|
|
|
192
523
|
...(agentBackend
|
|
193
524
|
? {
|
|
194
525
|
approvals: {
|
|
195
|
-
list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listApprovals({ tenantRef, limit })),
|
|
526
|
+
list: (tenantRef, limit) => store.withTenant(tenantRef, async (tenant) => reachable(tenant, tenantRef, await tenant.listApprovals({ tenantRef, limit }))),
|
|
196
527
|
async decide(tenantRef, id, decision, actor, reason) {
|
|
197
528
|
const decidedBy = actor ?? 'dashboard';
|
|
529
|
+
// A member limited to some resources decides only what asks for theirs alone.
|
|
530
|
+
if (slotlockDashboardRequestScope()) {
|
|
531
|
+
const found = await store.withTenant(tenantRef, async (tenant) => {
|
|
532
|
+
const approval = await tenant.getApproval({ tenantRef, id });
|
|
533
|
+
return approval ? (await reachable(tenant, tenantRef, [approval])).length > 0 : false;
|
|
534
|
+
});
|
|
535
|
+
if (!found)
|
|
536
|
+
return { ok: false, code: 'approval_not_found' };
|
|
537
|
+
}
|
|
198
538
|
if (decision === 'decline') {
|
|
199
539
|
const declined = await store.withTenant(tenantRef, (tenant) => tenant.declineApproval({ tenantRef, id, decidedBy, ...(reason ? { reason } : {}) }), by(actor));
|
|
200
540
|
if (!declined.ok)
|
|
@@ -248,6 +588,22 @@ export function createSlotlockDashboardResources(store, options = {}) {
|
|
|
248
588
|
const updated = await tenant.setResourceAvailability({ tenantRef, id: target.id, rules });
|
|
249
589
|
return updated ? 1 : 0;
|
|
250
590
|
}, by(actor)),
|
|
591
|
+
privacy: {
|
|
592
|
+
async createExport(tenantRef, actor) {
|
|
593
|
+
const made = await store.withTenant(tenantRef, (tenant) => tenant.createTenantExport({ tenantRef, ...(actor ? { requestedBy: actor } : {}) }), { isolation: 'read committed', ...by(actor) });
|
|
594
|
+
// Built after the answer; maintenance builds one a stopped server left pending.
|
|
595
|
+
void buildSlotlockTenantExport(store, tenantRef, made.id);
|
|
596
|
+
return made;
|
|
597
|
+
},
|
|
598
|
+
listExports: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listTenantExports({ tenantRef })),
|
|
599
|
+
exportPart: (tenantRef, exportId, part) => store.withTenant(tenantRef, (tenant) => tenant.getTenantExportPart({ tenantRef, exportId, part })),
|
|
600
|
+
deleteExport: (tenantRef, exportId) => store.withTenant(tenantRef, (tenant) => tenant.deleteTenantExport({ tenantRef, exportId })),
|
|
601
|
+
exportSubject: (tenantRef, subject) => store.withTenant(tenantRef, (tenant) => tenant.exportSubject({ tenantRef, subject, limit: 1_000 })),
|
|
602
|
+
eraseSubject: (tenantRef, subject, actor) => store.withTenant(tenantRef, (tenant) => tenant.eraseSubject({ tenantRef, subject }), {
|
|
603
|
+
isolation: 'read committed',
|
|
604
|
+
...by(actor),
|
|
605
|
+
}),
|
|
606
|
+
},
|
|
251
607
|
contacts: {
|
|
252
608
|
list: (tenantRef, limit, after) => store.withTenant(tenantRef, (tenant) => tenant.listContacts({ tenantRef, limit, ...(after ? { after } : {}) })),
|
|
253
609
|
forget: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteContact({ tenantRef, ref }), { isolation: 'read committed', ...by(actor) }),
|
|
@@ -344,6 +700,11 @@ export function createSlotlockDashboardResources(store, options = {}) {
|
|
|
344
700
|
enable: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.enableWebhookEndpoint({ tenantRef, id }), by(actor)),
|
|
345
701
|
},
|
|
346
702
|
assistant: createSlotlockAssistantStore(store),
|
|
703
|
+
audit: {
|
|
704
|
+
verify: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.verifyAuditChain({ tenantRef })),
|
|
705
|
+
record: (tenantRef, event) => store.withTenant(tenantRef, (tenant) => tenant.recordAuditEvent({ tenantRef, event })),
|
|
706
|
+
list: (tenantRef, params = {}) => store.withTenant(tenantRef, (tenant) => tenant.listAuditEvents({ tenantRef, ...params })),
|
|
707
|
+
},
|
|
347
708
|
};
|
|
348
709
|
}
|
|
349
710
|
function assertExpiry(expiresAt) {
|
|
@@ -547,6 +908,7 @@ const INHERITED_NAMES = Object.freeze({
|
|
|
547
908
|
pickupTimes: 'Pick-up times',
|
|
548
909
|
returnTimes: 'Return times',
|
|
549
910
|
cancellationFee: 'Cancellation fee',
|
|
911
|
+
holdPrice: 'Hold price',
|
|
550
912
|
hoursApply: 'Hours hold a hire at',
|
|
551
913
|
});
|
|
552
914
|
/** A rule's value as the dashboard shows it. */
|
|
@@ -840,7 +1202,7 @@ function readCookies(request) {
|
|
|
840
1202
|
if (index <= 0)
|
|
841
1203
|
continue;
|
|
842
1204
|
const name = part.slice(0, index).trim();
|
|
843
|
-
if (name === SESSION_COOKIE || name === SIGN_IN_COOKIE) {
|
|
1205
|
+
if (name === SESSION_COOKIE || name === SIGN_IN_COOKIE || name === INVITE_COOKIE || name === SSO_COOKIE) {
|
|
844
1206
|
cookies.set(name, part.slice(index + 1).trim());
|
|
845
1207
|
}
|
|
846
1208
|
}
|
|
@@ -1045,6 +1407,24 @@ export function createSlotlockDashboard(options) {
|
|
|
1045
1407
|
}
|
|
1046
1408
|
// Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
|
|
1047
1409
|
const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
|
|
1410
|
+
const workspaces = options.workspaces ?? null;
|
|
1411
|
+
// Team workspaces: with the membership store's database, where people have workspaces of their own.
|
|
1412
|
+
const teamStore = options.teamWorkspaces && !served ? (workspaces?.teams ?? null) : null;
|
|
1413
|
+
/** Verified domains: a team workspace's, with the database. */
|
|
1414
|
+
const domainStore = teamStore && options.domains ? (workspaces?.domains ?? null) : null;
|
|
1415
|
+
const domainResolvers = options.domains?.resolvers ?? [];
|
|
1416
|
+
/** Single sign-on: with verified domains and the database. */
|
|
1417
|
+
const ssoStore = domainStore && options.sso ? (workspaces?.sso ?? null) : null;
|
|
1418
|
+
const scimStore = domainStore && options.scim ? (workspaces?.scim ?? null) : null;
|
|
1419
|
+
const ssoFetch = (input, init) => (options.sso?.fetch ?? fetchImpl)(input, init);
|
|
1420
|
+
/** Teams' providers' keys, fetched as their Owners' URLs are: through ssoFetch. */
|
|
1421
|
+
const ssoJwks = createSlotlockJwksCache({ fetch: ssoFetch, now: () => now() });
|
|
1422
|
+
/** A session's team that requires single sign-on, met while it held no proof: to say so. */
|
|
1423
|
+
const ssoRequiredOf = new WeakMap();
|
|
1424
|
+
/** Each session's role in the workspace it acts in, as the database said when it was read. */
|
|
1425
|
+
const roles = new WeakMap();
|
|
1426
|
+
/** Where each request came from, for the audit log. */
|
|
1427
|
+
const origins = new WeakMap();
|
|
1048
1428
|
// Plans whether or not billing sells them; billing's own reader when only it is given.
|
|
1049
1429
|
const plans = options.plans ??
|
|
1050
1430
|
(options.billing
|
|
@@ -1066,6 +1446,12 @@ export function createSlotlockDashboard(options) {
|
|
|
1066
1446
|
const readsOrganizations = served !== null || teams !== null;
|
|
1067
1447
|
/** Each session's organizations turned on, read with it (teams mode). */
|
|
1068
1448
|
const enabledOf = new WeakMap();
|
|
1449
|
+
/** The team workspaces the session's person is a member of, read with their standing. */
|
|
1450
|
+
const teamListOf = new WeakMap();
|
|
1451
|
+
/** When the session's team, being deleted, goes. */
|
|
1452
|
+
const deletingOf = new WeakMap();
|
|
1453
|
+
/** The locations and groups each session's member is limited to (RFC 0004 D1), read with their standing. */
|
|
1454
|
+
const scopes = new WeakMap();
|
|
1069
1455
|
const brandName = options.name ?? 'Slotlock';
|
|
1070
1456
|
if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
|
|
1071
1457
|
throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
|
|
@@ -1087,7 +1473,8 @@ export function createSlotlockDashboard(options) {
|
|
|
1087
1473
|
// A list, or exactly '*': any other string would match ids by substring.
|
|
1088
1474
|
// A GitHub person by their bare id, a Google one as google:<subject>.
|
|
1089
1475
|
const allowed = (uid, idp) => options.allowedUsers === '*' ||
|
|
1090
|
-
|
|
1476
|
+
// A person a team's provider made is no account a list names: only where everyone may sign in.
|
|
1477
|
+
(idp !== 'sso' && Array.isArray(options.allowedUsers) && options.allowedUsers.includes(idp === 'google' ? `google:${uid}` : uid));
|
|
1091
1478
|
const google = served ? null : (options.google ?? null);
|
|
1092
1479
|
let signInsInFlight = 0;
|
|
1093
1480
|
/** Sign-ins in flight by client address. */
|
|
@@ -1100,8 +1487,12 @@ export function createSlotlockDashboard(options) {
|
|
|
1100
1487
|
// A failing reporter must not change the response.
|
|
1101
1488
|
}
|
|
1102
1489
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1490
|
+
/** The workspace a form is bound to: its organization's id, `team:<id>`, or '' for one's own. */
|
|
1491
|
+
function workspaceKeyOf(session) {
|
|
1492
|
+
return session.team !== undefined ? `team:${session.team}` : (session.org ?? '');
|
|
1493
|
+
}
|
|
1494
|
+
// Bound to the workspace too: a page opened before a switch cannot write into the new one.
|
|
1495
|
+
const formMac = (session, nonce, org = workspaceKeyOf(session)) => createHmac('sha256', secret)
|
|
1105
1496
|
.update(`slotlock-dashboard-form-v2.${session.sid}.${org}.${nonce}`)
|
|
1106
1497
|
.digest('base64url');
|
|
1107
1498
|
/** Random, and bound to the session by a MAC: a made-up or another session's value is refused unwritten. */
|
|
@@ -1118,8 +1509,13 @@ export function createSlotlockDashboard(options) {
|
|
|
1118
1509
|
// Compared as text, like the cookie MACs: decoding would drop the last character's spare bits.
|
|
1119
1510
|
if (safeEqual(mac, formMac(session, nonce)))
|
|
1120
1511
|
return 'ok';
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1512
|
+
const current = workspaceKeyOf(session);
|
|
1513
|
+
const others = [
|
|
1514
|
+
...(session.orgs ?? []).map((org) => org.id),
|
|
1515
|
+
...(teamListOf.get(session) ?? []).map((ws) => ws.tenantRef),
|
|
1516
|
+
...(teamStore ? [''] : []),
|
|
1517
|
+
].filter((other) => other !== current);
|
|
1518
|
+
return others.some((other) => safeEqual(mac, formMac(session, nonce, other))) ? 'other_org' : 'bad';
|
|
1123
1519
|
};
|
|
1124
1520
|
function seal(context, payload) {
|
|
1125
1521
|
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
@@ -1156,26 +1552,145 @@ export function createSlotlockDashboard(options) {
|
|
|
1156
1552
|
typeof payload.uid !== 'string' ||
|
|
1157
1553
|
typeof payload.login !== 'string' ||
|
|
1158
1554
|
typeof payload.sid !== 'string' ||
|
|
1159
|
-
(payload.idp !== undefined && (payload.idp
|
|
1555
|
+
(payload.idp !== undefined && !((payload.idp === 'google' && google) || (payload.idp === 'sso' && ssoStore))) ||
|
|
1556
|
+
(payload.sc !== undefined && (!ssoStore || typeof payload.sc !== 'string' || !UUID_PATTERN.test(payload.sc))) ||
|
|
1160
1557
|
!allowed(payload.uid, payload.idp) ||
|
|
1161
1558
|
!organizationsHold(payload) ||
|
|
1559
|
+
!teamHolds(payload) ||
|
|
1162
1560
|
(payload.env !== undefined && payload.env !== 'test') ||
|
|
1163
1561
|
(await options.state.sessionEnded(payload.sid, payload.exp))) {
|
|
1164
1562
|
return null;
|
|
1165
1563
|
}
|
|
1166
|
-
|
|
1564
|
+
let session = payload;
|
|
1167
1565
|
if (teams) {
|
|
1168
1566
|
// Which of the person's organizations are on now: one turned off since is left at once.
|
|
1169
1567
|
const enabled = session.orgs?.length ? await teams.enabled(session.orgs.map((org) => org.id)) : new Set();
|
|
1170
1568
|
if (session.org !== undefined && !enabled.has(session.org)) {
|
|
1171
1569
|
const { org: _, ...personal } = session;
|
|
1172
|
-
|
|
1173
|
-
return personal;
|
|
1570
|
+
session = personal;
|
|
1174
1571
|
}
|
|
1175
1572
|
enabledOf.set(session, enabled);
|
|
1176
1573
|
}
|
|
1177
|
-
return session;
|
|
1574
|
+
return await withStanding(session);
|
|
1178
1575
|
}
|
|
1576
|
+
/**
|
|
1577
|
+
* The session with the person's role in its workspace as the database says now (workspaces): one
|
|
1578
|
+
* signed out everywhere since it was sealed is refused, and one whose membership ended falls back
|
|
1579
|
+
* to the person's own workspace or, where there is none (a fixed list of organizations), ends.
|
|
1580
|
+
*/
|
|
1581
|
+
async function withStanding(session) {
|
|
1582
|
+
if (!workspaces) {
|
|
1583
|
+
roles.set(session, derivedRole(session));
|
|
1584
|
+
return session;
|
|
1585
|
+
}
|
|
1586
|
+
const person = personOf(session);
|
|
1587
|
+
const [standing, listed, alive, deleting, scope] = await Promise.all([
|
|
1588
|
+
workspaces.standing(tenantOf(session), person, session.sid),
|
|
1589
|
+
teamStore ? teamStore.workspacesOf(person) : Promise.resolve([]),
|
|
1590
|
+
workspaces.sessions ? workspaces.sessions.seen(person, session.sid) : Promise.resolve(true),
|
|
1591
|
+
teamStore && session.team !== undefined ? teamStore.deleteAfter(`team:${session.team}`) : Promise.resolve(null),
|
|
1592
|
+
workspaces.scope && sharedWorkspace(session) ? workspaces.scope(tenantOf(session), person) : Promise.resolve(null),
|
|
1593
|
+
]);
|
|
1594
|
+
if (deleting)
|
|
1595
|
+
deletingOf.set(session, deleting);
|
|
1596
|
+
// Sealed under the person's current epoch, or refused: one from before Sign out everywhere,
|
|
1597
|
+
// from before this store, or of a person erased since (no record, no epoch) never passes; nor
|
|
1598
|
+
// one the person signed out from another device.
|
|
1599
|
+
if (!alive || session.ep === undefined || standing.sessionEpoch === null || session.ep !== standing.sessionEpoch)
|
|
1600
|
+
return null;
|
|
1601
|
+
if (standing.role !== null) {
|
|
1602
|
+
roles.set(session, standing.role);
|
|
1603
|
+
// A session through a team's provider alone knows of no other team the person is in.
|
|
1604
|
+
teamListOf.set(session, session.sc === undefined ? listed : listed.filter((team) => team.tenantRef === `team:${session.team}`));
|
|
1605
|
+
// Limited to some resources: what this request reads for them from now on keeps to those.
|
|
1606
|
+
const limited = scope && standing.role !== 'owner' ? scope : null;
|
|
1607
|
+
if (limited)
|
|
1608
|
+
scopes.set(session, limited);
|
|
1609
|
+
setSlotlockDashboardRequestScope(limited);
|
|
1610
|
+
return session;
|
|
1611
|
+
}
|
|
1612
|
+
// Signed in through a team's provider alone, or as a person it made: no workspace to fall back to.
|
|
1613
|
+
if ((session.org === undefined && session.team === undefined) || served || session.sc !== undefined || session.idp === 'sso')
|
|
1614
|
+
return null;
|
|
1615
|
+
const { org: _, team: _team, ...personal } = session;
|
|
1616
|
+
if (standing.refusal === 'sso_required' && session.team !== undefined) {
|
|
1617
|
+
ssoRequiredOf.set(personal, { tenantRef: `team:${session.team}`, mayBypass: standing.mayBypass === true });
|
|
1618
|
+
}
|
|
1619
|
+
const enabled = enabledOf.get(session);
|
|
1620
|
+
if (enabled)
|
|
1621
|
+
enabledOf.set(personal, enabled);
|
|
1622
|
+
teamListOf.set(personal, listed);
|
|
1623
|
+
roles.set(personal, 'owner');
|
|
1624
|
+
setSlotlockDashboardRequestScope(null);
|
|
1625
|
+
return personal;
|
|
1626
|
+
}
|
|
1627
|
+
/** A team workspace only with team workspaces on, never beside an organization; addresses as kept. */
|
|
1628
|
+
function teamHolds(payload) {
|
|
1629
|
+
if (payload.team !== undefined &&
|
|
1630
|
+
(!teamStore || typeof payload.team !== 'string' || !TEAM_ID.test(payload.team) || payload.org !== undefined)) {
|
|
1631
|
+
return false;
|
|
1632
|
+
}
|
|
1633
|
+
if (payload.hd !== undefined && (payload.idp !== 'google' || typeof payload.hd !== 'string' || payload.hd.length > 253))
|
|
1634
|
+
return false;
|
|
1635
|
+
if (payload.em === undefined)
|
|
1636
|
+
return true;
|
|
1637
|
+
return (Array.isArray(payload.em) &&
|
|
1638
|
+
payload.em.length <= 3 &&
|
|
1639
|
+
payload.em.every((email) => typeof email === 'string' && email.length <= 254));
|
|
1640
|
+
}
|
|
1641
|
+
/** A workspace people share (an organization's or a team's), not a person's own. */
|
|
1642
|
+
const sharedWorkspace = (session) => session.org !== undefined || session.team !== undefined;
|
|
1643
|
+
/** The team workspace the session acts in (`team:<id>`, Live), or undefined. */
|
|
1644
|
+
const teamTenantOf = (session) => (session.team !== undefined ? `team:${session.team}` : undefined);
|
|
1645
|
+
const currentTeam = (session) => session.team === undefined ? undefined : teamListOf.get(session)?.find((ws) => ws.tenantRef === `team:${session.team}`);
|
|
1646
|
+
/** Addresses the person holds verified: their Google account's, or those GitHub confirmed for an invitation. */
|
|
1647
|
+
const verifiedEmails = (session) =>
|
|
1648
|
+
// A session through a team's provider alone holds no address verified for anything else.
|
|
1649
|
+
session.sc !== undefined
|
|
1650
|
+
? []
|
|
1651
|
+
: session.idp === 'google'
|
|
1652
|
+
? session.login.includes('@')
|
|
1653
|
+
? [session.login.toLowerCase()]
|
|
1654
|
+
: []
|
|
1655
|
+
: (session.em ?? []);
|
|
1656
|
+
/**
|
|
1657
|
+
* How those addresses were verified, for joining through a domain: by a Google Workspace account
|
|
1658
|
+
* of the address's own domain (the company controls it now), or once, by Google or GitHub.
|
|
1659
|
+
*/
|
|
1660
|
+
const joinEvidence = (session) => {
|
|
1661
|
+
if (session.idp !== 'google')
|
|
1662
|
+
return 'github';
|
|
1663
|
+
const domain = slotlockEmailDomain(session.login);
|
|
1664
|
+
return session.hd !== undefined && domain !== null && domain === slotlockEmailDomain(`x@${session.hd}`) ? 'google_workspace' : 'google';
|
|
1665
|
+
};
|
|
1666
|
+
/**
|
|
1667
|
+
* Without a membership store: the Owner of one's own workspace; in an organization's, its GitHub
|
|
1668
|
+
* owners are Owners and its other members Developers, as GitHub said at sign-in.
|
|
1669
|
+
*/
|
|
1670
|
+
function derivedRole(session) {
|
|
1671
|
+
if (session.org === undefined)
|
|
1672
|
+
return 'owner';
|
|
1673
|
+
return session.orgs?.find((org) => org.id === session.org)?.role === 'admin' ? 'owner' : 'developer';
|
|
1674
|
+
}
|
|
1675
|
+
const roleOf = (session) => roles.get(session) ?? derivedRole(session);
|
|
1676
|
+
/**
|
|
1677
|
+
* What the person may do: their role's capabilities, and limited to some resources only those
|
|
1678
|
+
* SLOTLOCK_SCOPED_CAPABILITIES keeps, and only their own account's where the resource adapters
|
|
1679
|
+
* cannot keep a request to their resources.
|
|
1680
|
+
*/
|
|
1681
|
+
const can = (session, capability) => {
|
|
1682
|
+
const limited = scopes.has(session);
|
|
1683
|
+
if (limited && !options.resources.keepsToScope && capability !== 'account.use')
|
|
1684
|
+
return false;
|
|
1685
|
+
return slotlockMemberCan(roleOf(session), limited, capability);
|
|
1686
|
+
};
|
|
1687
|
+
/** Whether the person reaches a page: its capability, and limited, the pages a limited member has. */
|
|
1688
|
+
const reaches = (session, page) => {
|
|
1689
|
+
const capability = SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES[page];
|
|
1690
|
+
return capability !== undefined && can(session, capability) && (!scopes.has(session) || SLOTLOCK_SCOPED_DASHBOARD_PAGES.has(page));
|
|
1691
|
+
};
|
|
1692
|
+
/** Signed in within the last 15 minutes: what a fresh capability needs. */
|
|
1693
|
+
const freshlySignedIn = (session) => now() - (session.exp - SESSION_TTL_MS) <= FRESH_SIGN_IN_MS;
|
|
1179
1694
|
/**
|
|
1180
1695
|
* With organizations, the session names one the server still serves, from its own list; without
|
|
1181
1696
|
* them, it names none. A session from the other mode is signed out.
|
|
@@ -1200,13 +1715,17 @@ export function createSlotlockDashboard(options) {
|
|
|
1200
1715
|
.update(`slotlock-dashboard-csrf-v1.${session.sid}`)
|
|
1201
1716
|
.digest('base64url');
|
|
1202
1717
|
/** The tenant the session acts in: the workspace's, or its test environment's (`~test`). */
|
|
1203
|
-
const tenantOf = (session) => `${session.
|
|
1718
|
+
const tenantOf = (session) => `${session.team !== undefined
|
|
1719
|
+
? `team:${session.team}`
|
|
1720
|
+
: session.org !== undefined
|
|
1721
|
+
? `github-org:${session.org}`
|
|
1722
|
+
: personOf(session)}${session.env === 'test' ? SLOTLOCK_TEST_TENANT_SUFFIX : ''}`;
|
|
1204
1723
|
/** Who did something, for the record: the person, whatever tenant they act in. */
|
|
1205
1724
|
function personOf(session) {
|
|
1206
|
-
return session.idp === 'google' ? `google:${session.uid}` : `github:${session.uid}`;
|
|
1725
|
+
return session.idp === 'google' ? `google:${session.uid}` : session.idp === 'sso' ? `sso:${session.uid}` : `github:${session.uid}`;
|
|
1207
1726
|
}
|
|
1208
|
-
/** How the dashboard shows the person: their GitHub login,
|
|
1209
|
-
const shownAs = (session) =>
|
|
1727
|
+
/** How the dashboard shows the person: their GitHub login, their Google account's email, or their provider's. */
|
|
1728
|
+
const shownAs = (session) => session.idp === 'google' || session.idp === 'sso' || session.sc !== undefined ? session.login : `@${session.login}`;
|
|
1210
1729
|
function respond(status, body, headers = {}, cookies = []) {
|
|
1211
1730
|
const response = new Response(String(body), {
|
|
1212
1731
|
status,
|
|
@@ -1246,7 +1765,7 @@ export function createSlotlockDashboard(options) {
|
|
|
1246
1765
|
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
1247
1766
|
: []),
|
|
1248
1767
|
...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
|
|
1249
|
-
...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
|
|
1768
|
+
...(teams || teamStore ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
|
|
1250
1769
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
1251
1770
|
];
|
|
1252
1771
|
/** Live or Test (RFC 0003 G9): the same workspace, a tenant of its own for trying things. */
|
|
@@ -1257,12 +1776,15 @@ export function createSlotlockDashboard(options) {
|
|
|
1257
1776
|
/** The workspace the session acts in: its organization, or the person themselves. */
|
|
1258
1777
|
function workspace(session) {
|
|
1259
1778
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1779
|
+
const team = currentTeam(session);
|
|
1260
1780
|
const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
|
|
1261
|
-
const name = current?.login ?? session.login;
|
|
1781
|
+
const name = team?.name ?? current?.login ?? session.login;
|
|
1262
1782
|
const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
|
|
1263
1783
|
const enabled = teams ? (session.orgs ?? []).filter((org) => enabledOf.get(session)?.has(org.id)) : [];
|
|
1264
|
-
|
|
1265
|
-
|
|
1784
|
+
const listed = teamListOf.get(session) ?? [];
|
|
1785
|
+
if (enabled.length > 0 || listed.length > 0) {
|
|
1786
|
+
const personal = !current && !team;
|
|
1787
|
+
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=""${personal ? html ` selected` : html ``}>${shownAs(session)}</option>${enabled.map((org) => html `<option value="${org.id}"${org.id === current?.id ? html ` selected` : html ``}>${org.login}</option>`)}${listed.map((ws) => html `<option value="${ws.tenantRef}"${ws.tenantRef === team?.tenantRef ? html ` selected` : html ``}>${ws.name}</option>`)}</select><button class="quiet" type="submit">Switch</button></form>`;
|
|
1266
1788
|
}
|
|
1267
1789
|
if (current && choices.length > 1) {
|
|
1268
1790
|
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>`;
|
|
@@ -1296,7 +1818,9 @@ ${live}
|
|
|
1296
1818
|
</body>
|
|
1297
1819
|
</html>`;
|
|
1298
1820
|
}
|
|
1299
|
-
const links = nav()
|
|
1821
|
+
const links = nav()
|
|
1822
|
+
.filter((item) => reaches(session, item.path || '/'))
|
|
1823
|
+
.map((item) => html `<a href="${root}${item.path}"${item.section === section ? html ` aria-current="page"` : html ``}>${item.icon}<span>${item.label}</span></a>`);
|
|
1300
1824
|
return html `${head}
|
|
1301
1825
|
<body>
|
|
1302
1826
|
${skip}
|
|
@@ -1308,6 +1832,8 @@ ${skip}
|
|
|
1308
1832
|
</header>
|
|
1309
1833
|
<main class="page" id="main">${session.env === 'test'
|
|
1310
1834
|
? 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>`
|
|
1835
|
+
: html ``}${deletingOf.has(session)
|
|
1836
|
+
? html `<p class="test-banner deleting-banner">This team is deleted on ${formatDate(deletingOf.get(session))}; its keys are refused.${can(session, 'workspace.own') ? html ` <a href="${root}/settings/team">Keep it</a>` : html ``}</p>`
|
|
1311
1837
|
: html ``}${content}</main>
|
|
1312
1838
|
</div>
|
|
1313
1839
|
${live}
|
|
@@ -1327,7 +1853,7 @@ ${live}
|
|
|
1327
1853
|
<h1>Sign in to ${brandName}</h1>
|
|
1328
1854
|
<p>Create keys for your agents, add what they book, and watch what they do.</p>
|
|
1329
1855
|
${noticeOf(notice, status)}
|
|
1330
|
-
<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 ``}
|
|
1856
|
+
<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 ``}${ssoStore ? html `<a class="button wide" href="${root}/sso">Continue with single sign-on</a>` : html ``}
|
|
1331
1857
|
${options.termsUrl
|
|
1332
1858
|
? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
|
|
1333
1859
|
? html ` The <a href="${options.privacyUrl}">Privacy notice</a> says what is kept.`
|
|
@@ -1355,7 +1881,7 @@ ${options.termsUrl
|
|
|
1355
1881
|
]
|
|
1356
1882
|
.filter(Boolean)
|
|
1357
1883
|
.join(', ');
|
|
1358
|
-
function keyRows(keys, token, session) {
|
|
1884
|
+
function keyRows(keys, token, session, names = new Map()) {
|
|
1359
1885
|
return keys.map((key) => {
|
|
1360
1886
|
const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
|
|
1361
1887
|
const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
|
|
@@ -1368,7 +1894,7 @@ ${options.termsUrl
|
|
|
1368
1894
|
<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>`
|
|
1369
1895
|
: html ``;
|
|
1370
1896
|
return html `<tr>
|
|
1371
|
-
<td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${session
|
|
1897
|
+
<td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${sharedWorkspace(session) ? html `<span class="meta">Created by ${key.createdBy === personOf(session) ? 'you' : (key.createdBy && names.get(key.createdBy)) ?? (key.createdBy?.startsWith('github:') ? `GitHub user ${key.createdBy.slice(7)}` : key.createdBy?.startsWith('google:') ? 'a Google user' : 'someone unknown')}</span>` : html ``}</span></td>
|
|
1372
1898
|
<td data-label="Key"><code class="chip">${key.prefix}…</code></td>
|
|
1373
1899
|
<td data-label="Access">${key.resourceScope || key.limits
|
|
1374
1900
|
? 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>`
|
|
@@ -1533,8 +2059,10 @@ ${options.termsUrl
|
|
|
1533
2059
|
const start = new Date(now());
|
|
1534
2060
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
1535
2061
|
const assistant = assistantStore();
|
|
2062
|
+
// A member limited to some resources sees theirs: no keys, plan or setup, which are the workspace's.
|
|
2063
|
+
const limited = scopes.has(session);
|
|
1536
2064
|
const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
|
|
1537
|
-
options.keys.list({ tenantRef }),
|
|
2065
|
+
limited ? Promise.resolve([]) : options.keys.list({ tenantRef }),
|
|
1538
2066
|
options.resources.list(tenantRef),
|
|
1539
2067
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
1540
2068
|
options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
|
|
@@ -1543,7 +2071,7 @@ ${options.termsUrl
|
|
|
1543
2071
|
assistant?.getSettings(tenantRef),
|
|
1544
2072
|
]);
|
|
1545
2073
|
// The plan is Live's: the test environment has none of its own.
|
|
1546
|
-
const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
|
|
2074
|
+
const standing = plans && session.env !== 'test' && !limited ? await planStanding(session, 'overview', keys) : null;
|
|
1547
2075
|
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
1548
2076
|
const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
|
|
1549
2077
|
const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
|
|
@@ -1562,7 +2090,7 @@ ${options.termsUrl
|
|
|
1562
2090
|
: []),
|
|
1563
2091
|
];
|
|
1564
2092
|
const admin = isAdmin(session);
|
|
1565
|
-
const steps = [
|
|
2093
|
+
const steps = limited ? [] : [
|
|
1566
2094
|
{ done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
|
|
1567
2095
|
{ done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
|
|
1568
2096
|
// Done once an agent has used a key.
|
|
@@ -1578,7 +2106,7 @@ ${options.termsUrl
|
|
|
1578
2106
|
const done = steps.filter((step) => step.done).length;
|
|
1579
2107
|
const next = steps.findIndex((step) => !step.done);
|
|
1580
2108
|
// Nothing made and nothing happened yet: only the way in.
|
|
1581
|
-
const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
|
|
2109
|
+
const fresh = !limited && resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
|
|
1582
2110
|
const setupCard = next === -1
|
|
1583
2111
|
? html ``
|
|
1584
2112
|
: html `<section class="panel setup-card"><div class="panel-head"><h2>Get started</h2><span class="meta">${String(done)} of ${String(steps.length)}</span></div><div class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</div><ol class="setup-steps">${steps.map((step, index) => html `<li class="${step.done ? 'done' : index === next ? 'next' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span><span class="step-title">${step.title}${step.done ? html `<span class="visually-hidden"> (done)</span>` : html ``}</span>${index === next ? html `<a class="button primary" href="${step.action.href}">${step.action.label}</a>` : html ``}</li>`)}</ol></section>`;
|
|
@@ -1592,21 +2120,21 @@ ${options.termsUrl
|
|
|
1592
2120
|
...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
|
|
1593
2121
|
...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
|
|
1594
2122
|
stat('Resources', String(resources.length), `${root}/resources`),
|
|
1595
|
-
stat('Active keys', String(active.length), `${root}/keys`),
|
|
2123
|
+
...(limited ? [] : [stat('Active keys', String(active.length), `${root}/keys`)]),
|
|
1596
2124
|
].slice(0, 4);
|
|
1597
2125
|
const planCard = standing
|
|
1598
2126
|
? html `<section class="panel plan-card"><div class="panel-head"><h2>${standing.name}</h2><a href="${root}/plan">Usage</a></div><div class="meters stacked">${standing.meters}</div>${standing.canUpgrade && standing.pressing
|
|
1599
2127
|
? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
|
|
1600
2128
|
: html ``}</section>`
|
|
1601
2129
|
: html ``;
|
|
1602
|
-
const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
|
|
2130
|
+
const connectCard = limited ? html `` : html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
|
|
1603
2131
|
const body = fresh
|
|
1604
2132
|
? standing
|
|
1605
2133
|
? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
|
|
1606
2134
|
: setupCard
|
|
1607
2135
|
: html `${done * 2 >= steps.length ? setupRow : setupCard}
|
|
1608
2136
|
<div class="stats">${tiles}</div>
|
|
1609
|
-
${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
2137
|
+
${session.env === 'test' && options.resources.sandbox && can(session, 'sandbox.manage') ? sandboxPanel(session, resources.length) : html ``}
|
|
1610
2138
|
<div class="split">
|
|
1611
2139
|
<div class="column">
|
|
1612
2140
|
${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
|
|
@@ -1617,7 +2145,7 @@ ${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(change
|
|
|
1617
2145
|
const content = html `${noticeOf(notice, status)}
|
|
1618
2146
|
${pageHead('Overview')}
|
|
1619
2147
|
${attentionList(attention)}
|
|
1620
|
-
${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
2148
|
+
${fresh && session.env === 'test' && options.resources.sandbox && can(session, 'sandbox.manage') ? sandboxPanel(session, resources.length) : html ``}
|
|
1621
2149
|
${body}`;
|
|
1622
2150
|
return respond(status, layout('Overview', content, session, 'overview'));
|
|
1623
2151
|
}
|
|
@@ -1671,11 +2199,14 @@ ${resourceCount === 0
|
|
|
1671
2199
|
async function keysPage(session, status = 200, notice) {
|
|
1672
2200
|
const tenantRef = tenantOf(session);
|
|
1673
2201
|
const grouping = options.resources.grouping;
|
|
1674
|
-
const
|
|
2202
|
+
const team = teamTenantOf(session);
|
|
2203
|
+
const [keys, locations, groups, members] = await Promise.all([
|
|
1675
2204
|
options.keys.list({ tenantRef }),
|
|
1676
2205
|
grouping?.locations(tenantRef),
|
|
1677
2206
|
grouping?.groups(tenantRef),
|
|
2207
|
+
teamStore && team !== undefined ? teamStore.members(team) : Promise.resolve([]),
|
|
1678
2208
|
]);
|
|
2209
|
+
const names = new Map(members.map((member) => [member.person, memberName(member)]));
|
|
1679
2210
|
const token = csrfToken(session);
|
|
1680
2211
|
const paths = locationPaths(locations ?? []);
|
|
1681
2212
|
// A key limited to locations or groups (RFC 0003 G6), when the tenant has any to choose.
|
|
@@ -1704,8 +2235,8 @@ ${limitChoices}
|
|
|
1704
2235
|
</section>
|
|
1705
2236
|
${keys.length === 0
|
|
1706
2237
|
? emptyState(ICONS.keys, 'No keys yet. Give every agent its own, with the least access it needs.', { href: '#key-name', label: 'Create a key' })
|
|
1707
|
-
: html
|
|
1708
|
-
<tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
|
|
2238
|
+
: 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>
|
|
2239
|
+
<tbody>${keyRows(keys, token, session, names)}</tbody></table></div>`}`;
|
|
1709
2240
|
return respond(status, layout('API keys', content, session, 'keys'));
|
|
1710
2241
|
}
|
|
1711
2242
|
/**
|
|
@@ -1831,20 +2362,55 @@ ${groups.length === 0
|
|
|
1831
2362
|
/** Settings' sections, each its own page: the assistant's form apart from what is only read. */
|
|
1832
2363
|
const SETTINGS_SECTIONS = Object.freeze([
|
|
1833
2364
|
{ id: 'assistant', label: 'Assistant', path: '/settings' },
|
|
2365
|
+
{ id: 'members', label: 'Members', path: '/settings/members' },
|
|
2366
|
+
{ id: 'team', label: 'Team', path: '/settings/team' },
|
|
2367
|
+
{ id: 'identity', label: 'Identity', path: '/settings/identity' },
|
|
2368
|
+
{ id: 'audit', label: 'Audit log', path: '/settings/audit' },
|
|
2369
|
+
{ id: 'privacy', label: 'Privacy', path: '/settings/privacy' },
|
|
1834
2370
|
{ id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
|
|
1835
2371
|
{ id: 'hours', label: 'Hours', path: '/settings/hours' },
|
|
1836
2372
|
{ id: 'account', label: 'Account', path: '/settings/account' },
|
|
1837
2373
|
]);
|
|
1838
|
-
async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
|
|
2374
|
+
async function settingsPage(session, status = 200, notice, draft, section = 'assistant', invitation, query, found) {
|
|
1839
2375
|
const store = assistantStore();
|
|
1840
|
-
|
|
1841
|
-
const
|
|
2376
|
+
// Members are a team workspace's: its people come from invitations, not from GitHub.
|
|
2377
|
+
const sections = SETTINGS_SECTIONS.filter((entry) => (entry.id !== 'assistant' || store) &&
|
|
2378
|
+
(entry.id !== 'members' || (teamStore && session.team !== undefined)) &&
|
|
2379
|
+
(entry.id !== 'team' || (teamStore && session.team !== undefined && can(session, 'workspace.own'))) &&
|
|
2380
|
+
(entry.id !== 'identity' || (domainStore && session.team !== undefined && can(session, 'identity.manage'))) &&
|
|
2381
|
+
(entry.id !== 'audit' || (options.resources.audit && can(session, 'audit.read'))) &&
|
|
2382
|
+
(entry.id !== 'privacy' || (options.resources.privacy && can(session, 'privacy.manage'))) &&
|
|
2383
|
+
// Limited to some resources: one's account and the team's people, nothing of the workspace's.
|
|
2384
|
+
(!scopes.has(session) || entry.id === 'account' || entry.id === 'members'));
|
|
2385
|
+
const shown = scopes.has(session) && !sections.some((entry) => entry.id === section)
|
|
2386
|
+
? 'account'
|
|
2387
|
+
: section === 'assistant' && !store
|
|
2388
|
+
? 'endpoints'
|
|
2389
|
+
: (section === 'members' || section === 'audit' || section === 'team' || section === 'privacy' || section === 'identity') &&
|
|
2390
|
+
!sections.some((entry) => entry.id === section)
|
|
2391
|
+
? 'account'
|
|
2392
|
+
: section;
|
|
1842
2393
|
const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
|
|
1843
2394
|
const nav = html `<nav class="subnav" aria-label="Settings">${sections.map((entry) => html `<a href="${root}${entry.path}"${entry.id === shown ? html ` aria-current="page"` : html ``}>${entry.label}${entry.id === 'assistant' && connected ? html `<span class="nav-dot" aria-hidden="true"></span>` : html ``}</a>`)}</nav>`;
|
|
1844
2395
|
let body;
|
|
1845
2396
|
if (shown === 'assistant') {
|
|
1846
2397
|
body = await assistantSettingsSection(session, status, notice, draft);
|
|
1847
2398
|
}
|
|
2399
|
+
else if (shown === 'members') {
|
|
2400
|
+
body = await membersSection(session, status, notice, invitation);
|
|
2401
|
+
}
|
|
2402
|
+
else if (shown === 'team') {
|
|
2403
|
+
body = await teamSection(session, status, notice);
|
|
2404
|
+
}
|
|
2405
|
+
else if (shown === 'identity') {
|
|
2406
|
+
body = await identitySection(session, status, notice);
|
|
2407
|
+
}
|
|
2408
|
+
else if (shown === 'audit') {
|
|
2409
|
+
body = await auditSection(session, status, notice, query ?? new URLSearchParams());
|
|
2410
|
+
}
|
|
2411
|
+
else if (shown === 'privacy') {
|
|
2412
|
+
body = await privacySection(session, status, notice, found);
|
|
2413
|
+
}
|
|
1848
2414
|
else if (shown === 'endpoints') {
|
|
1849
2415
|
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">MCP</span><div class="row-value">${copyField('mcp-url', mcpUrl)}</div></div><div class="row"><span class="row-label">A2A</span><div class="row-value">${copyField('a2a-url', a2aUrl)}</div></div></div>`;
|
|
1850
2416
|
}
|
|
@@ -1856,14 +2422,47 @@ ${groups.length === 0
|
|
|
1856
2422
|
}
|
|
1857
2423
|
else {
|
|
1858
2424
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
2425
|
+
const team = currentTeam(session);
|
|
1859
2426
|
const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
|
|
1860
|
-
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : 'Workspace'}</span><div class="row-value">${current ? current.login : 'Personal'}</div></div>${current && others.length > 0
|
|
2427
|
+
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : team ? 'Team' : 'Workspace'}</span><div class="row-value">${current ? current.login : team ? team.name : 'Personal'}</div></div>${current && others.length > 0
|
|
1861
2428
|
? html `<div class="row"><span class="row-label">Also a member of</span><div class="row-value">${others.map((org) => org.login).join(', ')}</div></div>`
|
|
1862
|
-
: html ``}<div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div
|
|
2429
|
+
: html ``}<div class="row"><span class="row-label">Role</span><div class="row-value">${SLOTLOCK_WORKSPACE_ROLE_NAMES[roleOf(session)]}</div></div><div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div>${workspaces && session.sc === undefined
|
|
2430
|
+
? html `<div class="row"><span class="row-label">Sessions</span><div class="row-value"><form method="post" action="${root}/sign-out-everywhere"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Sign out everywhere</button></form></div></div>`
|
|
2431
|
+
: html ``}</div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}${await sessionsList(session)}`;
|
|
1863
2432
|
}
|
|
1864
2433
|
const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
|
|
1865
2434
|
return respond(status, layout('Settings', content, session, 'settings'));
|
|
1866
2435
|
}
|
|
2436
|
+
/** Where the person is signed in, each but this one with Sign out. */
|
|
2437
|
+
async function sessionsList(session) {
|
|
2438
|
+
const kept = workspaces?.sessions;
|
|
2439
|
+
if (!kept)
|
|
2440
|
+
return html ``;
|
|
2441
|
+
const person = personOf(session);
|
|
2442
|
+
const mine = kept.idOf(session.sid);
|
|
2443
|
+
// A session through a team's provider alone sees only itself: the person's other sessions (and
|
|
2444
|
+
// where they were signed in from) are beyond its team.
|
|
2445
|
+
const listed = (await kept.list(person)).filter((entry) => session.sc === undefined || entry.id === mine);
|
|
2446
|
+
if (listed.length === 0)
|
|
2447
|
+
return html ``;
|
|
2448
|
+
const rows = listed.map((entry) => html `<tr><td data-label="Device" class="strong">${deviceOf(entry.userAgent)}${entry.id === mine ? html ` <span class="pill active">This device</span>` : html ``}</td><td data-label="Address">${entry.ipAddress ?? '—'}</td><td data-label="Signed in">${formatDate(entry.startedAt)}</td><td data-label="Last seen">${formatDate(entry.lastSeenAt)}</td><td class="actions">${entry.id === mine
|
|
2449
|
+
? html ``
|
|
2450
|
+
: html `<form class="inline" method="post" action="${root}/sessions/end"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${entry.id}"><button type="submit">Sign out<span class="visually-hidden"> ${deviceOf(entry.userAgent)}, signed in ${formatDate(entry.startedAt)}</span></button></form>`}</td></tr>`);
|
|
2451
|
+
return html `<h2 class="sessions-head">Signed in</h2><div class="table"><table><thead><tr><th>Device</th><th>Address</th><th>Signed in</th><th>Last seen</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
2452
|
+
}
|
|
2453
|
+
async function endSession(session, form) {
|
|
2454
|
+
const kept = workspaces?.sessions;
|
|
2455
|
+
if (!kept)
|
|
2456
|
+
return nothingHere(session);
|
|
2457
|
+
const id = (form.get('id') ?? '').toLowerCase();
|
|
2458
|
+
if (id === kept.idOf(session.sid)) {
|
|
2459
|
+
return settingsPage(session, 400, 'To sign out of this device, use Sign out.', undefined, 'account');
|
|
2460
|
+
}
|
|
2461
|
+
if (!(await kept.end(personOf(session), id))) {
|
|
2462
|
+
return settingsPage(session, 404, 'That session has ended already.', undefined, 'account');
|
|
2463
|
+
}
|
|
2464
|
+
return settingsPage(session, 200, 'Signed out of that device.', undefined, 'account');
|
|
2465
|
+
}
|
|
1867
2466
|
/** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
|
|
1868
2467
|
const copyField = (id, value) => html `<dl class="endpoints"><dd><code id="${id}">${value}</code><button class="quiet" type="button" data-copy="${id}">Copy</button></dd></dl>`;
|
|
1869
2468
|
/** The tenant's resource with that id and every resource (for names), or null for anything else. */
|
|
@@ -1938,11 +2537,12 @@ ${field('defaultDurationMinutes', 'Usual length, offered when none is asked', 'm
|
|
|
1938
2537
|
${field('cancelCutoffMinutes', 'Cancel up to, before the start', 'min')}
|
|
1939
2538
|
${field('rescheduleCutoffMinutes', 'Move up to, before the start', 'min')}
|
|
1940
2539
|
</div>
|
|
1941
|
-
${resource.bookingRules?.allowedDurations || resource.bookingRules?.pickupTimes || resource.bookingRules?.returnTimes || resource.bookingRules?.cancellationFee ? html `<p class="fine">Set through the API, and kept when you save: ${[
|
|
2540
|
+
${resource.bookingRules?.allowedDurations || resource.bookingRules?.pickupTimes || resource.bookingRules?.returnTimes || resource.bookingRules?.cancellationFee || resource.bookingRules?.holdPrice ? html `<p class="fine">Set through the API, and kept when you save: ${[
|
|
1942
2541
|
resource.bookingRules.allowedDurations ? `lengths ${ruleValueText(resource.bookingRules.allowedDurations)} min` : '',
|
|
1943
2542
|
resource.bookingRules.pickupTimes ? `pick-up ${ruleValueText(resource.bookingRules.pickupTimes)}` : '',
|
|
1944
2543
|
resource.bookingRules.returnTimes ? `return ${ruleValueText(resource.bookingRules.returnTimes)}` : '',
|
|
1945
2544
|
resource.bookingRules.cancellationFee ? `cancellation fee ${ruleValueText(resource.bookingRules.cancellationFee)}` : '',
|
|
2545
|
+
resource.bookingRules.holdPrice ? `hold price ${ruleValueText(resource.bookingRules.holdPrice)}` : '',
|
|
1946
2546
|
].filter(Boolean).join('; ')}.</p>` : html ``}
|
|
1947
2547
|
${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
|
|
1948
2548
|
${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
|
|
@@ -2053,7 +2653,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
2053
2653
|
if (requireCertain !== undefined)
|
|
2054
2654
|
rules.requireCertain = requireCertain;
|
|
2055
2655
|
// Rules the form does not edit (set through the API) are kept.
|
|
2056
|
-
for (const name of ['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee']) {
|
|
2656
|
+
for (const name of ['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee', 'holdPrice']) {
|
|
2057
2657
|
const kept = resource.bookingRules?.[name];
|
|
2058
2658
|
if (kept !== undefined)
|
|
2059
2659
|
rules[name] = kept;
|
|
@@ -2306,7 +2906,9 @@ ${endpoints.length === 0
|
|
|
2306
2906
|
const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
|
|
2307
2907
|
if (!created.ok) {
|
|
2308
2908
|
const message = created.code === 'webhook_limit_reached'
|
|
2309
|
-
?
|
|
2909
|
+
? plans
|
|
2910
|
+
? html `You have ${String(created.limit)} webhooks, as many as your plan allows. Delete one first, or ${await pastLimit(session)}.`
|
|
2911
|
+
: `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
|
|
2310
2912
|
: created.code === 'invalid_webhook_url'
|
|
2311
2913
|
? 'Give an https URL, with no user name, password or #fragment.'
|
|
2312
2914
|
: 'Choose events from the list.';
|
|
@@ -2619,11 +3221,17 @@ ${decided.length === 0
|
|
|
2619
3221
|
}
|
|
2620
3222
|
/** Owners of the organization in use may change its assistant; a person always may their own. */
|
|
2621
3223
|
/** An owner of the workspace: the person in their own, an organization's owner in its. */
|
|
2622
|
-
|
|
3224
|
+
/** May connect or change the assistant's model (Owner, Admin). */
|
|
3225
|
+
const isAdmin = (session) => can(session, 'assistant.settings');
|
|
3226
|
+
/** May change the plan and open billing (Owner, Billing). */
|
|
3227
|
+
const mayBill = (session) => can(session, 'billing.manage');
|
|
3228
|
+
/** Never more than the person may do: a Viewer's assistant only reads; it never holds admin. */
|
|
2623
3229
|
const assistantPrincipal = (session) => ({
|
|
2624
3230
|
subject: assistantActor(session),
|
|
2625
3231
|
tenantRef: tenantOf(session),
|
|
2626
|
-
scopes: ['read', 'write'],
|
|
3232
|
+
scopes: can(session, 'operations.run') ? ['read', 'write'] : ['read'],
|
|
3233
|
+
// Limited to some resources, as a key limited to them: nothing outside is found.
|
|
3234
|
+
...(scopes.has(session) ? { resourceScope: scopes.get(session) } : {}),
|
|
2627
3235
|
});
|
|
2628
3236
|
/** The fixed list, then the embedder's check: nothing the model sends adds to it. */
|
|
2629
3237
|
const assistantAuthorize = async (args) => SLOTLOCK_ASSISTANT_TOOL_USE[args.operation] !== 'excluded' &&
|
|
@@ -3121,7 +3729,7 @@ ${decided.length === 0
|
|
|
3121
3729
|
return { provider: preset.provider, baseUrl: preset.baseUrl };
|
|
3122
3730
|
return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
|
|
3123
3731
|
}
|
|
3124
|
-
const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this
|
|
3732
|
+
const adminOnly = (session) => settingsPage(session, 403, 'Only an owner or an admin of this workspace can change the assistant.');
|
|
3125
3733
|
async function saveAssistantSettings(session, form) {
|
|
3126
3734
|
const store = assistantStore();
|
|
3127
3735
|
if (!store || !assistantOptions)
|
|
@@ -3627,7 +4235,7 @@ ${decided.length === 0
|
|
|
3627
4235
|
? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
|
|
3628
4236
|
: null;
|
|
3629
4237
|
const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
|
|
3630
|
-
const admin =
|
|
4238
|
+
const admin = mayBill(session);
|
|
3631
4239
|
const paying = plan !== null && plan.plan !== 'free';
|
|
3632
4240
|
const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
|
|
3633
4241
|
const limits = plan?.limits ?? upcoming?.limits;
|
|
@@ -3636,8 +4244,10 @@ ${decided.length === 0
|
|
|
3636
4244
|
const counted = (value) => value.toLocaleString('en-GB');
|
|
3637
4245
|
const usagePage = `${root}/plan`;
|
|
3638
4246
|
const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
|
|
3639
|
-
|
|
3640
|
-
const
|
|
4247
|
+
// Only where the deployment sells plans (Stripe) or arranges them (a contact) is there an upgrade.
|
|
4248
|
+
const offersPlans = Boolean(billing || active.contact);
|
|
4249
|
+
const canUpgrade = !paying && admin && offersPlans;
|
|
4250
|
+
const upgradeHint = paying || !offersPlans ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
|
|
3641
4251
|
const attention = [];
|
|
3642
4252
|
// Whether anything is near or past a limit: the overview then offers the upgrade.
|
|
3643
4253
|
let pressing = false;
|
|
@@ -3661,22 +4271,35 @@ ${decided.length === 0
|
|
|
3661
4271
|
attention.push(attentionItem('warn', `You have used this month's ${counted(quota)} ${metric}.${billed ? ` Those past it are billed at ${billing?.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.${billed ? '' : upgradeHint}`, billed ? undefined : upgrade));
|
|
3662
4272
|
}
|
|
3663
4273
|
else if (spent * 100 >= quota * 80) {
|
|
3664
|
-
attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`,
|
|
4274
|
+
attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, upgrade));
|
|
3665
4275
|
}
|
|
3666
4276
|
}
|
|
3667
4277
|
}
|
|
4278
|
+
// What the workspace has against its plan's limits (or the coming plan's): at one, adding another
|
|
4279
|
+
// is refused; past one (a plan that allows less than it has), nothing is removed.
|
|
4280
|
+
const full = limits
|
|
4281
|
+
? [
|
|
4282
|
+
['resources', resourceCount, limits.resources],
|
|
4283
|
+
['active API keys', keyCount, limits.apiKeys],
|
|
4284
|
+
...(webhooks ? [['webhooks', webhooks.length, limits.webhookEndpoints]] : []),
|
|
4285
|
+
].filter(([, have, allowed]) => have >= allowed)
|
|
4286
|
+
: [];
|
|
4287
|
+
const over = full.filter(([, have, allowed]) => have > allowed);
|
|
4288
|
+
const holding = full.map(([what, have, allowed]) => `${counted(have)} of ${counted(allowed)} ${what}`).join(' and ');
|
|
4289
|
+
if (full.length > 0)
|
|
4290
|
+
pressing = true;
|
|
4291
|
+
const limitAction = canUpgrade
|
|
4292
|
+
? { href: upgradeAt, label: 'Upgrade' }
|
|
4293
|
+
: here === 'overview'
|
|
4294
|
+
? { href: usagePage, label: 'Usage' }
|
|
4295
|
+
: undefined;
|
|
3668
4296
|
if (upcoming) {
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
if (over.length > 0)
|
|
3676
|
-
pressing = true;
|
|
3677
|
-
attention.push(attentionItem(over.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${over.length > 0
|
|
3678
|
-
? ` You have ${over.map(([what, have]) => `${String(have)} ${what}`).join(', ')}: nothing is removed, but you cannot add more until you have fewer${paying || !admin ? '' : ', or upgrade'}.`
|
|
3679
|
-
: ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
|
|
4297
|
+
attention.push(attentionItem(full.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${full.length > 0
|
|
4298
|
+
? ` You have ${holding}: ${over.length > 0 ? 'nothing is removed, but ' : ''}from then, adding another is refused.${upgradeHint}`
|
|
4299
|
+
: ''}`, full.length > 0 ? limitAction : undefined));
|
|
4300
|
+
}
|
|
4301
|
+
else if (plan && full.length > 0) {
|
|
4302
|
+
attention.push(attentionItem(over.length > 0 ? 'bad' : 'warn', `On ${PLAN_NAMES[plan.plan]}, you have ${holding}: ${over.length > 0 ? 'nothing is removed, but ' : ''}adding another is refused.${upgradeHint}`, limitAction));
|
|
3680
4303
|
}
|
|
3681
4304
|
/** One use against its allowance: a bar, amber from 80%, red at the limit. */
|
|
3682
4305
|
const meter = (label, value, limit) => {
|
|
@@ -3714,6 +4337,17 @@ ${decided.length === 0
|
|
|
3714
4337
|
pressing,
|
|
3715
4338
|
};
|
|
3716
4339
|
}
|
|
4340
|
+
/**
|
|
4341
|
+
* What resolves an add refused at a plan limit, as a link: the upgrade on Usage, for an owner of a
|
|
4342
|
+
* workspace that does not pay yet where the deployment sells or arranges plans; else Usage.
|
|
4343
|
+
*/
|
|
4344
|
+
async function pastLimit(session) {
|
|
4345
|
+
const plan = plans ? await plans.plan(tenantOf(session)).catch(() => null) : null;
|
|
4346
|
+
const paying = plan !== null && plan.plan !== 'free';
|
|
4347
|
+
return !paying && mayBill(session) && Boolean(options.billing || plans?.contact)
|
|
4348
|
+
? html `<a href="${root}/plan#upgrade">upgrade</a>`
|
|
4349
|
+
: html `see <a href="${root}/plan">Usage</a>`;
|
|
4350
|
+
}
|
|
3717
4351
|
/** One thing that needs a person: a sentence, its tone, and what resolves it. */
|
|
3718
4352
|
const attentionItem = (tone, text, action) => html `<li class="attention ${tone}"><span class="attention-text">${text}</span>${action ? html `<a class="attention-action" href="${action.href}">${action.label}</a>` : html ``}</li>`;
|
|
3719
4353
|
/** The list of things that need a person, or nothing. */
|
|
@@ -3739,7 +4373,7 @@ ${decided.length === 0
|
|
|
3739
4373
|
const token = csrfToken(session);
|
|
3740
4374
|
const keyName = new Map(keys.map((key) => [key.id, key.name]));
|
|
3741
4375
|
// An organization is offered Team (where billing sells it, or by asking); a person, Developer.
|
|
3742
|
-
const offered = session
|
|
4376
|
+
const offered = sharedWorkspace(session) && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
|
|
3743
4377
|
const contact = plans.contact ?? billing?.contact;
|
|
3744
4378
|
const manage = billing && plan?.stripeCustomer && admin
|
|
3745
4379
|
? 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>`
|
|
@@ -3803,8 +4437,11 @@ ${contact
|
|
|
3803
4437
|
return notFound(session);
|
|
3804
4438
|
if (session.env === 'test')
|
|
3805
4439
|
return planPage(session, 400, 'Plans are changed in Live.');
|
|
3806
|
-
if (!
|
|
3807
|
-
return planPage(session, 403, 'Only
|
|
4440
|
+
if (!mayBill(session))
|
|
4441
|
+
return planPage(session, 403, 'Only an owner or a billing manager of this workspace can change its plan.');
|
|
4442
|
+
// A team being deleted takes no plan: kept first, so it is never billed while it waits to go.
|
|
4443
|
+
if (deletingOf.has(session))
|
|
4444
|
+
return planPage(session, 409, 'This team is being deleted. Keep it on Settings, Team before choosing a plan.');
|
|
3808
4445
|
const interval = form.get('interval') === 'year' ? 'year' : 'month';
|
|
3809
4446
|
const tenantRef = tenantOf(session);
|
|
3810
4447
|
const plan = await billing.plan(tenantRef);
|
|
@@ -3816,7 +4453,7 @@ ${contact
|
|
|
3816
4453
|
url = await billing.checkout({
|
|
3817
4454
|
tenantRef,
|
|
3818
4455
|
interval,
|
|
3819
|
-
...(session
|
|
4456
|
+
...(sharedWorkspace(session) && billing.teamPrices ? { plan: 'team' } : {}),
|
|
3820
4457
|
customer: plan?.stripeCustomer ?? null,
|
|
3821
4458
|
successUrl: `${publicRoot}/plan?checkout=done`,
|
|
3822
4459
|
cancelUrl: `${publicRoot}/plan`,
|
|
@@ -3837,8 +4474,8 @@ ${contact
|
|
|
3837
4474
|
return notFound(session);
|
|
3838
4475
|
if (session.env === 'test')
|
|
3839
4476
|
return planPage(session, 400, 'Billing is managed in Live.');
|
|
3840
|
-
if (!
|
|
3841
|
-
return planPage(session, 403, 'Only
|
|
4477
|
+
if (!mayBill(session))
|
|
4478
|
+
return planPage(session, 403, 'Only an owner or a billing manager of this workspace can manage its billing.');
|
|
3842
4479
|
const plan = await billing.plan(tenantOf(session));
|
|
3843
4480
|
if (!plan?.stripeCustomer)
|
|
3844
4481
|
return planPage(session, 409, 'There is no billing to manage yet.');
|
|
@@ -3853,33 +4490,226 @@ ${contact
|
|
|
3853
4490
|
return redirect(303, url);
|
|
3854
4491
|
}
|
|
3855
4492
|
/** The page a form was sent from, to show what went wrong with it. */
|
|
3856
|
-
|
|
4493
|
+
/** Where a request came from, as the audit log keeps it: the client's address, and its browser. */
|
|
4494
|
+
function requestOrigin(request) {
|
|
4495
|
+
const address = options.clientAddress?.(request);
|
|
4496
|
+
const agent = request.headers.get('user-agent')?.slice(0, 256);
|
|
4497
|
+
return { ...(address ? { ipAddress: address.slice(0, 64) } : {}), ...(agent ? { userAgent: agent } : {}) };
|
|
4498
|
+
}
|
|
4499
|
+
/**
|
|
4500
|
+
* Record what the person did (or was refused) in the workspace's audit log. An action already done
|
|
4501
|
+
* is not undone because its record failed: the failure goes to onError.
|
|
4502
|
+
*/
|
|
4503
|
+
async function audit(session, event, tenantRef = tenantOf(session)) {
|
|
4504
|
+
const log = options.resources.audit;
|
|
4505
|
+
if (!log)
|
|
4506
|
+
return;
|
|
4507
|
+
const from = origins.get(session) ?? {};
|
|
4508
|
+
try {
|
|
4509
|
+
await log.record(tenantRef, {
|
|
4510
|
+
actorType: 'person',
|
|
4511
|
+
actorRef: personOf(session),
|
|
4512
|
+
actorRole: roleOf(session),
|
|
4513
|
+
...from,
|
|
4514
|
+
...event,
|
|
4515
|
+
});
|
|
4516
|
+
}
|
|
4517
|
+
catch (error) {
|
|
4518
|
+
try {
|
|
4519
|
+
ignoreRejection(options.onError?.(error));
|
|
4520
|
+
}
|
|
4521
|
+
catch {
|
|
4522
|
+
// A failing reporter must not change the response.
|
|
4523
|
+
}
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
/** What an audited form changed: its target, and what describes the change (never a secret). */
|
|
4527
|
+
function auditDetails(route, form) {
|
|
4528
|
+
const id = form.get('id') ?? '';
|
|
4529
|
+
if (route === '/keys') {
|
|
4530
|
+
return { targetType: 'api_key', metadata: { access: form.get('access') ?? '', expires: form.get('expires') ?? '' } };
|
|
4531
|
+
}
|
|
4532
|
+
if (route.startsWith('/keys/') && UUID_PATTERN.test(id))
|
|
4533
|
+
return { targetType: 'api_key', targetRef: id };
|
|
4534
|
+
if (route === '/webhooks') {
|
|
4535
|
+
let host = '';
|
|
4536
|
+
try {
|
|
4537
|
+
host = new URL(form.get('url') ?? '').host;
|
|
4538
|
+
}
|
|
4539
|
+
catch {
|
|
4540
|
+
// An address the form refused names no host.
|
|
4541
|
+
}
|
|
4542
|
+
return { targetType: 'webhook', metadata: host ? { host } : {} };
|
|
4543
|
+
}
|
|
4544
|
+
if (route.startsWith('/webhooks/') && UUID_PATTERN.test(id))
|
|
4545
|
+
return { targetType: 'webhook', targetRef: id };
|
|
4546
|
+
if (route.startsWith('/teams/')) {
|
|
4547
|
+
const org = form.get('organization') ?? '';
|
|
4548
|
+
return ORGANIZATION_ID.test(org) ? { targetType: 'organization', targetRef: `github-org:${org}` } : {};
|
|
4549
|
+
}
|
|
4550
|
+
if (route === '/settings/assistant') {
|
|
4551
|
+
return { metadata: { provider: (form.get('provider') ?? '').slice(0, 40), model: (form.get('model') ?? '').slice(0, 120) } };
|
|
4552
|
+
}
|
|
4553
|
+
return {};
|
|
4554
|
+
}
|
|
4555
|
+
/**
|
|
4556
|
+
* Every capability a form needs: its route's, and more where the form decides. A Full-access key
|
|
4557
|
+
* asks `keys.full`; a key someone else created asks `keys.manage_any`; rotating or limiting a
|
|
4558
|
+
* Full-access key asks `keys.full` as well, so a person who lost that power cannot keep it by
|
|
4559
|
+
* rotating, and anyone rotating one signed in recently. An empty list is a route with none.
|
|
4560
|
+
*/
|
|
4561
|
+
async function capabilitiesOf(session, route, form) {
|
|
4562
|
+
const base = SLOTLOCK_DASHBOARD_POST_CAPABILITIES[route];
|
|
4563
|
+
if (base === undefined)
|
|
4564
|
+
return [];
|
|
4565
|
+
const needed = [base];
|
|
4566
|
+
if (route === '/keys' && form.get('access') === 'full')
|
|
4567
|
+
needed.push('keys.full');
|
|
4568
|
+
// Giving a role needs its powers: making an Owner `workspace.own`, a Billing manager `billing.manage`.
|
|
4569
|
+
if (route === '/members/invite' || route === '/members/role') {
|
|
4570
|
+
const role = form.get('role');
|
|
4571
|
+
if (role === 'owner')
|
|
4572
|
+
needed.push('workspace.own');
|
|
4573
|
+
if (role === 'billing')
|
|
4574
|
+
needed.push('billing.manage');
|
|
4575
|
+
}
|
|
4576
|
+
if (route === '/keys/rotate' || route === '/keys/revoke' || route === '/keys/limits') {
|
|
4577
|
+
// As PostgreSQL reads a uuid, whatever its case: the key the handler would act on.
|
|
4578
|
+
const id = (form.get('id') ?? '').toLowerCase();
|
|
4579
|
+
if (UUID_PATTERN.test(id)) {
|
|
4580
|
+
const key = (await options.keys.list({ tenantRef: tenantOf(session) })).find((candidate) => candidate.id.toLowerCase() === id);
|
|
4581
|
+
// A key not listed here is not one the person made: only who may change anyone's tries it.
|
|
4582
|
+
if (!key || key.createdBy !== personOf(session))
|
|
4583
|
+
needed.push('keys.manage_any');
|
|
4584
|
+
if (key?.scopes.includes('admin') && route !== '/keys/revoke')
|
|
4585
|
+
needed.push('keys.full');
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
return needed;
|
|
4589
|
+
}
|
|
4590
|
+
/** The page a form's route belongs to, to go back to. */
|
|
4591
|
+
const backOf = (route) => {
|
|
4592
|
+
if (route.startsWith('/members/'))
|
|
4593
|
+
return '/settings/members';
|
|
4594
|
+
if (route.startsWith('/identity/'))
|
|
4595
|
+
return '/settings/identity';
|
|
4596
|
+
if (route.startsWith('/join/'))
|
|
4597
|
+
return '/teams';
|
|
4598
|
+
if (route === '/teams/delete' || route === '/teams/restore')
|
|
4599
|
+
return '/settings/team';
|
|
4600
|
+
if (route.startsWith('/settings/privacy'))
|
|
4601
|
+
return '/settings/privacy';
|
|
4602
|
+
const first = `/${route.split('/')[1] ?? ''}`;
|
|
4603
|
+
return first === '/' ? '' : first;
|
|
4604
|
+
};
|
|
4605
|
+
/** Refuse a form the person's role does not allow, or ask them to sign in again for a fresh one. */
|
|
4606
|
+
async function authorizePost(session, route, form) {
|
|
4607
|
+
// Signed in through a team's provider alone: the session acts in that team, and reaches no other
|
|
4608
|
+
// (joining, invitations, switching, creating, proving oneself elsewhere). The provider is the
|
|
4609
|
+
// team Owner's, so what it asserts must open nothing outside the team.
|
|
4610
|
+
if (session.sc !== undefined && SSO_SCOPED_REFUSED.has(route)) {
|
|
4611
|
+
await audit(session, { action: 'authz.denied', outcome: 'denied', reason: 'sso_scope', metadata: { route } });
|
|
4612
|
+
return respond(403, layout('Not allowed', html `<p class="notice" role="alert">You signed in with your team’s single sign-on, which reaches this team only. Sign in with GitHub or Google to do that.</p>`, session));
|
|
4613
|
+
}
|
|
4614
|
+
const needed = await capabilitiesOf(session, route, form);
|
|
4615
|
+
const missing = needed.length === 0 ? undefined : needed.find((capability) => !can(session, capability));
|
|
4616
|
+
const outOfScope = scopes.has(session) && !SLOTLOCK_SCOPED_DASHBOARD_POSTS.has(route);
|
|
4617
|
+
if (needed.length === 0 || missing !== undefined || outOfScope) {
|
|
4618
|
+
await audit(session, {
|
|
4619
|
+
action: 'authz.denied',
|
|
4620
|
+
outcome: 'denied',
|
|
4621
|
+
reason: missing ? `needs ${missing}` : needed.length === 0 ? 'not a listed route' : 'limited to locations and groups',
|
|
4622
|
+
metadata: { route },
|
|
4623
|
+
});
|
|
4624
|
+
return deniedPage(session, missing, backOf(route));
|
|
4625
|
+
}
|
|
4626
|
+
const fresh = needed.find((capability) => SLOTLOCK_FRESH_CAPABILITIES.has(capability));
|
|
4627
|
+
if (fresh !== undefined && !freshlySignedIn(session))
|
|
4628
|
+
return confirmPage(session, fresh, backOf(route));
|
|
4629
|
+
return null;
|
|
4630
|
+
}
|
|
4631
|
+
/** Refuse a page the person's role does not reach; a billing manager's overview is Usage. */
|
|
4632
|
+
function authorizePage(session, page) {
|
|
4633
|
+
const capability = SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES[page];
|
|
4634
|
+
if (reaches(session, page))
|
|
4635
|
+
return null;
|
|
4636
|
+
if (page === '/' && plans && can(session, 'usage.read'))
|
|
4637
|
+
return redirect(303, `${root}/plan`);
|
|
4638
|
+
return deniedPage(session, capability, plans && can(session, 'usage.read') ? '/plan' : '');
|
|
4639
|
+
}
|
|
4640
|
+
function deniedPage(session, capability, back) {
|
|
4641
|
+
const role = SLOTLOCK_WORKSPACE_ROLE_NAMES[roleOf(session)];
|
|
4642
|
+
const text = scopes.has(session) && (capability === undefined || slotlockRoleCan(roleOf(session), capability))
|
|
4643
|
+
? 'Your access to this workspace is limited to some locations and groups, so this is not yours to see or change.'
|
|
4644
|
+
: capability
|
|
4645
|
+
? `As ${/^[AEIOU]/.test(role) ? 'an' : 'a'} ${role} in this workspace, you can't ${CAPABILITY_WORDS[capability]}.`
|
|
4646
|
+
: 'Nothing is here.';
|
|
4647
|
+
return respond(403, layout('Not allowed', html `<p class="notice" role="alert">${text}</p><p><a href="${root}${back}">Back</a></p>`, session));
|
|
4648
|
+
}
|
|
4649
|
+
/** A fresh capability after more than 15 minutes: sign in again, then come back to the page. */
|
|
4650
|
+
function confirmPage(session, capability, back) {
|
|
4651
|
+
const query = new URLSearchParams({
|
|
4652
|
+
...(session.idp === 'google' ? { with: 'google' } : {}),
|
|
4653
|
+
...(STEP_UP_PAGES.has(back) ? { then: back } : {}),
|
|
4654
|
+
}).toString();
|
|
4655
|
+
return respond(403, layout('Confirm it’s you', html `${pageHead('Confirm it’s you')}<p>To ${CAPABILITY_WORDS[capability]}, sign in again: your last sign-in was more than 15 minutes ago.</p><p class="actions"><a class="button primary" href="${root}/sign-in${query ? `?${query}` : ''}">Sign in again</a> <a class="button" href="${root}${back}">Cancel</a></p>`, session));
|
|
4656
|
+
}
|
|
4657
|
+
/**
|
|
4658
|
+
* The page a form's route belongs to, and the page path whose capability reading it needs: the
|
|
4659
|
+
* page rendered, not the one the route names (without the store a page needs, it is the overview).
|
|
4660
|
+
*/
|
|
4661
|
+
function pageEntryFor(route) {
|
|
3857
4662
|
if (route.startsWith('/keys'))
|
|
3858
|
-
return keysPage;
|
|
4663
|
+
return { path: '/keys', page: keysPage };
|
|
3859
4664
|
if (route.startsWith('/webhooks') && options.resources.webhooks)
|
|
3860
|
-
return webhooksPage;
|
|
4665
|
+
return { path: '/webhooks', page: webhooksPage };
|
|
3861
4666
|
if (route.startsWith('/waitlist') && options.resources.waitlist)
|
|
3862
|
-
return waitlistPage;
|
|
4667
|
+
return { path: '/waitlist', page: waitlistPage };
|
|
3863
4668
|
if (route.startsWith('/approvals') && options.resources.approvals)
|
|
3864
|
-
return approvalsPage;
|
|
4669
|
+
return { path: '/approvals', page: approvalsPage };
|
|
3865
4670
|
if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
|
|
3866
|
-
return locationsPage;
|
|
4671
|
+
return { path: '/locations', page: locationsPage };
|
|
3867
4672
|
}
|
|
3868
4673
|
if (route.startsWith('/closures') && options.resources.hours)
|
|
3869
|
-
return closuresPage;
|
|
4674
|
+
return { path: '/closures', page: closuresPage };
|
|
3870
4675
|
if (route.startsWith('/contacts') && options.resources.contacts)
|
|
3871
|
-
return contactsPage;
|
|
4676
|
+
return { path: '/contacts', page: contactsPage };
|
|
3872
4677
|
if (route.startsWith('/resources') || route === '/hours')
|
|
3873
|
-
return resourcesPage;
|
|
4678
|
+
return { path: '/resources', page: resourcesPage };
|
|
3874
4679
|
if (route.startsWith('/assistant') && assistantStore())
|
|
3875
|
-
return assistantPage;
|
|
4680
|
+
return { path: '/assistant', page: assistantPage };
|
|
4681
|
+
if ((route === '/teams/delete' || route === '/teams/restore') && teamStore) {
|
|
4682
|
+
return { path: '/settings/team', page: (session, status, notice) => settingsPage(session, status, notice, undefined, 'team') };
|
|
4683
|
+
}
|
|
4684
|
+
if (route.startsWith('/members/') && teamStore) {
|
|
4685
|
+
return { path: '/settings/members', page: (session, status, notice) => settingsPage(session, status, notice, undefined, 'members') };
|
|
4686
|
+
}
|
|
4687
|
+
if (route.startsWith('/identity/') && domainStore) {
|
|
4688
|
+
return { path: '/settings/identity', page: (session, status, notice) => settingsPage(session, status, notice, undefined, 'identity') };
|
|
4689
|
+
}
|
|
4690
|
+
if (route.startsWith('/join/') && domainStore)
|
|
4691
|
+
return { path: '/teams', page: teamsPage };
|
|
3876
4692
|
if (route.startsWith('/settings'))
|
|
3877
|
-
return settingsPage;
|
|
4693
|
+
return { path: '/settings', page: settingsPage };
|
|
3878
4694
|
if (route.startsWith('/plan') && plans)
|
|
3879
|
-
return planPage;
|
|
3880
|
-
if (route.startsWith('/teams') && teams)
|
|
3881
|
-
return teamsPage;
|
|
3882
|
-
return overviewPage;
|
|
4695
|
+
return { path: '/plan', page: planPage };
|
|
4696
|
+
if ((route.startsWith('/teams') || route.startsWith('/invite')) && (teams || teamStore))
|
|
4697
|
+
return { path: '/teams', page: teamsPage };
|
|
4698
|
+
return { path: '/', page: overviewPage };
|
|
4699
|
+
}
|
|
4700
|
+
/**
|
|
4701
|
+
* The page a form's route belongs to, for an answer about the form itself (already sent, too many
|
|
4702
|
+
* forms, another organization's page), only to a person whose role reads the page rendered:
|
|
4703
|
+
* anyone else gets the notice alone. These answers come before the role is checked.
|
|
4704
|
+
*/
|
|
4705
|
+
function pageOrNotice(route) {
|
|
4706
|
+
const { path, page } = pageEntryFor(route);
|
|
4707
|
+
return (session, status, notice) => {
|
|
4708
|
+
const capability = SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES[path];
|
|
4709
|
+
if (capability !== undefined && can(session, capability))
|
|
4710
|
+
return page(session, status, notice);
|
|
4711
|
+
return respond(status, layout('Refused', html `<p class="notice" role="alert">${notice}</p>`, session));
|
|
4712
|
+
};
|
|
3883
4713
|
}
|
|
3884
4714
|
const isLocationTarget = (target) => typeof target === 'object' && 'location' in target && typeof target.location === 'object';
|
|
3885
4715
|
/** How many resources keep hours of their own, a few by name, and what saving does to them. */
|
|
@@ -3979,6 +4809,7 @@ ${target === 'all' ? ownHoursChoice(state.own ?? []) : html ``}
|
|
|
3979
4809
|
}
|
|
3980
4810
|
const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
|
|
3981
4811
|
const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
4812
|
+
const nothingHere404 = () => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, null), { 'Referrer-Policy': 'no-referrer' });
|
|
3982
4813
|
/** The tenant's resources in use that keep hours of their own, by name. */
|
|
3983
4814
|
async function ownHoursOf(session) {
|
|
3984
4815
|
const resources = await options.resources.list(tenantOf(session));
|
|
@@ -4332,9 +5163,39 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
4332
5163
|
* Teams on Cloud: the person's GitHub organizations, as read at sign-in, each on or off. An owner
|
|
4333
5164
|
* turns one on or off; a member asks an owner; one that is on is a workspace to switch to.
|
|
4334
5165
|
*/
|
|
4335
|
-
async function teamsPage(session, status = 200, notice) {
|
|
4336
|
-
if (!teams)
|
|
5166
|
+
async function teamsPage(session, status = 200, notice, checked = false) {
|
|
5167
|
+
if (!teams && !teamStore)
|
|
4337
5168
|
return nothingHere(session);
|
|
5169
|
+
const own = teamStore ? await teamWorkspacesSection(session, checked) : html ``;
|
|
5170
|
+
const organizations = teams ? await organizationsSection(session) : html ``;
|
|
5171
|
+
const content = html `${noticeOf(notice, status)}${pageHead('Teams')}${own}${organizations}`;
|
|
5172
|
+
return respond(status, layout('Teams', content, session, 'teams'));
|
|
5173
|
+
}
|
|
5174
|
+
/** The person's team workspaces, the invitations open to them, and creating one. */
|
|
5175
|
+
async function teamWorkspacesSection(session, checked = false) {
|
|
5176
|
+
if (!teamStore)
|
|
5177
|
+
return html ``;
|
|
5178
|
+
const listed = teamListOf.get(session) ?? [];
|
|
5179
|
+
const [offered, atDomain] = await Promise.all([
|
|
5180
|
+
teamStore.pendingFor(personOf(session), verifiedEmails(session)),
|
|
5181
|
+
domainTeamsSection(session, checked),
|
|
5182
|
+
]);
|
|
5183
|
+
const token = csrfToken(session);
|
|
5184
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
5185
|
+
const rows = listed.map((ws) => html `<tr><td data-label="Team" class="strong">${ws.name}</td><td data-label="Handle" class="muted">@${ws.handle}</td><td data-label="You are">${SLOTLOCK_WORKSPACE_ROLE_NAMES[ws.role]}</td><td class="actions">${ws.tenantRef === teamTenantOf(session)
|
|
5186
|
+
? html `<span class="muted">In use</span>`
|
|
5187
|
+
: html `<form class="inline" method="post" action="${root}/organization">${hidden()}<input type="hidden" name="organization" value="${ws.tenantRef}"><button type="submit">Switch to it</button></form>`}</td></tr>`);
|
|
5188
|
+
const invitations = offered.map((invitation) => html `<tr><td data-label="Team" class="strong">${invitation.workspaceName}</td><td data-label="From">${invitation.inviterDisplay}</td><td data-label="As">${SLOTLOCK_WORKSPACE_ROLE_NAMES[invitation.role]}</td><td class="actions"><form class="inline" method="post" action="${root}/invite/accept">${hidden()}<input type="hidden" name="invitation" value="${invitation.id}"><button class="primary" type="submit">Join ${invitation.workspaceName}</button></form></td></tr>`);
|
|
5189
|
+
return html `<section class="team-workspaces" aria-labelledby="team-workspaces"><h2 id="team-workspaces">Team workspaces</h2>${invitations.length > 0
|
|
5190
|
+
? html `<div class="table"><table><caption class="visually-hidden">Invitations</caption><thead><tr><th>Invited to</th><th>From</th><th>As</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${invitations}</tbody></table></div>`
|
|
5191
|
+
: html ``}${atDomain}${rows.length > 0
|
|
5192
|
+
? html `<div class="table"><table><thead><tr><th>Team</th><th>Handle</th><th>You are</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
5193
|
+
: html ``}<form class="create-team" method="post" action="${root}/teams/create">${hidden()}<label for="team-name">Name</label><input id="team-name" name="name" maxlength="80" autocomplete="organization" required><button class="primary" type="submit">Create team</button></form></section>`;
|
|
5194
|
+
}
|
|
5195
|
+
/** GitHub organizations turned on for Slotlock (organizations: 'enabled'). */
|
|
5196
|
+
async function organizationsSection(session) {
|
|
5197
|
+
if (!teams)
|
|
5198
|
+
return html ``;
|
|
4338
5199
|
const orgs = session.orgs ?? [];
|
|
4339
5200
|
const enabled = orgs.length > 0 ? await teams.enabled(orgs.map((org) => org.id)) : new Set();
|
|
4340
5201
|
enabledOf.set(session, enabled);
|
|
@@ -4353,13 +5214,1661 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
4353
5214
|
: html `<span class="muted">An owner can turn it on</span>`;
|
|
4354
5215
|
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>`;
|
|
4355
5216
|
});
|
|
4356
|
-
|
|
5217
|
+
return html `${teamStore ? html `<h2>GitHub organizations</h2>` : html ``}
|
|
4357
5218
|
<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>
|
|
4358
5219
|
${rows.length === 0
|
|
4359
5220
|
? 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.')
|
|
4360
5221
|
: 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>
|
|
4361
5222
|
<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>`}`;
|
|
4362
|
-
|
|
5223
|
+
}
|
|
5224
|
+
/** People a team workspace may have, open invitations included: 3 on the Free plan where plans apply. */
|
|
5225
|
+
async function memberCapOf(tenantRef) {
|
|
5226
|
+
if (!plans)
|
|
5227
|
+
return null;
|
|
5228
|
+
const plan = await plans.plan(tenantRef);
|
|
5229
|
+
return plan === null || plan.plan === 'free' || plan.status === 'canceled' ? FREE_TEAM_MEMBERS : null;
|
|
5230
|
+
}
|
|
5231
|
+
/** The session moved into a workspace (its own when `team` is undefined), Live or Test as it was. */
|
|
5232
|
+
function moveTo(session, team, path, cookies = [], keepEmails = true) {
|
|
5233
|
+
const { org: _org, team: _team, em, ...rest } = session;
|
|
5234
|
+
const kept = keepEmails && em !== undefined ? { ...rest, em } : rest;
|
|
5235
|
+
const sealed = seal('slotlock-dashboard-session-v1', team === undefined ? kept : { ...kept, team });
|
|
5236
|
+
const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
|
|
5237
|
+
return redirect(303, `${root}${path}`, [setCookie(SESSION_COOKIE, sealed, left), ...cookies]);
|
|
5238
|
+
}
|
|
5239
|
+
const refusalPage = (session, refusal) => settingsPage(session, TEAM_REFUSALS[refusal].status, TEAM_REFUSALS[refusal].text, undefined, 'members');
|
|
5240
|
+
async function createTeam(session, form) {
|
|
5241
|
+
if (!teamStore)
|
|
5242
|
+
return nothingHere(session);
|
|
5243
|
+
if (session.idp === 'sso' || session.sc !== undefined)
|
|
5244
|
+
return teamsPage(session, 403, 'Sign in with GitHub or Google to create a team.');
|
|
5245
|
+
const name = (form.get('name') ?? '').trim();
|
|
5246
|
+
if (name.length === 0 || name.length > 80 || /\p{Cc}/u.test(name)) {
|
|
5247
|
+
return teamsPage(session, 400, TEAM_REFUSALS.invalid_name.text);
|
|
5248
|
+
}
|
|
5249
|
+
// A name goes into the invitations Slotlock sends: never a web or email address in it.
|
|
5250
|
+
if (/:\/\/|www\.|@|\b[a-z0-9-]+\.(?:[a-z]{2,}|xn--[a-z0-9-]+)\b/i.test(name)) {
|
|
5251
|
+
return teamsPage(session, 400, "A team's name can't hold a web or email address.");
|
|
5252
|
+
}
|
|
5253
|
+
const person = personOf(session);
|
|
5254
|
+
const cap = plans ? FREE_TEAMS_PER_PERSON : null;
|
|
5255
|
+
let made = { refusal: 'handle_taken' };
|
|
5256
|
+
// The handle follows the name; one taken gets a random end.
|
|
5257
|
+
for (let attempt = 0; attempt < 4 && 'refusal' in made && made.refusal === 'handle_taken'; attempt += 1) {
|
|
5258
|
+
made = await teamStore.create(person, name, teamHandleFrom(name, attempt), cap);
|
|
5259
|
+
}
|
|
5260
|
+
if ('refusal' in made)
|
|
5261
|
+
return teamsPage(session, TEAM_REFUSALS[made.refusal].status, TEAM_REFUSALS[made.refusal].text);
|
|
5262
|
+
await audit(session, { action: 'workspace.created', actorRole: 'owner', targetType: 'workspace', targetRef: made.tenantRef }, made.tenantRef);
|
|
5263
|
+
return moveTo(session, made.tenantRef.slice('team:'.length), '/settings/members');
|
|
5264
|
+
}
|
|
5265
|
+
/** A team's people as the others see them: their GitHub login or Google email, as last signed in. */
|
|
5266
|
+
const memberName = (member) => member.display ?? (member.person.startsWith('google:') ? 'A Google account' : 'A GitHub account');
|
|
5267
|
+
async function membersSection(session, status, notice, shown) {
|
|
5268
|
+
const tenantRef = teamTenantOf(session);
|
|
5269
|
+
if (!teamStore || tenantRef === undefined)
|
|
5270
|
+
return noticeOf(notice, status);
|
|
5271
|
+
const manage = can(session, 'members.manage');
|
|
5272
|
+
const me = personOf(session);
|
|
5273
|
+
const mine = roleOf(session);
|
|
5274
|
+
const grouping = options.resources.grouping;
|
|
5275
|
+
// Limiting people to locations and groups: by an Owner or Admin who is not limited themselves.
|
|
5276
|
+
const limits = manage && grouping !== undefined && (mine === 'owner' || mine === 'admin') && !scopes.has(session);
|
|
5277
|
+
const [members, invitations, cap, limited, places, requests, viaDomain] = await Promise.all([
|
|
5278
|
+
teamStore.members(tenantRef),
|
|
5279
|
+
manage ? teamStore.invitations(tenantRef) : Promise.resolve([]),
|
|
5280
|
+
memberCapOf(tenantRef),
|
|
5281
|
+
teamStore.scopes(tenantRef),
|
|
5282
|
+
limits && grouping ? Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]) : Promise.resolve(null),
|
|
5283
|
+
manage && domainStore ? domainStore.requests(tenantRef) : Promise.resolve([]),
|
|
5284
|
+
domainStore ? domainStore.domainMembers(tenantRef) : Promise.resolve(new Map()),
|
|
5285
|
+
]);
|
|
5286
|
+
const open = invitations.filter((invitation) => invitation.expiresAt.getTime() > now());
|
|
5287
|
+
const seats = members.length + open.length;
|
|
5288
|
+
const full = cap !== null && seats >= cap;
|
|
5289
|
+
const team = currentTeam(session)?.name ?? 'this team';
|
|
5290
|
+
const owners = members.filter((member) => member.role === 'owner').length;
|
|
5291
|
+
const grantable = SLOTLOCK_WORKSPACE_ROLES.filter((role) => slotlockRoleMayGrant(mine, role));
|
|
5292
|
+
const token = csrfToken(session);
|
|
5293
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
5294
|
+
const roleOptions = (selected) => grantable.map((role) => html `<option value="${role}"${role === selected ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`);
|
|
5295
|
+
const invite = !manage
|
|
5296
|
+
? html ``
|
|
5297
|
+
: full
|
|
5298
|
+
? html `<p class="seats">${String(seats)} of ${String(cap)} people.${plans && can(session, 'usage.read') ? html ` <a href="${root}/plan">Upgrade</a>` : html ``}</p>`
|
|
5299
|
+
: html `<form class="invite" method="post" action="${root}/members/invite">${hidden()}<label for="invite-email">Email</label><input id="invite-email" name="email" type="email" maxlength="254" autocomplete="off" required${shown ? html `` : html ` autofocus`}><label class="visually-hidden" for="invite-role">Role</label><select id="invite-role" name="role">${roleOptions('member')}</select><button class="primary" type="submit">Invite</button></form>`;
|
|
5300
|
+
const link = shown
|
|
5301
|
+
? html `<div class="set-card"><div class="row"><span class="row-label">Link for ${shown.to}</span><div class="row-value">${copyField('invite-link', shown.link)}</div></div></div><p class="fine">It works once, for that address, until ${formatDate(shown.expiresAt)}. It is not shown again.</p>`
|
|
5302
|
+
: html ``;
|
|
5303
|
+
const pendingRows = invitations.map((invitation) => {
|
|
5304
|
+
const expired = invitation.expiresAt.getTime() <= now();
|
|
5305
|
+
const to = invitation.email ?? `@${invitation.githubLogin ?? 'GitHub account'}`;
|
|
5306
|
+
return html `<tr><td data-label="Invited" class="strong">${to}</td><td data-label="Role">${SLOTLOCK_WORKSPACE_ROLE_NAMES[invitation.role]}</td><td data-label="By">${invitation.inviterDisplay}</td><td data-label="Expires">${expired ? html `<span class="pill revoked">Expired</span>` : formatDate(invitation.expiresAt)}</td><td class="actions">${slotlockRoleMayGrant(mine, invitation.role)
|
|
5307
|
+
? html `<form class="inline" method="post" action="${root}/members/resend">${hidden()}<input type="hidden" name="id" value="${invitation.id}"><button type="submit">Send again<span class="visually-hidden"> to ${to}</span></button></form><form class="inline" method="post" action="${root}/members/revoke">${hidden()}<input type="hidden" name="id" value="${invitation.id}"><button class="danger" type="submit">Revoke<span class="visually-hidden"> the invitation to ${to}</span></button></form>`
|
|
5308
|
+
: html ``}</td></tr>`;
|
|
5309
|
+
});
|
|
5310
|
+
const memberRows = members.map((member, index) => {
|
|
5311
|
+
const name = memberName(member);
|
|
5312
|
+
const self = member.person === me;
|
|
5313
|
+
const changeable = manage && slotlockRoleMayGrant(mine, member.role);
|
|
5314
|
+
const role = changeable
|
|
5315
|
+
? html `<form class="inline" method="post" action="${root}/members/role">${hidden()}<input type="hidden" name="person" value="${member.person}"><label class="visually-hidden" for="role-${String(index)}">Role for ${name}</label><select id="role-${String(index)}" name="role">${roleOptions(member.role)}</select><button type="submit">Save<span class="visually-hidden"> the role for ${name}</span></button></form>`
|
|
5316
|
+
: html `${SLOTLOCK_WORKSPACE_ROLE_NAMES[member.role]}`;
|
|
5317
|
+
let action = html ``;
|
|
5318
|
+
if (self) {
|
|
5319
|
+
action =
|
|
5320
|
+
member.role === 'owner' && owners <= 1
|
|
5321
|
+
? html `<span class="muted">You’re the only owner. Make someone else an owner before leaving.</span>`
|
|
5322
|
+
: html `<details class="act danger"><summary>Leave</summary><div class="pop"><p>You lose access to ${team} at once. Keys you created keep working until an owner or admin revokes them.</p><form method="post" action="${root}/members/leave">${hidden()}<button class="danger" type="submit">Leave ${team}</button></form></div></details>`;
|
|
5323
|
+
}
|
|
5324
|
+
else if (changeable) {
|
|
5325
|
+
action = html `${member.role !== 'owner' && places ? limitForm(member.person, name, index, limited.get(member.person) ?? null, places[0], places[1], hidden) : html ``}<details class="act danger"><summary>Remove<span class="visually-hidden"> ${name}</span></summary><div class="pop"><p>${name} loses access to ${team} at their next request. Resources, bookings and history stay.</p><form method="post" action="${root}/members/remove">${hidden()}<input type="hidden" name="person" value="${member.person}"><fieldset><legend>Keys ${name} created</legend><label><input type="radio" name="keys" value="revoke" checked> Revoke them</label><label><input type="radio" name="keys" value="keep"> Keep them working</label></fieldset><button class="danger" type="submit">Remove ${name}</button></form></div></details>`;
|
|
5326
|
+
}
|
|
5327
|
+
const reach = member.role === 'owner' ? undefined : limited.get(member.person);
|
|
5328
|
+
const via = viaDomain.get(member.person);
|
|
5329
|
+
return html `<tr><td data-label="Member" class="strong">${name}${self ? html ` <span class="muted">(you)</span>` : html ``}${via ? html ` <span class="muted">via ${via.domain}${via.status === 'verified' ? '' : ', no longer verified'}</span>` : html ``}</td><td data-label="Role">${role}</td><td data-label="Reaches">${reach ? scopeWords(reach) : 'All'}</td><td data-label="Joined">${formatDate(member.joinedAt)}</td><td class="actions">${action}</td></tr>`;
|
|
5330
|
+
});
|
|
5331
|
+
// Requests to join through a verified domain: approved as Viewer, Member or Developer.
|
|
5332
|
+
const joinRoles = ['viewer', 'member', 'developer'].filter((role) => slotlockRoleMayGrant(mine, role));
|
|
5333
|
+
const requestRows = requests.map((request, index) => {
|
|
5334
|
+
const name = request.display ?? request.email;
|
|
5335
|
+
return html `<tr><td data-label="Asked" class="strong">${name}${request.display ? html ` <span class="muted">${request.email}</span>` : html ``} <span class="muted">${JOIN_EVIDENCE_WORDS[request.evidence]}</span></td><td data-label="Asked on">${formatDate(request.createdAt)}</td><td class="actions"><form class="inline" method="post" action="${root}/members/requests">${hidden()}<input type="hidden" name="id" value="${request.id}"><label class="visually-hidden" for="join-role-${String(index)}">Role for ${name}</label><select id="join-role-${String(index)}" name="role">${joinRoles.map((role) => html `<option value="${role}"${role === 'member' ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`)}</select><button class="primary" type="submit" name="decision" value="approve">Approve<span class="visually-hidden"> ${name}</span></button><button type="submit" name="decision" value="decline">Decline<span class="visually-hidden"> ${name}</span></button></form></td></tr>`;
|
|
5336
|
+
});
|
|
5337
|
+
return html `${noticeOf(notice, status)}<h2>Members · ${String(cap === null ? members.length : seats)}${cap === null ? '' : ` of ${String(cap)}`}</h2>${invite}${link}${requestRows.length > 0
|
|
5338
|
+
? html `<h3>Asking to join</h3><div class="table"><table><thead><tr><th>Asked</th><th>Asked on</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${requestRows}</tbody></table></div>`
|
|
5339
|
+
: html ``}${pendingRows.length > 0
|
|
5340
|
+
? html `<h3>Pending</h3><div class="table"><table><thead><tr><th>Invited</th><th>Role</th><th>By</th><th>Expires</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${pendingRows}</tbody></table></div>`
|
|
5341
|
+
: html ``}<div class="table"><table><thead><tr><th>Member</th><th>Role</th><th>Reaches</th><th>Joined</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${memberRows}</tbody></table></div>`;
|
|
5342
|
+
}
|
|
5343
|
+
/** What a limited member reaches, in words. */
|
|
5344
|
+
const scopeWords = (scope) => [
|
|
5345
|
+
scope.locations.length > 0 ? `Locations ${scope.locations.join(', ')}` : '',
|
|
5346
|
+
scope.groups.length > 0 ? `Groups ${scope.groups.join(', ')}` : '',
|
|
5347
|
+
]
|
|
5348
|
+
.filter(Boolean)
|
|
5349
|
+
.join(' · ');
|
|
5350
|
+
/** Limit a member to locations and groups, or give them all of the workspace. */
|
|
5351
|
+
function limitForm(person, name, index, scope, locations, groups, hidden) {
|
|
5352
|
+
const id = `limit-${String(index)}`;
|
|
5353
|
+
const options_ = (entries, chosen) => entries.map((entry) => html `<option value="${entry.ref}"${chosen.includes(entry.ref) ? html ` selected` : html ``}>${entry.name} (${entry.ref})</option>`);
|
|
5354
|
+
const picker = (field, label, entries, chosen) => entries.length === 0
|
|
5355
|
+
? html ``
|
|
5356
|
+
: html `<label for="${id}-${field}">${label}</label><select id="${id}-${field}" name="${field}" multiple size="${String(Math.min(6, entries.length))}">${options_(entries, chosen)}</select>`;
|
|
5357
|
+
if (locations.length === 0 && groups.length === 0 && scope === null)
|
|
5358
|
+
return html ``;
|
|
5359
|
+
return html `<details class="act"><summary>Limit<span class="visually-hidden"> ${name}</span></summary><div class="pop"><p>${name} reaches only the resources at these locations (and the locations in them) and in these groups: their bookings, holds, waitlist and approvals. Keys, webhooks, contacts, hours, usage and settings are not theirs.</p><form method="post" action="${root}/members/scope">${hidden()}<input type="hidden" name="person" value="${person}">${picker('locations', 'Locations', locations, scope?.locations ?? [])}${picker('groups', 'Groups', groups, scope?.groups ?? [])}<fieldset><legend>Keys ${name} created</legend><label><input type="radio" name="keys" value="revoke" checked> Revoke them: they reach all of it</label><label><input type="radio" name="keys" value="keep"> Keep them working</label></fieldset><button type="submit" name="access" value="limited">Limit ${name} to these</button>${scope === null ? html `` : html `<button type="submit" name="access" value="all">Give ${name} all of the workspace</button>`}</form></div></details>`;
|
|
5360
|
+
}
|
|
5361
|
+
/** Who did something, as the audit log names them: you, a member by their display, else their account. */
|
|
5362
|
+
function actorName(event, session, names) {
|
|
5363
|
+
if (event.actorType === 'api_key')
|
|
5364
|
+
return `API key ${event.actorRef.replace(/^api_key:/, '').slice(0, 8)}`;
|
|
5365
|
+
if (event.actorType === 'assistant')
|
|
5366
|
+
return 'The assistant';
|
|
5367
|
+
if (event.actorType !== 'person')
|
|
5368
|
+
return 'Slotlock';
|
|
5369
|
+
if (event.actorRef === personOf(session))
|
|
5370
|
+
return 'You';
|
|
5371
|
+
return (names.get(event.actorRef) ??
|
|
5372
|
+
(event.actorRef.startsWith('github:') ? `GitHub user ${event.actorRef.slice(7)}` : event.actorRef.startsWith('google:') ? 'A Google account' : event.actorRef));
|
|
5373
|
+
}
|
|
5374
|
+
/** The ways Privacy finds a person, as the form names them. */
|
|
5375
|
+
const PERSON_KINDS = Object.freeze({
|
|
5376
|
+
email: 'Email address',
|
|
5377
|
+
holder: 'Holder reference',
|
|
5378
|
+
contact: 'Contact reference',
|
|
5379
|
+
reference: 'Booking reference',
|
|
5380
|
+
});
|
|
5381
|
+
/** A person named on a Privacy form, checked: one kind and a value, or null. */
|
|
5382
|
+
function personOf_(form) {
|
|
5383
|
+
const kind = form.get('kind') ?? '';
|
|
5384
|
+
const value = (form.get('value') ?? '').trim();
|
|
5385
|
+
if (!Object.hasOwn(PERSON_KINDS, kind) || value.length === 0 || Buffer.byteLength(value) > 500)
|
|
5386
|
+
return null;
|
|
5387
|
+
const named = kind;
|
|
5388
|
+
return { kind: named, value, subject: { [named]: value } };
|
|
5389
|
+
}
|
|
5390
|
+
/**
|
|
5391
|
+
* Privacy (RFC 0004 D3): an export of everything the workspace keeps, to download for a day; and
|
|
5392
|
+
* one person found by email, holder, contact or booking reference, what is kept about them
|
|
5393
|
+
* downloaded, or them erased. Owners and Admins, signed in within 15 minutes.
|
|
5394
|
+
*/
|
|
5395
|
+
async function privacySection(session, status, notice, found) {
|
|
5396
|
+
const privacy = options.resources.privacy;
|
|
5397
|
+
if (!privacy)
|
|
5398
|
+
return noticeOf(notice, status);
|
|
5399
|
+
const exports = await privacy.listExports(tenantOf(session));
|
|
5400
|
+
const hidden = html `<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
5401
|
+
const rows = exports.map((made) => html `<tr><td data-label="Asked for">${formatDate(made.createdAt)}</td><td data-label="State">${made.status === 'ready'
|
|
5402
|
+
? html `<span class="pill active">Ready</span>`
|
|
5403
|
+
: made.status === 'pending'
|
|
5404
|
+
? html `<span class="pill">Being made</span>`
|
|
5405
|
+
: html `<span class="pill revoked">Failed</span>${made.error === 'export_too_large' ? html ` <span class="muted">too large: ask the operator</span>` : html ``}`}</td><td data-label="Rows">${made.status === 'ready' ? String(made.rows) : '—'}</td><td data-label="Deleted">${formatDate(made.expiresAt)}</td><td class="actions">${made.status === 'ready' ? html `<a class="button" href="${root}/settings/privacy/download?id=${made.id}">Download</a> ` : html ``}<form method="post" action="${root}/settings/privacy/export/delete">${hidden}<input type="hidden" name="id" value="${made.id}"><button type="submit">Delete</button></form></td></tr>`);
|
|
5406
|
+
const exportCard = html `<h2>Everything</h2><p>A copy of everything this workspace keeps, every table read at one moment, as JSON lines: one <code>{"table", "row"}</code> a line. Secrets stay out. It is kept for a day, then deleted.</p><form method="post" action="${root}/settings/privacy/export">${hidden}<button type="submit" class="primary">Export the workspace</button></form>${rows.length > 0
|
|
5407
|
+
? html `<div class="table"><table><thead><tr><th>Asked for</th><th>State</th><th>Rows</th><th>Deleted</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
5408
|
+
: html ``}`;
|
|
5409
|
+
const kinds = Object.entries(PERSON_KINDS).map(([kind, label]) => html `<option value="${kind}"${found?.kind === kind ? html ` selected` : html ``}>${label}</option>`);
|
|
5410
|
+
const finder = html `<h2>One person</h2><p>For a person who asks what you keep about them, or to be erased. Find them by one thing you know.</p><form class="person-find" method="post" action="${root}/settings/privacy/person">${hidden}<label for="person-kind">By</label><select id="person-kind" name="kind">${kinds}</select><label for="person-value" class="visually-hidden">Value</label><input id="person-value" name="value" maxlength="320" required value="${found?.value ?? ''}"><button type="submit">Find</button></form>`;
|
|
5411
|
+
let person = html ``;
|
|
5412
|
+
if (found) {
|
|
5413
|
+
const counted = Object.entries(found.records).filter(([, rows_]) => rows_.length > 0);
|
|
5414
|
+
const keep = html `<input type="hidden" name="kind" value="${found.kind}"><input type="hidden" name="value" value="${found.value}">`;
|
|
5415
|
+
person =
|
|
5416
|
+
counted.length === 0
|
|
5417
|
+
? html `<p class="empty">Nothing kept names them.</p>`
|
|
5418
|
+
: html `<div class="set-card">${counted.map(([table, rows_]) => html `<div class="row"><span class="row-label">${table.replaceAll('_', ' ')}</span><div class="row-value">${String(rows_.length)}${found.truncated && rows_.length >= 1_000 ? '+' : ''}</div></div>`)}</div><div class="actions"><form method="post" action="${root}/settings/privacy/person/export">${hidden}${keep}<button type="submit">Download what is kept</button></form></div><form method="post" action="${root}/settings/privacy/person/erase" class="danger-zone">${hidden}${keep}<p>Erasing deletes their contacts, attendee and waitlist entries, takes their email out of events and approvals, and replaces their references with a name that is no one's, everywhere, history included. Their bookings stay, as time taken. This cannot be undone.</p><label class="choice"><input type="checkbox" name="confirm" value="yes" required> I understand</label><button type="submit" class="danger">Erase this person</button></form>`;
|
|
5419
|
+
}
|
|
5420
|
+
return html `${noticeOf(notice, status)}${exportCard}${finder}${person}`;
|
|
5421
|
+
}
|
|
5422
|
+
async function askPrivacyExport(session) {
|
|
5423
|
+
const privacy = options.resources.privacy;
|
|
5424
|
+
if (!privacy)
|
|
5425
|
+
return nothingHere(session);
|
|
5426
|
+
const tenantRef = tenantOf(session);
|
|
5427
|
+
try {
|
|
5428
|
+
const made = await privacy.createExport(tenantRef, personOf(session));
|
|
5429
|
+
await audit(session, { action: 'export.created', targetType: 'export', targetRef: made.id });
|
|
5430
|
+
return settingsPage(session, 200, 'Asked for. It is made in a moment: reload this page to download it.', undefined, 'privacy');
|
|
5431
|
+
}
|
|
5432
|
+
catch (error) {
|
|
5433
|
+
const code = error?.code;
|
|
5434
|
+
if (code === 'export_in_progress') {
|
|
5435
|
+
return settingsPage(session, 409, 'One is being made already: wait for it.', undefined, 'privacy');
|
|
5436
|
+
}
|
|
5437
|
+
if (code === 'export_limit_reached') {
|
|
5438
|
+
return settingsPage(session, 409, 'Five exports are kept already: delete one first.', undefined, 'privacy');
|
|
5439
|
+
}
|
|
5440
|
+
throw error;
|
|
5441
|
+
}
|
|
5442
|
+
}
|
|
5443
|
+
async function deletePrivacyExport(session, form) {
|
|
5444
|
+
const privacy = options.resources.privacy;
|
|
5445
|
+
if (!privacy)
|
|
5446
|
+
return nothingHere(session);
|
|
5447
|
+
const id = form.get('id') ?? '';
|
|
5448
|
+
if (!UUID_PATTERN.test(id) || !(await privacy.deleteExport(tenantOf(session), id))) {
|
|
5449
|
+
return settingsPage(session, 404, 'That export is not here.', undefined, 'privacy');
|
|
5450
|
+
}
|
|
5451
|
+
return settingsPage(session, 200, 'Deleted.', undefined, 'privacy');
|
|
5452
|
+
}
|
|
5453
|
+
/**
|
|
5454
|
+
* A ready export as one file of JSON lines, `{"table", "row"}` each, read a part at a time. Only
|
|
5455
|
+
* with a sign-in in the last 15 minutes, as every Privacy action.
|
|
5456
|
+
*/
|
|
5457
|
+
async function downloadPrivacyExport(session, query) {
|
|
5458
|
+
const privacy = options.resources.privacy;
|
|
5459
|
+
if (!privacy)
|
|
5460
|
+
return nothingHere(session);
|
|
5461
|
+
if (!freshlySignedIn(session))
|
|
5462
|
+
return confirmPage(session, 'privacy.manage', '/settings/privacy');
|
|
5463
|
+
const id = query.get('id') ?? '';
|
|
5464
|
+
const tenantRef = tenantOf(session);
|
|
5465
|
+
const first = UUID_PATTERN.test(id) ? await privacy.exportPart(tenantRef, id, 1) : null;
|
|
5466
|
+
if (!first || (!first.ok && first.code !== 'export_part_not_found')) {
|
|
5467
|
+
return settingsPage(session, first && !first.ok && first.code === 'export_not_ready' ? 409 : 404, 'That export is not ready, or not here.', undefined, 'privacy');
|
|
5468
|
+
}
|
|
5469
|
+
await audit(session, { action: 'export.downloaded', targetType: 'export', targetRef: id });
|
|
5470
|
+
const encoder = new TextEncoder();
|
|
5471
|
+
let next = first;
|
|
5472
|
+
let part = 1;
|
|
5473
|
+
const body = new ReadableStream({
|
|
5474
|
+
async pull(controller) {
|
|
5475
|
+
const read = next ?? (await privacy.exportPart(tenantRef, id, part));
|
|
5476
|
+
next = null;
|
|
5477
|
+
if (!read.ok) {
|
|
5478
|
+
controller.close();
|
|
5479
|
+
return;
|
|
5480
|
+
}
|
|
5481
|
+
controller.enqueue(encoder.encode(read.lines.map((line) => `{"table":${JSON.stringify(read.table)},"row":${line}}\n`).join('')));
|
|
5482
|
+
part += 1;
|
|
5483
|
+
},
|
|
5484
|
+
});
|
|
5485
|
+
return new Response(body, {
|
|
5486
|
+
status: 200,
|
|
5487
|
+
headers: {
|
|
5488
|
+
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
5489
|
+
'Content-Disposition': `attachment; filename="slotlock-export-${new Date(now()).toISOString().slice(0, 10)}.ndjson"`,
|
|
5490
|
+
'Cache-Control': 'no-store',
|
|
5491
|
+
'X-Content-Type-Options': 'nosniff',
|
|
5492
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
5493
|
+
},
|
|
5494
|
+
});
|
|
5495
|
+
}
|
|
5496
|
+
async function findPerson(session, form) {
|
|
5497
|
+
const privacy = options.resources.privacy;
|
|
5498
|
+
if (!privacy)
|
|
5499
|
+
return nothingHere(session);
|
|
5500
|
+
const named = personOf_(form);
|
|
5501
|
+
if (!named)
|
|
5502
|
+
return settingsPage(session, 400, 'Choose how to find them, and what you know.', undefined, 'privacy');
|
|
5503
|
+
try {
|
|
5504
|
+
const exported = await privacy.exportSubject(tenantOf(session), named.subject);
|
|
5505
|
+
return settingsPage(session, 200, undefined, undefined, 'privacy', undefined, undefined, {
|
|
5506
|
+
kind: named.kind,
|
|
5507
|
+
value: named.value,
|
|
5508
|
+
records: exported.records,
|
|
5509
|
+
truncated: exported.truncated,
|
|
5510
|
+
});
|
|
5511
|
+
}
|
|
5512
|
+
catch (error) {
|
|
5513
|
+
if (error?.code === 'invalid_subject') {
|
|
5514
|
+
return settingsPage(session, 400, 'That is not one: an email address, or a reference of up to 500 bytes.', undefined, 'privacy');
|
|
5515
|
+
}
|
|
5516
|
+
throw error;
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
/** What is kept about one person, as a JSON file: posted, so the person is never in an address. */
|
|
5520
|
+
async function exportPerson(session, form) {
|
|
5521
|
+
const privacy = options.resources.privacy;
|
|
5522
|
+
if (!privacy)
|
|
5523
|
+
return nothingHere(session);
|
|
5524
|
+
const named = personOf_(form);
|
|
5525
|
+
if (!named)
|
|
5526
|
+
return settingsPage(session, 400, 'Choose how to find them, and what you know.', undefined, 'privacy');
|
|
5527
|
+
const exported = await privacy.exportSubject(tenantOf(session), named.subject);
|
|
5528
|
+
await audit(session, { action: 'subject.exported', targetType: 'subject', metadata: { by: named.kind } });
|
|
5529
|
+
return new Response(JSON.stringify(exported, null, 2), {
|
|
5530
|
+
status: 200,
|
|
5531
|
+
headers: {
|
|
5532
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
5533
|
+
'Content-Disposition': `attachment; filename="slotlock-person-${new Date(now()).toISOString().slice(0, 10)}.json"`,
|
|
5534
|
+
'Cache-Control': 'no-store',
|
|
5535
|
+
'X-Content-Type-Options': 'nosniff',
|
|
5536
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
5537
|
+
},
|
|
5538
|
+
});
|
|
5539
|
+
}
|
|
5540
|
+
async function erasePerson(session, form) {
|
|
5541
|
+
const privacy = options.resources.privacy;
|
|
5542
|
+
if (!privacy)
|
|
5543
|
+
return nothingHere(session);
|
|
5544
|
+
const named = personOf_(form);
|
|
5545
|
+
if (!named)
|
|
5546
|
+
return settingsPage(session, 400, 'Choose how to find them, and what you know.', undefined, 'privacy');
|
|
5547
|
+
if (form.get('confirm') !== 'yes')
|
|
5548
|
+
return settingsPage(session, 400, 'Tick that you understand first.', undefined, 'privacy');
|
|
5549
|
+
const { erased } = await privacy.eraseSubject(tenantOf(session), named.subject, personOf(session));
|
|
5550
|
+
const changed = Object.entries(erased).filter(([, rows]) => rows > 0);
|
|
5551
|
+
// Never the person: how they were named, and what went.
|
|
5552
|
+
await audit(session, { action: 'subject.erased', targetType: 'subject', metadata: { by: named.kind, ...Object.fromEntries(changed) } });
|
|
5553
|
+
const total = changed.reduce((sum, [, rows]) => sum + rows, 0);
|
|
5554
|
+
return settingsPage(session, 200, total === 0 ? 'Nothing kept named them.' : `Erased: ${String(total)} row${total === 1 ? '' : 's'} deleted or changed. Their bookings stay, as time taken.`, undefined, 'privacy');
|
|
5555
|
+
}
|
|
5556
|
+
/** A workspace's audit log, newest first, 50 a page: who did what, refused or done, from where. */
|
|
5557
|
+
async function auditSection(session, status, notice, query) {
|
|
5558
|
+
const log = options.resources.audit;
|
|
5559
|
+
if (!log)
|
|
5560
|
+
return noticeOf(notice, status);
|
|
5561
|
+
const tenantRef = tenantOf(session);
|
|
5562
|
+
const wanted = query.get('action') ?? '';
|
|
5563
|
+
const action = AUDIT_ACTION_NAMES.has(wanted) ? wanted : undefined;
|
|
5564
|
+
const cursor = /^(\d{1,15})\.([0-9a-f-]{36})$/.exec(query.get('before') ?? '');
|
|
5565
|
+
const before = cursor && UUID_PATTERN.test(cursor[2])
|
|
5566
|
+
? { occurredAt: new Date(Number(cursor[1])), id: cursor[2].toLowerCase() }
|
|
5567
|
+
: undefined;
|
|
5568
|
+
const team = teamTenantOf(session);
|
|
5569
|
+
const [events, members] = await Promise.all([
|
|
5570
|
+
log.list(tenantRef, { limit: AUDIT_PAGE, ...(before ? { before } : {}), ...(action ? { action } : {}) }),
|
|
5571
|
+
teamStore && team !== undefined ? teamStore.members(team) : Promise.resolve([]),
|
|
5572
|
+
]);
|
|
5573
|
+
const names = new Map(members.map((member) => [member.person, memberName(member)]));
|
|
5574
|
+
const options_ = AUDIT_ACTION_GROUPS.map(([group, actions]) => html `<optgroup label="${group}">${actions.map((name) => html `<option value="${name}"${name === action ? html ` selected` : html ``}>${AUDIT_ACTION_WORDS[name] ?? name}</option>`)}</optgroup>`);
|
|
5575
|
+
const filter = html `<form class="audit-filter" method="get" action="${root}/settings/audit"><label for="audit-action">Action</label><select id="audit-action" name="action"><option value="">Everything</option>${options_}</select><button type="submit">Show</button></form>`;
|
|
5576
|
+
const rows = events.map((event) => {
|
|
5577
|
+
const details = [
|
|
5578
|
+
...(event.targetRef ? [['Target', `${event.targetType ?? ''} ${event.targetRef}`.trim()]] : []),
|
|
5579
|
+
...(event.reason ? [['Reason', event.reason]] : []),
|
|
5580
|
+
...Object.entries(event.metadata).map(([key, value]) => [key, String(value)]),
|
|
5581
|
+
...(event.ipAddress ? [['Address', event.ipAddress]] : []),
|
|
5582
|
+
...(event.userAgent ? [['Browser', event.userAgent]] : []),
|
|
5583
|
+
];
|
|
5584
|
+
return html `<tr><td data-label="When">${formatDate(event.occurredAt)}</td><td data-label="Who" class="strong">${actorName(event, session, names)}${event.actorRole ? html ` <span class="muted">(${SLOTLOCK_WORKSPACE_ROLE_NAMES[event.actorRole]})</span>` : html ``}</td><td data-label="What">${AUDIT_ACTION_WORDS[event.action] ?? event.action}${event.outcome === 'denied' ? html ` <span class="pill revoked">Refused</span>` : html ``}</td><td class="actions">${details.length > 0
|
|
5585
|
+
? html `<details class="act"><summary>Details</summary><div class="pop"><dl class="audit-details">${details.map(([label, value]) => html `<dt>${label}</dt><dd>${value}</dd>`)}</dl></div></details>`
|
|
5586
|
+
: html ``}</td></tr>`;
|
|
5587
|
+
});
|
|
5588
|
+
const last = events[events.length - 1];
|
|
5589
|
+
const older = last && events.length === AUDIT_PAGE
|
|
5590
|
+
? `${root}/settings/audit?${new URLSearchParams({ ...(action ? { action } : {}), before: `${String(last.occurredAt.getTime())}.${last.id}` }).toString()}`
|
|
5591
|
+
: null;
|
|
5592
|
+
// The chain, checked on asking: every event kept since the oldest still links to the one before.
|
|
5593
|
+
const verified = query.get('verify') === '1' && log.verify ? await log.verify(tenantRef) : null;
|
|
5594
|
+
const chain = verified
|
|
5595
|
+
? verified.brokenAt === null
|
|
5596
|
+
? html `<p class="notice done" role="status">The log is unbroken: ${String(verified.checked)} event${verified.checked === 1 ? '' : 's'} checked.</p>`
|
|
5597
|
+
: html `<p class="notice" role="alert">The log was changed outside Slotlock at event ${String(verified.brokenAt)}, after ${String(verified.checked)} that check out.</p>`
|
|
5598
|
+
: html ``;
|
|
5599
|
+
const exportQuery = action ? `&action=${action}` : '';
|
|
5600
|
+
const tools = html `<p class="audit-tools">${log.verify ? html `<a href="${root}/settings/audit?verify=1${exportQuery}">Check the log</a>` : html ``} <a href="${root}/settings/audit/export?format=csv${exportQuery}">Export CSV</a> <a href="${root}/settings/audit/export?format=json${exportQuery}">Export JSON</a></p>`;
|
|
5601
|
+
return html `${noticeOf(notice, status)}${chain}${filter}${tools}${rows.length === 0
|
|
5602
|
+
? html `<p class="empty">${before ? 'Nothing older.' : 'Nothing recorded yet.'}</p>`
|
|
5603
|
+
: html `<div class="table"><table><thead><tr><th>When</th><th>Who</th><th>What</th><th><span class="visually-hidden">Details</span></th></tr></thead><tbody>${rows}</tbody></table></div>`}<p class="pager">${before ? html `<a href="${root}/settings/audit${action ? `?action=${action}` : ''}">Newest</a>` : html ``}${older ? html ` <a href="${older}">Older</a>` : html ``}</p>`;
|
|
5604
|
+
}
|
|
5605
|
+
/**
|
|
5606
|
+
* A workspace's audit log as a file, filtered as the page was: up to 10,000 events, newest first,
|
|
5607
|
+
* with each event's place in the chain and its hash. On the Team plan where plans apply.
|
|
5608
|
+
*/
|
|
5609
|
+
async function exportAudit(session, query) {
|
|
5610
|
+
const log = options.resources.audit;
|
|
5611
|
+
if (!log)
|
|
5612
|
+
return nothingHere(session);
|
|
5613
|
+
const tenantRef = tenantOf(session);
|
|
5614
|
+
if (plans) {
|
|
5615
|
+
const plan = await plans.plan(tenantRef.replace(/~test$/, ''));
|
|
5616
|
+
if (plan?.plan !== 'team' || plan.status === 'canceled') {
|
|
5617
|
+
return settingsPage(session, 403, 'Exports come with the Team plan. Upgrade on Usage.', undefined, 'audit', undefined, query);
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
const format = query.get('format') === 'json' ? 'json' : 'csv';
|
|
5621
|
+
const wanted = query.get('action') ?? '';
|
|
5622
|
+
const action = AUDIT_ACTION_NAMES.has(wanted) ? wanted : undefined;
|
|
5623
|
+
const events = [];
|
|
5624
|
+
let before;
|
|
5625
|
+
while (events.length < AUDIT_EXPORT_MOST) {
|
|
5626
|
+
const page = await log.list(tenantRef, { limit: 200, ...(before ? { before } : {}), ...(action ? { action } : {}) });
|
|
5627
|
+
events.push(...page);
|
|
5628
|
+
const last = page[page.length - 1];
|
|
5629
|
+
if (!last || page.length < 200)
|
|
5630
|
+
break;
|
|
5631
|
+
before = { occurredAt: last.occurredAt, id: last.id };
|
|
5632
|
+
}
|
|
5633
|
+
await audit(session, { action: 'audit.exported', metadata: { format, events: events.length, ...(action ? { filter: action } : {}) } });
|
|
5634
|
+
const rows = events.slice(0, AUDIT_EXPORT_MOST).map((event) => ({
|
|
5635
|
+
occurred_at: event.occurredAt.toISOString(),
|
|
5636
|
+
action: event.action,
|
|
5637
|
+
outcome: event.outcome,
|
|
5638
|
+
actor_type: event.actorType,
|
|
5639
|
+
actor_ref: event.actorRef,
|
|
5640
|
+
actor_role: event.actorRole ?? '',
|
|
5641
|
+
target_type: event.targetType ?? '',
|
|
5642
|
+
target_ref: event.targetRef ?? '',
|
|
5643
|
+
reason: event.reason ?? '',
|
|
5644
|
+
ip_address: event.ipAddress ?? '',
|
|
5645
|
+
user_agent: event.userAgent ?? '',
|
|
5646
|
+
metadata: JSON.stringify(event.metadata),
|
|
5647
|
+
chain_index: event.chainIndex === null ? '' : String(event.chainIndex),
|
|
5648
|
+
hash: event.hash ?? '',
|
|
5649
|
+
}));
|
|
5650
|
+
const day = new Date(now()).toISOString().slice(0, 10);
|
|
5651
|
+
const body = format === 'json'
|
|
5652
|
+
? JSON.stringify(rows.map((row) => ({ ...row, metadata: JSON.parse(row.metadata) })), null, 2)
|
|
5653
|
+
: [
|
|
5654
|
+
Object.keys(rows[0] ?? AUDIT_EXPORT_EMPTY).join(','),
|
|
5655
|
+
...rows.map((row) => Object.values(row).map(csvCell).join(',')),
|
|
5656
|
+
].join('\r\n');
|
|
5657
|
+
return new Response(body, {
|
|
5658
|
+
status: 200,
|
|
5659
|
+
headers: {
|
|
5660
|
+
'Content-Type': format === 'json' ? 'application/json; charset=utf-8' : 'text/csv; charset=utf-8',
|
|
5661
|
+
'Content-Disposition': `attachment; filename="slotlock-audit-${day}.${format}"`,
|
|
5662
|
+
'Cache-Control': 'no-store',
|
|
5663
|
+
'X-Content-Type-Options': 'nosniff',
|
|
5664
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
5665
|
+
},
|
|
5666
|
+
});
|
|
5667
|
+
}
|
|
5668
|
+
/** A team's name and handle, and deleting it: what goes with it, the handle typed to confirm. */
|
|
5669
|
+
async function teamSection(session, status, notice) {
|
|
5670
|
+
const tenantRef = teamTenantOf(session);
|
|
5671
|
+
if (!teamStore || tenantRef === undefined)
|
|
5672
|
+
return noticeOf(notice, status);
|
|
5673
|
+
const [info, deleting, members, resources, keys, webhooks] = await Promise.all([
|
|
5674
|
+
teamStore.info(tenantRef),
|
|
5675
|
+
teamStore.deleteAfter(tenantRef),
|
|
5676
|
+
teamStore.members(tenantRef),
|
|
5677
|
+
options.resources.count ? options.resources.count(tenantRef) : options.resources.list(tenantRef).then((list) => list.length),
|
|
5678
|
+
options.keys.list({ tenantRef }),
|
|
5679
|
+
options.resources.webhooks ? options.resources.webhooks.list(tenantRef) : Promise.resolve([]),
|
|
5680
|
+
]);
|
|
5681
|
+
if (!info)
|
|
5682
|
+
return noticeOf(notice, status);
|
|
5683
|
+
const hidden = html `<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
5684
|
+
const live = keys.filter((key) => key.revokedAt === null && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
|
|
5685
|
+
const card = html `<div class="set-card"><div class="row"><span class="row-label">Name</span><div class="row-value">${info.name}</div></div><div class="row"><span class="row-label">Handle</span><div class="row-value">@${info.handle}</div></div></div>`;
|
|
5686
|
+
const danger = deleting
|
|
5687
|
+
? html `<h2>Being deleted</h2><p>${info.name} and everything in it go on ${formatDate(deleting)}.</p><form method="post" action="${root}/teams/restore">${hidden}<button class="primary" type="submit">Keep ${info.name}</button></form>`
|
|
5688
|
+
: html `<h2>Delete team</h2><p>Its keys stop at once, and seven days later it goes with everything in it: ${String(members.length)} member${members.length === 1 ? '' : 's'}, ${String(resources)} resource${resources === 1 ? '' : 's'} with their bookings and history, ${String(live)} key${live === 1 ? '' : 's'} and ${String(webhooks.length)} webhook${webhooks.length === 1 ? '' : 's'}. Until then any owner can keep it.</p><form class="create-team" method="post" action="${root}/teams/delete">${hidden}<label for="confirm-handle">Type ${info.handle} to confirm</label><input id="confirm-handle" name="confirm" autocomplete="off" required><button class="danger" type="submit">Delete ${info.name}</button></form>`;
|
|
5689
|
+
return html `${noticeOf(notice, status)}${card}${danger}`;
|
|
5690
|
+
}
|
|
5691
|
+
async function deleteTeam(session, form) {
|
|
5692
|
+
const tenantRef = teamTenantOf(session);
|
|
5693
|
+
if (!teamStore || tenantRef === undefined)
|
|
5694
|
+
return nothingHere(session);
|
|
5695
|
+
const refusal = await teamStore.scheduleDeletion(tenantRef, personOf(session), (form.get('confirm') ?? '').trim());
|
|
5696
|
+
if (refusal === 'confirm')
|
|
5697
|
+
return settingsPage(session, 400, "Type the team's handle to confirm.", undefined, 'team');
|
|
5698
|
+
if (refusal === 'subscription_live') {
|
|
5699
|
+
return settingsPage(session, 409, 'Cancel its Team plan on Usage first: deleting the team would not stop the payments.', undefined, 'team');
|
|
5700
|
+
}
|
|
5701
|
+
if (refusal)
|
|
5702
|
+
return settingsPage(session, TEAM_REFUSALS[refusal].status, TEAM_REFUSALS[refusal].text, undefined, 'team');
|
|
5703
|
+
await audit(session, { action: 'workspace.deletion_scheduled', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
|
|
5704
|
+
return redirect(303, `${root}/settings/team`);
|
|
5705
|
+
}
|
|
5706
|
+
async function restoreTeam(session) {
|
|
5707
|
+
const tenantRef = teamTenantOf(session);
|
|
5708
|
+
if (!teamStore || tenantRef === undefined)
|
|
5709
|
+
return nothingHere(session);
|
|
5710
|
+
const refusal = await teamStore.cancelDeletion(tenantRef, personOf(session));
|
|
5711
|
+
if (refusal)
|
|
5712
|
+
return settingsPage(session, TEAM_REFUSALS[refusal].status, TEAM_REFUSALS[refusal].text, undefined, 'team');
|
|
5713
|
+
await audit(session, { action: 'workspace.deletion_cancelled', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
|
|
5714
|
+
return redirect(303, `${root}/settings/team`);
|
|
5715
|
+
}
|
|
5716
|
+
/** What a refused change to a team's domains, or a request to join, says, and its status. */
|
|
5717
|
+
const DOMAIN_REFUSALS = Object.freeze({
|
|
5718
|
+
invalid_domain: { status: 400, text: 'Enter a domain your organization owns, such as acme.com.' },
|
|
5719
|
+
public_suffix: { status: 400, text: 'Nobody owns all of that domain. Enter your own domain under it.' },
|
|
5720
|
+
consumer_domain: { status: 400, text: 'Anyone can get an address at that domain. Enter your organization’s own.' },
|
|
5721
|
+
not_found: { status: 404, text: 'That is no longer here.' },
|
|
5722
|
+
not_allowed: { status: 403, text: 'Only an owner can change the team’s domains.' },
|
|
5723
|
+
already_claimed: { status: 409, text: 'That domain is listed already.' },
|
|
5724
|
+
domain_taken: { status: 409, text: 'Another team holds that domain.' },
|
|
5725
|
+
domain_limit: { status: 403, text: `A team has up to ${String(SLOTLOCK_WORKSPACE_DOMAIN_LIMIT)} domains.` },
|
|
5726
|
+
too_soon: { status: 429, text: 'Checked less than a minute ago. Try again shortly.' },
|
|
5727
|
+
expired: { status: 409, text: 'That record expired after seven days. Add the domain again for a new one.' },
|
|
5728
|
+
restricted: { status: 409, text: 'Invitations go only to verified domains: allow any address before removing the last one.' },
|
|
5729
|
+
no_verified_domain: { status: 409, text: 'Verify a domain first.' },
|
|
5730
|
+
already_member: { status: 409, text: 'You are a member already.' },
|
|
5731
|
+
pending: { status: 409, text: 'You asked already. An owner or admin decides.' },
|
|
5732
|
+
request_limit: { status: 429, text: 'You asked to join 20 teams in the last day. Try again tomorrow.' },
|
|
5733
|
+
member_cap: TEAM_REFUSALS.member_cap,
|
|
5734
|
+
role_not_allowed: { status: 400, text: 'Someone joining through a domain joins as Viewer, Member or Developer.' },
|
|
5735
|
+
domain_not_verified: { status: 409, text: 'That domain is no longer verified, so the request cannot be approved.' },
|
|
5736
|
+
});
|
|
5737
|
+
/** How a request's address was verified, as the people deciding read it. */
|
|
5738
|
+
const JOIN_EVIDENCE_WORDS = Object.freeze({
|
|
5739
|
+
google_workspace: 'verified by Google Workspace',
|
|
5740
|
+
google: 'verified once by Google, not checked since',
|
|
5741
|
+
github: 'verified once by GitHub, not checked since',
|
|
5742
|
+
});
|
|
5743
|
+
/** What a domain lets the people whose verified address is at it do, as the Identity page offers it. */
|
|
5744
|
+
const JOIN_POLICY_WORDS = Object.freeze({
|
|
5745
|
+
request: 'Ask to join',
|
|
5746
|
+
auto: 'Join as Viewer',
|
|
5747
|
+
off: 'Invitations only',
|
|
5748
|
+
});
|
|
5749
|
+
const identityPage = (session, status, notice) => settingsPage(session, status, notice, undefined, 'identity');
|
|
5750
|
+
const domainRefused = (session, refusal) => identityPage(session, DOMAIN_REFUSALS[refusal].status, DOMAIN_REFUSALS[refusal].text);
|
|
5751
|
+
/** A team's domains: each with its record until verified, who may join through it, and invitations. */
|
|
5752
|
+
async function identitySection(session, status, notice) {
|
|
5753
|
+
const tenantRef = teamTenantOf(session);
|
|
5754
|
+
if (!domainStore || tenantRef === undefined)
|
|
5755
|
+
return noticeOf(notice, status);
|
|
5756
|
+
const [listed, restricted] = await Promise.all([domainStore.list(tenantRef), domainStore.invitesRestricted(tenantRef)]);
|
|
5757
|
+
const token = csrfToken(session);
|
|
5758
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
5759
|
+
const cards = listed.map((domain, index) => {
|
|
5760
|
+
const name = domain.unicode === domain.ascii ? html `${domain.unicode}` : html `${domain.unicode} <span class="muted">${domain.ascii}</span>`;
|
|
5761
|
+
const [pill, word] = domain.expired
|
|
5762
|
+
? ['expired', 'Expired']
|
|
5763
|
+
: domain.status === 'verified'
|
|
5764
|
+
? ['active', 'Verified']
|
|
5765
|
+
: domain.status === 'lapsing'
|
|
5766
|
+
? ['revoked', 'Record missing']
|
|
5767
|
+
: ['held', 'Pending'];
|
|
5768
|
+
const record = slotlockDomainRecord(domain.ascii, domain.token);
|
|
5769
|
+
const id = html `<input type="hidden" name="id" value="${domain.id}">`;
|
|
5770
|
+
let rows;
|
|
5771
|
+
if (domain.status === 'verified') {
|
|
5772
|
+
rows = html `<div class="row"><span class="row-label">Who may join</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/domains/policy">${hidden()}${id}<label class="visually-hidden" for="policy-${String(index)}">Who may join through ${domain.unicode}</label><select id="policy-${String(index)}" name="policy">${['request', 'auto', 'off'].map((policy) => html `<option value="${policy}"${policy === domain.joinPolicy ? html ` selected` : html ``}>${JOIN_POLICY_WORDS[policy]}</option>`)}</select><button type="submit">Save<span class="visually-hidden"> who may join through ${domain.unicode}</span></button></form></div></div>`;
|
|
5773
|
+
}
|
|
5774
|
+
else if (domain.expired) {
|
|
5775
|
+
rows = html `<div class="row"><span class="row-label">Record</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/domains">${hidden()}<input type="hidden" name="domain" value="${domain.ascii}"><button type="submit">Get a new record<span class="visually-hidden"> for ${domain.unicode}</span></button></form></div></div>`;
|
|
5776
|
+
}
|
|
5777
|
+
else {
|
|
5778
|
+
rows = html `${domain.status === 'lapsing' && domain.lapseAfter
|
|
5779
|
+
? html `<div class="row"><span class="row-label">Lapses</span><div class="row-value">${formatDate(domain.lapseAfter)}, unless this new record is published</div></div>`
|
|
5780
|
+
: html ``}<div class="row"><span class="row-label">TXT name</span><div class="row-value">${copyField(`record-name-${String(index)}`, record.name)}</div></div><div class="row"><span class="row-label">TXT value</span><div class="row-value">${copyField(`record-value-${String(index)}`, record.value)}</div></div><div class="row"><span class="row-label">Check</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/domains/verify">${hidden()}${id}<button class="primary" type="submit">Verify now<span class="visually-hidden"> ${domain.unicode}</span></button></form></div></div>`;
|
|
5781
|
+
}
|
|
5782
|
+
const remove = html `<details class="act danger"><summary>Remove<span class="visually-hidden"> ${domain.unicode}</span></summary><div class="pop"><p>${domain.status === 'pending' ? 'The claim is withdrawn.' : 'Nobody new joins through it. Members who joined through it stay.'}</p><form method="post" action="${root}/identity/domains/remove">${hidden()}${id}<button class="danger" type="submit">Remove ${domain.unicode}</button></form></div></details>`;
|
|
5783
|
+
return html `<div class="set-card"><div class="row"><span class="row-label">Domain</span><div class="row-value"><span class="strong">${name}</span> <span class="pill ${pill}">${word}</span> ${remove}</div></div>${rows}</div>`;
|
|
5784
|
+
});
|
|
5785
|
+
const add = listed.length >= SLOTLOCK_WORKSPACE_DOMAIN_LIMIT
|
|
5786
|
+
? html ``
|
|
5787
|
+
: html `<form class="invite" method="post" action="${root}/identity/domains">${hidden()}<label for="domain">Domain</label><input id="domain" name="domain" maxlength="253" autocomplete="off" spellcheck="false" placeholder="acme.com" required><button class="primary" type="submit">Add domain</button></form>`;
|
|
5788
|
+
const verified = listed.some((domain) => domain.status === 'verified');
|
|
5789
|
+
const invites = html `<h2>Invitations</h2><div class="set-card"><div class="row"><span class="row-label">Go to</span><div class="row-value">${restricted ? 'Addresses at verified domains' : 'Any address'} ${restricted || verified
|
|
5790
|
+
? html `<form class="inline" method="post" action="${root}/identity/invites">${hidden()}<input type="hidden" name="on" value="${restricted ? '0' : '1'}"><button type="submit">${restricted ? 'Allow any address' : 'Only verified domains'}</button></form>`
|
|
5791
|
+
: html ``}</div></div></div>`;
|
|
5792
|
+
return html `${noticeOf(notice, status)}<h2>Domains</h2>${add}${cards}${invites}${await ssoSection(session, tenantRef)}${await scimSection(session, tenantRef)}`;
|
|
5793
|
+
}
|
|
5794
|
+
async function claimDomain(session, form) {
|
|
5795
|
+
const tenantRef = teamTenantOf(session);
|
|
5796
|
+
if (!domainStore || tenantRef === undefined)
|
|
5797
|
+
return nothingHere(session);
|
|
5798
|
+
const claimed = await domainStore.claim(tenantRef, personOf(session), (form.get('domain') ?? '').slice(0, 300));
|
|
5799
|
+
if ('refusal' in claimed)
|
|
5800
|
+
return domainRefused(session, claimed.refusal);
|
|
5801
|
+
const domain = (await domainStore.list(tenantRef)).find((listed) => listed.id === claimed.id);
|
|
5802
|
+
await audit(session, { action: 'domain.claimed', targetType: 'domain', targetRef: claimed.id, metadata: { domain: domain?.ascii ?? '' } }, tenantRef);
|
|
5803
|
+
return redirect(303, `${root}/settings/identity`);
|
|
5804
|
+
}
|
|
5805
|
+
/** What an Owner's "Verify now" found, as the page says it. */
|
|
5806
|
+
const VERIFY_ANSWERS = Object.freeze({
|
|
5807
|
+
verified: { status: 200, text: (domain) => `${domain} is verified.` },
|
|
5808
|
+
restored: { status: 200, text: (domain) => `${domain} is verified again.` },
|
|
5809
|
+
seen: { status: 200, text: (domain) => `${domain} is still verified.` },
|
|
5810
|
+
absent: { status: 409, text: () => 'The record isn’t there yet. Slotlock also checks every hour for three days.' },
|
|
5811
|
+
error: { status: 503, text: () => 'DNS didn’t answer. Try again shortly.' },
|
|
5812
|
+
domain_taken: { status: 409, text: () => 'Another team verified that domain first.' },
|
|
5813
|
+
});
|
|
5814
|
+
async function verifyDomain(session, form) {
|
|
5815
|
+
const tenantRef = teamTenantOf(session);
|
|
5816
|
+
if (!domainStore || tenantRef === undefined)
|
|
5817
|
+
return nothingHere(session);
|
|
5818
|
+
const found = await domainStore.verifyNow(tenantRef, personOf(session), form.get('id') ?? '', domainResolvers);
|
|
5819
|
+
const event = slotlockDomainCheckEvent(found, false);
|
|
5820
|
+
if (event)
|
|
5821
|
+
await audit(session, event, tenantRef);
|
|
5822
|
+
const answer = VERIFY_ANSWERS[found.outcome];
|
|
5823
|
+
if (answer)
|
|
5824
|
+
return identityPage(session, answer.status, answer.text(found.domain ?? 'The domain'));
|
|
5825
|
+
if (found.outcome in DOMAIN_REFUSALS)
|
|
5826
|
+
return domainRefused(session, found.outcome);
|
|
5827
|
+
return identityPage(session, 409, 'The record isn’t there.');
|
|
5828
|
+
}
|
|
5829
|
+
async function releaseDomain(session, form) {
|
|
5830
|
+
const tenantRef = teamTenantOf(session);
|
|
5831
|
+
if (!domainStore || tenantRef === undefined)
|
|
5832
|
+
return nothingHere(session);
|
|
5833
|
+
const id = form.get('id') ?? '';
|
|
5834
|
+
const domain = (await domainStore.list(tenantRef)).find((listed) => listed.id === id);
|
|
5835
|
+
const refusal = await domainStore.release(tenantRef, personOf(session), id);
|
|
5836
|
+
if (refusal)
|
|
5837
|
+
return domainRefused(session, refusal);
|
|
5838
|
+
await audit(session, { action: 'domain.released', targetType: 'domain', targetRef: id, metadata: { domain: domain?.ascii ?? '' } }, tenantRef);
|
|
5839
|
+
return redirect(303, `${root}/settings/identity`);
|
|
5840
|
+
}
|
|
5841
|
+
async function changeJoinPolicy(session, form) {
|
|
5842
|
+
const tenantRef = teamTenantOf(session);
|
|
5843
|
+
if (!domainStore || tenantRef === undefined)
|
|
5844
|
+
return nothingHere(session);
|
|
5845
|
+
const id = form.get('id') ?? '';
|
|
5846
|
+
const policy = form.get('policy') ?? '';
|
|
5847
|
+
if (policy !== 'off' && policy !== 'request' && policy !== 'auto')
|
|
5848
|
+
return identityPage(session, 400, 'Choose who may join.');
|
|
5849
|
+
const domain = (await domainStore.list(tenantRef)).find((listed) => listed.id === id);
|
|
5850
|
+
const refusal = await domainStore.setJoinPolicy(tenantRef, personOf(session), id, policy);
|
|
5851
|
+
if (refusal)
|
|
5852
|
+
return domainRefused(session, refusal);
|
|
5853
|
+
await audit(session, { action: 'domain.join_policy_changed', targetType: 'domain', targetRef: id, metadata: { domain: domain?.ascii ?? '', policy } }, tenantRef);
|
|
5854
|
+
return identityPage(session, 200, `${JOIN_POLICY_WORDS[policy]}: saved for ${domain?.unicode ?? 'the domain'}.`);
|
|
5855
|
+
}
|
|
5856
|
+
async function restrictInvites(session, form) {
|
|
5857
|
+
const tenantRef = teamTenantOf(session);
|
|
5858
|
+
if (!domainStore || tenantRef === undefined)
|
|
5859
|
+
return nothingHere(session);
|
|
5860
|
+
const on = form.get('on') === '1';
|
|
5861
|
+
const refusal = await domainStore.restrictInvites(tenantRef, personOf(session), on);
|
|
5862
|
+
if (refusal)
|
|
5863
|
+
return domainRefused(session, refusal);
|
|
5864
|
+
await audit(session, { action: on ? 'workspace.invites_restricted' : 'workspace.invites_unrestricted', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
|
|
5865
|
+
return redirect(303, `${root}/settings/identity`);
|
|
5866
|
+
}
|
|
5867
|
+
/** Teams at the person's verified addresses' domains: ask to join, or join. */
|
|
5868
|
+
async function domainTeamsSection(session, checked) {
|
|
5869
|
+
if (!domainStore)
|
|
5870
|
+
return html ``;
|
|
5871
|
+
const emails = verifiedEmails(session);
|
|
5872
|
+
const found = emails.length > 0 ? await domainStore.discoverable(personOf(session), emails) : [];
|
|
5873
|
+
if (found.length === 0) {
|
|
5874
|
+
// A GitHub sign-in carries no address until asked: one look with those GitHub verified.
|
|
5875
|
+
if (session.idp === 'google')
|
|
5876
|
+
return html ``;
|
|
5877
|
+
return checked
|
|
5878
|
+
? html `<p class="muted">No team has verified the domain of an address GitHub verified for you.</p>`
|
|
5879
|
+
: html `<p><a href="${root}/sign-in?then=%2Fteams&email=1">${ICONS.github}Find your organization’s team</a></p>`;
|
|
5880
|
+
}
|
|
5881
|
+
const token = csrfToken(session);
|
|
5882
|
+
const rows = found.map((team) => html `<tr><td data-label="Team" class="strong">${team.name}</td><td data-label="Domain" class="muted">${team.domain}</td><td class="actions">${team.requested
|
|
5883
|
+
? html `<span class="pill held">Asked</span>`
|
|
5884
|
+
: html `<form class="inline" method="post" action="${root}/join/request"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="team" value="${team.tenantRef}"><button class="primary" type="submit">${team.joinPolicy === 'auto' && joinEvidence(session) === 'google_workspace'
|
|
5885
|
+
? html `Join ${team.name}`
|
|
5886
|
+
: html `Ask to join<span class="visually-hidden"> ${team.name}</span>`}</button></form>`}</td></tr>`);
|
|
5887
|
+
return html `<div class="table"><table><caption class="visually-hidden">Teams at your domain</caption><thead><tr><th>At your domain</th><th>Domain</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
5888
|
+
}
|
|
5889
|
+
async function requestToJoin(session, form) {
|
|
5890
|
+
if (!domainStore)
|
|
5891
|
+
return nothingHere(session);
|
|
5892
|
+
const team = form.get('team') ?? '';
|
|
5893
|
+
const offered = (await domainStore.discoverable(personOf(session), verifiedEmails(session))).find((found) => found.tenantRef === team);
|
|
5894
|
+
if (!offered)
|
|
5895
|
+
return teamsPage(session, 404, DOMAIN_REFUSALS.not_found.text);
|
|
5896
|
+
const asked = await domainStore.requestToJoin(team, personOf(session), verifiedEmails(session), joinEvidence(session), await memberCapOf(team));
|
|
5897
|
+
if ('refusal' in asked)
|
|
5898
|
+
return teamsPage(session, DOMAIN_REFUSALS[asked.refusal].status, DOMAIN_REFUSALS[asked.refusal].text);
|
|
5899
|
+
const person = personOf(session);
|
|
5900
|
+
if (asked.outcome === 'joined') {
|
|
5901
|
+
await audit(session, { action: 'member.joined', actorRole: 'viewer', targetType: 'person', targetRef: person, metadata: { domain: offered.domainKey } }, team);
|
|
5902
|
+
return moveTo(session, team.slice('team:'.length), '');
|
|
5903
|
+
}
|
|
5904
|
+
await audit(session, { action: 'member.join_requested', actorRole: null, targetType: 'person', targetRef: person, metadata: { domain: offered.domainKey, evidence: joinEvidence(session) } }, team);
|
|
5905
|
+
return teamsPage(session, 200, `Asked to join ${offered.name}. An owner or admin decides.`);
|
|
5906
|
+
}
|
|
5907
|
+
async function decideJoinRequest(session, form) {
|
|
5908
|
+
const tenantRef = teamTenantOf(session);
|
|
5909
|
+
if (!domainStore || tenantRef === undefined)
|
|
5910
|
+
return nothingHere(session);
|
|
5911
|
+
const decision = form.get('decision');
|
|
5912
|
+
if (decision !== 'approve' && decision !== 'decline')
|
|
5913
|
+
return refusalPage(session, 'not_found');
|
|
5914
|
+
const role = slotlockJoinRole(form.get('role'));
|
|
5915
|
+
if (decision === 'approve' && role === null)
|
|
5916
|
+
return settingsPage(session, 400, 'Choose a role.', undefined, 'members');
|
|
5917
|
+
const id = form.get('id') ?? '';
|
|
5918
|
+
const decided = await domainStore.decide({ tenantRef, actor: personOf(session), id, decision, role, memberCap: await memberCapOf(tenantRef) });
|
|
5919
|
+
if (decided.refusal) {
|
|
5920
|
+
const { status, text } = DOMAIN_REFUSALS[decided.refusal];
|
|
5921
|
+
return settingsPage(session, status, text, undefined, 'members');
|
|
5922
|
+
}
|
|
5923
|
+
await audit(session, {
|
|
5924
|
+
action: decision === 'approve' ? 'member.join_approved' : 'member.join_declined',
|
|
5925
|
+
targetType: 'person',
|
|
5926
|
+
...(decided.person ? { targetRef: decided.person } : {}),
|
|
5927
|
+
...(role && decision === 'approve' ? { metadata: { role } } : {}),
|
|
5928
|
+
}, tenantRef);
|
|
5929
|
+
return redirect(303, `${root}/settings/members`);
|
|
5930
|
+
}
|
|
5931
|
+
// ---- Single sign-on through a team's own identity provider (teams phase 5.3–5.5) ----
|
|
5932
|
+
/** What a refused single sign-on change or sign-in says, and its status. */
|
|
5933
|
+
const SSO_REFUSALS = Object.freeze({
|
|
5934
|
+
not_found: { status: 404, text: 'That is no longer here.' },
|
|
5935
|
+
not_allowed: { status: 403, text: 'Only an owner can change the team’s single sign-on.' },
|
|
5936
|
+
no_verified_domain: { status: 409, text: 'Verify a domain first: single sign-on trusts addresses only there.' },
|
|
5937
|
+
secret_required: { status: 400, text: 'Enter the client secret.' },
|
|
5938
|
+
conflict: { status: 409, text: 'The connection changed since this page was opened. Reload it.' },
|
|
5939
|
+
stale_config: { status: 409, text: 'The connection changed during the test. Test it again.' },
|
|
5940
|
+
untested: { status: 409, text: 'Test the connection as it is now before turning it on.' },
|
|
5941
|
+
no_breakglass_owner: { status: 409, text: 'Keep an owner who signs in with GitHub or Google first, for when the provider is down.' },
|
|
5942
|
+
role_not_allowed: { status: 400, text: 'People the provider adds join as Viewer, Member or Developer.' },
|
|
5943
|
+
owner_not_mappable: { status: 400, text: 'No group makes an owner: owners are made in Slotlock.' },
|
|
5944
|
+
invalid_value: { status: 400, text: 'Enter the group as the provider sends it.' },
|
|
5945
|
+
mapping_limit: { status: 403, text: 'A team maps up to 50 groups.' },
|
|
5946
|
+
connection_inactive: { status: 409, text: 'That team’s single sign-on is off.' },
|
|
5947
|
+
guest_refused: { status: 403, text: 'Guests of the directory cannot sign in to this team.' },
|
|
5948
|
+
not_member: { status: 403, text: 'You are not a member of this team. Ask an owner to add you.' },
|
|
5949
|
+
identity_taken: { status: 409, text: 'That account is linked to someone else in Slotlock.' },
|
|
5950
|
+
identity_untrusted: { status: 403, text: 'Use your work account at the team’s verified domain.' },
|
|
5951
|
+
hd_mismatch: { status: 403, text: 'Use a Google Workspace account at the team’s verified domain.' },
|
|
5952
|
+
member_cap: TEAM_REFUSALS.member_cap,
|
|
5953
|
+
invalid_max_age: { status: 400, text: 'Sessions last from 1 to 168 hours.' },
|
|
5954
|
+
no_active_connection: { status: 409, text: 'Turn the connection on first.' },
|
|
5955
|
+
stale_sso_proof: { status: 409, text: 'Sign in with the team’s single sign-on first, then turn this on within 10 minutes.' },
|
|
5956
|
+
});
|
|
5957
|
+
/** The providers, as the Identity page names them. */
|
|
5958
|
+
const SSO_KIND_WORDS = Object.freeze({
|
|
5959
|
+
okta: 'Okta',
|
|
5960
|
+
entra: 'Microsoft Entra ID',
|
|
5961
|
+
google_workspace: 'Google Workspace',
|
|
5962
|
+
oidc: 'Other OpenID Connect',
|
|
5963
|
+
});
|
|
5964
|
+
const ssoRedirectUri = (id) => `${publicRoot}/sso/callback/${id}`;
|
|
5965
|
+
const ssoSecretContext = (tenantRef, id) => `${tenantRef}|${id}|v1`;
|
|
5966
|
+
const ssoRefused = (session, refusal) => identityPage(session, SSO_REFUSALS[refusal].status, SSO_REFUSALS[refusal].text);
|
|
5967
|
+
/** The team's connection on its Identity page: set it up, test it, turn it on, and who it lets in. */
|
|
5968
|
+
async function ssoSection(session, tenantRef) {
|
|
5969
|
+
if (!ssoStore)
|
|
5970
|
+
return html ``;
|
|
5971
|
+
const [connection, mappings] = await Promise.all([ssoStore.connection(tenantRef), ssoStore.mappings(tenantRef)]);
|
|
5972
|
+
const token = csrfToken(session);
|
|
5973
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
5974
|
+
const kinds = (selected) => ['okta', 'entra', 'google_workspace', 'oidc'].map((kind) => html `<option value="${kind}"${kind === selected ? html ` selected` : html ``}>${SSO_KIND_WORDS[kind]}</option>`);
|
|
5975
|
+
const form = html `<form class="sso-form" method="post" action="${root}/identity/sso">${hidden()}<label for="sso-kind">Provider</label><select id="sso-kind" name="kind">${kinds(connection?.kind ?? 'okta')}</select><label for="sso-issuer">Issuer URL, or Entra tenant ID</label><input id="sso-issuer" name="issuer" maxlength="500" autocomplete="off" spellcheck="false" required value="${connection ? (connection.kind === 'entra' ? (connection.entraTenant ?? '') : connection.issuer) : ''}"><label for="sso-client">Client ID</label><input id="sso-client" name="client_id" maxlength="255" autocomplete="off" spellcheck="false" required value="${connection?.clientId ?? ''}"><label for="sso-secret">Client secret</label><input id="sso-secret" name="client_secret" type="password" maxlength="1024" autocomplete="off"${connection ? html ` placeholder="••••${connection.secretLast4 ?? ''}"` : html ` required`}><button class="primary" type="submit">${connection ? 'Save' : 'Connect'}</button></form>`;
|
|
5976
|
+
if (!connection)
|
|
5977
|
+
return html `<h2>Single sign-on</h2>${form}`;
|
|
5978
|
+
const tested = connection.testedVersion === connection.configVersion;
|
|
5979
|
+
const [pill, word] = connection.status === 'active' ? ['active', 'On'] : connection.status === 'disabled' ? ['revoked', 'Off'] : ['held', tested ? 'Tested' : 'Not tested'];
|
|
5980
|
+
const id = html `<input type="hidden" name="id" value="${connection.id}">`;
|
|
5981
|
+
const claims = connection.lastTestClaims;
|
|
5982
|
+
const testRow = claims
|
|
5983
|
+
? html `<div class="row"><span class="row-label">Last test</span><div class="row-value"><code>${JSON.stringify(claims)}</code></div></div>`
|
|
5984
|
+
: html ``;
|
|
5985
|
+
const status = html `<div class="set-card"><div class="row"><span class="row-label">${SSO_KIND_WORDS[connection.kind]}</span><div class="row-value"><span class="pill ${pill}">${word}</span> <span class="muted">${connection.issuer}</span></div></div><div class="row"><span class="row-label">Redirect URI</span><div class="row-value">${copyField('sso-redirect', ssoRedirectUri(connection.id))}</div></div>${testRow}<div class="row"><span class="row-label">Check</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/sso/test">${hidden()}${id}<button type="submit">Test sign-in</button></form>${connection.status === 'active'
|
|
5986
|
+
? html `<form class="inline" method="post" action="${root}/identity/sso/status">${hidden()}${id}<input type="hidden" name="status" value="disabled"><button type="submit">Turn off</button></form>`
|
|
5987
|
+
: html `<form class="inline" method="post" action="${root}/identity/sso/status">${hidden()}${id}<input type="hidden" name="status" value="active"><button class="primary" type="submit"${tested ? html `` : html ` disabled`}>Turn on</button></form>`}</div></div></div>`;
|
|
5988
|
+
const roleOptions = (selected, choices) => choices.map((role) => html `<option value="${role}"${role === selected ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`);
|
|
5989
|
+
const provisioning = html `<h3>Who joins</h3><form class="sso-form" method="post" action="${root}/identity/sso/provisioning">${hidden()}<label><input type="checkbox" name="jit" value="1"${connection.jitEnabled ? html ` checked` : html ``}> Add people at the verified domains on their first sign-in</label><label for="sso-default-role">As</label><select id="sso-default-role" name="default_role">${roleOptions(connection.defaultRole, ['viewer', 'member', 'developer'])}</select><label for="sso-groups-claim">Groups claim</label><input id="sso-groups-claim" name="groups_claim" maxlength="100" value="${connection.groupsClaim}"><button type="submit">Save</button></form>${mappings.length > 0
|
|
5990
|
+
? html `<div class="table"><table><thead><tr><th>Group</th><th>Role</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${mappings.map((mapping) => html `<tr><td data-label="Group" class="strong">${mapping.value}</td><td data-label="Role">${SLOTLOCK_WORKSPACE_ROLE_NAMES[mapping.role]}</td><td class="actions"><form class="inline" method="post" action="${root}/identity/sso/mapping">${hidden()}<input type="hidden" name="value" value="${mapping.value}"><input type="hidden" name="role" value=""><button class="danger" type="submit">Remove<span class="visually-hidden"> ${mapping.value}</span></button></form></td></tr>`)}</tbody></table></div>`
|
|
5991
|
+
: html ``}<form class="invite" method="post" action="${root}/identity/sso/mapping">${hidden()}<label for="sso-group">Group</label><input id="sso-group" name="value" maxlength="255" autocomplete="off" required><label class="visually-hidden" for="sso-group-role">Role</label><select id="sso-group-role" name="role">${roleOptions('member', ['admin', 'developer', 'member', 'viewer', 'billing'])}</select><button type="submit">Map group</button></form>`;
|
|
5992
|
+
const enforcement = html `<h3>Require single sign-on</h3><form class="sso-form" method="post" action="${root}/identity/sso/require">${hidden()}<input type="hidden" name="on" value="${connection.requireSso ? '0' : '1'}"><label for="sso-max-age">Sign in again every (hours)</label><input id="sso-max-age" name="max_age" type="number" min="1" max="168" value="${String(connection.maxAgeHours)}"><button type="submit"${connection.status === 'active' || connection.requireSso ? html `` : html ` disabled`}>${connection.requireSso ? 'Stop requiring it' : 'Require it'}</button></form>${connection.status === 'active' && !connection.requireSso && session.sc === undefined
|
|
5993
|
+
? html `<form class="inline" method="post" action="${root}/sso/proof">${hidden()}<input type="hidden" name="team" value="${tenantRef}"><button type="submit">Sign in with single sign-on</button></form>`
|
|
5994
|
+
: html ``}`;
|
|
5995
|
+
const remove = html `<details class="act danger"><summary>Delete connection</summary><div class="pop"><p>People who sign in only through it are erased with it.</p><form method="post" action="${root}/identity/sso/delete">${hidden()}${id}<button class="danger" type="submit">Delete connection</button></form></div></details>`;
|
|
5996
|
+
return html `<h2>Single sign-on</h2>${status}${form}${provisioning}${enforcement}${remove}`;
|
|
5997
|
+
}
|
|
5998
|
+
async function saveSsoConnection(session, form) {
|
|
5999
|
+
const tenantRef = teamTenantOf(session);
|
|
6000
|
+
if (!ssoStore || !options.sso || tenantRef === undefined)
|
|
6001
|
+
return nothingHere(session);
|
|
6002
|
+
const kind = form.get('kind');
|
|
6003
|
+
if (kind !== 'okta' && kind !== 'entra' && kind !== 'google_workspace' && kind !== 'oidc')
|
|
6004
|
+
return identityPage(session, 400, 'Choose the provider.');
|
|
6005
|
+
const typed = (form.get('issuer') ?? '').trim();
|
|
6006
|
+
const clientId = (form.get('client_id') ?? '').trim();
|
|
6007
|
+
const secret = form.get('client_secret') ?? '';
|
|
6008
|
+
if (clientId.length === 0 || clientId.length > 255 || /\s/.test(clientId))
|
|
6009
|
+
return identityPage(session, 400, 'Enter the client ID.');
|
|
6010
|
+
let issuer;
|
|
6011
|
+
let entraTenant = null;
|
|
6012
|
+
if (kind === 'entra') {
|
|
6013
|
+
const pinned = slotlockEntraIssuer(typed);
|
|
6014
|
+
if (!pinned)
|
|
6015
|
+
return identityPage(session, 400, 'Enter your Entra tenant ID (a GUID): common, organizations and consumers are not one tenant.');
|
|
6016
|
+
issuer = pinned;
|
|
6017
|
+
entraTenant = typed.toLowerCase();
|
|
6018
|
+
}
|
|
6019
|
+
else if (kind === 'google_workspace') {
|
|
6020
|
+
issuer = 'https://accounts.google.com';
|
|
6021
|
+
}
|
|
6022
|
+
else {
|
|
6023
|
+
if (!slotlockOidcUrlAllowed(typed))
|
|
6024
|
+
return identityPage(session, 400, 'Enter the issuer URL: https, on a public host name.');
|
|
6025
|
+
issuer = typed;
|
|
6026
|
+
}
|
|
6027
|
+
let provider;
|
|
6028
|
+
try {
|
|
6029
|
+
provider = await discoverSlotlockOidc(issuer, { fetch: ssoFetch });
|
|
6030
|
+
}
|
|
6031
|
+
catch (error) {
|
|
6032
|
+
return identityPage(session, 502, `The issuer’s discovery document did not check out: ${error instanceof Error ? error.message : 'no answer'}.`);
|
|
6033
|
+
}
|
|
6034
|
+
const existing = await ssoStore.connection(tenantRef);
|
|
6035
|
+
const id = existing?.id ?? randomUUID();
|
|
6036
|
+
if (!existing && secret.length === 0)
|
|
6037
|
+
return ssoRefused(session, 'secret_required');
|
|
6038
|
+
if (secret.length > 1024 || /[\r\n]/.test(secret))
|
|
6039
|
+
return identityPage(session, 400, 'That is not a client secret.');
|
|
6040
|
+
const sealed = secret.length > 0 ? await options.sso.secretBox.seal(secret, ssoSecretContext(tenantRef, id)) : null;
|
|
6041
|
+
const saved = await ssoStore.save({
|
|
6042
|
+
tenantRef,
|
|
6043
|
+
actor: personOf(session),
|
|
6044
|
+
id,
|
|
6045
|
+
kind,
|
|
6046
|
+
issuer: provider.issuer,
|
|
6047
|
+
clientId,
|
|
6048
|
+
secretSealed: sealed,
|
|
6049
|
+
secretLast4: secret.length > 0 ? secret.slice(-4) : null,
|
|
6050
|
+
authorizationEndpoint: provider.authorizationEndpoint,
|
|
6051
|
+
tokenEndpoint: provider.tokenEndpoint,
|
|
6052
|
+
jwksUri: provider.jwksUri,
|
|
6053
|
+
algorithms: provider.algorithms,
|
|
6054
|
+
issParameter: provider.issParameter,
|
|
6055
|
+
entraTenant,
|
|
6056
|
+
});
|
|
6057
|
+
if ('refusal' in saved)
|
|
6058
|
+
return ssoRefused(session, saved.refusal);
|
|
6059
|
+
await audit(session, { action: 'sso.connection_saved', targetType: 'sso_connection', targetRef: saved.id, metadata: { kind, issuer: provider.issuer } }, tenantRef);
|
|
6060
|
+
if (saved.enforcementOff) {
|
|
6061
|
+
await audit(session, { action: 'sso.not_required', targetType: 'workspace', targetRef: tenantRef, metadata: { reason: 'connection_changed' } }, tenantRef);
|
|
6062
|
+
}
|
|
6063
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6064
|
+
}
|
|
6065
|
+
async function setSsoStatus(session, form) {
|
|
6066
|
+
const tenantRef = teamTenantOf(session);
|
|
6067
|
+
if (!ssoStore || tenantRef === undefined)
|
|
6068
|
+
return nothingHere(session);
|
|
6069
|
+
const status = form.get('status') === 'active' ? 'active' : 'disabled';
|
|
6070
|
+
const id = form.get('id') ?? '';
|
|
6071
|
+
const refusal = await ssoStore.setStatus(tenantRef, personOf(session), id, status);
|
|
6072
|
+
if (refusal)
|
|
6073
|
+
return ssoRefused(session, refusal);
|
|
6074
|
+
await audit(session, { action: status === 'active' ? 'sso.connection_activated' : 'sso.connection_disabled', targetType: 'sso_connection', targetRef: id }, tenantRef);
|
|
6075
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6076
|
+
}
|
|
6077
|
+
async function deleteSsoConnection(session, form) {
|
|
6078
|
+
const tenantRef = teamTenantOf(session);
|
|
6079
|
+
if (!ssoStore || tenantRef === undefined)
|
|
6080
|
+
return nothingHere(session);
|
|
6081
|
+
const id = form.get('id') ?? '';
|
|
6082
|
+
const refusal = await ssoStore.remove(tenantRef, personOf(session), id);
|
|
6083
|
+
if (refusal)
|
|
6084
|
+
return ssoRefused(session, refusal);
|
|
6085
|
+
await audit(session, { action: 'sso.connection_deleted', targetType: 'sso_connection', targetRef: id }, tenantRef);
|
|
6086
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6087
|
+
}
|
|
6088
|
+
async function setSsoProvisioning(session, form) {
|
|
6089
|
+
const tenantRef = teamTenantOf(session);
|
|
6090
|
+
if (!ssoStore || tenantRef === undefined)
|
|
6091
|
+
return nothingHere(session);
|
|
6092
|
+
const jit = form.get('jit') === '1';
|
|
6093
|
+
const defaultRole = form.get('default_role') ?? '';
|
|
6094
|
+
const groupsClaim = (form.get('groups_claim') ?? '').trim();
|
|
6095
|
+
if (groupsClaim !== '' && !/^[A-Za-z0-9_:./-]{1,100}$/.test(groupsClaim))
|
|
6096
|
+
return identityPage(session, 400, 'Enter the groups claim as the provider names it.');
|
|
6097
|
+
const refusal = await ssoStore.setProvisioning(tenantRef, personOf(session), jit, defaultRole, groupsClaim);
|
|
6098
|
+
if (refusal)
|
|
6099
|
+
return ssoRefused(session, refusal);
|
|
6100
|
+
await audit(session, { action: 'sso.provisioning_changed', targetType: 'workspace', targetRef: tenantRef, metadata: { jit, defaultRole } }, tenantRef);
|
|
6101
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6102
|
+
}
|
|
6103
|
+
async function setSsoMapping(session, form) {
|
|
6104
|
+
const tenantRef = teamTenantOf(session);
|
|
6105
|
+
if (!ssoStore || tenantRef === undefined)
|
|
6106
|
+
return nothingHere(session);
|
|
6107
|
+
const value = (form.get('value') ?? '').trim();
|
|
6108
|
+
const role = form.get('role') || null;
|
|
6109
|
+
const refusal = await ssoStore.setMapping(tenantRef, personOf(session), value, role);
|
|
6110
|
+
if (refusal)
|
|
6111
|
+
return ssoRefused(session, refusal);
|
|
6112
|
+
await audit(session, { action: 'sso.mapping_changed', targetType: 'workspace', targetRef: tenantRef, metadata: { group: value.slice(0, 100), role: role ?? 'none' } }, tenantRef);
|
|
6113
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6114
|
+
}
|
|
6115
|
+
async function setRequireSso(session, form) {
|
|
6116
|
+
const tenantRef = teamTenantOf(session);
|
|
6117
|
+
if (!ssoStore || tenantRef === undefined)
|
|
6118
|
+
return nothingHere(session);
|
|
6119
|
+
const on = form.get('on') === '1';
|
|
6120
|
+
const maxAge = Number(form.get('max_age') ?? '');
|
|
6121
|
+
const refusal = await ssoStore.requireSso(tenantRef, personOf(session), session.sid, on, Number.isSafeInteger(maxAge) ? maxAge : 0);
|
|
6122
|
+
if (refusal)
|
|
6123
|
+
return ssoRefused(session, refusal);
|
|
6124
|
+
await audit(session, { action: on ? 'sso.required' : 'sso.not_required', targetType: 'workspace', targetRef: tenantRef, metadata: { maxAgeHours: maxAge } }, tenantRef);
|
|
6125
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6126
|
+
}
|
|
6127
|
+
/** The page a member meets at a team that requires single sign-on: sign in through it, or (an Owner) bypass. */
|
|
6128
|
+
function ssoRequiredPage(session, tenantRef, name, mayBypass) {
|
|
6129
|
+
const token = csrfToken(session);
|
|
6130
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="team" value="${tenantRef}">`;
|
|
6131
|
+
const content = html `${pageHead(name)}<p>${name} requires single sign-on.</p><p class="actions"><form class="inline" method="post" action="${root}/sso/proof">${hidden()}<button class="primary" type="submit">Sign in with single sign-on</button></form>${mayBypass
|
|
6132
|
+
? html `<form class="inline" method="post" action="${root}/sso/bypass">${hidden()}<button type="submit">Use owner bypass</button></form>`
|
|
6133
|
+
: html ``}</p>${mayBypass ? html `<p class="fine">An owner’s bypass lasts an hour, in this browser, and every other owner can see it in the audit log.</p>` : html ``}`;
|
|
6134
|
+
return respond(403, layout(name, content, session));
|
|
6135
|
+
}
|
|
6136
|
+
/** Start a sign-in through a team's provider: to sign in alone, prove oneself (or link) while signed in, or test it. */
|
|
6137
|
+
async function startSsoSignIn(connection, purpose, person, version) {
|
|
6138
|
+
const state = randomBytes(32).toString('base64url');
|
|
6139
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
6140
|
+
const nonce = randomBytes(24).toString('base64url');
|
|
6141
|
+
const target = new URL(connection.authorizationEndpoint);
|
|
6142
|
+
target.search = new URLSearchParams({
|
|
6143
|
+
client_id: connection.clientId,
|
|
6144
|
+
redirect_uri: ssoRedirectUri(connection.id),
|
|
6145
|
+
response_type: 'code',
|
|
6146
|
+
scope: 'openid email profile',
|
|
6147
|
+
state,
|
|
6148
|
+
nonce,
|
|
6149
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
6150
|
+
code_challenge_method: 'S256',
|
|
6151
|
+
...(purpose === 'test' ? { prompt: 'login' } : {}),
|
|
6152
|
+
}).toString();
|
|
6153
|
+
const sealed = seal('slotlock-dashboard-sso-v1', {
|
|
6154
|
+
state,
|
|
6155
|
+
verifier,
|
|
6156
|
+
nonce,
|
|
6157
|
+
conn: connection.id,
|
|
6158
|
+
purpose,
|
|
6159
|
+
exp: now() + SIGN_IN_TTL_MS,
|
|
6160
|
+
...(person !== undefined ? { person } : {}),
|
|
6161
|
+
...(version !== undefined ? { version } : {}),
|
|
6162
|
+
});
|
|
6163
|
+
return redirect(302, target.href, [setCookie(SSO_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
|
|
6164
|
+
}
|
|
6165
|
+
async function testSsoConnection(session) {
|
|
6166
|
+
const tenantRef = teamTenantOf(session);
|
|
6167
|
+
if (!ssoStore || tenantRef === undefined)
|
|
6168
|
+
return nothingHere(session);
|
|
6169
|
+
const connection = await ssoStore.connection(tenantRef);
|
|
6170
|
+
if (!connection)
|
|
6171
|
+
return ssoRefused(session, 'not_found');
|
|
6172
|
+
return startSsoSignIn({ ...connection, tenantRef }, 'test', personOf(session), connection.configVersion);
|
|
6173
|
+
}
|
|
6174
|
+
/** A signed-in member's sign-in through their team's provider: to link it, or prove themselves to a team that requires it. */
|
|
6175
|
+
async function startSsoProof(session, form) {
|
|
6176
|
+
if (!ssoStore)
|
|
6177
|
+
return nothingHere(session);
|
|
6178
|
+
const team = form.get('team') ?? '';
|
|
6179
|
+
const connection = TEAM_ID.test(team.replace(/^team:/, '')) ? await ssoStore.connection(team) : null;
|
|
6180
|
+
if (!connection || connection.status !== 'active')
|
|
6181
|
+
return teamsPage(session, 409, SSO_REFUSALS.connection_inactive.text);
|
|
6182
|
+
return startSsoSignIn({ ...connection, tenantRef: team }, 'proof', personOf(session));
|
|
6183
|
+
}
|
|
6184
|
+
const SCIM_REFUSALS = Object.freeze({
|
|
6185
|
+
not_found: { status: 404, text: 'That is no longer here.' },
|
|
6186
|
+
not_allowed: { status: 403, text: 'Only an owner can change the team’s provisioning.' },
|
|
6187
|
+
not_enabled: { status: 409, text: 'Turn on SCIM first.' },
|
|
6188
|
+
no_verified_domain: { status: 409, text: 'Verify a domain first: the directory adds people only there.' },
|
|
6189
|
+
key_policy: { status: 400, text: 'Choose what happens to a deactivated member’s API keys.' },
|
|
6190
|
+
token_limit: { status: 403, text: 'A team keeps two tokens. Revoke one first.' },
|
|
6191
|
+
bad_token: { status: 401, text: 'That token is not this team’s.' },
|
|
6192
|
+
wrong_workspace: { status: 401, text: 'That token is not this team’s.' },
|
|
6193
|
+
domain_not_verified: { status: 400, text: 'The directory adds people at the verified domains only.' },
|
|
6194
|
+
uniqueness: { status: 409, text: 'That name is taken.' },
|
|
6195
|
+
owner_protected: { status: 409, text: 'The directory never changes an owner.' },
|
|
6196
|
+
invalid_member: { status: 400, text: 'That member is not in the directory.' },
|
|
6197
|
+
owner_not_mappable: { status: 400, text: 'No group makes an owner: owners are made in Slotlock.' },
|
|
6198
|
+
role_not_allowed: { status: 400, text: 'Choose a role.' },
|
|
6199
|
+
});
|
|
6200
|
+
const scimRefused = (session, refusal) => identityPage(session, SCIM_REFUSALS[refusal].status, SCIM_REFUSALS[refusal].text);
|
|
6201
|
+
/** A token just made: shown on the page that answers, once. */
|
|
6202
|
+
const scimTokenShown = new WeakMap();
|
|
6203
|
+
const scimBaseUrl = (tenantRef) => `${publicRoot}/scim/v2/${tenantRef.slice('team:'.length)}`;
|
|
6204
|
+
/** A team's SCIM provisioning on its Identity page: off with the Owner's choice of key policy, or its address, tokens and groups. */
|
|
6205
|
+
async function scimSection(session, tenantRef) {
|
|
6206
|
+
if (!scimStore)
|
|
6207
|
+
return html ``;
|
|
6208
|
+
const state = await scimStore.state(tenantRef);
|
|
6209
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
6210
|
+
if (!state.enabled) {
|
|
6211
|
+
return html `<h2>Provisioning</h2><form class="sso-form" method="post" action="${root}/identity/scim/enable">${hidden()}<fieldset><legend>When the directory deactivates someone, their API keys</legend><label><input type="radio" name="keys" value="revoke" required> Are revoked</label><label><input type="radio" name="keys" value="keep" required> Keep working</label></fieldset><button class="primary" type="submit">Turn on SCIM</button></form>`;
|
|
6212
|
+
}
|
|
6213
|
+
const shown = scimTokenShown.get(session);
|
|
6214
|
+
const tokens = state.tokens.map((token) => html `<tr><td data-label="Token" class="strong"><code>${token.prefix}…</code></td><td data-label="Made">${formatDate(token.createdAt)}</td><td data-label="Last used">${token.lastUsedAt ? formatDate(token.lastUsedAt) : 'Never'}</td><td class="actions"><form class="inline" method="post" action="${root}/identity/scim/token/revoke">${hidden()}<input type="hidden" name="id" value="${token.id}"><button class="danger" type="submit">Revoke<span class="visually-hidden"> ${token.prefix}</span></button></form></td></tr>`);
|
|
6215
|
+
const card = html `<div class="set-card"><div class="row"><span class="row-label">SCIM URL</span><div class="row-value">${copyField('scim-url', scimBaseUrl(tenantRef))}</div></div>${shown ? html `<div class="row"><span class="row-label">New token</span><div class="row-value">${copyField('scim-token', shown)}</div></div>` : html ``}<div class="row"><span class="row-label">Deactivated members’ keys</span><div class="row-value">${state.keyPolicy === 'revoke' ? 'Revoked' : 'Keep working'}</div></div></div>`;
|
|
6216
|
+
const tokenTable = tokens.length > 0
|
|
6217
|
+
? html `<div class="table"><table><thead><tr><th>Token</th><th>Made</th><th>Last used</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${tokens}</tbody></table></div>`
|
|
6218
|
+
: html ``;
|
|
6219
|
+
const { groups } = await scimStore.groups(tenantRef, null, 1, 100);
|
|
6220
|
+
const roleChoices = ['admin', 'developer', 'member', 'viewer', 'billing'];
|
|
6221
|
+
const reserved = (name) => /^slotlock-role-(admin|developer|member|viewer|billing)$/i.test(name);
|
|
6222
|
+
const groupRows = groups.map((group) => html `<tr><td data-label="Group" class="strong">${group.displayName}</td><td data-label="People">${String(group.members.length)}</td><td data-label="Role">${reserved(group.displayName)
|
|
6223
|
+
? SLOTLOCK_WORKSPACE_ROLE_NAMES[group.displayName.slice('slotlock-role-'.length).toLowerCase()]
|
|
6224
|
+
: html `<form class="inline" method="post" action="${root}/identity/scim/group">${hidden()}<input type="hidden" name="id" value="${group.id}"><label class="visually-hidden" for="scim-group-${group.id}">Role for ${group.displayName}</label><select id="scim-group-${group.id}" name="role"><option value="">None</option>${roleChoices.map((role) => html `<option value="${role}"${group.role === role ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`)}</select><button type="submit">Save</button></form>`}</td></tr>`);
|
|
6225
|
+
const groupTable = groupRows.length > 0
|
|
6226
|
+
? html `<h3>Groups</h3><div class="table"><table><thead><tr><th>Group</th><th>People</th><th>Role</th></tr></thead><tbody>${groupRows}</tbody></table></div>`
|
|
6227
|
+
: html ``;
|
|
6228
|
+
const off = html `<details class="act danger"><summary>Turn off SCIM</summary><div class="pop"><p>Its tokens stop working. Members it added stay.</p><form method="post" action="${root}/identity/scim/disable">${hidden()}<button class="danger" type="submit">Turn off SCIM</button></form></div></details>`;
|
|
6229
|
+
return html `<h2>Provisioning</h2>${card}${tokenTable}<form class="inline" method="post" action="${root}/identity/scim/token">${hidden()}<button type="submit">New token</button></form>${groupTable}${off}`;
|
|
6230
|
+
}
|
|
6231
|
+
async function enableScim(session, form) {
|
|
6232
|
+
const tenantRef = teamTenantOf(session);
|
|
6233
|
+
if (!scimStore || tenantRef === undefined)
|
|
6234
|
+
return nothingHere(session);
|
|
6235
|
+
const keys = form.get('keys');
|
|
6236
|
+
if (keys !== 'keep' && keys !== 'revoke')
|
|
6237
|
+
return scimRefused(session, 'key_policy');
|
|
6238
|
+
const refusal = await scimStore.enable(tenantRef, personOf(session), keys);
|
|
6239
|
+
if (refusal)
|
|
6240
|
+
return scimRefused(session, refusal);
|
|
6241
|
+
await audit(session, { action: 'scim.enabled', targetType: 'workspace', targetRef: tenantRef, metadata: { keys } }, tenantRef);
|
|
6242
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6243
|
+
}
|
|
6244
|
+
async function disableScim(session) {
|
|
6245
|
+
const tenantRef = teamTenantOf(session);
|
|
6246
|
+
if (!scimStore || tenantRef === undefined)
|
|
6247
|
+
return nothingHere(session);
|
|
6248
|
+
const refusal = await scimStore.disable(tenantRef, personOf(session));
|
|
6249
|
+
if (refusal)
|
|
6250
|
+
return scimRefused(session, refusal);
|
|
6251
|
+
await audit(session, { action: 'scim.disabled', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
|
|
6252
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6253
|
+
}
|
|
6254
|
+
async function createScimToken(session) {
|
|
6255
|
+
const tenantRef = teamTenantOf(session);
|
|
6256
|
+
if (!scimStore || tenantRef === undefined)
|
|
6257
|
+
return nothingHere(session);
|
|
6258
|
+
const made = await scimStore.createToken(tenantRef, personOf(session));
|
|
6259
|
+
if ('refusal' in made)
|
|
6260
|
+
return scimRefused(session, made.refusal);
|
|
6261
|
+
await audit(session, { action: 'scim.token_created', targetType: 'scim_token', targetRef: made.prefix }, tenantRef);
|
|
6262
|
+
scimTokenShown.set(session, made.token);
|
|
6263
|
+
return identityPage(session, 201, 'Copy the token into your identity provider now: it is not shown again.');
|
|
6264
|
+
}
|
|
6265
|
+
async function revokeScimToken(session, form) {
|
|
6266
|
+
const tenantRef = teamTenantOf(session);
|
|
6267
|
+
if (!scimStore || tenantRef === undefined)
|
|
6268
|
+
return nothingHere(session);
|
|
6269
|
+
const id = form.get('id') ?? '';
|
|
6270
|
+
const refusal = await scimStore.revokeToken(tenantRef, personOf(session), id);
|
|
6271
|
+
if (refusal)
|
|
6272
|
+
return scimRefused(session, refusal);
|
|
6273
|
+
await audit(session, { action: 'scim.token_revoked', targetType: 'scim_token', targetRef: id }, tenantRef);
|
|
6274
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6275
|
+
}
|
|
6276
|
+
async function mapScimGroup(session, form) {
|
|
6277
|
+
const tenantRef = teamTenantOf(session);
|
|
6278
|
+
if (!scimStore || tenantRef === undefined)
|
|
6279
|
+
return nothingHere(session);
|
|
6280
|
+
const id = form.get('id') ?? '';
|
|
6281
|
+
const role = form.get('role') || null;
|
|
6282
|
+
const refusal = await scimStore.mapGroup(tenantRef, personOf(session), id, role);
|
|
6283
|
+
if (refusal)
|
|
6284
|
+
return scimRefused(session, refusal);
|
|
6285
|
+
await audit(session, { action: 'scim.group_mapped', targetType: 'scim_group', targetRef: id, metadata: { role } }, tenantRef);
|
|
6286
|
+
return redirect(303, `${root}/settings/identity`);
|
|
6287
|
+
}
|
|
6288
|
+
async function useSsoBypass(session, form) {
|
|
6289
|
+
if (!ssoStore)
|
|
6290
|
+
return nothingHere(session);
|
|
6291
|
+
const team = form.get('team') ?? '';
|
|
6292
|
+
if (!TEAM_ID.test(team.replace(/^team:/, '')))
|
|
6293
|
+
return nothingHere(session);
|
|
6294
|
+
// As fresh as any Owner's power: a sign-in in the last 15 minutes.
|
|
6295
|
+
if (!freshlySignedIn(session))
|
|
6296
|
+
return confirmPage(session, 'workspace.own', '/teams');
|
|
6297
|
+
const refusal = await ssoStore.bypass(team, personOf(session), session.sid);
|
|
6298
|
+
if (refusal)
|
|
6299
|
+
return teamsPage(session, 403, 'Only an owner who signs in with GitHub or Google can bypass single sign-on.');
|
|
6300
|
+
await audit(session, { action: 'sso.bypass_used', actorRole: 'owner', targetType: 'workspace', targetRef: team }, team);
|
|
6301
|
+
return moveTo(session, team.slice('team:'.length), '');
|
|
6302
|
+
}
|
|
6303
|
+
/** The claims a test sign-in shows the Owner: what decides who someone is and what they get, no token. */
|
|
6304
|
+
function ssoTestClaims(claims, groupsClaim) {
|
|
6305
|
+
const pick = (name) => (claims[name] === undefined ? {} : { [name]: claims[name] });
|
|
6306
|
+
const groups = claims[groupsClaim];
|
|
6307
|
+
return {
|
|
6308
|
+
...pick('iss'),
|
|
6309
|
+
...pick('sub'),
|
|
6310
|
+
...pick('tid'),
|
|
6311
|
+
...pick('oid'),
|
|
6312
|
+
...pick('email'),
|
|
6313
|
+
...pick('email_verified'),
|
|
6314
|
+
...pick('xms_edov'),
|
|
6315
|
+
...pick('hd'),
|
|
6316
|
+
...pick('acct'),
|
|
6317
|
+
...(Array.isArray(groups) ? { groups: groups.slice(0, 20) } : isRecord(claims._claim_names) ? { groups: 'overage' } : {}),
|
|
6318
|
+
};
|
|
6319
|
+
}
|
|
6320
|
+
/** The callback of a team's provider: the code exchanged, the ID token checked, then the purpose served. */
|
|
6321
|
+
async function finishSsoSignIn(request, url, connectionId) {
|
|
6322
|
+
const clear = setCookie(SSO_COOKIE, '', 0);
|
|
6323
|
+
const pending = unseal('slotlock-dashboard-sso-v1', readCookies(request).get(SSO_COOKIE));
|
|
6324
|
+
const code = url.searchParams.get('code');
|
|
6325
|
+
const state = url.searchParams.get('state');
|
|
6326
|
+
if (!ssoStore ||
|
|
6327
|
+
!options.sso ||
|
|
6328
|
+
url.searchParams.has('error') ||
|
|
6329
|
+
!pending ||
|
|
6330
|
+
pending.conn !== connectionId ||
|
|
6331
|
+
typeof pending.state !== 'string' ||
|
|
6332
|
+
typeof pending.verifier !== 'string' ||
|
|
6333
|
+
typeof pending.nonce !== 'string' ||
|
|
6334
|
+
code === null ||
|
|
6335
|
+
code.length === 0 ||
|
|
6336
|
+
code.length > 2048 ||
|
|
6337
|
+
state === null ||
|
|
6338
|
+
!safeEqual(state, pending.state)) {
|
|
6339
|
+
return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
|
|
6340
|
+
}
|
|
6341
|
+
const connection = await ssoStore.connectionById(connectionId);
|
|
6342
|
+
if (!connection || (pending.purpose !== 'test' && connection.status !== 'active')) {
|
|
6343
|
+
return signedOutPage(409, SSO_REFUSALS.connection_inactive.text, [clear]);
|
|
6344
|
+
}
|
|
6345
|
+
// RFC 9207: the issuer that answered, when it says, must be this connection's (mix-up defence).
|
|
6346
|
+
const answeredBy = url.searchParams.get('iss');
|
|
6347
|
+
if ((answeredBy !== null || connection.issParameter) && answeredBy !== connection.issuer) {
|
|
6348
|
+
return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
|
|
6349
|
+
}
|
|
6350
|
+
if (!(await options.state.use('sign_in', `sso.${pending.state}`, pending.exp)))
|
|
6351
|
+
return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
|
|
6352
|
+
const secret = connection.secretSealed ? await options.sso.secretBox.open(connection.secretSealed, ssoSecretContext(connection.tenantRef, connection.id)) : null;
|
|
6353
|
+
if (secret === null) {
|
|
6354
|
+
report('Single sign-on refused: the client secret does not open');
|
|
6355
|
+
return signedOutPage(502, `${connection.name}’s single sign-on did not complete. Try again.`, [clear]);
|
|
6356
|
+
}
|
|
6357
|
+
let checked = null;
|
|
6358
|
+
try {
|
|
6359
|
+
// Okta and other providers take the client's secret by HTTP Basic (RFC 6749 2.3.1); Entra and
|
|
6360
|
+
// Google, in the body.
|
|
6361
|
+
const basic = connection.kind === 'okta' || connection.kind === 'oidc';
|
|
6362
|
+
const body = new URLSearchParams({
|
|
6363
|
+
grant_type: 'authorization_code',
|
|
6364
|
+
code,
|
|
6365
|
+
redirect_uri: ssoRedirectUri(connection.id),
|
|
6366
|
+
code_verifier: pending.verifier,
|
|
6367
|
+
...(basic ? {} : { client_id: connection.clientId, client_secret: secret }),
|
|
6368
|
+
});
|
|
6369
|
+
if (!slotlockOidcUrlAllowed(connection.tokenEndpoint))
|
|
6370
|
+
throw new Error('token endpoint');
|
|
6371
|
+
const response = await ssoFetch(connection.tokenEndpoint, {
|
|
6372
|
+
method: 'POST',
|
|
6373
|
+
headers: {
|
|
6374
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
6375
|
+
Accept: 'application/json',
|
|
6376
|
+
...(basic
|
|
6377
|
+
? { Authorization: `Basic ${btoa(`${encodeURIComponent(connection.clientId)}:${encodeURIComponent(secret)}`)}` }
|
|
6378
|
+
: {}),
|
|
6379
|
+
},
|
|
6380
|
+
body: body.toString(),
|
|
6381
|
+
redirect: 'manual',
|
|
6382
|
+
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
|
|
6383
|
+
});
|
|
6384
|
+
// Read to 64 KB at most: the endpoint is the Owner's to choose, and a token answer is small.
|
|
6385
|
+
const answer = response.ok ? await slotlockReadJsonBody(response, SSO_TOKEN_ANSWER_BYTES).catch(() => null) : null;
|
|
6386
|
+
if (!response.ok)
|
|
6387
|
+
await response.body?.cancel().catch(() => undefined);
|
|
6388
|
+
const token = answer !== null && typeof answer.id_token === 'string' ? answer.id_token : null;
|
|
6389
|
+
if (token !== null) {
|
|
6390
|
+
checked = await verifySlotlockIdToken(token, {
|
|
6391
|
+
issuers: connection.kind === 'google_workspace' ? SLOTLOCK_GOOGLE_ISSUERS : [connection.issuer],
|
|
6392
|
+
clientId: connection.clientId,
|
|
6393
|
+
algorithms: connection.algorithms,
|
|
6394
|
+
keys: ssoJwks.lookup(connection.jwksUri),
|
|
6395
|
+
nonce: pending.nonce,
|
|
6396
|
+
now: now(),
|
|
6397
|
+
});
|
|
6398
|
+
}
|
|
6399
|
+
else {
|
|
6400
|
+
report(`Single sign-on token exchange failed: HTTP ${response.status}`);
|
|
6401
|
+
}
|
|
6402
|
+
}
|
|
6403
|
+
catch (error) {
|
|
6404
|
+
report(`Single sign-on failed: ${error instanceof Error ? error.name : 'unknown error'}`);
|
|
6405
|
+
}
|
|
6406
|
+
if (!checked || 'refusal' in checked) {
|
|
6407
|
+
if (checked)
|
|
6408
|
+
report(`Single sign-on refused: the ID token did not check out (${checked.refusal})`);
|
|
6409
|
+
return signedOutPage(502, `${connection.name}’s single sign-on did not complete. Try again.`, [clear]);
|
|
6410
|
+
}
|
|
6411
|
+
const claims = checked.claims;
|
|
6412
|
+
const subject = claims.sub;
|
|
6413
|
+
// Entra: the token's tenant is the one pinned, the object id is the stable key, guests stay out,
|
|
6414
|
+
// and an email counts only when Entra says the domain's owner verified it (xms_edov).
|
|
6415
|
+
if (connection.kind === 'entra' && (claims.tid !== connection.entraTenant || typeof claims.oid !== 'string')) {
|
|
6416
|
+
return signedOutPage(403, `${connection.name}’s single sign-on did not complete. Try again.`, [clear]);
|
|
6417
|
+
}
|
|
6418
|
+
// The team's verified domains: an email is trusted only there, and a Google account only of one.
|
|
6419
|
+
const verifiedDomains = domainStore
|
|
6420
|
+
? (await domainStore.list(connection.tenantRef)).filter((claim) => claim.status === 'verified').map((claim) => claim.ascii)
|
|
6421
|
+
: [];
|
|
6422
|
+
const hostedDomain = typeof claims.hd === 'string' ? slotlockEmailDomain(`x@${claims.hd}`) : null;
|
|
6423
|
+
if (connection.kind === 'google_workspace' && (hostedDomain === null || !verifiedDomains.includes(hostedDomain))) {
|
|
6424
|
+
// Any Google account can pass an "External" OAuth client: only the team's Workspace counts.
|
|
6425
|
+
const refused = SSO_REFUSALS.hd_mismatch;
|
|
6426
|
+
if (pending.purpose === 'signin')
|
|
6427
|
+
return signedOutPage(refused.status, refused.text, [clear]);
|
|
6428
|
+
const session = await readSession(request);
|
|
6429
|
+
if (!session)
|
|
6430
|
+
return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
|
|
6431
|
+
return pending.purpose === 'test' ? identityPage(session, refused.status, refused.text) : teamsPage(session, refused.status, refused.text);
|
|
6432
|
+
}
|
|
6433
|
+
const email = typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email) ? claims.email.toLowerCase() : null;
|
|
6434
|
+
const emailDomain = email === null ? null : slotlockEmailDomain(email);
|
|
6435
|
+
const emailOk = email !== null &&
|
|
6436
|
+
(connection.kind === 'entra'
|
|
6437
|
+
? claims.xms_edov === true || claims.xms_edov === 'true' || claims.xms_edov === 1
|
|
6438
|
+
: connection.kind === 'google_workspace'
|
|
6439
|
+
? (claims.email_verified === true || claims.email_verified === 'true') && emailDomain !== null && emailDomain === hostedDomain
|
|
6440
|
+
: // Okta and other providers: the team's directory vouches for its own domain's addresses,
|
|
6441
|
+
// unless the token itself says the address is unverified.
|
|
6442
|
+
claims.email_verified !== false && claims.email_verified !== 'false');
|
|
6443
|
+
const groupsValue = claims[connection.groupsClaim];
|
|
6444
|
+
const groups = Array.isArray(groupsValue) ? groupsValue.filter((group) => typeof group === 'string').slice(0, 200) : [];
|
|
6445
|
+
const overage = isRecord(claims._claim_names) && 'groups' in claims._claim_names;
|
|
6446
|
+
const guest = connection.kind === 'entra' && (claims.acct === 1 || claims.acct === '1');
|
|
6447
|
+
const entra = connection.kind === 'entra' ? { tid: claims.tid, oid: claims.oid } : null;
|
|
6448
|
+
const session = pending.purpose === 'signin' ? null : await readSession(request);
|
|
6449
|
+
if (pending.purpose === 'test') {
|
|
6450
|
+
if (!session || personOf(session) !== pending.person || typeof pending.version !== 'number')
|
|
6451
|
+
return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
|
|
6452
|
+
const shown = { ...ssoTestClaims(claims, connection.groupsClaim), trusted_email: emailOk };
|
|
6453
|
+
const refusal = await ssoStore.recordTest(connection.tenantRef, personOf(session), connection.id, pending.version, shown);
|
|
6454
|
+
if (refusal)
|
|
6455
|
+
return identityPage(session, SSO_REFUSALS[refusal].status, SSO_REFUSALS[refusal].text);
|
|
6456
|
+
await audit(session, { action: 'sso.connection_tested', targetType: 'sso_connection', targetRef: connection.id }, connection.tenantRef);
|
|
6457
|
+
return redirect(303, `${root}/settings/identity`, [clear]);
|
|
6458
|
+
}
|
|
6459
|
+
if (pending.purpose === 'proof') {
|
|
6460
|
+
// A session through one team's provider alone proves nothing to another's, nor links to it.
|
|
6461
|
+
if (!session || personOf(session) !== pending.person || session.sc !== undefined)
|
|
6462
|
+
return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
|
|
6463
|
+
const result = await ssoStore.signIn({
|
|
6464
|
+
connectionId: connection.id,
|
|
6465
|
+
issuer: connection.issuer,
|
|
6466
|
+
subject,
|
|
6467
|
+
entraTid: entra?.tid ?? null,
|
|
6468
|
+
entraOid: entra?.oid ?? null,
|
|
6469
|
+
email,
|
|
6470
|
+
emailOk,
|
|
6471
|
+
groups,
|
|
6472
|
+
overage,
|
|
6473
|
+
guest,
|
|
6474
|
+
sessionPerson: personOf(session),
|
|
6475
|
+
memberCap: null,
|
|
6476
|
+
});
|
|
6477
|
+
if (result.refusal)
|
|
6478
|
+
return teamsPage(session, SSO_REFUSALS[result.refusal].status, SSO_REFUSALS[result.refusal].text);
|
|
6479
|
+
// Recorded only while the connection is on as it was: turned off meanwhile, no proof.
|
|
6480
|
+
if (!(await ssoStore.recordProof(session.sid, personOf(session), connection.id, false, connection.configVersion))) {
|
|
6481
|
+
return teamsPage(session, SSO_REFUSALS.connection_inactive.status, SSO_REFUSALS.connection_inactive.text);
|
|
6482
|
+
}
|
|
6483
|
+
// The team's own provider vouched for the address: the directory's user there is this person.
|
|
6484
|
+
if (scimStore && emailOk && email !== null && emailDomain !== null && verifiedDomains.includes(emailDomain)) {
|
|
6485
|
+
await scimStore.bind(personOf(session), [email], plans ? FREE_TEAM_MEMBERS : null);
|
|
6486
|
+
}
|
|
6487
|
+
if (result.outcome === 'linked') {
|
|
6488
|
+
await audit(session, { action: 'sso.linked', targetType: 'sso_connection', targetRef: connection.id }, connection.tenantRef);
|
|
6489
|
+
}
|
|
6490
|
+
return moveTo(session, connection.tenantRef.slice('team:'.length), '', [clear]);
|
|
6491
|
+
}
|
|
6492
|
+
// Through the provider alone.
|
|
6493
|
+
const result = await ssoStore.signIn({
|
|
6494
|
+
connectionId: connection.id,
|
|
6495
|
+
issuer: connection.issuer,
|
|
6496
|
+
subject,
|
|
6497
|
+
entraTid: entra?.tid ?? null,
|
|
6498
|
+
entraOid: entra?.oid ?? null,
|
|
6499
|
+
email,
|
|
6500
|
+
emailOk,
|
|
6501
|
+
groups,
|
|
6502
|
+
overage,
|
|
6503
|
+
guest,
|
|
6504
|
+
sessionPerson: null,
|
|
6505
|
+
memberCap: await memberCapOf(connection.tenantRef),
|
|
6506
|
+
});
|
|
6507
|
+
if (result.refusal || result.person === null) {
|
|
6508
|
+
const refusal = result.refusal ?? 'not_member';
|
|
6509
|
+
return signedOutPage(SSO_REFUSALS[refusal].status, SSO_REFUSALS[refusal].text, [clear]);
|
|
6510
|
+
}
|
|
6511
|
+
const person = result.person;
|
|
6512
|
+
const [idp, uid] = person.startsWith('github:')
|
|
6513
|
+
? [undefined, person.slice('github:'.length)]
|
|
6514
|
+
: person.startsWith('google:')
|
|
6515
|
+
? ['google', person.slice('google:'.length)]
|
|
6516
|
+
: ['sso', person.slice('sso:'.length)];
|
|
6517
|
+
if (!allowed(uid, idp))
|
|
6518
|
+
return signedOutPage(403, 'This Slotlock server does not let that account sign in.', [clear]);
|
|
6519
|
+
const epoch = workspaces ? await workspaces.signedIn(person) : undefined;
|
|
6520
|
+
// Named by the provider's email only where the team trusts it: at a domain it verified.
|
|
6521
|
+
const trusted = emailOk && emailDomain !== null && verifiedDomains.includes(emailDomain);
|
|
6522
|
+
if (scimStore && trusted && email !== null)
|
|
6523
|
+
await scimStore.bind(person, [email], plans ? FREE_TEAM_MEMBERS : null);
|
|
6524
|
+
const login = trusted && email !== null ? email : 'SSO account';
|
|
6525
|
+
if (teamStore && idp === 'sso')
|
|
6526
|
+
await teamStore.setDisplay(person, login === 'SSO account' ? null : login);
|
|
6527
|
+
const sid = randomBytes(24).toString('base64url');
|
|
6528
|
+
const expires = now() + SESSION_TTL_MS;
|
|
6529
|
+
await workspaces?.sessions?.started(person, sid, new Date(expires), requestOrigin(request));
|
|
6530
|
+
// Scoped to this team for the session's life: the provider reaches nothing else of the person's.
|
|
6531
|
+
if (!(await ssoStore.recordProof(sid, person, connection.id, true, connection.configVersion))) {
|
|
6532
|
+
await workspaces?.sessions?.end(person, workspaces.sessions.idOf(sid));
|
|
6533
|
+
return signedOutPage(409, SSO_REFUSALS.connection_inactive.text, [clear]);
|
|
6534
|
+
}
|
|
6535
|
+
const sealedSession = seal('slotlock-dashboard-session-v1', {
|
|
6536
|
+
uid,
|
|
6537
|
+
login,
|
|
6538
|
+
sid,
|
|
6539
|
+
exp: expires,
|
|
6540
|
+
...(idp !== undefined ? { idp } : {}),
|
|
6541
|
+
...(epoch !== undefined ? { ep: epoch } : {}),
|
|
6542
|
+
team: connection.tenantRef.slice('team:'.length),
|
|
6543
|
+
sc: connection.id,
|
|
6544
|
+
});
|
|
6545
|
+
if (options.resources.audit) {
|
|
6546
|
+
await options.resources.audit
|
|
6547
|
+
.record(connection.tenantRef, {
|
|
6548
|
+
action: result.outcome === 'joined' ? 'member.joined' : 'sso.signed_in',
|
|
6549
|
+
actorType: 'person',
|
|
6550
|
+
actorRef: person,
|
|
6551
|
+
actorRole: result.role,
|
|
6552
|
+
...requestOrigin(request),
|
|
6553
|
+
...(overage ? { metadata: { groups: 'overage' } } : {}),
|
|
6554
|
+
})
|
|
6555
|
+
.catch((error) => ignoreRejection(options.onError?.(error)));
|
|
6556
|
+
}
|
|
6557
|
+
return redirect(303, root, [clear, setCookie(SESSION_COOKIE, sealedSession, SESSION_TTL_MS / 1000)]);
|
|
6558
|
+
}
|
|
6559
|
+
/**
|
|
6560
|
+
* Where single sign-on starts without a session: the team's handle, or an address at its verified
|
|
6561
|
+
* domain. A link only names the team; the person continues to its provider by a button here (a
|
|
6562
|
+
* same-origin POST), so another site can neither sign someone in to a team they did not choose
|
|
6563
|
+
* nor send them on to a provider's address through this one.
|
|
6564
|
+
*/
|
|
6565
|
+
async function ssoStartPage(url, handle) {
|
|
6566
|
+
if (!ssoStore)
|
|
6567
|
+
return nothingHere404();
|
|
6568
|
+
const email = (url.searchParams.get('email') ?? '').trim();
|
|
6569
|
+
const team = (handle ?? url.searchParams.get('team') ?? '').trim();
|
|
6570
|
+
if (team !== '' || email !== '') {
|
|
6571
|
+
const domain = email === '' ? null : slotlockEmailDomain(email);
|
|
6572
|
+
const route = await ssoStore.route(team !== '' ? { handle: team } : domain !== null ? { domain } : {});
|
|
6573
|
+
if (route) {
|
|
6574
|
+
const provider = new URL(route.authorizationEndpoint).host;
|
|
6575
|
+
const content = html `<section class="sign-in"><span class="sign-in-mark">${ICONS.mark}</span><h1>Sign in to ${route.name}</h1><form method="post" action="${root}/sso/start"><input type="hidden" name="team" value="${route.handle}"><button class="primary wide" type="submit">Continue to ${provider}</button></form><p class="fine"><a href="${root}">Other ways to sign in</a></p></section>`;
|
|
6576
|
+
return respond(200, layout('Single sign-on', content, null));
|
|
6577
|
+
}
|
|
6578
|
+
}
|
|
6579
|
+
const asked = team !== '' || email !== '';
|
|
6580
|
+
const content = html `<section class="sign-in"><span class="sign-in-mark">${ICONS.mark}</span><h1>Single sign-on</h1>${asked ? html `<p class="notice" role="alert">No team signs in that way. Check the address, or ask your team’s owner.</p>` : html ``}<form method="get" action="${root}/sso"><label for="sso-email">Work email</label><input id="sso-email" name="email" type="email" maxlength="254" autocomplete="email" required><button class="primary wide" type="submit">Continue</button></form><p class="fine"><a href="${root}">Other ways to sign in</a></p></section>`;
|
|
6581
|
+
return respond(asked ? 404 : 200, layout('Single sign-on', content, null));
|
|
6582
|
+
}
|
|
6583
|
+
/** Send an invitation's link where a sender is set: whether it went. The link is shown either way. */
|
|
6584
|
+
async function deliverInvitation(session, to, role, link, expiresAt) {
|
|
6585
|
+
if (!options.invitations)
|
|
6586
|
+
return false;
|
|
6587
|
+
try {
|
|
6588
|
+
await options.invitations.send({
|
|
6589
|
+
to,
|
|
6590
|
+
workspace: currentTeam(session)?.name ?? 'a team',
|
|
6591
|
+
inviter: shownAs(session),
|
|
6592
|
+
role: SLOTLOCK_WORKSPACE_ROLE_NAMES[role],
|
|
6593
|
+
link,
|
|
6594
|
+
expiresAt,
|
|
6595
|
+
});
|
|
6596
|
+
return true;
|
|
6597
|
+
}
|
|
6598
|
+
catch (error) {
|
|
6599
|
+
// The sender's own message names the provider and its status, never the address.
|
|
6600
|
+
const known = error instanceof Error && error.name === 'SlotlockInvitationEmailError';
|
|
6601
|
+
report(`Invitation email failed: ${known ? error.message : error instanceof Error ? error.name : 'unknown error'}`);
|
|
6602
|
+
return false;
|
|
6603
|
+
}
|
|
6604
|
+
}
|
|
6605
|
+
async function inviteMember(session, form) {
|
|
6606
|
+
const tenantRef = teamTenantOf(session);
|
|
6607
|
+
if (!teamStore || tenantRef === undefined)
|
|
6608
|
+
return nothingHere(session);
|
|
6609
|
+
const email = (form.get('email') ?? '').trim().toLowerCase();
|
|
6610
|
+
const role = form.get('role') ?? '';
|
|
6611
|
+
if (email.length > 254 || !EMAIL_ADDRESS.test(email)) {
|
|
6612
|
+
return settingsPage(session, 400, 'Enter an email address.', undefined, 'members');
|
|
6613
|
+
}
|
|
6614
|
+
if (!isSlotlockWorkspaceRole(role))
|
|
6615
|
+
return settingsPage(session, 400, 'Choose a role.', undefined, 'members');
|
|
6616
|
+
const raw = randomBytes(32).toString('base64url');
|
|
6617
|
+
const sent = await teamStore.invite({
|
|
6618
|
+
tenantRef,
|
|
6619
|
+
inviter: personOf(session),
|
|
6620
|
+
to: { email },
|
|
6621
|
+
role,
|
|
6622
|
+
tokenSha256: tokenDigest(raw),
|
|
6623
|
+
days: INVITATION_DAYS,
|
|
6624
|
+
dailyLimit: INVITATION_DAILY_LIMIT,
|
|
6625
|
+
memberCap: await memberCapOf(tenantRef),
|
|
6626
|
+
});
|
|
6627
|
+
if ('refusal' in sent)
|
|
6628
|
+
return refusalPage(session, sent.refusal);
|
|
6629
|
+
await audit(session, { action: 'member.invited', targetType: 'invitation', targetRef: sent.id, metadata: { role } }, tenantRef);
|
|
6630
|
+
const link = `${publicRoot}/invite?t=${raw}`;
|
|
6631
|
+
const expiresAt = new Date(now() + INVITATION_DAYS * DAY_MS);
|
|
6632
|
+
const delivered = await deliverInvitation(session, email, role, link, expiresAt);
|
|
6633
|
+
return settingsPage(session, 200, delivered ? `Invitation sent to ${email}.` : `Invitation made for ${email}. Send them the link.`, undefined, 'members', { link, to: email, expiresAt });
|
|
6634
|
+
}
|
|
6635
|
+
async function resendInvitation(session, form) {
|
|
6636
|
+
const tenantRef = teamTenantOf(session);
|
|
6637
|
+
if (!teamStore || tenantRef === undefined)
|
|
6638
|
+
return nothingHere(session);
|
|
6639
|
+
const id = (form.get('id') ?? '').toLowerCase();
|
|
6640
|
+
const invitation = (await teamStore.invitations(tenantRef)).find((candidate) => candidate.id === id);
|
|
6641
|
+
if (!invitation)
|
|
6642
|
+
return refusalPage(session, 'not_found');
|
|
6643
|
+
const raw = randomBytes(32).toString('base64url');
|
|
6644
|
+
const refusal = await teamStore.resend(tenantRef, personOf(session), id, tokenDigest(raw), INVITATION_DAYS, INVITATION_DAILY_LIMIT, await memberCapOf(tenantRef));
|
|
6645
|
+
if (refusal)
|
|
6646
|
+
return refusalPage(session, refusal);
|
|
6647
|
+
await audit(session, { action: 'invitation.resent', targetType: 'invitation', targetRef: id }, tenantRef);
|
|
6648
|
+
const to = invitation.email ?? `@${invitation.githubLogin ?? 'GitHub account'}`;
|
|
6649
|
+
const link = `${publicRoot}/invite?t=${raw}`;
|
|
6650
|
+
const expiresAt = new Date(now() + INVITATION_DAYS * DAY_MS);
|
|
6651
|
+
const delivered = invitation.email !== null && (await deliverInvitation(session, invitation.email, invitation.role, link, expiresAt));
|
|
6652
|
+
return settingsPage(session, 200, delivered ? `Invitation sent to ${to} again. The old link no longer works.` : `A new link for ${to}. The old one no longer works.`, undefined, 'members', { link, to, expiresAt });
|
|
6653
|
+
}
|
|
6654
|
+
async function revokeInvitation(session, form) {
|
|
6655
|
+
const tenantRef = teamTenantOf(session);
|
|
6656
|
+
if (!teamStore || tenantRef === undefined)
|
|
6657
|
+
return nothingHere(session);
|
|
6658
|
+
const id = (form.get('id') ?? '').toLowerCase();
|
|
6659
|
+
if (!(await teamStore.revoke(tenantRef, personOf(session), id)))
|
|
6660
|
+
return refusalPage(session, 'not_found');
|
|
6661
|
+
await audit(session, { action: 'invitation.revoked', targetType: 'invitation', targetRef: id }, tenantRef);
|
|
6662
|
+
return settingsPage(session, 200, 'Invitation revoked: its link no longer works.', undefined, 'members');
|
|
6663
|
+
}
|
|
6664
|
+
async function changeMemberRole(session, form) {
|
|
6665
|
+
const tenantRef = teamTenantOf(session);
|
|
6666
|
+
if (!teamStore || tenantRef === undefined)
|
|
6667
|
+
return nothingHere(session);
|
|
6668
|
+
const person = form.get('person') ?? '';
|
|
6669
|
+
const role = form.get('role') ?? '';
|
|
6670
|
+
if (!isSlotlockWorkspaceRole(role))
|
|
6671
|
+
return settingsPage(session, 400, 'Choose a role.', undefined, 'members');
|
|
6672
|
+
const members = await teamStore.members(tenantRef);
|
|
6673
|
+
const member = members.find((candidate) => candidate.person === person);
|
|
6674
|
+
if (!member)
|
|
6675
|
+
return refusalPage(session, 'not_found');
|
|
6676
|
+
if (member.role === role)
|
|
6677
|
+
return settingsPage(session, 200, 'Nothing changed.', undefined, 'members');
|
|
6678
|
+
const refusal = await teamStore.changeRole(tenantRef, personOf(session), person, role);
|
|
6679
|
+
if (refusal)
|
|
6680
|
+
return refusalPage(session, refusal);
|
|
6681
|
+
await audit(session, { action: 'member.role_changed', targetType: 'person', targetRef: person, metadata: { from: member.role, to: role } }, tenantRef);
|
|
6682
|
+
// One's own role changed: the page is read again under the new one.
|
|
6683
|
+
if (person === personOf(session))
|
|
6684
|
+
return redirect(303, `${root}/settings/members`);
|
|
6685
|
+
return settingsPage(session, 200, `${memberName(member)} is now ${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}.`, undefined, 'members');
|
|
6686
|
+
}
|
|
6687
|
+
/** Revoke the keys a member created in a team's Live and Test environments: how many were. */
|
|
6688
|
+
async function revokeKeysOf(tenantRef, person, actor) {
|
|
6689
|
+
let revoked = 0;
|
|
6690
|
+
for (const tenant of [tenantRef, `${tenantRef}${SLOTLOCK_TEST_TENANT_SUFFIX}`]) {
|
|
6691
|
+
for (const key of await options.keys.list({ tenantRef: tenant })) {
|
|
6692
|
+
if (key.createdBy !== person || key.revokedAt !== null)
|
|
6693
|
+
continue;
|
|
6694
|
+
if (await options.keys.revoke({ tenantRef: tenant, actor, origin: 'dashboard', id: key.id }))
|
|
6695
|
+
revoked += 1;
|
|
6696
|
+
}
|
|
6697
|
+
}
|
|
6698
|
+
return revoked;
|
|
6699
|
+
}
|
|
6700
|
+
/**
|
|
6701
|
+
* Limit a member to locations and groups (RFC 0004 D1), or give them all of the workspace again.
|
|
6702
|
+
* Keys they created reach all of it, so limiting them revokes those unless the form keeps them.
|
|
6703
|
+
*/
|
|
6704
|
+
async function limitMember(session, form) {
|
|
6705
|
+
const tenantRef = teamTenantOf(session);
|
|
6706
|
+
const grouping = options.resources.grouping;
|
|
6707
|
+
if (!teamStore || tenantRef === undefined || !grouping)
|
|
6708
|
+
return nothingHere(session);
|
|
6709
|
+
const me = personOf(session);
|
|
6710
|
+
const person = form.get('person') ?? '';
|
|
6711
|
+
const member = (await teamStore.members(tenantRef)).find((candidate) => candidate.person === person);
|
|
6712
|
+
if (!member)
|
|
6713
|
+
return refusalPage(session, 'not_found');
|
|
6714
|
+
let scope = null;
|
|
6715
|
+
if (form.get('access') !== 'all') {
|
|
6716
|
+
const chosen = { locations: [...new Set(form.getAll('locations'))].sort(), groups: [...new Set(form.getAll('groups'))].sort() };
|
|
6717
|
+
if (chosen.locations.length + chosen.groups.length === 0) {
|
|
6718
|
+
return settingsPage(session, 400, 'Choose at least one location or group, or all of the workspace.', undefined, 'members');
|
|
6719
|
+
}
|
|
6720
|
+
if (chosen.locations.length > SLOTLOCK_MAX_KEY_SCOPE_REFS || chosen.groups.length > SLOTLOCK_MAX_KEY_SCOPE_REFS) {
|
|
6721
|
+
return settingsPage(session, 400, `Choose up to ${String(SLOTLOCK_MAX_KEY_SCOPE_REFS)} locations and ${String(SLOTLOCK_MAX_KEY_SCOPE_REFS)} groups.`, undefined, 'members');
|
|
6722
|
+
}
|
|
6723
|
+
// Each one the workspace has.
|
|
6724
|
+
const [locations, groups] = await Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]);
|
|
6725
|
+
const known = (refs, kept) => refs.every((ref) => kept.some((entry) => entry.ref === ref));
|
|
6726
|
+
if (!isSlotlockResourceScope(chosen) || !known(chosen.locations, locations) || !known(chosen.groups, groups)) {
|
|
6727
|
+
return settingsPage(session, 400, 'Choose locations and groups this workspace has.', undefined, 'members');
|
|
6728
|
+
}
|
|
6729
|
+
scope = chosen;
|
|
6730
|
+
}
|
|
6731
|
+
const refusal = await teamStore.setScope(tenantRef, me, person, scope);
|
|
6732
|
+
if (refusal)
|
|
6733
|
+
return refusalPage(session, refusal);
|
|
6734
|
+
const keep = scope === null || form.get('keys') === 'keep';
|
|
6735
|
+
const revoked = keep ? 0 : await revokeKeysOf(tenantRef, person, me);
|
|
6736
|
+
await audit(session, {
|
|
6737
|
+
action: 'member.scope_changed',
|
|
6738
|
+
targetType: 'person',
|
|
6739
|
+
targetRef: person,
|
|
6740
|
+
metadata: scope
|
|
6741
|
+
? { locations: scope.locations, groups: scope.groups, keys: keep ? 'kept' : 'revoked', revoked }
|
|
6742
|
+
: { reaches: 'all' },
|
|
6743
|
+
}, tenantRef);
|
|
6744
|
+
const name = memberName(member);
|
|
6745
|
+
const team = currentTeam(session)?.name ?? 'this team';
|
|
6746
|
+
return settingsPage(session, 200, scope
|
|
6747
|
+
? `${name} now reaches only the resources at those locations and in those groups${revoked > 0 ? `, and ${String(revoked)} key${revoked === 1 ? '' : 's'} they created ${revoked === 1 ? 'was' : 'were'} revoked` : ''}.`
|
|
6748
|
+
: `${name} now reaches all of ${team}.`, undefined, 'members');
|
|
6749
|
+
}
|
|
6750
|
+
async function removeMember(session, form) {
|
|
6751
|
+
const tenantRef = teamTenantOf(session);
|
|
6752
|
+
if (!teamStore || tenantRef === undefined)
|
|
6753
|
+
return nothingHere(session);
|
|
6754
|
+
const me = personOf(session);
|
|
6755
|
+
const person = form.get('person') ?? '';
|
|
6756
|
+
if (person === me)
|
|
6757
|
+
return settingsPage(session, 400, 'To leave this team, use Leave.', undefined, 'members');
|
|
6758
|
+
const member = (await teamStore.members(tenantRef)).find((candidate) => candidate.person === person);
|
|
6759
|
+
if (!member)
|
|
6760
|
+
return refusalPage(session, 'not_found');
|
|
6761
|
+
const refusal = await teamStore.remove(tenantRef, me, person);
|
|
6762
|
+
if (refusal)
|
|
6763
|
+
return refusalPage(session, refusal);
|
|
6764
|
+
// Keys they created, Live and Test: revoked unless the form keeps them.
|
|
6765
|
+
const keep = form.get('keys') === 'keep';
|
|
6766
|
+
const revoked = keep ? 0 : await revokeKeysOf(tenantRef, person, me);
|
|
6767
|
+
await audit(session, { action: 'member.removed', targetType: 'person', targetRef: person, metadata: { keys: keep ? 'kept' : 'revoked', revoked } }, tenantRef);
|
|
6768
|
+
return settingsPage(session, 200, `${memberName(member)} was removed${revoked > 0 ? `, and ${String(revoked)} key${revoked === 1 ? '' : 's'} they created revoked` : ''}.`, undefined, 'members');
|
|
6769
|
+
}
|
|
6770
|
+
async function leaveTeam(session) {
|
|
6771
|
+
const tenantRef = teamTenantOf(session);
|
|
6772
|
+
if (!teamStore || tenantRef === undefined)
|
|
6773
|
+
return nothingHere(session);
|
|
6774
|
+
const me = personOf(session);
|
|
6775
|
+
const refusal = await teamStore.remove(tenantRef, me, me);
|
|
6776
|
+
if (refusal)
|
|
6777
|
+
return refusalPage(session, refusal);
|
|
6778
|
+
await audit(session, { action: 'member.left', targetType: 'person', targetRef: me }, tenantRef);
|
|
6779
|
+
return moveTo(session, undefined, '');
|
|
6780
|
+
}
|
|
6781
|
+
/** The invitation link being opened in this browser: its token, or null. */
|
|
6782
|
+
function inviteTokenOf(request) {
|
|
6783
|
+
const opened = unseal('slotlock-dashboard-invite-v1', readCookies(request).get(INVITE_COOKIE));
|
|
6784
|
+
return opened &&
|
|
6785
|
+
typeof opened.t === 'string' &&
|
|
6786
|
+
INVITE_TOKEN.test(opened.t) &&
|
|
6787
|
+
typeof opened.exp === 'number' &&
|
|
6788
|
+
opened.exp > now()
|
|
6789
|
+
? opened.t
|
|
6790
|
+
: null;
|
|
6791
|
+
}
|
|
6792
|
+
/** An invitation's own page: what it is for, and joining, in the states it can be in. Never consumes it. */
|
|
6793
|
+
async function invitePage(session, raw, status = 200, notice, checked = false) {
|
|
6794
|
+
const preview = raw && teamStore ? await teamStore.preview(tokenDigest(raw)) : null;
|
|
6795
|
+
const headers = { 'Referrer-Policy': 'no-referrer' };
|
|
6796
|
+
if (!preview) {
|
|
6797
|
+
const content = html `${noticeOf(notice, status)}${pageHead('Invitation')}<p>This invitation link can’t be used here. Open the link you were sent again, in this browser.</p>`;
|
|
6798
|
+
return respond(status === 200 ? 404 : status, layout('Invitation', content, session), headers);
|
|
6799
|
+
}
|
|
6800
|
+
const name = preview.workspaceName;
|
|
6801
|
+
const role = SLOTLOCK_WORKSPACE_ROLE_NAMES[preview.role];
|
|
6802
|
+
const forWhom = preview.email !== null ? maskedEmail(preview.email) : `@${preview.githubLogin ?? 'a GitHub account'}`;
|
|
6803
|
+
let body;
|
|
6804
|
+
if (preview.state !== 'pending') {
|
|
6805
|
+
body = html `<p>${preview.state === 'expired'
|
|
6806
|
+
? `This invitation expired. Ask ${preview.inviterDisplay} to send it again.`
|
|
6807
|
+
: preview.state === 'revoked'
|
|
6808
|
+
? 'This invitation was withdrawn.'
|
|
6809
|
+
: 'This invitation was already used.'}</p>`;
|
|
6810
|
+
}
|
|
6811
|
+
else if (!session) {
|
|
6812
|
+
const then = new URLSearchParams({ then: '/invite' }).toString();
|
|
6813
|
+
// An invitation by email asks GitHub for the person's verified addresses on the way in.
|
|
6814
|
+
const github = preview.email !== null ? `${then}&email=1` : then;
|
|
6815
|
+
body = html `<p>${preview.inviterDisplay} invited ${forWhom} as ${role}.</p><p class="actions"><a class="button primary" href="${root}/sign-in?${github}">${ICONS.github}Continue with GitHub</a>${google && preview.email !== null
|
|
6816
|
+
? html ` <a class="button" href="${root}/sign-in?with=google&${then}">${ICONS.google}Continue with Google</a>`
|
|
6817
|
+
: html ``}</p>`;
|
|
6818
|
+
}
|
|
6819
|
+
else if ((teamListOf.get(session) ?? []).some((ws) => ws.tenantRef === preview.tenantRef)) {
|
|
6820
|
+
body = html `<p>You’re already a member of ${name}.</p><form method="post" action="${root}/organization"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="organization" value="${preview.tenantRef}"><button class="primary" type="submit">Open ${name}</button></form>`;
|
|
6821
|
+
}
|
|
6822
|
+
else {
|
|
6823
|
+
const person = personOf(session);
|
|
6824
|
+
const bound = (preview.githubRef !== null && preview.githubRef === person) ||
|
|
6825
|
+
(preview.email !== null && verifiedEmails(session).includes(preview.email));
|
|
6826
|
+
if (bound) {
|
|
6827
|
+
body = html `<p>${preview.inviterDisplay} invited you as ${role}.</p><form method="post" action="${root}/invite/accept"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><button class="primary" type="submit">Join as ${shownAs(session)}</button></form>`;
|
|
6828
|
+
}
|
|
6829
|
+
else if (preview.email !== null && session.idp !== 'google') {
|
|
6830
|
+
// A GitHub account shows its addresses only when asked: once, for this invitation.
|
|
6831
|
+
body = html `<p>${preview.inviterDisplay} invited ${forWhom} as ${role}.${checked ? html ` None of the addresses GitHub verified for ${shownAs(session)} is it.` : html ``}</p><p class="actions"><a class="button primary" href="${root}/sign-in?then=%2Finvite&email=1">${ICONS.github}Confirm the address with GitHub</a></p>`;
|
|
6832
|
+
}
|
|
6833
|
+
else {
|
|
6834
|
+
body = html `<p>This invitation is for ${forWhom}, and you’re signed in as ${shownAs(session)}. Sign out, then open the link again and sign in as that account.</p>`;
|
|
6835
|
+
}
|
|
6836
|
+
}
|
|
6837
|
+
const content = html `${noticeOf(notice, status)}${pageHead(`Join ${name}`)}${body}`;
|
|
6838
|
+
return respond(status, layout(`Join ${name}`, content, session), headers);
|
|
6839
|
+
}
|
|
6840
|
+
async function acceptInvitation(session, form, request) {
|
|
6841
|
+
if (!teamStore)
|
|
6842
|
+
return nothingHere(session);
|
|
6843
|
+
const person = personOf(session);
|
|
6844
|
+
const emails = verifiedEmails(session);
|
|
6845
|
+
const id = (form.get('invitation') ?? '').toLowerCase();
|
|
6846
|
+
let accepted;
|
|
6847
|
+
let role;
|
|
6848
|
+
let raw = null;
|
|
6849
|
+
if (id !== '') {
|
|
6850
|
+
// One offered on Teams: an invitation open to this person, found again here.
|
|
6851
|
+
const offered = (await teamStore.pendingFor(person, emails)).find((invitation) => invitation.id === id);
|
|
6852
|
+
if (!offered)
|
|
6853
|
+
return teamsPage(session, 404, 'That invitation is no longer open to you.');
|
|
6854
|
+
role = offered.role;
|
|
6855
|
+
accepted = await teamStore.accept({ id: offered.id, person, emails, memberCap: await memberCapOf(offered.tenantRef) });
|
|
6856
|
+
}
|
|
6857
|
+
else {
|
|
6858
|
+
raw = inviteTokenOf(request);
|
|
6859
|
+
const preview = raw ? await teamStore.preview(tokenDigest(raw)) : null;
|
|
6860
|
+
if (!raw || !preview)
|
|
6861
|
+
return invitePage(session, null, 400);
|
|
6862
|
+
role = preview.role;
|
|
6863
|
+
accepted = await teamStore.accept({ tokenSha256: tokenDigest(raw), person, emails, memberCap: await memberCapOf(preview.tenantRef) });
|
|
6864
|
+
}
|
|
6865
|
+
if ('refusal' in accepted) {
|
|
6866
|
+
const { status, text } = TEAM_REFUSALS[accepted.refusal];
|
|
6867
|
+
return id !== '' ? teamsPage(session, status, text) : invitePage(session, raw, status, text);
|
|
6868
|
+
}
|
|
6869
|
+
await audit(session, { action: 'member.joined', actorRole: role, targetType: 'person', targetRef: person }, accepted.tenantRef);
|
|
6870
|
+
// Into the team joined; the link is spent, and the addresses kept for it go.
|
|
6871
|
+
return moveTo(session, accepted.tenantRef.slice('team:'.length), '', [setCookie(INVITE_COOKIE, '', 0)], false);
|
|
4363
6872
|
}
|
|
4364
6873
|
async function changeTeam(session, form, on) {
|
|
4365
6874
|
if (!teams)
|
|
@@ -4369,6 +6878,10 @@ ${rows.length === 0
|
|
|
4369
6878
|
return teamsPage(session, 400, 'That is not one of your organizations.');
|
|
4370
6879
|
if (org.role !== 'admin')
|
|
4371
6880
|
return teamsPage(session, 403, `Only an owner of ${org.login} can turn Slotlock ${on ? 'on' : 'off'} for it.`);
|
|
6881
|
+
// And an Owner of its workspace in Slotlock: a GitHub owner Slotlock gave a lesser role may not.
|
|
6882
|
+
if (workspaces && (await workspaces.standing(`github-org:${org.id}`, personOf(session), session.sid)).role !== 'owner') {
|
|
6883
|
+
return teamsPage(session, 403, `Only an owner of ${org.login}'s workspace can turn Slotlock ${on ? 'on' : 'off'} for it.`);
|
|
6884
|
+
}
|
|
4372
6885
|
// Who owns it was read as the person signed in: only a recent sign-in is trusted to say so.
|
|
4373
6886
|
if (now() - (session.exp - SESSION_TTL_MS) > FRESH_SIGN_IN_MS) {
|
|
4374
6887
|
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.`);
|
|
@@ -4384,13 +6897,28 @@ ${rows.length === 0
|
|
|
4384
6897
|
}
|
|
4385
6898
|
async function switchOrganization(session, form) {
|
|
4386
6899
|
const wanted = form.get('organization') ?? '';
|
|
6900
|
+
const teamId = /^team:(.+)$/.exec(wanted)?.[1];
|
|
6901
|
+
if (teamStore && (teamId !== undefined || (wanted === '' && !teams))) {
|
|
6902
|
+
// Their own tenant, or a team workspace they are a member of now.
|
|
6903
|
+
if (teamId !== undefined) {
|
|
6904
|
+
const standing = TEAM_ID.test(teamId) && workspaces ? await workspaces.standing(wanted, personOf(session), session.sid) : null;
|
|
6905
|
+
if (standing?.refusal === 'sso_required') {
|
|
6906
|
+
const name = teamListOf.get(session)?.find((ws) => ws.tenantRef === wanted)?.name ?? 'This team';
|
|
6907
|
+
return ssoRequiredPage(session, wanted, name, standing.mayBypass === true);
|
|
6908
|
+
}
|
|
6909
|
+
if (!standing || standing.role === null) {
|
|
6910
|
+
return respond(400, layout('Refused', html `<p class="notice">You are not a member of that team.</p>`, session));
|
|
6911
|
+
}
|
|
6912
|
+
}
|
|
6913
|
+
return moveTo(session, teamId, '');
|
|
6914
|
+
}
|
|
4387
6915
|
if (teams) {
|
|
4388
6916
|
// Their own tenant, or an organization of theirs that is turned on now.
|
|
4389
6917
|
const org = session.orgs?.find((candidate) => candidate.id === wanted);
|
|
4390
6918
|
if (wanted !== '' && (!org || !(await teams.enabled([wanted])).has(wanted))) {
|
|
4391
6919
|
return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization. An owner turns it on from Teams.</p>`, session));
|
|
4392
6920
|
}
|
|
4393
|
-
const { org: _, ...personal } = session;
|
|
6921
|
+
const { org: _, team: _team, ...personal } = session;
|
|
4394
6922
|
const sealed = seal('slotlock-dashboard-session-v1', wanted === '' ? personal : { ...personal, org: wanted });
|
|
4395
6923
|
const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
|
|
4396
6924
|
return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
|
|
@@ -4403,7 +6931,7 @@ ${rows.length === 0
|
|
|
4403
6931
|
const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
|
|
4404
6932
|
return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
|
|
4405
6933
|
}
|
|
4406
|
-
async function startSignIn() {
|
|
6934
|
+
async function startSignIn(then, emails = false) {
|
|
4407
6935
|
const state = randomBytes(32).toString('base64url');
|
|
4408
6936
|
const verifier = randomBytes(32).toString('base64url');
|
|
4409
6937
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
@@ -4415,12 +6943,17 @@ ${rows.length === 0
|
|
|
4415
6943
|
code_challenge: challenge,
|
|
4416
6944
|
code_challenge_method: 'S256',
|
|
4417
6945
|
// Reading organization membership needs read:org; a personal tenant needs no scope at all.
|
|
4418
|
-
|
|
6946
|
+
// Accepting an invitation by email asks GitHub for the person's verified addresses, then only.
|
|
6947
|
+
...(readsOrganizations || emails
|
|
6948
|
+
? { scope: [...(readsOrganizations ? ['read:org'] : []), ...(emails ? ['user:email'] : [])].join(' ') }
|
|
6949
|
+
: {}),
|
|
4419
6950
|
}).toString();
|
|
4420
6951
|
const sealed = seal('slotlock-dashboard-sign-in-v1', {
|
|
4421
6952
|
state,
|
|
4422
6953
|
verifier,
|
|
4423
6954
|
exp: now() + SIGN_IN_TTL_MS,
|
|
6955
|
+
...(then ? { then } : {}),
|
|
6956
|
+
...(emails ? { emails: true } : {}),
|
|
4424
6957
|
});
|
|
4425
6958
|
return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
|
|
4426
6959
|
}
|
|
@@ -4482,6 +7015,30 @@ ${rows.length === 0
|
|
|
4482
7015
|
}
|
|
4483
7016
|
return { uid: String(body.id), login: body.login };
|
|
4484
7017
|
}
|
|
7018
|
+
/** The addresses GitHub verified for the token's person, lower-cased; none when it does not say. */
|
|
7019
|
+
async function readGitHubEmails(token) {
|
|
7020
|
+
const response = await fetchImpl(`${userUrl}/emails`, {
|
|
7021
|
+
headers: {
|
|
7022
|
+
Authorization: `Bearer ${token}`,
|
|
7023
|
+
Accept: 'application/vnd.github+json',
|
|
7024
|
+
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
|
7025
|
+
'User-Agent': USER_AGENT,
|
|
7026
|
+
},
|
|
7027
|
+
redirect: 'manual',
|
|
7028
|
+
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
|
|
7029
|
+
});
|
|
7030
|
+
if (!response.ok) {
|
|
7031
|
+
report(`GitHub email lookup failed: HTTP ${response.status}`);
|
|
7032
|
+
return [];
|
|
7033
|
+
}
|
|
7034
|
+
const body = await response.json().catch(() => null);
|
|
7035
|
+
if (!Array.isArray(body))
|
|
7036
|
+
return [];
|
|
7037
|
+
return body
|
|
7038
|
+
.slice(0, 100)
|
|
7039
|
+
.filter((entry) => isRecord(entry) && entry.verified === true && typeof entry.email === 'string' && entry.email.length <= 254)
|
|
7040
|
+
.map((entry) => entry.email.toLowerCase());
|
|
7041
|
+
}
|
|
4485
7042
|
/**
|
|
4486
7043
|
* The served organizations the token's person is an active member of, by login, or null when
|
|
4487
7044
|
* GitHub does not answer. Only `state: active` counts; at most MAX_SESSION_ORGANIZATIONS.
|
|
@@ -4530,7 +7087,7 @@ ${rows.length === 0
|
|
|
4530
7087
|
.slice(0, MAX_SESSION_ORGANIZATIONS);
|
|
4531
7088
|
}
|
|
4532
7089
|
/** Google's sign-in (OpenID Connect, code flow with PKCE and a nonce), back to the same callback. */
|
|
4533
|
-
async function startGoogleSignIn(provider) {
|
|
7090
|
+
async function startGoogleSignIn(provider, then) {
|
|
4534
7091
|
const state = randomBytes(32).toString('base64url');
|
|
4535
7092
|
const verifier = randomBytes(32).toString('base64url');
|
|
4536
7093
|
const nonce = randomBytes(24).toString('base64url');
|
|
@@ -4551,54 +7108,12 @@ ${rows.length === 0
|
|
|
4551
7108
|
nonce,
|
|
4552
7109
|
idp: 'google',
|
|
4553
7110
|
exp: now() + SIGN_IN_TTL_MS,
|
|
7111
|
+
...(then ? { then } : {}),
|
|
4554
7112
|
});
|
|
4555
7113
|
return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
|
|
4556
7114
|
}
|
|
4557
|
-
/**
|
|
4558
|
-
|
|
4559
|
-
async function readGoogleKeys(provider, refresh) {
|
|
4560
|
-
if (googleKeys && !refresh && googleKeys.until > now())
|
|
4561
|
-
return googleKeys.keys;
|
|
4562
|
-
const response = await fetchImpl(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs', {
|
|
4563
|
-
headers: { Accept: 'application/json' },
|
|
4564
|
-
redirect: 'manual',
|
|
4565
|
-
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
|
|
4566
|
-
});
|
|
4567
|
-
if (!response.ok)
|
|
4568
|
-
throw new Error(`Google keys answered HTTP ${response.status}`);
|
|
4569
|
-
const body = await response.json();
|
|
4570
|
-
const keys = new Map();
|
|
4571
|
-
for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
|
|
4572
|
-
if (!isRecord(jwk) || jwk.kty !== 'RSA' || typeof jwk.kid !== 'string' || (jwk.alg !== undefined && jwk.alg !== 'RS256'))
|
|
4573
|
-
continue;
|
|
4574
|
-
const key = await globalThis.crypto.subtle
|
|
4575
|
-
.importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e, alg: 'RS256', ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'])
|
|
4576
|
-
.catch(() => null);
|
|
4577
|
-
if (key)
|
|
4578
|
-
keys.set(jwk.kid, key);
|
|
4579
|
-
}
|
|
4580
|
-
const maxAge = Number(/max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]);
|
|
4581
|
-
googleKeys = { keys, until: now() + (Number.isFinite(maxAge) && maxAge > 0 ? Math.min(maxAge * 1000, 86_400_000) : GOOGLE_KEYS_DEFAULT_TTL_MS) };
|
|
4582
|
-
return keys;
|
|
4583
|
-
}
|
|
4584
|
-
/** 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). */
|
|
4585
|
-
async function googleSigned(provider, header, payload, signature) {
|
|
4586
|
-
let parsed;
|
|
4587
|
-
try {
|
|
4588
|
-
parsed = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
|
|
4589
|
-
}
|
|
4590
|
-
catch {
|
|
4591
|
-
return false;
|
|
4592
|
-
}
|
|
4593
|
-
if (!isRecord(parsed) || parsed.alg !== 'RS256' || typeof parsed.kid !== 'string')
|
|
4594
|
-
return false;
|
|
4595
|
-
let key = (await readGoogleKeys(provider, false)).get(parsed.kid);
|
|
4596
|
-
if (!key)
|
|
4597
|
-
key = (await readGoogleKeys(provider, true)).get(parsed.kid);
|
|
4598
|
-
if (!key)
|
|
4599
|
-
return false;
|
|
4600
|
-
return globalThis.crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, Buffer.from(signature, 'base64url'), Buffer.from(`${header}.${payload}`, 'ascii'));
|
|
4601
|
-
}
|
|
7115
|
+
/** Issuers' signing keys (Google's, and teams' identity providers'), by JWKS URL (oidc.ts). */
|
|
7116
|
+
const jwks = createSlotlockJwksCache({ fetch: fetchImpl, now });
|
|
4602
7117
|
/**
|
|
4603
7118
|
* The person Google signed in, from the ID token its token endpoint returned for this code. The
|
|
4604
7119
|
* token's signature is checked against Google's published keys, and its issuer, audience (and
|
|
@@ -4626,48 +7141,45 @@ ${rows.length === 0
|
|
|
4626
7141
|
}
|
|
4627
7142
|
const body = await response.json().catch(() => null);
|
|
4628
7143
|
const token = isRecord(body) && typeof body.id_token === 'string' ? body.id_token : null;
|
|
4629
|
-
|
|
4630
|
-
if (!header || !payload || !signature || extra.length > 0 || ![header, payload, signature].every((part) => BASE64URL.test(part))) {
|
|
7144
|
+
if (token === null)
|
|
4631
7145
|
return null;
|
|
4632
|
-
|
|
4633
|
-
//
|
|
4634
|
-
|
|
4635
|
-
|
|
7146
|
+
// Signed by Google with a key it publishes (RS256), for this client, unexpired, with this
|
|
7147
|
+
// sign-in's nonce (oidc.ts verifySlotlockIdToken: OpenID Connect Core 1.0, 3.1.3.7).
|
|
7148
|
+
const checked = await verifySlotlockIdToken(token, {
|
|
7149
|
+
issuers: SLOTLOCK_GOOGLE_ISSUERS,
|
|
7150
|
+
clientId: provider.clientId,
|
|
7151
|
+
algorithms: ['RS256'],
|
|
7152
|
+
keys: jwks.lookup(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs'),
|
|
7153
|
+
nonce,
|
|
7154
|
+
now: now(),
|
|
7155
|
+
});
|
|
7156
|
+
if ('refusal' in checked) {
|
|
7157
|
+
report(`Google sign-in refused: the ID token did not check out (${checked.refusal})`);
|
|
4636
7158
|
return null;
|
|
4637
7159
|
}
|
|
4638
|
-
const claims =
|
|
4639
|
-
if (!
|
|
4640
|
-
return null;
|
|
4641
|
-
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
4642
|
-
if ((claims.iss !== 'https://accounts.google.com' && claims.iss !== 'accounts.google.com') ||
|
|
4643
|
-
!audience.includes(provider.clientId) ||
|
|
4644
|
-
(audience.length > 1 && claims.azp !== provider.clientId) ||
|
|
4645
|
-
typeof claims.exp !== 'number' ||
|
|
4646
|
-
claims.exp * 1000 <= now() ||
|
|
4647
|
-
typeof claims.nonce !== 'string' ||
|
|
4648
|
-
!safeEqual(claims.nonce, nonce) ||
|
|
4649
|
-
typeof claims.sub !== 'string' ||
|
|
4650
|
-
!GOOGLE_SUBJECT.test(claims.sub)) {
|
|
4651
|
-
report('Google sign-in refused: the ID token did not check out');
|
|
7160
|
+
const claims = checked.claims;
|
|
7161
|
+
if (typeof claims.sub !== 'string' || !GOOGLE_SUBJECT.test(claims.sub))
|
|
4652
7162
|
return null;
|
|
4653
|
-
}
|
|
4654
7163
|
// The email names the person on the page only, and only once Google has verified it.
|
|
4655
|
-
const email = claims.email_verified === true && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
|
|
7164
|
+
const email = (claims.email_verified === true || claims.email_verified === 'true') && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
|
|
4656
7165
|
? claims.email
|
|
4657
7166
|
: 'Google account';
|
|
4658
|
-
|
|
7167
|
+
// A Google Workspace account names its domain (hd); a consumer account names none.
|
|
7168
|
+
const hd = typeof claims.hd === 'string' && /^[a-z0-9.-]{1,253}$/i.test(claims.hd) ? claims.hd.toLowerCase() : undefined;
|
|
7169
|
+
return { uid: claims.sub, login: email, idp: 'google', ...(hd !== undefined && email.includes('@') ? { hd } : {}) };
|
|
4659
7170
|
}
|
|
4660
7171
|
catch (error) {
|
|
4661
7172
|
report(`Google sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
|
|
4662
7173
|
return null;
|
|
4663
7174
|
}
|
|
4664
7175
|
}
|
|
4665
|
-
async function askGitHub(code, verifier) {
|
|
7176
|
+
async function askGitHub(code, verifier, emails = false) {
|
|
4666
7177
|
try {
|
|
4667
7178
|
const token = await exchangeCode(code, verifier);
|
|
4668
7179
|
if (token === null)
|
|
4669
7180
|
return null;
|
|
4670
|
-
const
|
|
7181
|
+
const found = await readGitHubUser(token);
|
|
7182
|
+
const user = found && emails ? { ...found, emails: await readGitHubEmails(token) } : found;
|
|
4671
7183
|
if (!user || !readsOrganizations)
|
|
4672
7184
|
return user;
|
|
4673
7185
|
const orgs = await readMemberships(token);
|
|
@@ -4715,7 +7227,7 @@ ${rows.length === 0
|
|
|
4715
7227
|
? google && typeof pending.nonce === 'string'
|
|
4716
7228
|
? await askGoogle(google, code, verifier, pending.nonce)
|
|
4717
7229
|
: null
|
|
4718
|
-
: await askGitHub(code, verifier);
|
|
7230
|
+
: await askGitHub(code, verifier, pending.emails === true);
|
|
4719
7231
|
}
|
|
4720
7232
|
finally {
|
|
4721
7233
|
signInsInFlight -= 1;
|
|
@@ -4737,15 +7249,64 @@ ${rows.length === 0
|
|
|
4737
7249
|
if (served && orgs.length === 0) {
|
|
4738
7250
|
return signedOutPage(403, `@${user.login} is not a member of an organization this Slotlock server serves.`, [clearSignIn]);
|
|
4739
7251
|
}
|
|
7252
|
+
// The person and, with GitHub, the organizations GitHub lists them in, as memberships now; the
|
|
7253
|
+
// epoch their sessions must carry.
|
|
7254
|
+
const person = user.idp === 'google' ? `google:${user.uid}` : `github:${user.uid}`;
|
|
7255
|
+
const epoch = workspaces
|
|
7256
|
+
? await workspaces.signedIn(person, user.idp === 'google' || user.orgs === undefined
|
|
7257
|
+
? undefined
|
|
7258
|
+
: user.orgs.map((org) => ({ id: org.id, role: org.role === 'admin' ? 'admin' : 'member' })))
|
|
7259
|
+
: undefined;
|
|
7260
|
+
// How the person is shown to the others in their teams: their GitHub login, or Google's verified email.
|
|
7261
|
+
if (teamStore) {
|
|
7262
|
+
await teamStore.setDisplay(person, user.idp === 'google' ? (user.login.includes('@') ? user.login : null) : `@${user.login}`);
|
|
7263
|
+
}
|
|
7264
|
+
// Of the addresses GitHub verified, only the invitation being opened's is kept, to accept it.
|
|
7265
|
+
let em;
|
|
7266
|
+
if (teamStore && user.emails) {
|
|
7267
|
+
const raw = inviteTokenOf(request);
|
|
7268
|
+
const invited = raw ? (await teamStore.preview(tokenDigest(raw)))?.email : null;
|
|
7269
|
+
if (invited && user.emails.includes(invited))
|
|
7270
|
+
em = [invited];
|
|
7271
|
+
else if (domainStore && pending.then === '/teams' && pending.emails === true) {
|
|
7272
|
+
// Looking for a team at one's domain: only the addresses at a team one may ask to join.
|
|
7273
|
+
const keys = new Set((await domainStore.discoverable(person, user.emails)).map((found) => found.domainKey));
|
|
7274
|
+
const kept = user.emails.filter((email) => keys.has(slotlockEmailDomain(email) ?? '')).slice(0, 3);
|
|
7275
|
+
if (kept.length > 0)
|
|
7276
|
+
em = kept;
|
|
7277
|
+
}
|
|
7278
|
+
}
|
|
7279
|
+
// A Google Workspace account of its own domain binds the directory's user at that address (SCIM):
|
|
7280
|
+
// the domain vouches for it. An address GitHub or a consumer Google account verified once does not.
|
|
7281
|
+
if (scimStore && user.idp === 'google' && user.hd !== undefined && user.login.includes('@')) {
|
|
7282
|
+
const address = user.login.toLowerCase();
|
|
7283
|
+
if (slotlockEmailDomain(address) === slotlockEmailDomain(`x@${user.hd}`))
|
|
7284
|
+
await scimStore.bind(person, [address], plans ? FREE_TEAM_MEMBERS : null);
|
|
7285
|
+
}
|
|
7286
|
+
const sealedOrg = served ? orgs[0].id : undefined;
|
|
7287
|
+
const sid = randomBytes(24).toString('base64url');
|
|
7288
|
+
const expires = now() + SESSION_TTL_MS;
|
|
7289
|
+
// Listed on the person's Account page, to sign out from another device.
|
|
7290
|
+
await workspaces?.sessions?.started(person, sid, new Date(expires), requestOrigin(request));
|
|
4740
7291
|
const session = seal('slotlock-dashboard-session-v1', {
|
|
4741
7292
|
uid: user.uid,
|
|
4742
7293
|
login: user.login,
|
|
4743
|
-
sid
|
|
4744
|
-
exp:
|
|
4745
|
-
...(user.idp === 'google' ? { idp: 'google' } : {}),
|
|
4746
|
-
...(served ? { orgs, org:
|
|
7294
|
+
sid,
|
|
7295
|
+
exp: expires,
|
|
7296
|
+
...(user.idp === 'google' ? { idp: 'google', ...(user.hd !== undefined ? { hd: user.hd } : {}) } : {}),
|
|
7297
|
+
...(served ? { orgs, org: sealedOrg } : teams && user.idp !== 'google' ? { orgs } : {}),
|
|
7298
|
+
...(epoch !== undefined ? { ep: epoch } : {}),
|
|
7299
|
+
...(em ? { em } : {}),
|
|
4747
7300
|
});
|
|
4748
|
-
|
|
7301
|
+
if (options.resources.audit) {
|
|
7302
|
+
const signedInTo = sealedOrg !== undefined ? `github-org:${sealedOrg}` : person;
|
|
7303
|
+
const from = requestOrigin(request);
|
|
7304
|
+
await options.resources.audit
|
|
7305
|
+
.record(signedInTo, { action: 'session.signed_in', actorType: 'person', actorRef: person, ...from })
|
|
7306
|
+
.catch((error) => ignoreRejection(options.onError?.(error)));
|
|
7307
|
+
}
|
|
7308
|
+
const back = typeof pending.then === 'string' && STEP_UP_PAGES.has(pending.then) ? pending.then : '';
|
|
7309
|
+
return redirect(303, `${root}${back}${(back === '/invite' || back === '/teams') && pending.emails === true ? '?checked=1' : ''}`, [
|
|
4749
7310
|
clearSignIn,
|
|
4750
7311
|
setCookie(SESSION_COOKIE, session, SESSION_TTL_MS / 1000),
|
|
4751
7312
|
]);
|
|
@@ -4844,7 +7405,7 @@ ${rows.length === 0
|
|
|
4844
7405
|
}
|
|
4845
7406
|
if (code === 'api_key_limit_reached') {
|
|
4846
7407
|
return keysPage(session, 409, plans
|
|
4847
|
-
?
|
|
7408
|
+
? html `You have as many active keys as your plan allows. Revoke one first, or ${await pastLimit(session)}.`
|
|
4848
7409
|
: 'You have as many keys as Slotlock allows. Revoke one first.');
|
|
4849
7410
|
}
|
|
4850
7411
|
throw error;
|
|
@@ -4919,7 +7480,7 @@ ${rows.length === 0
|
|
|
4919
7480
|
if (code === 'resource_limit_reached') {
|
|
4920
7481
|
const limit = error.limit;
|
|
4921
7482
|
return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
|
|
4922
|
-
? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or
|
|
7483
|
+
? html `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or ${await pastLimit(session)}.`
|
|
4923
7484
|
: `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
|
|
4924
7485
|
}
|
|
4925
7486
|
if (code === 'invalid_timezone') {
|
|
@@ -4937,6 +7498,13 @@ ${rows.length === 0
|
|
|
4937
7498
|
if (request.headers.get('origin') !== origin) {
|
|
4938
7499
|
return respond(403, layout('Refused', html `<p class="notice">That request did not come from this dashboard.</p>`, null));
|
|
4939
7500
|
}
|
|
7501
|
+
// Single sign-on continues to the team's provider from its own page, with or without a session.
|
|
7502
|
+
if (route === '/sso/start' && ssoStore) {
|
|
7503
|
+
const form = await readForm(request);
|
|
7504
|
+
const handle = form === null || form === 'too_large' ? '' : (form.get('team') ?? '');
|
|
7505
|
+
const found = /^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$/.test(handle) ? await ssoStore.route({ handle }) : null;
|
|
7506
|
+
return found ? startSsoSignIn(found, 'signin') : ssoStartPage(new URL(`${publicRoot}/sso?team=${encodeURIComponent(handle)}`));
|
|
7507
|
+
}
|
|
4940
7508
|
const session = await readSession(request);
|
|
4941
7509
|
if (!session)
|
|
4942
7510
|
return redirect(303, root);
|
|
@@ -4960,6 +7528,7 @@ ${rows.length === 0
|
|
|
4960
7528
|
if (!POST_ROUTES.has(route)) {
|
|
4961
7529
|
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
4962
7530
|
}
|
|
7531
|
+
origins.set(session, requestOrigin(request));
|
|
4963
7532
|
// A form sent again (a reload of its result, a double click) must not create or rotate twice.
|
|
4964
7533
|
// Every form but sign-out carries a single-use value, so leaving it out cannot skip this check
|
|
4965
7534
|
// or the per-person form limit.
|
|
@@ -4970,7 +7539,7 @@ ${rows.length === 0
|
|
|
4970
7539
|
if (once !== null) {
|
|
4971
7540
|
const checked = checkFormNonce(session, once);
|
|
4972
7541
|
if (checked === 'other_org') {
|
|
4973
|
-
return
|
|
7542
|
+
return pageOrNotice(route)(session, 409, 'That page was for another workspace. Nothing was changed: check the workspace and try again.');
|
|
4974
7543
|
}
|
|
4975
7544
|
let first;
|
|
4976
7545
|
try {
|
|
@@ -4981,7 +7550,7 @@ ${rows.length === 0
|
|
|
4981
7550
|
catch (error) {
|
|
4982
7551
|
if (error.code !== 'dashboard_form_limit')
|
|
4983
7552
|
throw error;
|
|
4984
|
-
return
|
|
7553
|
+
return pageOrNotice(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
|
|
4985
7554
|
}
|
|
4986
7555
|
if (!first) {
|
|
4987
7556
|
// The first one was taken: a second click, a reload or a resend shows what it did.
|
|
@@ -4989,17 +7558,41 @@ ${rows.length === 0
|
|
|
4989
7558
|
const chat = chatOf(form.get('chat'));
|
|
4990
7559
|
return redirect(303, chat ? `${chatUrl(chat)}#latest` : `${root}/assistant#latest`);
|
|
4991
7560
|
}
|
|
4992
|
-
return
|
|
7561
|
+
return pageOrNotice(route)(session, 409, 'That form was already sent. Reload the page to start again.');
|
|
4993
7562
|
}
|
|
4994
7563
|
}
|
|
7564
|
+
// What the person may do, from their role now: before anything changes, and after the form's
|
|
7565
|
+
// single-use value and the per-person form limit, so refusals cannot flood the audit log.
|
|
7566
|
+
const refused = await authorizePost(session, route, form);
|
|
7567
|
+
if (refused)
|
|
7568
|
+
return refused;
|
|
4995
7569
|
// A tenant new to plans is put on the default plan before it adds what plans limit.
|
|
4996
7570
|
if (route === '/keys' || route === '/resources' || route === '/webhooks') {
|
|
4997
7571
|
await plans?.enrol?.(tenantOf(session));
|
|
4998
7572
|
}
|
|
7573
|
+
const response = await dispatchPost(session, route, form, request);
|
|
7574
|
+
const action = AUDITED_ROUTES[route];
|
|
7575
|
+
if (action && response.status < 400)
|
|
7576
|
+
await audit(session, { action, ...auditDetails(route, form) });
|
|
7577
|
+
return response;
|
|
7578
|
+
}
|
|
7579
|
+
async function dispatchPost(session, route, form, request) {
|
|
4999
7580
|
switch (route) {
|
|
7581
|
+
case '/sign-out-everywhere':
|
|
7582
|
+
// Every session of the person, on every device and server, this one included: only with
|
|
7583
|
+
// the membership store, which holds the epoch every session carries.
|
|
7584
|
+
if (!workspaces)
|
|
7585
|
+
return notFound(session);
|
|
7586
|
+
await workspaces.signOutEverywhere(personOf(session));
|
|
7587
|
+
await workspaces.sessions?.end(personOf(session), null);
|
|
7588
|
+
await options.state.endSession(session.sid, session.exp);
|
|
7589
|
+
return redirect(303, root, [setCookie(SESSION_COOKIE, '', 0)]);
|
|
5000
7590
|
case '/sign-out':
|
|
5001
7591
|
await options.state.endSession(session.sid, session.exp);
|
|
7592
|
+
await workspaces?.sessions?.end(personOf(session), workspaces.sessions.idOf(session.sid));
|
|
5002
7593
|
return redirect(303, root, [setCookie(SESSION_COOKIE, '', 0)]);
|
|
7594
|
+
case '/sessions/end':
|
|
7595
|
+
return endSession(session, form);
|
|
5003
7596
|
case '/keys':
|
|
5004
7597
|
return createKey(session, form);
|
|
5005
7598
|
case '/keys/rotate':
|
|
@@ -5022,6 +7615,16 @@ ${rows.length === 0
|
|
|
5022
7615
|
return deleteHolidayCalendar(session, form);
|
|
5023
7616
|
case '/contacts/forget':
|
|
5024
7617
|
return forgetContact(session, form);
|
|
7618
|
+
case '/settings/privacy/export':
|
|
7619
|
+
return askPrivacyExport(session);
|
|
7620
|
+
case '/settings/privacy/export/delete':
|
|
7621
|
+
return deletePrivacyExport(session, form);
|
|
7622
|
+
case '/settings/privacy/person':
|
|
7623
|
+
return findPerson(session, form);
|
|
7624
|
+
case '/settings/privacy/person/export':
|
|
7625
|
+
return exportPerson(session, form);
|
|
7626
|
+
case '/settings/privacy/person/erase':
|
|
7627
|
+
return erasePerson(session, form);
|
|
5025
7628
|
case '/organization':
|
|
5026
7629
|
return switchOrganization(session, form);
|
|
5027
7630
|
case '/environment':
|
|
@@ -5090,6 +7693,70 @@ ${rows.length === 0
|
|
|
5090
7693
|
return changeTeam(session, form, true);
|
|
5091
7694
|
case '/teams/disable':
|
|
5092
7695
|
return changeTeam(session, form, false);
|
|
7696
|
+
case '/teams/create':
|
|
7697
|
+
return createTeam(session, form);
|
|
7698
|
+
case '/teams/delete':
|
|
7699
|
+
return deleteTeam(session, form);
|
|
7700
|
+
case '/teams/restore':
|
|
7701
|
+
return restoreTeam(session);
|
|
7702
|
+
case '/invite/accept':
|
|
7703
|
+
return acceptInvitation(session, form, request);
|
|
7704
|
+
case '/members/invite':
|
|
7705
|
+
return inviteMember(session, form);
|
|
7706
|
+
case '/members/resend':
|
|
7707
|
+
return resendInvitation(session, form);
|
|
7708
|
+
case '/members/revoke':
|
|
7709
|
+
return revokeInvitation(session, form);
|
|
7710
|
+
case '/members/role':
|
|
7711
|
+
return changeMemberRole(session, form);
|
|
7712
|
+
case '/members/scope':
|
|
7713
|
+
return limitMember(session, form);
|
|
7714
|
+
case '/members/remove':
|
|
7715
|
+
return removeMember(session, form);
|
|
7716
|
+
case '/members/leave':
|
|
7717
|
+
return leaveTeam(session);
|
|
7718
|
+
case '/members/requests':
|
|
7719
|
+
return decideJoinRequest(session, form);
|
|
7720
|
+
case '/join/request':
|
|
7721
|
+
return requestToJoin(session, form);
|
|
7722
|
+
case '/identity/domains':
|
|
7723
|
+
return claimDomain(session, form);
|
|
7724
|
+
case '/identity/domains/verify':
|
|
7725
|
+
return verifyDomain(session, form);
|
|
7726
|
+
case '/identity/domains/remove':
|
|
7727
|
+
return releaseDomain(session, form);
|
|
7728
|
+
case '/identity/domains/policy':
|
|
7729
|
+
return changeJoinPolicy(session, form);
|
|
7730
|
+
case '/identity/invites':
|
|
7731
|
+
return restrictInvites(session, form);
|
|
7732
|
+
case '/identity/sso':
|
|
7733
|
+
return saveSsoConnection(session, form);
|
|
7734
|
+
case '/identity/sso/test':
|
|
7735
|
+
return testSsoConnection(session);
|
|
7736
|
+
case '/identity/sso/status':
|
|
7737
|
+
return setSsoStatus(session, form);
|
|
7738
|
+
case '/identity/sso/delete':
|
|
7739
|
+
return deleteSsoConnection(session, form);
|
|
7740
|
+
case '/identity/sso/provisioning':
|
|
7741
|
+
return setSsoProvisioning(session, form);
|
|
7742
|
+
case '/identity/sso/mapping':
|
|
7743
|
+
return setSsoMapping(session, form);
|
|
7744
|
+
case '/identity/sso/require':
|
|
7745
|
+
return setRequireSso(session, form);
|
|
7746
|
+
case '/sso/proof':
|
|
7747
|
+
return startSsoProof(session, form);
|
|
7748
|
+
case '/sso/bypass':
|
|
7749
|
+
return useSsoBypass(session, form);
|
|
7750
|
+
case '/identity/scim/enable':
|
|
7751
|
+
return enableScim(session, form);
|
|
7752
|
+
case '/identity/scim/disable':
|
|
7753
|
+
return disableScim(session);
|
|
7754
|
+
case '/identity/scim/token':
|
|
7755
|
+
return createScimToken(session);
|
|
7756
|
+
case '/identity/scim/token/revoke':
|
|
7757
|
+
return revokeScimToken(session, form);
|
|
7758
|
+
case '/identity/scim/group':
|
|
7759
|
+
return mapScimGroup(session, form);
|
|
5093
7760
|
default:
|
|
5094
7761
|
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
5095
7762
|
}
|
|
@@ -5099,126 +7766,220 @@ ${rows.length === 0
|
|
|
5099
7766
|
const { pathname } = new URL(request.url);
|
|
5100
7767
|
return pathname === root || pathname.startsWith(`${root}/`);
|
|
5101
7768
|
},
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
7769
|
+
// Each request with its own scope (dashboard-scope.ts), set once the person's standing is read.
|
|
7770
|
+
fetch: (request) => runSlotlockDashboardRequest(() => serve(request)),
|
|
7771
|
+
};
|
|
7772
|
+
async function serve(request) {
|
|
7773
|
+
const url = new URL(request.url);
|
|
7774
|
+
const route = url.pathname.slice(root.length).replace(/\/$/, '') || '/';
|
|
7775
|
+
try {
|
|
7776
|
+
// A team's SCIM endpoint: its identity provider's, by bearer token, never a session or a form.
|
|
7777
|
+
if (scimStore && route.startsWith('/scim/v2/')) {
|
|
7778
|
+
const log = options.resources.audit;
|
|
7779
|
+
return await handleSlotlockScimRequest(request, route.slice('/scim/v2/'.length), {
|
|
7780
|
+
scim: scimStore,
|
|
7781
|
+
publicRoot,
|
|
7782
|
+
documentationUri: 'https://slotlock.pylota.io/docs/dashboard/#scim',
|
|
7783
|
+
...(log ? { record: (tenantRef, event) => log.record(tenantRef, event) } : {}),
|
|
7784
|
+
// Their keys go as the team chose when it turned SCIM on.
|
|
7785
|
+
deactivated: (tenantRef, person, keyPolicy) => (keyPolicy === 'revoke' ? revokeKeysOf(tenantRef, person, 'scim') : Promise.resolve(0)),
|
|
7786
|
+
});
|
|
7787
|
+
}
|
|
7788
|
+
if (request.method === 'GET') {
|
|
7789
|
+
switch (route) {
|
|
7790
|
+
case '/': {
|
|
7791
|
+
const session = await readSession(request);
|
|
7792
|
+
if (!session)
|
|
7793
|
+
return signedOutPage(200);
|
|
7794
|
+
return authorizePage(session, '/') ?? (await overviewPage(session));
|
|
7795
|
+
}
|
|
7796
|
+
case '/keys':
|
|
7797
|
+
case '/resources':
|
|
7798
|
+
case '/webhooks':
|
|
7799
|
+
case '/waitlist':
|
|
7800
|
+
case '/approvals':
|
|
7801
|
+
case '/assistant':
|
|
7802
|
+
case '/assistant/chats':
|
|
7803
|
+
case '/plan':
|
|
7804
|
+
case '/teams':
|
|
7805
|
+
case '/settings/endpoints':
|
|
7806
|
+
case '/settings/hours':
|
|
7807
|
+
case '/settings/account':
|
|
7808
|
+
case '/settings/members':
|
|
7809
|
+
case '/settings/audit':
|
|
7810
|
+
case '/settings/audit/export':
|
|
7811
|
+
case '/settings/team':
|
|
7812
|
+
case '/settings/privacy':
|
|
7813
|
+
case '/settings/privacy/download':
|
|
7814
|
+
case '/settings/identity':
|
|
7815
|
+
case '/settings': {
|
|
7816
|
+
const session = await readSession(request);
|
|
7817
|
+
if (!session)
|
|
7818
|
+
return redirect(303, root);
|
|
7819
|
+
const denied = authorizePage(session, route);
|
|
7820
|
+
if (denied)
|
|
7821
|
+
return denied;
|
|
7822
|
+
if (route === '/teams')
|
|
7823
|
+
return await teamsPage(session, 200, undefined, url.searchParams.get('checked') === '1');
|
|
7824
|
+
if (route === '/plan') {
|
|
7825
|
+
return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
|
|
7826
|
+
? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
|
|
7827
|
+
: undefined, url.searchParams.get('checkout') === 'done');
|
|
5111
7828
|
}
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
case '/approvals':
|
|
5117
|
-
case '/assistant':
|
|
5118
|
-
case '/assistant/chats':
|
|
5119
|
-
case '/plan':
|
|
5120
|
-
case '/teams':
|
|
5121
|
-
case '/settings/endpoints':
|
|
5122
|
-
case '/settings/hours':
|
|
5123
|
-
case '/settings/account':
|
|
5124
|
-
case '/settings': {
|
|
5125
|
-
const session = await readSession(request);
|
|
5126
|
-
if (!session)
|
|
5127
|
-
return redirect(303, root);
|
|
5128
|
-
if (route === '/teams')
|
|
5129
|
-
return await teamsPage(session);
|
|
5130
|
-
if (route === '/plan') {
|
|
5131
|
-
return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
|
|
5132
|
-
? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
|
|
5133
|
-
: undefined, url.searchParams.get('checkout') === 'done');
|
|
5134
|
-
}
|
|
5135
|
-
if (route === '/keys')
|
|
5136
|
-
return await keysPage(session);
|
|
5137
|
-
if (route === '/resources') {
|
|
5138
|
-
return await resourcesPage(session, 200, undefined, url.searchParams.get('hours') === 'own');
|
|
5139
|
-
}
|
|
5140
|
-
if (route === '/webhooks')
|
|
5141
|
-
return await webhooksPage(session);
|
|
5142
|
-
if (route === '/waitlist')
|
|
5143
|
-
return await waitlistPage(session);
|
|
5144
|
-
if (route === '/approvals')
|
|
5145
|
-
return await approvalsPage(session);
|
|
5146
|
-
if (route === '/assistant')
|
|
5147
|
-
return await assistantPage(session, 200, undefined, url.searchParams.get('c') ?? undefined);
|
|
5148
|
-
if (route === '/assistant/chats')
|
|
5149
|
-
return await chatsPage(session);
|
|
5150
|
-
if (route === '/settings/endpoints')
|
|
5151
|
-
return await settingsPage(session, 200, undefined, undefined, 'endpoints');
|
|
5152
|
-
if (route === '/settings/hours')
|
|
5153
|
-
return await settingsPage(session, 200, undefined, undefined, 'hours');
|
|
5154
|
-
if (route === '/settings/account')
|
|
5155
|
-
return await settingsPage(session, 200, undefined, undefined, 'account');
|
|
5156
|
-
// A provider preset fills the assistant's form; nothing is saved until Save.
|
|
5157
|
-
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
|
|
5158
|
-
return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
|
|
7829
|
+
if (route === '/keys')
|
|
7830
|
+
return await keysPage(session);
|
|
7831
|
+
if (route === '/resources') {
|
|
7832
|
+
return await resourcesPage(session, 200, undefined, url.searchParams.get('hours') === 'own');
|
|
5159
7833
|
}
|
|
5160
|
-
|
|
5161
|
-
return
|
|
5162
|
-
|
|
5163
|
-
return
|
|
5164
|
-
|
|
5165
|
-
return
|
|
5166
|
-
|
|
5167
|
-
return
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
return await
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
7834
|
+
if (route === '/webhooks')
|
|
7835
|
+
return await webhooksPage(session);
|
|
7836
|
+
if (route === '/waitlist')
|
|
7837
|
+
return await waitlistPage(session);
|
|
7838
|
+
if (route === '/approvals')
|
|
7839
|
+
return await approvalsPage(session);
|
|
7840
|
+
if (route === '/assistant')
|
|
7841
|
+
return await assistantPage(session, 200, undefined, url.searchParams.get('c') ?? undefined);
|
|
7842
|
+
if (route === '/assistant/chats')
|
|
7843
|
+
return await chatsPage(session);
|
|
7844
|
+
if (route === '/settings/endpoints')
|
|
7845
|
+
return await settingsPage(session, 200, undefined, undefined, 'endpoints');
|
|
7846
|
+
if (route === '/settings/hours')
|
|
7847
|
+
return await settingsPage(session, 200, undefined, undefined, 'hours');
|
|
7848
|
+
if (route === '/settings/account')
|
|
7849
|
+
return await settingsPage(session, 200, undefined, undefined, 'account');
|
|
7850
|
+
if (route === '/settings/members') {
|
|
7851
|
+
return teamStore && session.team !== undefined
|
|
7852
|
+
? await settingsPage(session, 200, undefined, undefined, 'members')
|
|
7853
|
+
: nothingHere(session);
|
|
5175
7854
|
}
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
7855
|
+
if (route === '/settings/audit/export')
|
|
7856
|
+
return await exportAudit(session, url.searchParams);
|
|
7857
|
+
if (route === '/settings/privacy') {
|
|
7858
|
+
return options.resources.privacy
|
|
7859
|
+
? await settingsPage(session, 200, undefined, undefined, 'privacy')
|
|
7860
|
+
: nothingHere(session);
|
|
5179
7861
|
}
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
return
|
|
7862
|
+
if (route === '/settings/privacy/download')
|
|
7863
|
+
return await downloadPrivacyExport(session, url.searchParams);
|
|
7864
|
+
if (route === '/settings/team') {
|
|
7865
|
+
return teamStore && session.team !== undefined
|
|
7866
|
+
? await settingsPage(session, 200, undefined, undefined, 'team')
|
|
7867
|
+
: nothingHere(session);
|
|
5184
7868
|
}
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
7869
|
+
if (route === '/settings/identity') {
|
|
7870
|
+
return domainStore && session.team !== undefined
|
|
7871
|
+
? await settingsPage(session, 200, undefined, undefined, 'identity')
|
|
7872
|
+
: nothingHere(session);
|
|
7873
|
+
}
|
|
7874
|
+
if (route === '/settings/audit') {
|
|
7875
|
+
return options.resources.audit
|
|
7876
|
+
? await settingsPage(session, 200, undefined, undefined, 'audit', undefined, url.searchParams)
|
|
7877
|
+
: nothingHere(session);
|
|
5188
7878
|
}
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
7879
|
+
// A provider preset fills the assistant's form; nothing is saved until Save.
|
|
7880
|
+
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
|
|
7881
|
+
return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
|
|
7882
|
+
}
|
|
7883
|
+
case '/style.css':
|
|
7884
|
+
return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
7885
|
+
case '/app.js':
|
|
7886
|
+
return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
|
|
7887
|
+
case '/favicon.svg':
|
|
7888
|
+
return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
|
|
7889
|
+
case '/sign-in': {
|
|
7890
|
+
// After signing in again to confirm it's you, back to the page that asked.
|
|
7891
|
+
const then = url.searchParams.get('then') ?? '';
|
|
7892
|
+
const back = STEP_UP_PAGES.has(then) ? then : undefined;
|
|
7893
|
+
return google && url.searchParams.get('with') === 'google'
|
|
7894
|
+
? await startGoogleSignIn(google, back)
|
|
7895
|
+
: await startSignIn(back, Boolean(teamStore) && (back === '/invite' || (back === '/teams' && Boolean(domainStore))) && url.searchParams.get('email') === '1');
|
|
7896
|
+
}
|
|
7897
|
+
case '/invite': {
|
|
7898
|
+
if (!teamStore)
|
|
7899
|
+
return nothingHere404();
|
|
7900
|
+
// An invitation's link: its token (in the query, which request logs leave out) is kept
|
|
7901
|
+
// in this browser for the sign-in that accepts it, and nothing else; opening it twice
|
|
7902
|
+
// (a link scanner, a preview) consumes nothing.
|
|
7903
|
+
const raw = url.searchParams.get('t');
|
|
7904
|
+
if (raw !== null) {
|
|
7905
|
+
if (!INVITE_TOKEN.test(raw))
|
|
7906
|
+
return nothingHere404();
|
|
7907
|
+
const opened = seal('slotlock-dashboard-invite-v1', { t: raw, exp: now() + INVITE_LINK_TTL_MS });
|
|
7908
|
+
return respond(303, '', { Location: `${root}/invite`, 'Referrer-Policy': 'no-referrer' }, [
|
|
7909
|
+
setCookie(INVITE_COOKIE, opened, INVITE_LINK_TTL_MS / 1000),
|
|
7910
|
+
]);
|
|
5198
7911
|
}
|
|
7912
|
+
const session = await readSession(request);
|
|
7913
|
+
return await invitePage(session, inviteTokenOf(request), 200, undefined, url.searchParams.get('checked') === '1');
|
|
7914
|
+
}
|
|
7915
|
+
case '/callback':
|
|
7916
|
+
return await finishSignIn(request, url);
|
|
7917
|
+
case '/sso':
|
|
7918
|
+
return await ssoStartPage(url);
|
|
7919
|
+
case '/hours': {
|
|
7920
|
+
const session = await readSession(request);
|
|
7921
|
+
if (!session)
|
|
7922
|
+
return redirect(303, root);
|
|
7923
|
+
return authorizePage(session, route) ?? (await hoursEditor(session, url));
|
|
7924
|
+
}
|
|
7925
|
+
case '/closures': {
|
|
7926
|
+
const session = await readSession(request);
|
|
7927
|
+
if (!session)
|
|
7928
|
+
return redirect(303, root);
|
|
7929
|
+
return authorizePage(session, route) ?? (await closuresPage(session));
|
|
7930
|
+
}
|
|
7931
|
+
case '/contacts': {
|
|
7932
|
+
const session = await readSession(request);
|
|
7933
|
+
const after = url.searchParams.get('after') ?? undefined;
|
|
7934
|
+
if (!session)
|
|
7935
|
+
return redirect(303, root);
|
|
7936
|
+
return authorizePage(session, route) ?? (await contactsPage(session, 200, undefined, after));
|
|
7937
|
+
}
|
|
7938
|
+
case '/locations': {
|
|
7939
|
+
const session = await readSession(request);
|
|
7940
|
+
if (!session)
|
|
7941
|
+
return redirect(303, root);
|
|
7942
|
+
return authorizePage(session, route) ?? (await locationsPage(session));
|
|
7943
|
+
}
|
|
7944
|
+
default: {
|
|
7945
|
+
// A team's provider's callback, by connection; and single sign-on by a team's handle.
|
|
7946
|
+
const ssoCallback = /^\/sso\/callback\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(route)?.[1];
|
|
7947
|
+
if (ssoCallback !== undefined && ssoStore)
|
|
7948
|
+
return await finishSsoSignIn(request, url, ssoCallback);
|
|
7949
|
+
const ssoHandle = /^\/sso\/([a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?)$/.exec(route)?.[1];
|
|
7950
|
+
if (ssoHandle !== undefined && ssoStore)
|
|
7951
|
+
return await ssoStartPage(url, ssoHandle);
|
|
7952
|
+
const resourceId = RESOURCE_PAGE.exec(route)?.[1];
|
|
7953
|
+
if (resourceId !== undefined) {
|
|
7954
|
+
const session = await readSession(request);
|
|
7955
|
+
if (!session)
|
|
7956
|
+
return redirect(303, root);
|
|
7957
|
+
return authorizePage(session, '/resources/:id') ?? (await resourceDetailPage(session, resourceId.toLowerCase()));
|
|
7958
|
+
}
|
|
7959
|
+
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, null));
|
|
5199
7960
|
}
|
|
5200
7961
|
}
|
|
5201
|
-
if (request.method === 'POST')
|
|
5202
|
-
return await handlePost(request, route);
|
|
5203
|
-
return respond(405, layout('Not allowed', html `<p class="notice">Use the dashboard's pages and forms.</p>`, null), {
|
|
5204
|
-
Allow: 'GET, POST',
|
|
5205
|
-
});
|
|
5206
7962
|
}
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
}
|
|
5218
|
-
return respond(500, layout('Error', html `<p class="notice">Something went wrong. Try again in a moment.</p>`, null));
|
|
7963
|
+
if (request.method === 'POST')
|
|
7964
|
+
return await handlePost(request, route);
|
|
7965
|
+
return respond(405, layout('Not allowed', html `<p class="notice">Use the dashboard's pages and forms.</p>`, null), {
|
|
7966
|
+
Allow: 'GET, POST',
|
|
7967
|
+
});
|
|
7968
|
+
}
|
|
7969
|
+
catch (error) {
|
|
7970
|
+
// A suspended tenant may look, not change: the store refuses its writes.
|
|
7971
|
+
if (error?.code === 'tenant_suspended') {
|
|
7972
|
+
return respond(403, layout('Suspended', html `<p class="notice">This account is suspended: its data can be seen, not changed.</p>`, null));
|
|
5219
7973
|
}
|
|
5220
|
-
|
|
5221
|
-
|
|
7974
|
+
try {
|
|
7975
|
+
ignoreRejection(options.onError?.(error));
|
|
7976
|
+
}
|
|
7977
|
+
catch {
|
|
7978
|
+
// A failing reporter must not change the response.
|
|
7979
|
+
}
|
|
7980
|
+
return respond(500, layout('Error', html `<p class="notice">Something went wrong. Try again in a moment.</p>`, null));
|
|
7981
|
+
}
|
|
7982
|
+
}
|
|
5222
7983
|
}
|
|
5223
7984
|
// Progressive enhancement only, loaded as a file so the policy needs no inline code: copy buttons
|
|
5224
7985
|
// (announced in the page's live region), confirmations that close with Escape or a click outside
|
|
@@ -5496,6 +8257,29 @@ form .choice { margin: var(--space-1) 0 var(--space-3); }
|
|
|
5496
8257
|
.fine + .table, .fine + .empty { margin-top: var(--space-4); }
|
|
5497
8258
|
.pop label { margin-bottom: var(--space-3); }
|
|
5498
8259
|
.pop textarea { width: 100%; }
|
|
8260
|
+
/* A team being deleted */
|
|
8261
|
+
.deleting-banner { background: var(--bad-soft, var(--surface-2)); }
|
|
8262
|
+
/* Sessions (Settings, Account) */
|
|
8263
|
+
h2.sessions-head { margin-top: var(--space-6); }
|
|
8264
|
+
/* Audit log */
|
|
8265
|
+
form.audit-filter { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-2) var(--space-3); margin: 0 0 var(--space-4); }
|
|
8266
|
+
form.audit-filter label { flex-basis: 100%; margin: 0; }
|
|
8267
|
+
form.audit-filter select { width: auto; }
|
|
8268
|
+
dl.audit-details { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); margin: 0; font-size: var(--text-sm); }
|
|
8269
|
+
dl.audit-details dt { color: var(--ink-2); }
|
|
8270
|
+
dl.audit-details dd { margin: 0; overflow-wrap: anywhere; }
|
|
8271
|
+
.pager, .audit-tools { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-top: var(--space-4); }
|
|
8272
|
+
.audit-tools { margin: 0 0 var(--space-4); }
|
|
8273
|
+
/* Teams: one-line forms that create a team or invite to one */
|
|
8274
|
+
form.create-team, form.invite { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-2) var(--space-3); margin: var(--space-5) 0; }
|
|
8275
|
+
form.create-team label, form.invite label { flex-basis: 100%; margin: 0; }
|
|
8276
|
+
form.create-team input, form.invite input { flex: 1 1 240px; min-width: 0; }
|
|
8277
|
+
form.invite select { flex: 0 1 auto; width: auto; }
|
|
8278
|
+
.team-workspaces .table + .table { margin-top: var(--space-4); }
|
|
8279
|
+
.seats { margin: var(--space-4) 0; color: var(--ink-2); }
|
|
8280
|
+
.pop fieldset { border: 0; padding: 0; margin: 0 0 var(--space-3); }
|
|
8281
|
+
.pop legend { font-weight: 500; padding: 0; margin-bottom: var(--space-2); }
|
|
8282
|
+
.pop fieldset label { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-1); font-weight: 400; }
|
|
5499
8283
|
|
|
5500
8284
|
/* Buttons */
|
|
5501
8285
|
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); }
|