@jskit-ai/users-web 0.1.53 → 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 (70) hide show
  1. package/package.descriptor.mjs +15 -53
  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 -11
  31. package/test/theme.test.js +0 -56
  32. package/src/client/components/MembersAdminClientElement.vue +0 -400
  33. package/src/client/components/UsersProfileSurfaceSwitchMenuItem.vue +0 -39
  34. package/src/client/components/UsersWorkspaceMembersMenuItem.vue +0 -36
  35. package/src/client/components/UsersWorkspacePermissionMenuItem.vue +0 -90
  36. package/src/client/components/UsersWorkspaceSelector.vue +0 -248
  37. package/src/client/components/UsersWorkspaceSettingsMenuItem.vue +0 -39
  38. package/src/client/components/UsersWorkspaceToolsWidget.vue +0 -12
  39. package/src/client/components/WorkspaceMembersClientElement.vue +0 -655
  40. package/src/client/components/WorkspaceProfileClientElement.vue +0 -116
  41. package/src/client/components/WorkspaceSettingsClientElement.vue +0 -102
  42. package/src/client/components/WorkspaceSettingsFieldsClientElement.vue +0 -265
  43. package/src/client/components/WorkspacesClientElement.vue +0 -509
  44. package/src/client/composables/account-settings/accountSettingsInvitesRuntime.js +0 -88
  45. package/src/client/composables/useBootstrapQuery.js +0 -52
  46. package/src/client/composables/useWorkspaceRouteContext.js +0 -28
  47. package/src/client/composables/useWorkspaceSurfaceId.js +0 -43
  48. package/src/client/lib/menuIcons.js +0 -201
  49. package/src/client/lib/profileSurfaceMenuLinks.js +0 -142
  50. package/src/client/lib/surfaceAccessPolicy.js +0 -350
  51. package/src/client/lib/workspaceLinkResolver.js +0 -207
  52. package/src/client/lib/workspaceSurfaceContext.js +0 -82
  53. package/src/client/lib/workspaceSurfacePaths.js +0 -163
  54. package/src/client/providers/UsersWorkspacesClientProvider.js +0 -24
  55. package/src/client/runtime/bootstrapPlacementRouteGuards.js +0 -371
  56. package/src/client/runtime/bootstrapPlacementRuntime.js +0 -463
  57. package/src/client/runtime/bootstrapPlacementRuntimeConstants.js +0 -28
  58. package/src/client/runtime/bootstrapPlacementRuntimeHelpers.js +0 -147
  59. package/src/client/support/menuLinkTarget.js +0 -93
  60. package/src/client/support/realtimeWorkspace.js +0 -21
  61. package/src/client/support/runtimeNormalization.js +0 -27
  62. package/src/client/support/workspaceQueryKeys.js +0 -15
  63. package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +0 -77
  64. package/test/bootstrapPlacementRuntime.test.js +0 -1095
  65. package/test/menuIcons.test.js +0 -34
  66. package/test/menuLinkTarget.test.js +0 -116
  67. package/test/profileSurfaceMenuLinks.test.js +0 -208
  68. package/test/surfaceAccessPolicy.test.js +0 -129
  69. package/test/workspaceLinkResolver.test.js +0 -61
  70. package/test/workspaceSurfacePaths.test.js +0 -39
@@ -1,67 +1,10 @@
1
- import { createHttpClient } from "@jskit-ai/http-runtime/client";
2
- import {
3
- isTransientQueryError,
4
- transientQueryRetryDelay
5
- } from "@jskit-ai/kernel/shared/support";
1
+ import { createTransientRetryHttpClient } from "@jskit-ai/http-runtime/client";
6
2
 
