@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,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
  });