@jskit-ai/users-web 0.1.52 → 0.1.54

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.
Files changed (73) hide show
  1. package/package.descriptor.mjs +14 -96
  2. package/package.json +16 -11
  3. package/src/client/account-settings/sections.js +74 -0
  4. package/src/client/composables/account-settings/accountSettingsRuntimeHelpers.js +2 -38
  5. package/src/client/composables/crud/crudLookupFieldRuntime.js +2 -2
  6. package/src/client/composables/internal/crudListParentTitleSupport.js +1 -1
  7. package/src/client/composables/internal/useOperationScope.js +12 -12
  8. package/src/client/composables/records/useAddEdit.js +2 -2
  9. package/src/client/composables/records/useList.js +3 -3
  10. package/src/client/composables/records/useView.js +2 -2
  11. package/src/client/composables/support/scopeHelpers.js +19 -19
  12. package/src/client/composables/useAccess.js +3 -3
  13. package/src/client/composables/useAccountSettingsRuntime.js +8 -156
  14. package/src/client/composables/useCommand.js +2 -2
  15. package/src/client/composables/useCrudListParentTitle.js +2 -2
  16. package/src/client/composables/usePaths.js +50 -38
  17. package/src/client/composables/useScopeRuntime.js +55 -27
  18. package/src/client/composables/useSurfaceRouteContext.js +1 -7
  19. package/src/client/index.js +0 -1
  20. package/src/client/lib/bootstrap.js +0 -63
  21. package/src/client/lib/httpClient.js +2 -59
  22. package/src/client/lib/theme.js +12 -189
  23. package/src/client/providers/UsersWebClientProvider.js +2 -25
  24. package/src/client/providers/bootUsersWebClientProvider.js +28 -0
  25. package/src/shared/toolsOutletContracts.js +1 -8
  26. package/templates/src/components/account/settings/AccountSettingsClientElement.vue +33 -21
  27. package/test/accountSettingsSections.test.js +79 -0
  28. package/test/exportsContract.test.js +2 -2
  29. package/test/scopeHelpers.test.js +6 -6
  30. package/test/settingsPlacementContract.test.js +4 -49
  31. package/test/theme.test.js +0 -56
  32. package/src/client/components/ConsoleSettingsClientElement.vue +0 -24
  33. package/src/client/components/MembersAdminClientElement.vue +0 -400
  34. package/src/client/components/UsersProfileSurfaceSwitchMenuItem.vue +0 -39
  35. package/src/client/components/UsersWorkspaceMembersMenuItem.vue +0 -36
  36. package/src/client/components/UsersWorkspacePermissionMenuItem.vue +0 -90
  37. package/src/client/components/UsersWorkspaceSelector.vue +0 -248
  38. package/src/client/components/UsersWorkspaceSettingsMenuItem.vue +0 -39
  39. package/src/client/components/UsersWorkspaceToolsWidget.vue +0 -12
  40. package/src/client/components/WorkspaceMembersClientElement.vue +0 -655
  41. package/src/client/components/WorkspaceProfileClientElement.vue +0 -116
  42. package/src/client/components/WorkspaceSettingsClientElement.vue +0 -102
  43. package/src/client/components/WorkspaceSettingsFieldsClientElement.vue +0 -265
  44. package/src/client/components/WorkspacesClientElement.vue +0 -509
  45. package/src/client/composables/account-settings/accountSettingsInvitesRuntime.js +0 -88
  46. package/src/client/composables/useBootstrapQuery.js +0 -52
  47. package/src/client/composables/useWorkspaceRouteContext.js +0 -28
  48. package/src/client/composables/useWorkspaceSurfaceId.js +0 -43
  49. package/src/client/lib/menuIcons.js +0 -210
  50. package/src/client/lib/profileSurfaceMenuLinks.js +0 -142
  51. package/src/client/lib/surfaceAccessPolicy.js +0 -350
  52. package/src/client/lib/workspaceLinkResolver.js +0 -207
  53. package/src/client/lib/workspaceSurfaceContext.js +0 -82
  54. package/src/client/lib/workspaceSurfacePaths.js +0 -163
  55. package/src/client/providers/UsersWorkspacesClientProvider.js +0 -24
  56. package/src/client/runtime/bootstrapPlacementRouteGuards.js +0 -371
  57. package/src/client/runtime/bootstrapPlacementRuntime.js +0 -463
  58. package/src/client/runtime/bootstrapPlacementRuntimeConstants.js +0 -28
  59. package/src/client/runtime/bootstrapPlacementRuntimeHelpers.js +0 -147
  60. package/src/client/support/menuLinkTarget.js +0 -93
  61. package/src/client/support/realtimeWorkspace.js +0 -21
  62. package/src/client/support/runtimeNormalization.js +0 -27
  63. package/src/client/support/workspaceQueryKeys.js +0 -15
  64. package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +0 -77
  65. package/templates/src/pages/console/settings/index.vue +0 -8
  66. package/templates/src/pages/console/settings.vue +0 -32
  67. package/test/bootstrapPlacementRuntime.test.js +0 -1095
  68. package/test/menuIcons.test.js +0 -35
  69. package/test/menuLinkTarget.test.js +0 -116
  70. package/test/profileSurfaceMenuLinks.test.js +0 -207
  71. package/test/surfaceAccessPolicy.test.js +0 -129
  72. package/test/workspaceLinkResolver.test.js +0 -61
  73. package/test/workspaceSurfacePaths.test.js +0 -39