7
- const SAFE_RETRY_METHODS = Object.freeze(new Set(["GET", "HEAD"]));
8
- const MAX_TRANSIENT_HTTP_RETRIES = 2;
9
-
10
- const baseUsersWebHttpClient = createHttpClient({
3
+ const usersWebHttpClient = createTransientRetryHttpClient({
11
4
  credentials: "include",
12
5
  csrf: {
13
6
  sessionPath: "/api/session"
14
7
  }
15
8
  });
16
9
 
17
- function sleep(delayMs) {
18
- return new Promise((resolve) => {
19
- setTimeout(resolve, delayMs);
20
- });
21
- }
22
-
23
- function shouldRetryTransientHttpFailure(error, method, attemptIndex) {
24
- if (!SAFE_RETRY_METHODS.has(String(method || "GET").toUpperCase())) {
25
- return false;
26
- }
27
- if (!isTransientQueryError(error)) {
28
- return false;
29
- }
30
- return Number(attemptIndex) < MAX_TRANSIENT_HTTP_RETRIES;
31
- }
32
-
33
- async function requestWithTransientRetry(executor, method) {
34
- let attemptIndex = 0;
35
-
36
- while (true) {
37
- try {
38
- return await executor();
39
- } catch (error) {
40
- if (!shouldRetryTransientHttpFailure(error, method, attemptIndex)) {
41
- throw error;
42
- }
43
- attemptIndex += 1;
44
- await sleep(transientQueryRetryDelay(attemptIndex));
45
- }
46
- }
47
- }
48
-
49
- const usersWebHttpClient = Object.freeze({
50
- ...baseUsersWebHttpClient,
51
- request(url, requestOptions = {}, state = null) {
52
- const method = String(requestOptions?.method || "GET").toUpperCase();
53
- return requestWithTransientRetry(
54
- () => baseUsersWebHttpClient.request(url, requestOptions, state),
55
- method
56
- );
57
- },
58
- requestStream(url, requestOptions = {}, handlers = {}, state = null) {
59
- const method = String(requestOptions?.method || "GET").toUpperCase();
60
- return requestWithTransientRetry(
61
- () => baseUsersWebHttpClient.requestStream(url, requestOptions, handlers, state),
62
- method
63
- );
64
- }
65
- });
66
-
67
10
  export { usersWebHttpClient };
@@ -1,12 +1,8 @@
1
1
  import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
2
- import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settings";
3
2
 
4
3
  const THEME_PREFERENCE_LIGHT = "light";
5
4
  const THEME_PREFERENCE_DARK = "dark";
6
5
  const THEME_PREFERENCE_SYSTEM = "system";
7
- const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
8
- const WORKSPACE_THEME_NAME_LIGHT = "workspace-light";
9
- const WORKSPACE_THEME_NAME_DARK = "workspace-dark";
10
6
 
11
7
  function normalizeThemePreference(value) {
12
8
  const normalized = String(value || "").trim().toLowerCase();
@@ -22,8 +18,9 @@ function resolveSystemThemeName({ prefersDark } = {}) {
22
18
  }
23
19
 
24
20
  if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
25
- const prefersDarkFromMedia = window.matchMedia("(prefers-color-scheme: dark)").matches;
26
- return prefersDarkFromMedia ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
21
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
22
+ ? THEME_PREFERENCE_DARK
23
+ : THEME_PREFERENCE_LIGHT;
27
24
  }
28
25
 
29
26
  return THEME_PREFERENCE_LIGHT;
@@ -51,16 +48,11 @@ function resolveThemePreferenceStorage(options = {}) {
51
48
  return null;
52
49
  }
53
50
 
54
- let storage = null;
55
51
  try {
56
- storage = window.localStorage;
52
+ return window.localStorage;
57
53
  } catch {
58
54
  return null;
59
55
  }
60
- if (!storage || typeof storage !== "object") {
61
- return null;
62
- }
63
- return storage;
64
56
  }
65
57
 
66
58
  function readPersistedThemePreference(options = {}) {
@@ -70,8 +62,7 @@ function readPersistedThemePreference(options = {}) {
70
62
  }
71
63
 
72
64
  try {
73
- const value = storage.getItem("jskit.themePreference");
74
- return normalizeThemePreference(value);
65
+ return normalizeThemePreference(storage.getItem("jskit.themePreference"));
75
66
  } catch {
76
67
  return THEME_PREFERENCE_SYSTEM;
77
68
  }
@@ -83,9 +74,8 @@ function persistThemePreference(themePreference, options = {}) {
83
74
  return false;
84
75
  }
85
76
 
86
- const normalizedPreference = normalizeThemePreference(themePreference);
87
77
  try {
88
- storage.setItem("jskit.themePreference", normalizedPreference);
78
+ storage.setItem("jskit.themePreference", normalizeThemePreference(themePreference));
89
79
  return true;
90
80
  } catch {
91
81
  return false;
@@ -128,12 +118,7 @@ function resolveVuetifyThemeController(vueApp) {
128
118
  }
129
119
 
130
120
  const themeController = provides[ThemeSymbol];
131
- if (
132
- !themeController ||
133
- typeof themeController !== "object" ||
134
- !themeController.global ||
135
- !themeController.global.name
136
- ) {
121
+ if (!themeController || typeof themeController !== "object" || !themeController.global || !themeController.global.name) {
137
122
  return null;
138
123
  }
139
124
 
@@ -141,12 +126,7 @@ function resolveVuetifyThemeController(vueApp) {
141
126
  }
142
127
 
143
128
  function setVuetifyThemeName(themeController, themeName) {
144
- if (
145
- !themeController ||
146
- typeof themeController !== "object" ||
147
- !themeController.global ||
148
- !themeController.global.name
149
- ) {
129
+ if (!themeController || typeof themeController !== "object" || !themeController.global || !themeController.global.name) {
150
130
  return false;
151
131
  }
152
132
 
@@ -154,171 +134,15 @@ function setVuetifyThemeName(themeController, themeName) {
154
134
  if (themeController.global.name.value === normalizedThemeName) {
155
135
  return false;
156
136
  }
157
- themeController.global.name.value = normalizedThemeName;
158
- return true;
159
- }
160
-
161
- function normalizeHexColor(value = "") {
162
- const normalized = String(value || "").trim();
163
- if (!HEX_COLOR_PATTERN.test(normalized)) {
164
- return "";
165
- }
166
- return normalized.toUpperCase();
167
- }
168
-
169
- function hexColorToRgb(value = "") {
170
- const normalized = normalizeHexColor(value);
171
- if (!normalized) {
172
- return "";
173
- }
174
-
175
- const red = Number.parseInt(normalized.slice(1, 3), 16);
176
- const green = Number.parseInt(normalized.slice(3, 5), 16);
177
- const blue = Number.parseInt(normalized.slice(5, 7), 16);
178
- return `${red},${green},${blue}`;
179
- }
180
-
181
- function resolveVuetifyThemeDefinitions(themeController) {
182
- if (!themeController || typeof themeController !== "object") {
183
- return null;
184
- }
185
- const themes = themeController.themes?.value;
186
- if (!themes || typeof themes !== "object") {
187
- return null;
188
- }
189
- return themes;
190
- }
191
-
192
- function normalizeThemeColors(colors) {
193
- const source = colors && typeof colors === "object" ? colors : {};
194
- const normalized = {};
195
- for (const [key, value] of Object.entries(source)) {
196
- normalized[String(key)] = String(value);
197
- }
198
- return normalized;
199
- }
200
-
201
- function normalizeWorkspaceBaseThemeName(themeName = "") {
202
- const normalized = String(themeName || "").trim().toLowerCase();
203
- if (normalized === WORKSPACE_THEME_NAME_LIGHT) {
204
- return THEME_PREFERENCE_LIGHT;
205
- }
206
- if (normalized === WORKSPACE_THEME_NAME_DARK) {
207
- return THEME_PREFERENCE_DARK;
208
- }
209
- if (normalized === THEME_PREFERENCE_DARK) {
210
- return THEME_PREFERENCE_DARK;
211
- }
212
- return THEME_PREFERENCE_LIGHT;
213
- }
214
137
 
215
- function areThemeColorsEqual(leftColors = {}, rightColors = {}) {
216
- const leftEntries = Object.entries(leftColors);
217
- const rightEntries = Object.entries(rightColors);
218
- if (leftEntries.length !== rightEntries.length) {
219
- return false;
220
- }
221
-
222
- for (const [key, value] of leftEntries) {
223
- if (!Object.hasOwn(rightColors, key)) {
224
- return false;
225
- }
226
- if (String(rightColors[key]) !== String(value)) {
227
- return false;
228
- }
229
- }
230
- return true;
231
- }
232
-
233
- function composeWorkspaceThemeDefinition(baseThemeDefinition, palette) {
234
- const baseTheme = baseThemeDefinition && typeof baseThemeDefinition === "object" ? baseThemeDefinition : {};
235
- const baseColors = normalizeThemeColors(baseTheme.colors);
236
- return {
237
- ...baseTheme,
238
- colors: {
239
- ...baseColors,
240
- primary: palette.color,
241
- secondary: palette.secondaryColor,
242
- surface: palette.surfaceColor,
243
- "surface-variant": palette.surfaceVariantColor
244
- }
245
- };
246
- }
247
-
248
- function upsertThemeDefinition(themeDefinitions, themeName, nextDefinition) {
249
- const currentDefinition =
250
- themeDefinitions[themeName] && typeof themeDefinitions[themeName] === "object" ? themeDefinitions[themeName] : null;
251
- const currentColors = normalizeThemeColors(currentDefinition?.colors);
252
- const nextColors = normalizeThemeColors(nextDefinition?.colors);
253
- const sameDarkFlag = Boolean(currentDefinition?.dark) === Boolean(nextDefinition?.dark);
254
- if (sameDarkFlag && areThemeColorsEqual(currentColors, nextColors)) {
255
- return false;
256
- }
257
- themeDefinitions[themeName] = nextDefinition;
138
+ themeController.global.name.value = normalizedThemeName;
258
139
  return true;
259
140
  }
260
141
 
261
- function setVuetifyPrimaryColorOverride(themeController, themeInput = null) {
262
- if (
263
- !themeController ||
264
- typeof themeController !== "object" ||
265
- !themeController.global ||
266
- !themeController.global.name
267
- ) {
268
- return false;
269
- }
270
-
271
- const themeDefinitions = resolveVuetifyThemeDefinitions(themeController);
272
- if (!themeDefinitions) {
273
- return false;
274
- }
275
-
276
- const currentThemeName = String(themeController.global.name.value || "").trim();
277
- const normalizedBaseThemeName = normalizeWorkspaceBaseThemeName(currentThemeName);
278
- const normalizedThemeName =
279
- normalizedBaseThemeName === THEME_PREFERENCE_DARK ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
280
- const source = themeInput && typeof themeInput === "object" ? themeInput : null;
281
-
282
- if (!source) {
283
- if (currentThemeName === normalizedThemeName) {
284
- return false;
285
- }
286
- themeController.global.name.value = normalizedThemeName;
287
- return true;
288
- }
289
-
290
- const baseLightTheme =
291
- themeDefinitions[THEME_PREFERENCE_LIGHT] && typeof themeDefinitions[THEME_PREFERENCE_LIGHT] === "object"
292
- ? themeDefinitions[THEME_PREFERENCE_LIGHT]
293
- : null;
294
- const baseDarkTheme =
295
- themeDefinitions[THEME_PREFERENCE_DARK] && typeof themeDefinitions[THEME_PREFERENCE_DARK] === "object"
296
- ? themeDefinitions[THEME_PREFERENCE_DARK]
297
- : null;
298
- if (!baseLightTheme || !baseDarkTheme) {
299
- return false;
300
- }
301
-
302
- const lightPalette = resolveWorkspaceThemePalette(source, { mode: THEME_PREFERENCE_LIGHT });
303
- const darkPalette = resolveWorkspaceThemePalette(source, { mode: THEME_PREFERENCE_DARK });
304
- const nextLightTheme = composeWorkspaceThemeDefinition(baseLightTheme, lightPalette);
305
- const nextDarkTheme = composeWorkspaceThemeDefinition(baseDarkTheme, darkPalette);
306
- const nextThemeName =
307
- normalizedThemeName === THEME_PREFERENCE_DARK ? WORKSPACE_THEME_NAME_DARK : WORKSPACE_THEME_NAME_LIGHT;
308
-
309
- let changed = false;
310
- changed = upsertThemeDefinition(themeDefinitions, WORKSPACE_THEME_NAME_LIGHT, nextLightTheme) || changed;
311
- changed = upsertThemeDefinition(themeDefinitions, WORKSPACE_THEME_NAME_DARK, nextDarkTheme) || changed;
312
- if (themeController.global.name.value !== nextThemeName) {
313
- themeController.global.name.value = nextThemeName;
314
- changed = true;
315
- }
316
-
317
- return changed;
318
- }
319
-
320
142
  export {
321
- hexColorToRgb,
143
+ THEME_PREFERENCE_DARK,
144
+ THEME_PREFERENCE_LIGHT,
145
+ THEME_PREFERENCE_SYSTEM,
322
146
  normalizeThemePreference,
323
147
  persistBootstrapThemePreference,
324
148
  persistThemePreference,
@@ -327,6 +151,5 @@ export {
327
151
  resolveThemeNameForPreference,
328
152
  resolveBootstrapThemeName,
329
153
  resolveVuetifyThemeController,
330
- setVuetifyPrimaryColorOverride,
331
154
  setVuetifyThemeName
332
155
  };
@@ -1,9 +1,6 @@
1
- import UsersProfileSurfaceSwitchMenuItem from "../components/UsersProfileSurfaceSwitchMenuItem.vue";
2
1
  import UsersHomeToolsWidget from "../components/UsersHomeToolsWidget.vue";
3
2
  import ProfileClientElement from "../components/ProfileClientElement.vue";
4
- import {
5
- createBootstrapPlacementRuntime
6
- } from "../runtime/bootstrapPlacementRuntime.js";
3
+ import { bootUsersWebClientProvider } from "./bootUsersWebClientProvider.js";
7
4
 
8
5
  class UsersWebClientProvider {
9
6
  static id = "users.web.client";
@@ -14,32 +11,12 @@ class UsersWebClientProvider {
14
11
  throw new Error("UsersWebClientProvider requires application singleton().");
15
12
  }
16
13
 
17
- app.singleton("users.web.profile.menu.surface-switch-item", () => UsersProfileSurfaceSwitchMenuItem);
18
14
  app.singleton("users.web.home.tools.widget", () => UsersHomeToolsWidget);
19
15
  app.singleton("users.web.profile.element", () => ProfileClientElement);
20
- app.singleton("users.web.bootstrap-placement.runtime", (scope) => createBootstrapPlacementRuntime({ app: scope }));
21
16
  }
22
17
 
23
18
  async boot(app) {
24
- if (!app || typeof app.make !== "function") {
25
- throw new Error("UsersWebClientProvider requires application make().");
26
- }
27
-
28
- const runtime = app.make("users.web.bootstrap-placement.runtime");
29
- if (runtime && typeof runtime.initialize === "function") {
30
- await runtime.initialize();
31
- }
32
- }
33
-
34
- shutdown(app) {
35
- if (!app || typeof app.make !== "function") {
36
- return;
37
- }
38
-
39
- const runtime = app.make("users.web.bootstrap-placement.runtime");
40
- if (runtime && typeof runtime.shutdown === "function") {
41
- runtime.shutdown();
42
- }
19
+ await bootUsersWebClientProvider(app);
43
20
  }
44
21
  }
45
22
 
@@ -0,0 +1,28 @@
1
+ import {
2
+ ACCOUNT_SETTINGS_SECTIONS_INJECTION_KEY,
3
+ ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG,
4
+ resolveAccountSettingsSections
5
+ } from "../account-settings/sections.js";
6
+
7
+ async function bootUsersWebClientProvider(app) {
8
+ if (!app || typeof app.make !== "function") {
9
+ throw new Error("bootUsersWebClientProvider requires application make().");
10
+ }
11
+
12
+ const vueApp = app.make("jskit.client.vue.app");
13
+ if (!vueApp || typeof vueApp.provide !== "function") {
14
+ throw new Error("bootUsersWebClientProvider requires jskit.client.vue.app provide().");
15
+ }
16
+
17
+ const extensionSections =
18
+ typeof app.resolveTag === "function"
19
+ ? app.resolveTag(ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG)
20
+ : [];
21
+
22
+ vueApp.provide(
23
+ ACCOUNT_SETTINGS_SECTIONS_INJECTION_KEY,
24
+ resolveAccountSettingsSections(extensionSections)
25
+ );
26
+ }
27
+
28
+ export { bootUsersWebClientProvider };
@@ -6,14 +6,7 @@ const HOME_TOOLS_OUTLET = Object.freeze({
6
6
  ariaLabel: "Home tools"
7
7
  });
8
8
 
9
- const WORKSPACE_TOOLS_OUTLET = Object.freeze({
10
- target: "workspace-tools:primary-menu",
11
- defaultLinkComponentToken: DEFAULT_TOOLS_LINK_COMPONENT_TOKEN,
12
- ariaLabel: "Workspace tools"
13
- });
14
-
15
9
  export {
16
10
  DEFAULT_TOOLS_LINK_COMPONENT_TOKEN,
17
- HOME_TOOLS_OUTLET,
18
- WORKSPACE_TOOLS_OUTLET
11
+ HOME_TOOLS_OUTLET
19
12
  };
@@ -3,27 +3,48 @@ import { computed } from "vue";
3
3
  import { useRoute, useRouter } from "vue-router";
4
4
  import { normalizeOneOf } from "@jskit-ai/kernel/shared/support/normalize";
5
5
  import { useAccountSettingsRuntime } from "@jskit-ai/users-web/client/composables/useAccountSettingsRuntime";
6
+ import { useAccountSettingsSections } from "@jskit-ai/users-web/client/account-settings/sections";
6
7
  import AccountSettingsProfileSection from "./AccountSettingsProfileSection.vue";
7
8
  import AccountSettingsPreferencesSection from "./AccountSettingsPreferencesSection.vue";
8
9
  import AccountSettingsNotificationsSection from "./AccountSettingsNotificationsSection.vue";
9
- import AccountSettingsInvitesSection from "./AccountSettingsInvitesSection.vue";
10
10
 
11
11
  const runtime = useAccountSettingsRuntime();
12
12
  const route = useRoute();
13
13
  const router = useRouter();
14
+ const extensionSections = useAccountSettingsSections();
14
15
 
15
16
  const sections = computed(() => {
16
17
  const nextSections = [
17
- { title: "Profile", value: "profile" },
18
- { title: "Preferences", value: "preferences" },
19
- { title: "Notifications", value: "notifications" }
18
+ { title: "Profile", value: "profile", component: AccountSettingsProfileSection, usesSharedRuntime: true, order: 100 },
19
+ {
20
+ title: "Preferences",
21
+ value: "preferences",
22
+ component: AccountSettingsPreferencesSection,
23
+ usesSharedRuntime: true,
24
+ order: 200
25
+ },
26
+ {
27
+ title: "Notifications",
28
+ value: "notifications",
29
+ component: AccountSettingsNotificationsSection,
30
+ usesSharedRuntime: true,
31
+ order: 300
32
+ }
20
33
  ];
21
34
 
22
- if (runtime.invites.isAvailable.value) {
23
- nextSections.push({ title: "Invites", value: "invites" });
35
+ for (const entry of extensionSections) {
36
+ nextSections.push(entry);
24
37
  }
25
38
 
26
- return Object.freeze(nextSections);
39
+ return Object.freeze(
40
+ nextSections.sort((left, right) => {
41
+ const orderDelta = Number(left.order || 0) - Number(right.order || 0);
42
+ if (orderDelta !== 0) {
43
+ return orderDelta;
44
+ }
45
+ return String(left.value || "").localeCompare(String(right.value || ""));
46
+ })
47
+ );
27
48
  });
28
49
  const sectionValues = computed(() => Object.freeze(sections.value.map((section) => section.value)));
29
50
 
@@ -105,20 +126,11 @@ const activeTab = computed({
105
126
 
106
127
  <v-col cols="12" md="9" lg="10">
107
128
  <v-window v-model="activeTab" :touch="false" class="settings-sections-window">
108
- <v-window-item value="profile">
109
- <AccountSettingsProfileSection :runtime="runtime" />
110
- </v-window-item>
111
-
112
- <v-window-item value="preferences">
113
- <AccountSettingsPreferencesSection :runtime="runtime" />
114
- </v-window-item>
115
-
116
- <v-window-item value="notifications">
117
- <AccountSettingsNotificationsSection :runtime="runtime" />
118
- </v-window-item>
119
-
120
- <v-window-item value="invites">
121
- <AccountSettingsInvitesSection :runtime="runtime" />
129
+ <v-window-item v-for="section in sections" :key="section.value" :value="section.value">
130
+ <component
131
+ :is="section.component"
132
+ v-bind="section.usesSharedRuntime ? { runtime } : undefined"
133
+ />
122
134
  </v-window-item>
123
135
  </v-window>
124
136
  </v-col>
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG,
5
+ resolveAccountSettingsSections
6
+ } from "../src/client/account-settings/sections.js";
7
+ import { bootUsersWebClientProvider } from "../src/client/providers/bootUsersWebClientProvider.js";
8
+
9
+ test("resolveAccountSettingsSections normalizes, deduplicates, and sorts tagged account section entries", () => {
10
+ const SectionA = {};
11
+ const SectionB = {};
12
+
13
+ const resolved = resolveAccountSettingsSections([
14
+ { value: "notifications", title: "Ignore duplicate", component: SectionA, order: 999 },
15
+ { value: "invites", title: "Invites", component: SectionA, order: 400 },
16
+ { value: "profile", title: "Broken missing component" },
17
+ { value: "security", title: "Security", component: SectionB, order: 350 },
18
+ { value: "invites", title: "Second duplicate", component: SectionB, order: 100 }
19
+ ]);
20
+
21
+ assert.deepEqual(
22
+ resolved.map((entry) => ({ value: entry.value, title: entry.title, order: entry.order })),
23
+ [
24
+ { value: "security", title: "Security", order: 350 },
25
+ { value: "invites", title: "Invites", order: 400 }
26
+ ]
27
+ );
28
+ });
29
+
30
+ test("bootUsersWebClientProvider provides normalized account section extensions", async () => {
31
+ const SectionComponent = {};
32
+ const provided = new Map();
33
+ let resolvedTagName = "";
34
+
35
+ await bootUsersWebClientProvider({
36
+ make(token) {
37
+ if (token === "jskit.client.vue.app") {
38
+ return {
39
+ provide(key, value) {
40
+ provided.set(key, value);
41
+ }
42
+ };
43
+ }
44
+ throw new Error(`Unexpected token: ${token}`);
45
+ },
46
+ resolveTag(tagName) {
47
+ resolvedTagName = tagName;
48
+ return [
49
+ {
50
+ value: "invites",
51
+ title: "Invites",
52
+ component: SectionComponent,
53
+ order: 400,
54
+ usesSharedRuntime: false
55
+ }
56
+ ];
57
+ }
58
+ });
59
+
60
+ assert.equal(resolvedTagName, ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG);
61
+ const [providedSections] = [...provided.values()];
62
+ assert.equal(Array.isArray(providedSections), true);
63
+ assert.deepEqual(
64
+ providedSections.map((entry) => ({
65
+ value: entry.value,
66
+ title: entry.title,
67
+ order: entry.order,
68
+ usesSharedRuntime: entry.usesSharedRuntime
69
+ })),
70
+ [
71
+ {
72
+ value: "invites",
73
+ title: "Invites",
74
+ order: 400,
75
+ usesSharedRuntime: false
76
+ }
77
+ ]
78
+ );
79
+ });
@@ -15,13 +15,13 @@ test("users-web exports are explicit and aligned with production/template usage"
15
15
  packageId: "@jskit-ai/users-web",
16
16
  requiredExports: [
17
17
  "./client",
18
+ "./client/account-settings/sections",
18
19
  "./client/composables/useAddEdit",
19
20
  "./client/composables/useList",
20
21
  "./client/composables/useView",
21
22
  "./client/composables/useCrudAddEdit",
22
23
  "./client/composables/useCrudList",
23
- "./client/composables/useCrudView",
24
- "./client/support/menuLinkTarget"
24
+ "./client/composables/useCrudView"
25
25
  ]
26
26
  });
27
27
 
@@ -2,7 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { computed } from "vue";
4
4
  import {
5
- isWorkspaceOwnershipFilter,
5
+ isScopedOwnershipFilter,
6
6
  normalizeOwnershipFilter,
7
7
  resolveApiSuffix,
8
8
  resolveResourceMessages
@@ -49,9 +49,9 @@ test("normalizeOwnershipFilter accepts users visibility levels", () => {
49
49
  assert.equal(normalizeOwnershipFilter("workspace_user"), "workspace_user");
50
50
  });
51
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);
52
+ test("isScopedOwnershipFilter only matches scoped ownership levels", () => {
53
+ assert.equal(isScopedOwnershipFilter("workspace"), true);
54
+ assert.equal(isScopedOwnershipFilter("workspace_user"), true);
55
+ assert.equal(isScopedOwnershipFilter("public"), false);
56
+ assert.equal(isScopedOwnershipFilter("user"), false);
57
57
  });
@@ -80,15 +80,6 @@ test("users-web home tools widget exposes home-tools outlet", async () => {
80
80
  assert.match(source, /:default-link-component-token="HOME_TOOLS_OUTLET\.defaultLinkComponentToken"/);
81
81
  });
82
82
 
83
- test("users-web workspace tools widget exposes workspace-tools outlet", async () => {
84
- const source = await readFile(path.join(PACKAGE_DIR, "src", "client", "components", "UsersWorkspaceToolsWidget.vue"), "utf8");
85
-
86
- assert.match(source, /import \{ WORKSPACE_TOOLS_OUTLET \} from "\.\.\/\.\.\/shared\/toolsOutletContracts\.js";/);
87
- assert.match(source, /<ShellOutletMenuWidget/);
88
- assert.match(source, /:target="WORKSPACE_TOOLS_OUTLET\.target"/);
89
- assert.match(source, /:default-link-component-token="WORKSPACE_TOOLS_OUTLET\.defaultLinkComponentToken"/);
90
- });
91
-
92
83
  test("users-web descriptor metadata advertises home tools outlet and standard home settings placements", () => {
93
84
  assert.deepEqual(
94
85
  readOutlets("home-tools:primary-menu"),
@@ -140,8 +131,8 @@ test("users-web descriptor metadata advertises home tools outlet and standard ho
140
131
  'id: "users.home.menu.settings"',
141
132
  'target: "home-tools:primary-menu"',
142
133
  'componentToken: "local.main.ui.surface-aware-menu-link-item"',
143
- 'workspaceSuffix: "/settings"',
144
- 'nonWorkspaceSuffix: "/settings"'
134
+ 'scopedSuffix: "/settings"',
135
+ 'unscopedSuffix: "/settings"'
145
136
  ]
146
137
  });
147
138
 
@@ -158,4 +149,6 @@ test("users-web descriptor metadata advertises home tools outlet and standard ho
158
149
  ]
159
150
  });
160
151
 
152
+ assert.equal(findFileMutation("users-web-component-account-settings-invites"), null);
153
+
161
154
  });