@jskit-ai/users-web 0.1.4

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 (97) hide show
  1. package/package.descriptor.mjs +507 -0
  2. package/package.json +31 -0
  3. package/src/client/components/ConsoleSettingsClientElement.vue +24 -0
  4. package/src/client/components/MembersAdminClientElement.vue +404 -0
  5. package/src/client/components/ProfileClientElement.vue +242 -0
  6. package/src/client/components/UsersProfileSurfaceSwitchMenuItem.vue +39 -0
  7. package/src/client/components/UsersShellMenuLinkItem.vue +140 -0
  8. package/src/client/components/UsersSurfaceAwareMenuLinkItem.vue +87 -0
  9. package/src/client/components/UsersWorkspaceMembersMenuItem.vue +36 -0
  10. package/src/client/components/UsersWorkspacePermissionMenuItem.vue +90 -0
  11. package/src/client/components/UsersWorkspaceSelector.vue +237 -0
  12. package/src/client/components/UsersWorkspaceSettingsMenuItem.vue +39 -0
  13. package/src/client/components/UsersWorkspaceToolsWidget.vue +23 -0
  14. package/src/client/components/WorkspaceMembersClientElement.vue +663 -0
  15. package/src/client/components/WorkspaceSettingsClientElement.vue +230 -0
  16. package/src/client/components/WorkspacesClientElement.vue +514 -0
  17. package/src/client/composables/accountSettingsAvatarUploadRuntime.js +241 -0
  18. package/src/client/composables/accountSettingsInvitesRuntime.js +88 -0
  19. package/src/client/composables/accountSettingsRuntimeConstants.js +77 -0
  20. package/src/client/composables/accountSettingsRuntimeHelpers.js +75 -0
  21. package/src/client/composables/errorMessageHelpers.js +66 -0
  22. package/src/client/composables/internal/useOperationScope.js +144 -0
  23. package/src/client/composables/modelStateHelpers.js +49 -0
  24. package/src/client/composables/operationUiHelpers.js +121 -0
  25. package/src/client/composables/operationValidationHelpers.js +52 -0
  26. package/src/client/composables/refValueHelpers.js +19 -0
  27. package/src/client/composables/scopeHelpers.js +145 -0
  28. package/src/client/composables/useAccess.js +109 -0
  29. package/src/client/composables/useAccountSettingsRuntime.js +533 -0
  30. package/src/client/composables/useAddEdit.js +135 -0
  31. package/src/client/composables/useAddEditCore.js +137 -0
  32. package/src/client/composables/useBootstrapQuery.js +52 -0
  33. package/src/client/composables/useCommand.js +112 -0
  34. package/src/client/composables/useCommandCore.js +130 -0
  35. package/src/client/composables/useEndpointResource.js +104 -0
  36. package/src/client/composables/useFieldErrorBag.js +61 -0
  37. package/src/client/composables/useList.js +85 -0
  38. package/src/client/composables/useListCore.js +65 -0
  39. package/src/client/composables/usePagedCollection.js +125 -0
  40. package/src/client/composables/usePaths.js +108 -0
  41. package/src/client/composables/useRealtimeQueryInvalidation.js +105 -0
  42. package/src/client/composables/useScopeRuntime.js +107 -0
  43. package/src/client/composables/useSurfaceRouteContext.js +31 -0
  44. package/src/client/composables/useUiFeedback.js +96 -0
  45. package/src/client/composables/useView.js +89 -0
  46. package/src/client/composables/useViewCore.js +104 -0
  47. package/src/client/composables/useWorkspaceRouteContext.js +28 -0
  48. package/src/client/composables/useWorkspaceSurfaceId.js +43 -0
  49. package/src/client/index.js +7 -0
  50. package/src/client/lib/bootstrap.js +95 -0
  51. package/src/client/lib/httpClient.js +67 -0
  52. package/src/client/lib/menuIcons.js +192 -0
  53. package/src/client/lib/permissions.js +34 -0
  54. package/src/client/lib/profileSurfaceMenuLinks.js +142 -0
  55. package/src/client/lib/surfaceAccessPolicy.js +350 -0
  56. package/src/client/lib/theme.js +99 -0
  57. package/src/client/lib/workspaceLinkResolver.js +207 -0
  58. package/src/client/lib/workspaceSurfaceContext.js +82 -0
  59. package/src/client/lib/workspaceSurfacePaths.js +163 -0
  60. package/src/client/providers/UsersWebClientProvider.js +85 -0
  61. package/src/client/runtime/bootstrapPlacementRouteGuards.js +371 -0
  62. package/src/client/runtime/bootstrapPlacementRuntime.js +413 -0
  63. package/src/client/runtime/bootstrapPlacementRuntimeConstants.js +32 -0
  64. package/src/client/runtime/bootstrapPlacementRuntimeHelpers.js +157 -0
  65. package/src/client/support/contractGuards.js +34 -0
  66. package/src/client/support/realtimeWorkspace.js +12 -0
  67. package/src/client/support/runtimeNormalization.js +27 -0
  68. package/src/client/support/workspaceQueryKeys.js +15 -0
  69. package/templates/packages/main/src/client/components/AccountPendingInvitesCue.vue +162 -0
  70. package/templates/src/components/WorkspaceNotFoundCard.vue +33 -0
  71. package/templates/src/components/account/settings/AccountSettingsClientElement.vue +153 -0
  72. package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +77 -0
  73. package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +55 -0
  74. package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +125 -0
  75. package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +94 -0
  76. package/templates/src/composables/useWorkspaceNotFoundState.js +48 -0
  77. package/templates/src/pages/account/index.vue +17 -0
  78. package/templates/src/pages/admin/members/index.vue +7 -0
  79. package/templates/src/pages/admin/workspace/settings/index.vue +16 -0
  80. package/templates/src/pages/console/settings/index.vue +16 -0
  81. package/templates/src/surfaces/admin/index.vue +29 -0
  82. package/templates/src/surfaces/admin/root.vue +20 -0
  83. package/templates/src/surfaces/app/index.vue +27 -0
  84. package/templates/src/surfaces/app/root.vue +20 -0
  85. package/test/bootstrap.test.js +38 -0
  86. package/test/bootstrapPlacementRuntime.test.js +991 -0
  87. package/test/errorMessageHelpers.test.js +28 -0
  88. package/test/exportsContract.test.js +39 -0
  89. package/test/menuIcons.test.js +33 -0
  90. package/test/permissions.test.js +35 -0
  91. package/test/profileSurfaceMenuLinks.test.js +207 -0
  92. package/test/refValueHelpers.test.js +14 -0
  93. package/test/scopeHelpers.test.js +57 -0
  94. package/test/surfaceAccessPolicy.test.js +129 -0
  95. package/test/theme.test.js +95 -0
  96. package/test/workspaceLinkResolver.test.js +61 -0
  97. package/test/workspaceSurfacePaths.test.js +39 -0