@@ -1,1095 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settings";
4
- import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
5
- import {
6
- WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN,
7
- WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND,
8
- WORKSPACE_BOOTSTRAP_STATUS_RESOLVED,
9
- createBootstrapPlacementRuntime
10
- } from "../src/client/runtime/bootstrapPlacementRuntime.js";
11
-
12
- const SHELL_GUARD_EVALUATOR_KEY = "__JSKIT_WEB_SHELL_GUARD_EVALUATOR__";
13
-
14
- function flushTasks() {
15
- return new Promise((resolve) => {
16
- setTimeout(resolve, 0);
17
- });
18
- }
19
-
20
- test.afterEach(() => {
21
- try {
22
- delete globalThis[SHELL_GUARD_EVALUATOR_KEY];
23
- } catch {}
24
- });
25
-
26
- function createPlacementRuntimeStub() {
27
- const listeners = new Set();
28
- const setCalls = [];
29
- let context = Object.freeze({
30
- surfaceConfig: {
31
- tenancyMode: "workspaces",
32
- defaultSurfaceId: "app",
33
- enabledSurfaceIds: ["app", "admin", "home"],
34
- surfacesById: {
35
- home: {
36
- id: "home",
37
- enabled: true,
38
- pagesRoot: "",
39
- routeBase: "/",
40
- requiresWorkspace: false
41
- },
42
- app: {
43
- id: "app",
44
- enabled: true,
45
- pagesRoot: "w/[workspaceSlug]",
46
- routeBase: "/w/:workspaceSlug",
47
- requiresWorkspace: true
48
- },
49
- admin: {
50
- id: "admin",
51
- enabled: true,
52
- pagesRoot: "w/[workspaceSlug]/admin",
53
- routeBase: "/w/:workspaceSlug/admin",
54
- requiresWorkspace: true
55
- }
56
- }
57
- }
58
- });
59
-
60
- return {
61
- getContext() {
62
- return context;
63
- },
64
- setContext(patch = {}, { replace = false, source = "" } = {}) {
65
- context = Object.freeze(
66
- replace
67
- ? {
68
- ...patch
69
- }
70
- : {
71
- ...context,
72
- ...patch
73
- }
74
- );
75
- setCalls.push({
76
- patch,
77
- source
78
- });
79
- for (const listener of listeners) {
80
- listener({
81
- type: "context.updated",
82
- source
83
- });
84
- }
85
- return context;
86
- },
87
- subscribe(listener) {
88
- if (typeof listener !== "function") {
89
- return () => {};
90
- }
91
- listeners.add(listener);
92
- return () => {
93
- listeners.delete(listener);
94
- };
95
- },
96
- setCalls
97
- };
98
- }
99
-
100
- function resolvePathFromFullPath(fullPath = "/") {
101
- const normalizedFullPath = String(fullPath || "").trim() || "/";
102
- const queryStart = normalizedFullPath.indexOf("?");
103
- const hashStart = normalizedFullPath.indexOf("#");
104
- const stopIndex = [queryStart, hashStart]
105
- .filter((index) => index >= 0)
106
- .sort((left, right) => left - right)[0];
107
- if (typeof stopIndex !== "number") {
108
- return normalizedFullPath;
109
- }
110
- return normalizedFullPath.slice(0, stopIndex) || "/";
111
- }
112
-
113
- function createRouterStub(initialPath = "/w/acme/dashboard") {
114
- const afterEachListeners = [];
115
- const replaceCalls = [];
116
- const normalizedInitialPath = String(initialPath || "").trim() || "/";
117
- const router = {
118
- currentRoute: {
119
- value: {
120
- path: resolvePathFromFullPath(normalizedInitialPath),
121
- fullPath: normalizedInitialPath
122
- }
123
- },
124
- afterEach(listener) {
125
- afterEachListeners.push(listener);
126
- return () => {
127
- const index = afterEachListeners.indexOf(listener);
128
- if (index >= 0) {
129
- afterEachListeners.splice(index, 1);
130
- }
131
- };
132
- },
133
- replace(target) {
134
- const fullPath = String(target || "").trim() || "/";
135
- router.currentRoute.value.path = resolvePathFromFullPath(fullPath);
136
- router.currentRoute.value.fullPath = fullPath;
137
- replaceCalls.push(fullPath);
138
- for (const listener of [...afterEachListeners]) {
139
- listener();
140
- }
141
- return Promise.resolve();
142
- },
143
- push(target) {
144
- return router.replace(target);
145
- },
146
- emitAfterEach() {
147
- for (const listener of [...afterEachListeners]) {
148
- listener();
149
- }
150
- },
151
- replaceCalls
152
- };
153
-
154
- return router;
155
- }
156
-
157
- function createSocketStub() {
158
- const listeners = new Map();
159
- return {
160
- on(eventName, handler) {
161
- listeners.set(eventName, handler);
162
- },
163
- off(eventName, handler) {
164
- if (listeners.get(eventName) === handler) {
165
- listeners.delete(eventName);
166
- }
167
- },
168
- emit(eventName, payload) {
169
- listeners.get(eventName)?.(payload);
170
- }
171
- };
172
- }
173
-
174
- function createAppStub(records = {}) {
175
- const registry = new Map();
176
- for (const key of Reflect.ownKeys(records)) {
177
- registry.set(key, records[key]);
178
- }
179
- return {
180
- has(token) {
181
- return registry.has(token);
182
- },
183
- make(token) {
184
- return registry.get(token);
185
- },
186
- warn() {}
187
- };
188
- }
189
-
190
- function createVuetifyThemeController(initial = "light") {
191
- return {
192
- global: {
193
- name: {
194
- value: initial
195
- }
196
- },
197
- themes: {
198
- value: {
199
- light: {
200
- dark: false,
201
- colors: {
202
- primary: "#0f6b54",
203
- secondary: "#3f5150",
204
- background: "#eef3ee",
205
- surface: "#f7fbf6",
206
- "surface-variant": "#dfe8df"
207
- }
208
- },
209
- dark: {
210
- dark: true,
211
- colors: {
212
- primary: "#6fd0b5",
213
- secondary: "#9db2af",
214
- background: "#0f1715",
215
- surface: "#16211e",
216
- "surface-variant": "#253430"
217
- }
218
- }
219
- }
220
- }
221
- };
222
- }
223
-
224
- function createVueAppWithThemeController(themeController) {
225
- return {
226
- _context: {
227
- provides: {
228
- [ThemeSymbol]: themeController
229
- }
230
- }
231
- };
232
- }
233
-
234
- test("bootstrap placement runtime writes user/workspace/permissions into placement context", async () => {
235
- const placementRuntime = createPlacementRuntimeStub();
236
- const router = createRouterStub("/w/acme/dashboard");
237
- const fetchCalls = [];
238
- const runtime = createBootstrapPlacementRuntime({
239
- app: createAppStub({
240
- ["runtime.web-placement.client"]: placementRuntime,
241
- ["jskit.client.router"]: router
242
- }),
243
- fetchBootstrap: async (workspaceSlug) => {
244
- fetchCalls.push(workspaceSlug);
245
- return {
246
- session: {
247
- authenticated: true,
248
- userId: "7"
249
- },
250
- profile: {
251
- displayName: "Ada Lovelace",
252
- email: "ADA@EXAMPLE.COM",
253
- avatar: {
254
- effectiveUrl: "https://cdn.example.com/ada.png"
255
- }
256
- },
257
- app: {
258
- features: {
259
- workspaceInvites: true
260
- }
261
- },
262
- pendingInvites: [
263
- { id: "1", workspaceId: "1", token: "a" },
264
- { id: "2", workspaceId: "2", token: "b" }
265
- ],
266
- workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
267
- permissions: ["workspace.settings.view"]
268
- };
269
- }
270
- });
271
-
272
- await runtime.initialize();
273
- const context = placementRuntime.getContext();
274
-
275
- assert.deepEqual(fetchCalls, ["acme"]);
276
- assert.equal(context.workspace?.slug, "acme");
277
- assert.equal(Array.isArray(context.workspaces), true);
278
- assert.equal(context.workspaces.length, 1);
279
- assert.deepEqual(context.permissions, ["workspace.settings.view"]);
280
- assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
281
- assert.equal(context.workspaceBootstrapStatuses?.acme, WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
282
- assert.deepEqual(context.user, {
283
- id: "7",
284
- displayName: "Ada Lovelace",
285
- name: "Ada Lovelace",
286
- email: "ada@example.com",
287
- avatarUrl: "https://cdn.example.com/ada.png"
288
- });
289
- assert.equal(context.workspaceInvitesEnabled, true);
290
- assert.equal(context.pendingInvitesCount, 2);
291
- });
292
-
293
- test("bootstrap placement runtime resolves workspace slug from pathname when surface config is missing", async () => {
294
- const placementRuntime = createPlacementRuntimeStub();
295
- placementRuntime.setContext({}, { replace: true, source: "test.clear" });
296
- const router = createRouterStub("/w/acme/admin");
297
- const fetchCalls = [];
298
- const runtime = createBootstrapPlacementRuntime({
299
- app: createAppStub({
300
- ["runtime.web-placement.client"]: placementRuntime,
301
- ["jskit.client.router"]: router
302
- }),
303
- fetchBootstrap: async (workspaceSlug) => {
304
- fetchCalls.push(workspaceSlug);
305
- return {
306
- session: {
307
- authenticated: true,
308
- userId: "1"
309
- },
310
- profile: {
311
- displayName: "User",
312
- email: "user@example.com",
313
- avatar: {
314
- effectiveUrl: ""
315
- }
316
- },
317
- workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
318
- permissions: ["workspace.settings.view"]
319
- };
320
- }
321
- });
322
-
323
- await runtime.initialize();
324
-
325
- assert.deepEqual(fetchCalls, ["acme"]);
326
- assert.deepEqual(placementRuntime.getContext().permissions, ["workspace.settings.view"]);
327
- });
328
-
329
- test("bootstrap placement runtime does not mutate placement auth context", async () => {
330
- const placementRuntime = createPlacementRuntimeStub();
331
- placementRuntime.setContext(
332
- {
333
- auth: {
334
- authenticated: true,
335
- oauthDefaultProvider: "github",
336
- oauthProviders: [{ id: "github", label: "GitHub" }]
337
- }
338
- },
339
- { source: "test.seed" }
340
- );
341
- const router = createRouterStub("/w/acme/dashboard");
342
- const runtime = createBootstrapPlacementRuntime({
343
- app: createAppStub({
344
- ["runtime.web-placement.client"]: placementRuntime,
345
- ["jskit.client.router"]: router
346
- }),
347
- fetchBootstrap: async () => {
348
- return {
349
- session: {
350
- authenticated: true,
351
- userId: "9"
352
- },
353
- profile: {
354
- displayName: "User",
355
- email: "user@example.com",
356
- avatar: {
357
- effectiveUrl: ""
358
- }
359
- },
360
- workspaces: [{ id: "1", slug: "acme", name: "Workspace" }],
361
- permissions: []
362
- };
363
- }
364
- });
365
-
366
- await runtime.initialize();
367
- assert.deepEqual(placementRuntime.getContext().auth, {
368
- authenticated: true,
369
- oauthDefaultProvider: "github",
370
- oauthProviders: [{ id: "github", label: "GitHub" }]
371
- });
372
- });
373
-
374
- test("bootstrap placement runtime refetches on route changes and users.bootstrap.changed events", async () => {
375
- const placementRuntime = createPlacementRuntimeStub();
376
- const router = createRouterStub("/w/acme/dashboard");
377
- const socket = createSocketStub();
378
- const fetchCalls = [];
379
- const runtime = createBootstrapPlacementRuntime({
380
- app: createAppStub({
381
- ["runtime.web-placement.client"]: placementRuntime,
382
- ["jskit.client.router"]: router,
383
- ["runtime.realtime.client.socket"]: socket
384
- }),
385
- fetchBootstrap: async (workspaceSlug) => {
386
- fetchCalls.push(workspaceSlug);
387
- return {
388
- session: {
389
- authenticated: true,
390
- userId: "1"
391
- },
392
- profile: {
393
- displayName: "User",
394
- email: "user@example.com",
395
- avatar: {
396
- effectiveUrl: ""
397
- }
398
- },
399
- workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
400
- permissions: []
401
- };
402
- }
403
- });
404
-
405
- await runtime.initialize();
406
- assert.deepEqual(fetchCalls, ["acme"]);
407
-
408
- router.currentRoute.value.path = "/w/acme/customers";
409
- router.currentRoute.value.fullPath = "/w/acme/customers";
410
- router.emitAfterEach();
411
- await flushTasks();
412
- assert.deepEqual(fetchCalls, ["acme"]);
413
-
414
- router.currentRoute.value.path = "/w/zen/dashboard";
415
- router.currentRoute.value.fullPath = "/w/zen/dashboard";
416
- router.emitAfterEach();
417
- await flushTasks();
418
- assert.deepEqual(fetchCalls, ["acme", "zen"]);
419
-
420
- socket.emit("users.bootstrap.changed", {});
421
- await flushTasks();
422
- assert.deepEqual(fetchCalls, ["acme", "zen", "zen"]);
423
- });
424
-
425
- test("bootstrap placement runtime refetches when auth context changes", async () => {
426
- const placementRuntime = createPlacementRuntimeStub();
427
- const router = createRouterStub("/w/acme/dashboard");
428
- const fetchCalls = [];
429
- const runtime = createBootstrapPlacementRuntime({
430
- app: createAppStub({
431
- ["runtime.web-placement.client"]: placementRuntime,
432
- ["jskit.client.router"]: router
433
- }),
434
- fetchBootstrap: async (workspaceSlug) => {
435
- fetchCalls.push(workspaceSlug);
436
- return {
437
- session: {
438
- authenticated: true,
439
- userId: "1"
440
- },
441
- profile: {
442
- displayName: "User",
443
- email: "user@example.com",
444
- avatar: {
445
- effectiveUrl: ""
446
- }
447
- },
448
- workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
449
- permissions: []
450
- };
451
- }
452
- });
453
-
454
- await runtime.initialize();
455
- assert.deepEqual(fetchCalls, ["acme"]);
456
-
457
- placementRuntime.setContext(
458
- {
459
- auth: {
460
- authenticated: true,
461
- oauthDefaultProvider: "",
462
- oauthProviders: []
463
- }
464
- },
465
- {
466
- source: "test.auth"
467
- }
468
- );
469
- await flushTasks();
470
- await flushTasks();
471
- assert.deepEqual(fetchCalls, ["acme", "acme"]);
472
- });
473
-
474
- test("bootstrap placement runtime applies persisted theme preference for unauthenticated bootstrap payloads", async () => {
475
- const placementRuntime = createPlacementRuntimeStub();
476
- const router = createRouterStub("/auth/login");
477
- const themeController = createVuetifyThemeController("dark");
478
- const storage = new Map();
479
- storage.set("jskit.themePreference", "dark");
480
- const originalWindow = globalThis.window;
481
- globalThis.window = {
482
- localStorage: {
483
- getItem(key) {
484
- return storage.get(key) || null;
485
- },
486
- setItem(key, value) {
487
- storage.set(key, value);
488
- }
489
- }
490
- };
491
- const runtime = createBootstrapPlacementRuntime({
492
- app: createAppStub({
493
- ["runtime.web-placement.client"]: placementRuntime,
494
- ["jskit.client.router"]: router,
495
- ["jskit.client.vue.app"]: createVueAppWithThemeController(themeController)
496
- }),
497
- fetchBootstrap: async () => {
498
- return {
499
- session: {
500
- authenticated: false
501
- },
502
- workspaces: [],
503
- permissions: []
504
- };
505
- }
506
- });
507
-
508
- try {
509
- await runtime.initialize();
510
- assert.equal(themeController.global.name.value, "dark");
511
- } finally {
512
- if (typeof originalWindow === "undefined") {
513
- delete globalThis.window;
514
- } else {
515
- globalThis.window = originalWindow;
516
- }
517
- }
518
- });
519
-
520
- test("bootstrap placement runtime reapplies theme when bootstrap payload changes", async () => {
521
- const placementRuntime = createPlacementRuntimeStub();
522
- const router = createRouterStub("/w/acme/dashboard");
523
- const socket = createSocketStub();
524
- const themeController = createVuetifyThemeController("light");
525
- let fetchCount = 0;
526
- const runtime = createBootstrapPlacementRuntime({
527
- app: createAppStub({
528
- ["runtime.web-placement.client"]: placementRuntime,
529
- ["jskit.client.router"]: router,
530
- ["runtime.realtime.client.socket"]: socket,
531
- ["jskit.client.vue.app"]: createVueAppWithThemeController(themeController)
532
- }),
533
- fetchBootstrap: async (workspaceSlug) => {
534
- fetchCount += 1;
535
- return {
536
- session: {
537
- authenticated: true,
538
- userId: "1"
539
- },
540
- profile: {
541
- displayName: "User",
542
- email: "user@example.com",
543
- avatar: {
544
- effectiveUrl: ""
545
- }
546
- },
547
- userSettings: {
548
- theme: fetchCount === 1 ? "dark" : "light"
549
- },
550
- workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
551
- permissions: []
552
- };
553
- }
554
- });
555
-
556
- await runtime.initialize();
557
- assert.equal(themeController.global.name.value, "workspace-dark");
558
-
559
- socket.emit("users.bootstrap.changed", {});
560
- await flushTasks();
561
- assert.equal(themeController.global.name.value, "workspace-light");
562
- });
563
-
564
- test("bootstrap placement runtime applies workspace palette via Vuetify workspace themes and clears it off workspace routes", async () => {
565
- const placementRuntime = createPlacementRuntimeStub();
566
- const router = createRouterStub("/w/acme/dashboard");
567
- const themeController = createVuetifyThemeController("light");
568
- const runtime = createBootstrapPlacementRuntime({
569
- app: createAppStub({
570
- ["runtime.web-placement.client"]: placementRuntime,
571
- ["jskit.client.router"]: router,
572
- ["jskit.client.vue.app"]: createVueAppWithThemeController(themeController)
573
- }),
574
- fetchBootstrap: async (workspaceSlug) => {
575
- return {
576
- session: {
577
- authenticated: true,
578
- userId: "1"
579
- },
580
- workspaceSettings: {
581
- lightPrimaryColor: "#CC3344",
582
- lightSecondaryColor: "#884455",
583
- lightSurfaceColor: "#F4F4F4",
584
- lightSurfaceVariantColor: "#444444",
585
- darkPrimaryColor: "#BB2233",
586
- darkSecondaryColor: "#557799",
587
- darkSurfaceColor: "#202020",
588
- darkSurfaceVariantColor: "#A0A0A0"
589
- },
590
- workspaces: [
591
- {
592
- id: "1",
593
- slug: "acme",
594
- name: "Acme Workspace"
595
- }
596
- ],
597
- permissions: []
598
- };
599
- }
600
- });
601
-
602
- await runtime.initialize();
603
- const palette = resolveWorkspaceThemePalette({
604
- lightPrimaryColor: "#CC3344",
605
- lightSecondaryColor: "#884455",
606
- lightSurfaceColor: "#F4F4F4",
607
- lightSurfaceVariantColor: "#444444"
608
- }, {
609
- mode: "light"
610
- });
611
- assert.equal(themeController.global.name.value, "workspace-light");
612
- assert.equal(themeController.themes.value["workspace-light"].colors.primary, palette.color);
613
- assert.equal(themeController.themes.value["workspace-light"].colors.secondary, palette.secondaryColor);
614
- assert.equal(themeController.themes.value["workspace-light"].colors.surface, palette.surfaceColor);
615
- assert.equal(
616
- themeController.themes.value["workspace-light"].colors["surface-variant"],
617
- palette.surfaceVariantColor
618
- );
619
-
620
- router.currentRoute.value.path = "/home";
621
- router.currentRoute.value.fullPath = "/home";
622
- router.emitAfterEach();
623
- await flushTasks();
624
- await flushTasks();
625
-
626
- assert.equal(themeController.global.name.value, "light");
627
- });
628
-
629
- test("bootstrap placement runtime marks workspace slug as not_found and clears workspace context on 404", async () => {
630
- const placementRuntime = createPlacementRuntimeStub();
631
- placementRuntime.setContext(
632
- {
633
- workspace: { id: "1", slug: "acme", name: "Acme Workspace" },
634
- workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
635
- permissions: ["workspace.settings.view"]
636
- },
637
- { source: "test.seed" }
638
- );
639
-
640
- const runtime = createBootstrapPlacementRuntime({
641
- app: createAppStub({
642
- ["runtime.web-placement.client"]: placementRuntime,
643
- ["jskit.client.router"]: createRouterStub("/w/acme/dashboard")
644
- }),
645
- fetchBootstrap: async () => {
646
- const error = new Error("Workspace not found.");
647
- error.status = 404;
648
- throw error;
649
- }
650
- });
651
-
652
- await runtime.initialize();
653
-
654
- const context = placementRuntime.getContext();
655
- assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
656
- assert.equal(context.workspaceBootstrapStatuses?.acme, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
657
- assert.equal(context.workspace, null);
658
- assert.deepEqual(context.workspaces, []);
659
- assert.deepEqual(context.permissions, []);
660
- assert.equal(context.user, null);
661
- assert.equal(context.pendingInvitesCount, 0);
662
- assert.equal(context.workspaceInvitesEnabled, false);
663
- });
664
-
665
- test("bootstrap placement runtime updates status per workspace slug across route changes", async () => {
666
- const placementRuntime = createPlacementRuntimeStub();
667
- const router = createRouterStub("/w/acme/dashboard");
668
- const runtime = createBootstrapPlacementRuntime({
669
- app: createAppStub({
670
- ["runtime.web-placement.client"]: placementRuntime,
671
- ["jskit.client.router"]: router
672
- }),
673
- fetchBootstrap: async (workspaceSlug) => {
674
- if (workspaceSlug === "zen") {
675
- const error = new Error("Workspace not found.");
676
- error.status = 404;
677
- throw error;
678
- }
679
-
680
- return {
681
- session: {
682
- authenticated: true,
683
- userId: "1"
684
- },
685
- profile: {
686
- displayName: "User",
687
- email: "user@example.com",
688
- avatar: {
689
- effectiveUrl: ""
690
- }
691
- },
692
- workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
693
- permissions: []
694
- };
695
- }
696
- });
697
-
698
- await runtime.initialize();
699
- assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
700
-
701
- router.currentRoute.value.path = "/w/zen/dashboard";
702
- router.currentRoute.value.fullPath = "/w/zen/dashboard";
703
- router.emitAfterEach();
704
- await flushTasks();
705
-
706
- const context = placementRuntime.getContext();
707
- assert.equal(runtime.getWorkspaceBootstrapStatus("zen"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
708
- assert.equal(context.workspaceBootstrapStatuses?.zen, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
709
- assert.equal(context.workspace, null);
710
- });
711
-
712
- test("bootstrap placement runtime uses requestedWorkspace status and keeps global workspace list on inaccessible slug", async () => {
713
- const placementRuntime = createPlacementRuntimeStub();
714
- const router = createRouterStub("/w/tonymobily");
715
- const runtime = createBootstrapPlacementRuntime({
716
- app: createAppStub({
717
- ["runtime.web-placement.client"]: placementRuntime,
718
- ["jskit.client.router"]: router
719
- }),
720
- fetchBootstrap: async () => {
721
- return {
722
- session: {
723
- authenticated: true,
724
- userId: "4"
725
- },
726
- profile: {
727
- displayName: "Chiara",
728
- email: "chiara@example.com",
729
- avatar: {
730
- effectiveUrl: ""
731
- }
732
- },
733
- workspaces: [{ id: "3", slug: "chiaramobily", name: "Chiara Workspace" }],
734
- requestedWorkspace: {
735
- slug: "tonymobily",
736
- status: "forbidden"
737
- },
738
- permissions: []
739
- };
740
- }
741
- });
742
-
743
- await runtime.initialize();
744
-
745
- const context = placementRuntime.getContext();
746
- assert.equal(runtime.getWorkspaceBootstrapStatus("tonymobily"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
747
- assert.equal(context.workspaceBootstrapStatuses?.tonymobily, WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
748
- assert.equal(context.workspace, null);
749
- assert.equal(Array.isArray(context.workspaces), true);
750
- assert.equal(context.workspaces.length, 1);
751
- assert.equal(context.workspaces[0]?.slug, "chiaramobily");
752
- });
753
-
754
- test("bootstrap placement runtime uses requestedWorkspace=not_found without forcing forbidden fallback", async () => {
755
- const placementRuntime = createPlacementRuntimeStub();
756
- const router = createRouterStub("/w/missing");
757
- const runtime = createBootstrapPlacementRuntime({
758
- app: createAppStub({
759
- ["runtime.web-placement.client"]: placementRuntime,
760
- ["jskit.client.router"]: router
761
- }),
762
- fetchBootstrap: async () => {
763
- return {
764
- session: {
765
- authenticated: true,
766
- userId: "1"
767
- },
768
- profile: {
769
- displayName: "User",
770
- email: "user@example.com",
771
- avatar: {
772
- effectiveUrl: ""
773
- }
774
- },
775
- workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
776
- requestedWorkspace: {
777
- slug: "missing",
778
- status: "not_found"
779
- },
780
- permissions: []
781
- };
782
- }
783
- });
784
-
785
- await runtime.initialize();
786
-
787
- const context = placementRuntime.getContext();
788
- assert.equal(runtime.getWorkspaceBootstrapStatus("missing"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
789
- assert.equal(context.workspaceBootstrapStatuses?.missing, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
790
- assert.equal(Array.isArray(context.workspaces), true);
791
- assert.equal(context.workspaces.length, 1);
792
- assert.equal(context.workspaces[0]?.slug, "acme");
793
- });
794
-
795
- test("bootstrap placement runtime guard wrapper preserves delegated deny outcomes", async () => {
796
- const placementRuntime = createPlacementRuntimeStub();
797
- const router = createRouterStub("/w/acme/dashboard");
798
- const delegatedOutcome = Object.freeze({
799
- allow: false,
800
- redirectTo: "/auth/login?returnTo=%2Fw%2Facme%2Fdashboard",
801
- reason: "auth-required"
802
- });
803
- globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => delegatedOutcome;
804
-
805
- const runtime = createBootstrapPlacementRuntime({
806
- app: createAppStub({
807
- ["runtime.web-placement.client"]: placementRuntime,
808
- ["jskit.client.router"]: router
809
- }),
810
- fetchBootstrap: async () => {
811
- return {
812
- session: {
813
- authenticated: true,
814
- userId: "1"
815
- },
816
- workspaces: [{ id: "1", slug: "acme", name: "Acme" }],
817
- permissions: []
818
- };
819
- }
820
- });
821
-
822
- await runtime.initialize();
823
- const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
824
- const outcome = evaluator({
825
- guard: {
826
- policy: "authenticated"
827
- },
828
- context: {
829
- to: {
830
- path: "/w/acme/dashboard",
831
- fullPath: "/w/acme/dashboard"
832
- },
833
- location: {
834
- pathname: "/w/acme/dashboard",
835
- search: ""
836
- }
837
- }
838
- });
839
-
840
- assert.deepEqual(outcome, delegatedOutcome);
841
- });
842
-
843
- test("bootstrap placement runtime guard wrapper blocks forbidden workspace routes", async () => {
844
- const placementRuntime = createPlacementRuntimeStub();
845
- const router = createRouterStub("/w/acme/dashboard");
846
- globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => ({ allow: true, redirectTo: "", reason: "" });
847
-
848
- const runtime = createBootstrapPlacementRuntime({
849
- app: createAppStub({
850
- ["runtime.web-placement.client"]: placementRuntime,
851
- ["jskit.client.router"]: router
852
- }),
853
- fetchBootstrap: async () => {
854
- return {
855
- session: {
856
- authenticated: true,
857
- userId: "1"
858
- },
859
- workspaces: [],
860
- permissions: []
861
- };
862
- }
863
- });
864
-
865
- await runtime.initialize();
866
- assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
867
-
868
- const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
869
- const outcome = evaluator({
870
- guard: {
871
- policy: "authenticated"
872
- },
873
- context: {
874
- to: {
875
- path: "/w/acme/dashboard",
876
- fullPath: "/w/acme/dashboard?tab=general"
877
- },
878
- location: {
879
- pathname: "/w/acme/dashboard",
880
- search: "?tab=general"
881
- }
882
- }
883
- });
884
-
885
- assert.equal(outcome.allow, false);
886
- assert.equal(outcome.reason, "workspace-forbidden");
887
- assert.equal(outcome.redirectTo, "/w/acme");
888
- });
889
-
890
- test("bootstrap placement runtime guard wrapper redirects nested not_found routes to workspace surface root", async () => {
891
- const placementRuntime = createPlacementRuntimeStub();
892
- const router = createRouterStub("/w/acme/dashboard");
893
- const runtime = createBootstrapPlacementRuntime({
894
- app: createAppStub({
895
- ["runtime.web-placement.client"]: placementRuntime,
896
- ["jskit.client.router"]: router
897
- }),
898
- fetchBootstrap: async () => {
899
- const error = new Error("Not found");
900
- error.status = 404;
901
- throw error;
902
- }
903
- });
904
-
905
- await runtime.initialize();
906
- assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
907
-
908
- const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
909
- const nestedOutcome = evaluator({
910
- guard: {
911
- policy: "authenticated"
912
- },
913
- context: {
914
- to: {
915
- path: "/w/acme/projects",
916
- fullPath: "/w/acme/projects"
917
- },
918
- location: {
919
- pathname: "/w/acme/projects",
920
- search: ""
921
- }
922
- }
923
- });
924
- assert.deepEqual(nestedOutcome, {
925
- allow: false,
926
- redirectTo: "/w/acme",
927
- reason: "workspace-not-found"
928
- });
929
-
930
- const rootOutcome = evaluator({
931
- guard: {
932
- policy: "authenticated"
933
- },
934
- context: {
935
- to: {
936
- path: "/w/acme",
937
- fullPath: "/w/acme"
938
- },
939
- location: {
940
- pathname: "/w/acme",
941
- search: ""
942
- }
943
- }
944
- });
945
- assert.equal(rootOutcome, true);
946
- });
947
-
948
- test("bootstrap placement runtime redirects admin nested route to admin root when workspace is not_found", async () => {
949
- const placementRuntime = createPlacementRuntimeStub();
950
- const router = createRouterStub("/w/acme/admin/workspace/settings");
951
- const runtime = createBootstrapPlacementRuntime({
952
- app: createAppStub({
953
- ["runtime.web-placement.client"]: placementRuntime,
954
- ["jskit.client.router"]: router
955
- }),
956
- fetchBootstrap: async () => {
957
- const error = new Error("Not found");
958
- error.status = 404;
959
- throw error;
960
- }
961
- });
962
-
963
- await runtime.initialize();
964
- assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
965
- assert.deepEqual(router.replaceCalls, ["/w/acme/admin"]);
966
- });
967
-
968
- test("bootstrap placement runtime redirects forbidden workspace route to workspace surface root", async () => {
969
- const placementRuntime = createPlacementRuntimeStub();
970
- const router = createRouterStub("/w/acme/admin/workspace/settings?tab=general");
971
- const runtime = createBootstrapPlacementRuntime({
972
- app: createAppStub({
973
- ["runtime.web-placement.client"]: placementRuntime,
974
- ["jskit.client.router"]: router
975
- }),
976
- fetchBootstrap: async () => {
977
- const error = new Error("Forbidden");
978
- error.status = 403;
979
- throw error;
980
- }
981
- });
982
-
983
- await runtime.initialize();
984
- assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
985
- assert.deepEqual(router.replaceCalls, ["/w/acme/admin"]);
986
- });
987
-
988
- test("bootstrap placement runtime enforces surface access policies after bootstrap refresh", async () => {
989
- const placementRuntime = createPlacementRuntimeStub();
990
- placementRuntime.setContext({
991
- auth: {
992
- authenticated: true
993
- },
994
- surfaceAccessPolicies: {
995
- public: {},
996
- console_owner: {
997
- requireAuth: true,
998
- requireFlagsAll: ["console_owner"]
999
- }
1000
- },
1001
- surfaceConfig: {
1002
- tenancyMode: "workspaces",
1003
- defaultSurfaceId: "home",
1004
- enabledSurfaceIds: ["home", "console"],
1005
- surfacesById: {
1006
- home: {
1007
- id: "home",
1008
- enabled: true,
1009
- pagesRoot: "home",
1010
- routeBase: "/home",
1011
- requiresWorkspace: false,
1012
- accessPolicyId: "public"
1013
- },
1014
- console: {
1015
- id: "console",
1016
- enabled: true,
1017
- pagesRoot: "console",
1018
- routeBase: "/console",
1019
- requiresWorkspace: false,
1020
- accessPolicyId: "console_owner"
1021
- }
1022
- }
1023
- }
1024
- });
1025
- const router = createRouterStub("/console");
1026
- const runtime = createBootstrapPlacementRuntime({
1027
- app: createAppStub({
1028
- ["runtime.web-placement.client"]: placementRuntime,
1029
- ["jskit.client.router"]: router
1030
- }),
1031
- fetchBootstrap: async () => {
1032
- return {
1033
- session: {
1034
- authenticated: true,
1035
- userId: "1"
1036
- },
1037
- workspaces: [],
1038
- permissions: [],
1039
- surfaceAccess: {
1040
- consoleowner: false
1041
- }
1042
- };
1043
- }
1044
- });
1045
-
1046
- await runtime.initialize();
1047
- assert.deepEqual(router.replaceCalls, ["/home"]);
1048
- });
1049
-
1050
- test("bootstrap placement runtime captures guard evaluator assignments after initialization", async () => {
1051
- const placementRuntime = createPlacementRuntimeStub();
1052
- const router = createRouterStub("/w/acme/dashboard");
1053
- const runtime = createBootstrapPlacementRuntime({
1054
- app: createAppStub({
1055
- ["runtime.web-placement.client"]: placementRuntime,
1056
- ["jskit.client.router"]: router
1057
- }),
1058
- fetchBootstrap: async () => {
1059
- return {
1060
- session: {
1061
- authenticated: true,
1062
- userId: "1"
1063
- },
1064
- workspaces: [{ id: "1", slug: "acme", name: "Acme" }],
1065
- permissions: []
1066
- };
1067
- }
1068
- });
1069
-
1070
- await runtime.initialize();
1071
- const delegatedOutcome = {
1072
- allow: false,
1073
- redirectTo: "/auth/login",
1074
- reason: "auth-required"
1075
- };
1076
- globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => delegatedOutcome;
1077
-
1078
- const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
1079
- const outcome = evaluator({
1080
- guard: {
1081
- policy: "authenticated"
1082
- },
1083
- context: {
1084
- to: {
1085
- path: "/w/acme/dashboard",
1086
- fullPath: "/w/acme/dashboard"
1087
- },
1088
- location: {
1089
- pathname: "/w/acme/dashboard",
1090
- search: ""
1091
- }
1092
- }
1093
- });
1094
- assert.deepEqual(outcome, delegatedOutcome);
1095
- });