@@ -0,0 +1,28 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { toQueryErrorMessage, toUiErrorMessage } from "../src/client/composables/errorMessageHelpers.js";
4
+
5
+ test("toQueryErrorMessage returns empty when query has no error", () => {
6
+ assert.equal(toQueryErrorMessage(null, "Unable to load list."), "");
7
+ });
8
+
9
+ test("toQueryErrorMessage resolves specific runtime error messages before fallback", () => {
10
+ assert.equal(toQueryErrorMessage({ message: "Network timeout" }, "Unable to load list."), "Network timeout");
11
+ assert.equal(toQueryErrorMessage({}, "Unable to load list."), "Unable to load list.");
12
+ });
13
+
14
+ test("toQueryErrorMessage prefers fallback for generic transport messages", () => {
15
+ assert.equal(
16
+ toQueryErrorMessage({ status: 500, message: "Request failed with status 500." }, "Unable to load list."),
17
+ "Unable to load list."
18
+ );
19
+ assert.equal(
20
+ toQueryErrorMessage({ status: 0, message: "Network request failed." }, "Unable to load list."),
21
+ "Unable to load list."
22
+ );
23
+ });
24
+
25
+ test("toUiErrorMessage prefers fallback copy before runtime error text", () => {
26
+ assert.equal(toUiErrorMessage({ message: "Server exploded" }, "Unable to save."), "Unable to save.");
27
+ assert.equal(toUiErrorMessage({ message: "Server exploded" }, ""), "Server exploded");
28
+ });
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import { evaluatePackageExportsContract } from "../../../tooling/test-support/exportsContract.mjs";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
9
+ const PACKAGE_DIR = path.join(REPO_ROOT, "packages", "users-web");
10
+
11
+ test("users-web exports are explicit and aligned with production/template usage", () => {
12
+ const result = evaluatePackageExportsContract({
13
+ repoRoot: REPO_ROOT,
14
+ packageDir: PACKAGE_DIR,
15
+ packageId: "@jskit-ai/users-web",
16
+ requiredExports: ["./client"]
17
+ });
18
+
19
+ assert.deepEqual(
20
+ result.wildcardExports,
21
+ [],
22
+ `users-web exports must be explicit. Remove wildcard keys: ${result.wildcardExports.join(", ")}`
23
+ );
24
+ assert.deepEqual(
25
+ result.missingRequiredExports,
26
+ [],
27
+ `users-web required exports missing: ${result.missingRequiredExports.join(", ")}`
28
+ );
29
+ assert.deepEqual(
30
+ result.missingExports,
31
+ [],
32
+ `users-web imports missing from package exports:\n${result.missingExports.join("\n")}`
33
+ );
34
+ assert.deepEqual(
35
+ result.staleExports,
36
+ [],
37
+ `Stale users-web exports found. Remove stale keys: ${result.staleExports.join(", ")}`
38
+ );
39
+ });
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ mdiAccountCogOutline,
5
+ mdiConsoleNetworkOutline,
6
+ mdiCogOutline,
7
+ mdiFolderOutline,
8
+ mdiLogin,
9
+ mdiShieldCrownOutline,
10
+ mdiViewDashboardOutline,
11
+ mdiViewListOutline
12
+ } from "@mdi/js";
13
+ import {
14
+ resolveMenuLinkIcon,
15
+ resolveSurfaceSwitchIcon
16
+ } from "../src/client/lib/menuIcons.js";
17
+
18
+ test("resolveSurfaceSwitchIcon prefers explicit icon and maps known surfaces", () => {
19
+ assert.equal(resolveSurfaceSwitchIcon("admin"), mdiShieldCrownOutline);
20
+ assert.equal(resolveSurfaceSwitchIcon("console"), mdiConsoleNetworkOutline);
21
+ assert.equal(resolveSurfaceSwitchIcon("admin", "custom-icon"), "custom-icon");
22
+ });
23
+
24
+ test("resolveMenuLinkIcon resolves settings/login fallbacks and generic default", () => {
25
+ assert.equal(resolveMenuLinkIcon({ to: "/account" }), mdiAccountCogOutline);
26
+ assert.equal(resolveMenuLinkIcon({ label: "Sign in" }), mdiLogin);
27
+ assert.equal(resolveMenuLinkIcon({ label: "Go to admin" }), mdiShieldCrownOutline);
28
+ assert.equal(resolveMenuLinkIcon({ label: "Workspace", to: "/w/acme" }), mdiViewDashboardOutline);
29
+ assert.equal(resolveMenuLinkIcon({ to: "/projects" }), mdiFolderOutline);
30
+ assert.equal(resolveMenuLinkIcon({ label: "Settings", to: "/workspace/settings" }), mdiCogOutline);
31
+ assert.equal(resolveMenuLinkIcon({ label: "Anything else", to: "/foo" }), mdiFolderOutline);
32
+ assert.equal(resolveMenuLinkIcon({ label: "Anything else" }), mdiViewListOutline);
33
+ });
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { arePermissionListsEqual, hasPermission, normalizePermissionList } from "../src/client/lib/permissions.js";
4
+
5
+ test("normalizePermissionList trims, removes duplicates, and ignores empty entries", () => {
6
+ assert.deepEqual(
7
+ normalizePermissionList([" workspace.settings.view ", "", null, "workspace.settings.view", "workspace.members.view"]),
8
+ ["workspace.settings.view", "workspace.members.view"]
9
+ );
10
+ });
11
+
12
+ test("arePermissionListsEqual compares permission sets regardless of order or duplicates", () => {
13
+ assert.equal(
14
+ arePermissionListsEqual(
15
+ ["workspace.settings.view", "workspace.members.view", "workspace.settings.view"],
16
+ ["workspace.members.view", "workspace.settings.view"]
17
+ ),
18
+ true
19
+ );
20
+ });
21
+
22
+ test("arePermissionListsEqual returns false for different permission sets", () => {
23
+ assert.equal(
24
+ arePermissionListsEqual(
25
+ ["workspace.settings.view"],
26
+ ["workspace.members.view"]
27
+ ),
28
+ false
29
+ );
30
+ });
31
+
32
+ test("hasPermission supports namespace wildcard matches", () => {
33
+ assert.equal(hasPermission(["crud_contacts.*"], "crud_contacts.update"), true);
34
+ assert.equal(hasPermission(["crud_contacts.*"], "crud_projects.update"), false);
35
+ });
@@ -0,0 +1,207 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mdiShieldCrownOutline } from "@mdi/js";
4
+ import {
5
+ hasWorkspaceMembership,
6
+ resolvePrimarySurfaceSwitchLink,
7
+ resolveProfileSurfaceMenuLinks
8
+ } from "../src/client/lib/profileSurfaceMenuLinks.js";
9
+
10
+ function createPlacementContext({
11
+ workspace = null,
12
+ workspaces = [],
13
+ authenticated = true,
14
+ consoleOwner = false
15
+ } = {}) {
16
+ return {
17
+ auth: {
18
+ authenticated
19
+ },
20
+ workspace,
21
+ workspaces,
22
+ permissions: [],
23
+ surfaceAccess: {
24
+ consoleowner: consoleOwner
25
+ },
26
+ surfaceAccessPolicies: {
27
+ public: {},
28
+ workspace_member: {
29
+ requireAuth: true,
30
+ requireWorkspaceMembership: true
31
+ },
32
+ console_owner: {
33
+ requireAuth: true,
34
+ requireFlagsAll: ["console_owner"]
35
+ }
36
+ },
37
+ surfaceConfig: {
38
+ tenancyMode: "workspace",
39
+ defaultSurfaceId: "app",
40
+ enabledSurfaceIds: ["home", "app", "admin", "console"],
41
+ surfacesById: {
42
+ home: {
43
+ id: "home",
44
+ enabled: true,
45
+ pagesRoot: "",
46
+ routeBase: "/",
47
+ requiresAuth: false,
48
+ requiresWorkspace: false,
49
+ accessPolicyId: "public"
50
+ },
51
+ app: {
52
+ id: "app",
53
+ enabled: true,
54
+ pagesRoot: "w/[workspaceSlug]",
55
+ routeBase: "/w/:workspaceSlug",
56
+ requiresAuth: true,
57
+ requiresWorkspace: true,
58
+ accessPolicyId: "workspace_member"
59
+ },
60
+ admin: {
61
+ id: "admin",
62
+ enabled: true,
63
+ pagesRoot: "w/[workspaceSlug]/admin",
64
+ routeBase: "/w/:workspaceSlug/admin",
65
+ requiresAuth: true,
66
+ requiresWorkspace: true,
67
+ accessPolicyId: "workspace_member"
68
+ },
69
+ console: {
70
+ id: "console",
71
+ enabled: true,
72
+ pagesRoot: "console",
73
+ routeBase: "/console",
74
+ requiresAuth: true,
75
+ requiresWorkspace: false,
76
+ accessPolicyId: "console_owner"
77
+ }
78
+ }
79
+ }
80
+ };
81
+ }
82
+
83
+ const originalWindow = globalThis.window;
84
+
85
+ test.afterEach(() => {
86
+ if (typeof originalWindow === "undefined") {
87
+ delete globalThis.window;
88
+ return;
89
+ }
90
+ globalThis.window = originalWindow;
91
+ });
92
+
93
+ test("hasWorkspaceMembership matches context.workspace slug", () => {
94
+ const context = createPlacementContext({
95
+ workspace: {
96
+ id: 1,
97
+ slug: "acme"
98
+ },
99
+ workspaces: []
100
+ });
101
+
102
+ assert.equal(hasWorkspaceMembership(context, "acme"), true);
103
+ });
104
+
105
+ test("hasWorkspaceMembership matches context.workspaces slug", () => {
106
+ const context = createPlacementContext({
107
+ workspace: null,
108
+ workspaces: [
109
+ {
110
+ id: 1,
111
+ slug: "acme"
112
+ }
113
+ ]
114
+ });
115
+
116
+ assert.equal(hasWorkspaceMembership(context, "acme"), true);
117
+ });
118
+
119
+ test("resolvePrimarySurfaceSwitchLink shows Go to admin only for member workspace", () => {
120
+ const context = createPlacementContext({
121
+ workspace: {
122
+ id: 1,
123
+ slug: "acme"
124
+ },
125
+ workspaces: [
126
+ {
127
+ id: 1,
128
+ slug: "acme"
129
+ }
130
+ ]
131
+ });
132
+
133
+ const link = resolvePrimarySurfaceSwitchLink({
134
+ context,
135
+ surface: "app"
136
+ });
137
+
138
+ assert.deepEqual(link, {
139
+ id: "surface-switch.admin",
140
+ label: "Go to admin",
141
+ icon: mdiShieldCrownOutline,
142
+ to: "/w/acme/admin"
143
+ });
144
+ });
145
+
146
+ test("resolvePrimarySurfaceSwitchLink hides workspace switch when slug is only in URL and user is not member", () => {
147
+ globalThis.window = {
148
+ location: {
149
+ pathname: "/w/acme/admin",
150
+ search: "",
151
+ hash: ""
152
+ }
153
+ };
154
+
155
+ const context = createPlacementContext({
156
+ workspace: null,
157
+ workspaces: []
158
+ });
159
+
160
+ const link = resolvePrimarySurfaceSwitchLink({
161
+ context,
162
+ surface: "admin"
163
+ });
164
+
165
+ assert.equal(link, null);
166
+ });
167
+
168
+ test("resolveProfileSurfaceMenuLinks includes console switch only for console owners", () => {
169
+ const nonOwnerContext = createPlacementContext({
170
+ workspace: {
171
+ id: 1,
172
+ slug: "acme"
173
+ },
174
+ workspaces: [
175
+ {
176
+ id: 1,
177
+ slug: "acme"
178
+ }
179
+ ],
180
+ consoleOwner: false
181
+ });
182
+ const ownerContext = createPlacementContext({
183
+ workspace: {
184
+ id: 1,
185
+ slug: "acme"
186
+ },
187
+ workspaces: [
188
+ {
189
+ id: 1,
190
+ slug: "acme"
191
+ }
192
+ ],
193
+ consoleOwner: true
194
+ });
195
+
196
+ const nonOwnerLinks = resolveProfileSurfaceMenuLinks({
197
+ context: nonOwnerContext,
198
+ surface: "app"
199
+ });
200
+ const ownerLinks = resolveProfileSurfaceMenuLinks({
201
+ context: ownerContext,
202
+ surface: "app"
203
+ });
204
+
205
+ assert.equal(nonOwnerLinks.some((entry) => entry.id === "surface-switch.console"), false);
206
+ assert.equal(ownerLinks.some((entry) => entry.id === "surface-switch.console"), true);
207
+ });
@@ -0,0 +1,14 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computed, ref } from "vue";
4
+ import { resolveEnabledRef } from "../src/client/composables/refValueHelpers.js";
5
+
6
+ test("resolveEnabledRef unwraps refs", () => {
7
+ assert.equal(resolveEnabledRef(ref(true)), true);
8
+ assert.equal(resolveEnabledRef(computed(() => false)), false);
9
+ });
10
+
11
+ test("resolveEnabledRef executes callable enabled values", () => {
12
+ assert.equal(resolveEnabledRef(() => true), true);
13
+ assert.equal(resolveEnabledRef(() => false), false);
14
+ });
@@ -0,0 +1,57 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computed } from "vue";
4
+ import {
5
+ isWorkspaceOwnershipFilter,
6
+ normalizeOwnershipFilter,
7
+ resolveApiSuffix,
8
+ resolveResourceMessages
9
+ } from "../src/client/composables/scopeHelpers.js";
10
+
11
+ test("resolveResourceMessages merges defaults with resource messages", () => {
12
+ const messages = resolveResourceMessages(
13
+ {
14
+ messages: {
15
+ saveError: "Unable to update workspace settings.",
16
+ saveSuccess: "Workspace settings updated."
17
+ }
18
+ },
19
+ {
20
+ validation: "Fix invalid values and try again.",
21
+ saveSuccess: "Saved.",
22
+ saveError: "Unable to save."
23
+ }
24
+ );
25
+
26
+ assert.deepEqual(messages, {
27
+ validation: "Fix invalid values and try again.",
28
+ saveSuccess: "Workspace settings updated.",
29
+ saveError: "Unable to update workspace settings."
30
+ });
31
+ });
32
+
33
+ test("resolveApiSuffix unwraps computed refs", () => {
34
+ const suffix = computed(() => "/customers/42");
35
+
36
+ assert.equal(resolveApiSuffix(suffix), "/customers/42");
37
+ });
38
+
39
+ test("resolveApiSuffix unwraps function-returned computed refs", () => {
40
+ const suffix = computed(() => "/customers/42");
41
+
42
+ assert.equal(resolveApiSuffix(() => suffix), "/customers/42");
43
+ });
44
+
45
+ test("normalizeOwnershipFilter accepts users visibility levels", () => {
46
+ assert.equal(normalizeOwnershipFilter("public"), "public");
47
+ assert.equal(normalizeOwnershipFilter("workspace"), "workspace");
48
+ assert.equal(normalizeOwnershipFilter("user"), "user");
49
+ assert.equal(normalizeOwnershipFilter("workspace_user"), "workspace_user");
50
+ });
51
+
52
+ test("isWorkspaceOwnershipFilter only matches workspace-scoped ownership levels", () => {
53
+ assert.equal(isWorkspaceOwnershipFilter("workspace"), true);
54
+ assert.equal(isWorkspaceOwnershipFilter("workspace_user"), true);
55
+ assert.equal(isWorkspaceOwnershipFilter("public"), false);
56
+ assert.equal(isWorkspaceOwnershipFilter("user"), false);
57
+ });
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { evaluateSurfaceAccess } from "../src/client/lib/surfaceAccessPolicy.js";
4
+
5
+ function createContext(overrides = {}) {
6
+ return {
7
+ auth: {
8
+ authenticated: true
9
+ },
10
+ workspaces: [
11
+ {
12
+ id: 1,
13
+ slug: "acme"
14
+ }
15
+ ],
16
+ permissions: [],
17
+ surfaceAccess: {
18
+ consoleowner: false
19
+ },
20
+ surfaceAccessPolicies: {
21
+ public: {},
22
+ workspace_member: {
23
+ requireAuth: true,
24
+ requireWorkspaceMembership: true
25
+ },
26
+ console_owner: {
27
+ requireAuth: true,
28
+ requireFlagsAll: ["console_owner"]
29
+ }
30
+ },
31
+ surfaceConfig: {
32
+ defaultSurfaceId: "home",
33
+ enabledSurfaceIds: ["home", "app", "console"],
34
+ surfacesById: {
35
+ home: {
36
+ id: "home",
37
+ enabled: true,
38
+ routeBase: "/home",
39
+ requiresWorkspace: false,
40
+ accessPolicyId: "public"
41
+ },
42
+ app: {
43
+ id: "app",
44
+ enabled: true,
45
+ routeBase: "/w/:workspaceSlug",
46
+ requiresWorkspace: true,
47
+ accessPolicyId: "workspace_member"
48
+ },
49
+ console: {
50
+ id: "console",
51
+ enabled: true,
52
+ routeBase: "/console",
53
+ requiresWorkspace: false,
54
+ accessPolicyId: "console_owner"
55
+ }
56
+ }
57
+ },
58
+ ...overrides
59
+ };
60
+ }
61
+
62
+ test("evaluateSurfaceAccess allows workspace member surfaces for accessible workspace", () => {
63
+ const decision = evaluateSurfaceAccess({
64
+ context: createContext(),
65
+ surfaceId: "app",
66
+ workspaceSlug: "acme"
67
+ });
68
+
69
+ assert.equal(decision.allowed, true);
70
+ });
71
+
72
+ test("evaluateSurfaceAccess denies workspace member surfaces for inaccessible workspace", () => {
73
+ const decision = evaluateSurfaceAccess({
74
+ context: createContext(),
75
+ surfaceId: "app",
76
+ workspaceSlug: "missing"
77
+ });
78
+
79
+ assert.equal(decision.allowed, false);
80
+ assert.equal(decision.reason, "surface-access-workspace-membership-required");
81
+ });
82
+
83
+ test("evaluateSurfaceAccess allows unknown workspace membership when allowOnUnknown=true", () => {
84
+ const context = createContext();
85
+ delete context.workspaces;
86
+
87
+ const decision = evaluateSurfaceAccess({
88
+ context,
89
+ surfaceId: "app",
90
+ workspaceSlug: "acme",
91
+ allowOnUnknown: true
92
+ });
93
+
94
+ assert.equal(decision.allowed, true);
95
+ assert.equal(decision.pending, true);
96
+ });
97
+
98
+ test("evaluateSurfaceAccess enforces bootstrap surface access flags", () => {
99
+ const deniedDecision = evaluateSurfaceAccess({
100
+ context: createContext(),
101
+ surfaceId: "console"
102
+ });
103
+ assert.equal(deniedDecision.allowed, false);
104
+
105
+ const allowedDecision = evaluateSurfaceAccess({
106
+ context: createContext({
107
+ surfaceAccess: {
108
+ consoleowner: true
109
+ }
110
+ }),
111
+ surfaceId: "console"
112
+ });
113
+ assert.equal(allowedDecision.allowed, true);
114
+ });
115
+
116
+ test("evaluateSurfaceAccess denies workspace route with not_found status", () => {
117
+ const decision = evaluateSurfaceAccess({
118
+ context: createContext({
119
+ workspaceBootstrapStatuses: {
120
+ acme: "not_found"
121
+ }
122
+ }),
123
+ surfaceId: "app",
124
+ workspaceSlug: "acme"
125
+ });
126
+
127
+ assert.equal(decision.allowed, false);
128
+ assert.equal(decision.reason, "surface-access-workspace-not-found");
129
+ });
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
4
+ import {
5
+ normalizeThemePreference,
6
+ resolveThemeNameForPreference,
7
+ resolveBootstrapThemeName,
8
+ resolveVuetifyThemeController,
9
+ setVuetifyThemeName
10
+ } from "../src/client/lib/theme.js";
11
+
12
+ test("normalizeThemePreference accepts known preferences and falls back to system", () => {
13
+ assert.equal(normalizeThemePreference("light"), "light");
14
+ assert.equal(normalizeThemePreference(" DARK "), "dark");
15
+ assert.equal(normalizeThemePreference("system"), "system");
16
+ assert.equal(normalizeThemePreference("unknown"), "system");
17
+ });
18
+
19
+ test("resolveThemeNameForPreference resolves system using explicit prefersDark", () => {
20
+ assert.equal(resolveThemeNameForPreference("system", { prefersDark: true }), "dark");
21
+ assert.equal(resolveThemeNameForPreference("system", { prefersDark: false }), "light");
22
+ assert.equal(resolveThemeNameForPreference("dark", { prefersDark: false }), "dark");
23
+ assert.equal(resolveThemeNameForPreference("light", { prefersDark: true }), "light");
24
+ });
25
+
26
+ test("resolveBootstrapThemeName keeps unauthenticated payload in light theme", () => {
27
+ assert.equal(
28
+ resolveBootstrapThemeName(
29
+ {
30
+ session: { authenticated: false },
31
+ userSettings: { theme: "dark" }
32
+ },
33
+ { prefersDark: true }
34
+ ),
35
+ "light"
36
+ );
37
+ });
38
+
39
+ test("resolveBootstrapThemeName uses authenticated user preference", () => {
40
+ assert.equal(
41
+ resolveBootstrapThemeName(
42
+ {
43
+ session: { authenticated: true },
44
+ userSettings: { theme: "dark" }
45
+ },
46
+ { prefersDark: false }
47
+ ),
48
+ "dark"
49
+ );
50
+ assert.equal(
51
+ resolveBootstrapThemeName(
52
+ {
53
+ session: { authenticated: true },
54
+ userSettings: { theme: "system" }
55
+ },
56
+ { prefersDark: true }
57
+ ),
58
+ "dark"
59
+ );
60
+ });
61
+
62
+ test("resolveVuetifyThemeController reads theme controller from Vue app provides", () => {
63
+ const themeController = {
64
+ global: {
65
+ name: {
66
+ value: "light"
67
+ }
68
+ }
69
+ };
70
+ const vueApp = {
71
+ _context: {
72
+ provides: {
73
+ [ThemeSymbol]: themeController
74
+ }
75
+ }
76
+ };
77
+
78
+ assert.equal(resolveVuetifyThemeController(vueApp), themeController);
79
+ assert.equal(resolveVuetifyThemeController({ _context: { provides: {} } }), null);
80
+ });
81
+
82
+ test("setVuetifyThemeName updates only when the value changes", () => {
83
+ const themeController = {
84
+ global: {
85
+ name: {
86
+ value: "light"
87
+ }
88
+ }
89
+ };
90
+
91
+ assert.equal(setVuetifyThemeName(themeController, "light"), false);
92
+ assert.equal(setVuetifyThemeName(themeController, "dark"), true);
93
+ assert.equal(themeController.global.name.value, "dark");
94
+ });
95
+