@jskit-ai/users-web 0.1.47 → 0.1.49

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.
@@ -1,10 +1,16 @@
1
+ import {
2
+ HOME_TOOLS_OUTLET,
3
+ WORKSPACE_TOOLS_OUTLET
4
+ } from "./src/shared/toolsOutletContracts.js";
5
+
1
6
  export default Object.freeze({
2
7
  packageVersion: 1,
3
8
  packageId: "@jskit-ai/users-web",
4
- version: "0.1.47",
9
+ version: "0.1.49",
5
10
  kind: "runtime",
6
- description: "Users web module: account/profile UI plus shared shell link components.",
11
+ description: "Users web module: account/profile UI plus shared users web widgets.",
7
12
  dependsOn: [
13
+ "@jskit-ai/auth-web",
8
14
  "@jskit-ai/http-runtime",
9
15
  "@jskit-ai/shell-web",
10
16
  "@jskit-ai/uploads-image-web",
@@ -47,14 +53,6 @@ export default Object.freeze({
47
53
  subpath: "./client/components/ProfileClientElement",
48
54
  summary: "Exports profile settings client element scaffold component."
49
55
  },
50
- {
51
- subpath: "./client/components/ConsoleSettingsClientElement",
52
- summary: "Exports console settings landing-page client element."
53
- },
54
- {
55
- subpath: "./client/components/WorkspaceSettingsClientElement",
56
- summary: "Exports workspace settings client element."
57
- },
58
56
  {
59
57
  subpath: "./client/composables/useAddEdit",
60
58
  summary: "Exports add/edit operation composable."
@@ -95,9 +93,8 @@ export default Object.freeze({
95
93
  containerTokens: {
96
94
  server: [],
97
95
  client: [
98
- "users.web.shell.menu-link-item",
99
- "users.web.shell.surface-aware-menu-link-item",
100
96
  "users.web.profile.menu.surface-switch-item",
97
+ "users.web.home.tools.widget",
101
98
  "users.web.profile.element",
102
99
  "users.web.bootstrap-placement.runtime"
103
100
  ]
@@ -107,8 +104,20 @@ export default Object.freeze({
107
104
  placements: {
108
105
  outlets: [
109
106
  {
110
- host: "console-settings",
111
- position: "primary-menu",
107
+ target: HOME_TOOLS_OUTLET.target,
108
+ defaultLinkComponentToken: HOME_TOOLS_OUTLET.defaultLinkComponentToken,
109
+ surfaces: ["home"],
110
+ source: "src/client/components/UsersHomeToolsWidget.vue"
111
+ },
112
+ {
113
+ target: WORKSPACE_TOOLS_OUTLET.target,
114
+ defaultLinkComponentToken: WORKSPACE_TOOLS_OUTLET.defaultLinkComponentToken,
115
+ surfaces: ["admin"],
116
+ source: "src/client/components/UsersWorkspaceToolsWidget.vue"
117
+ },
118
+ {
119
+ target: "console-settings:primary-menu",
120
+ defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
112
121
  surfaces: ["console"],
113
122
  source: "templates/src/pages/console/settings.vue"
114
123
  }
@@ -116,8 +125,7 @@ export default Object.freeze({
116
125
  contributions: [
117
126
  {
118
127
  id: "users.profile.menu.surface-switch",
119
- host: "auth-profile-menu",
120
- position: "primary-menu",
128
+ target: "auth-profile-menu:primary-menu",
121
129
  surfaces: ["*"],
122
130
  order: 100,
123
131
  componentToken: "users.web.profile.menu.surface-switch-item",
@@ -126,23 +134,48 @@ export default Object.freeze({
126
134
  },
127
135
  {
128
136
  id: "users.profile.menu.settings",
129
- host: "auth-profile-menu",
130
- position: "primary-menu",
137
+ target: "auth-profile-menu:primary-menu",
131
138
  surfaces: ["*"],
132
139
  order: 500,
133
- componentToken: "users.web.shell.menu-link-item",
140
+ componentToken: "auth.web.profile.menu.link-item",
134
141
  when: "auth.authenticated === true",
135
142
  source: "mutations.text#users-web-profile-settings-placement"
136
143
  },
144
+ {
145
+ id: "users.home.menu.home",
146
+ target: "shell-layout:primary-menu",
147
+ surfaces: ["*"],
148
+ order: 50,
149
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
150
+ when: "auth.authenticated === true",
151
+ source: "mutations.text#users-web-home-shell-menu-placement"
152
+ },
137
153
  {
138
154
  id: "users.console.menu.settings",
139
- host: "shell-layout",
140
- position: "primary-menu",
155
+ target: "shell-layout:primary-menu",
141
156
  surfaces: ["console"],
142
157
  order: 100,
143
- componentToken: "users.web.shell.menu-link-item",
158
+ componentToken: "local.main.ui.menu-link-item",
144
159
  when: "auth.authenticated === true",
145
160
  source: "mutations.text#users-web-console-settings-placement"
161
+ },
162
+ {
163
+ id: "users.home.tools.widget",
164
+ target: "shell-layout:top-right",
165
+ surfaces: ["home"],
166
+ order: 900,
167
+ componentToken: "users.web.home.tools.widget",
168
+ when: "auth.authenticated === true",
169
+ source: "mutations.text#users-web-home-tools-placement"
170
+ },
171
+ {
172
+ id: "users.home.menu.settings",
173
+ target: "home-tools:primary-menu",
174
+ surfaces: ["home"],
175
+ order: 100,
176
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
177
+ when: "auth.authenticated === true",
178
+ source: "mutations.text#users-web-home-tools-placement"
146
179
  }
147
180
  ]
148
181
  }
@@ -153,12 +186,12 @@ export default Object.freeze({
153
186
  runtime: {
154
187
  "@tanstack/vue-query": "5.92.12",
155
188
  "@mdi/js": "^7.4.47",
156
- "@jskit-ai/http-runtime": "0.1.31",
157
- "@jskit-ai/realtime": "0.1.31",
158
- "@jskit-ai/kernel": "0.1.32",
159
- "@jskit-ai/shell-web": "0.1.31",
160
- "@jskit-ai/uploads-image-web": "0.1.10",
161
- "@jskit-ai/users-core": "0.1.42",
189
+ "@jskit-ai/http-runtime": "0.1.33",
190
+ "@jskit-ai/realtime": "0.1.33",
191
+ "@jskit-ai/kernel": "0.1.34",
192
+ "@jskit-ai/shell-web": "0.1.33",
193
+ "@jskit-ai/uploads-image-web": "0.1.12",
194
+ "@jskit-ai/users-core": "0.1.44",
162
195
  vuetify: "^4.0.0"
163
196
  },
164
197
  dev: {}
@@ -226,7 +259,7 @@ export default Object.freeze({
226
259
  from: "templates/src/pages/console/settings/index.vue",
227
260
  toSurface: "console",
228
261
  toSurfacePath: "settings/index.vue",
229
- reason: "Install console settings landing page scaffold for users-web console UI.",
262
+ reason: "Install console settings index stub scaffold for app-owned landing or redirect behavior.",
230
263
  category: "users-web",
231
264
  id: "users-web-page-console-settings"
232
265
  }
@@ -249,7 +282,7 @@ export default Object.freeze({
249
282
  position: "bottom",
250
283
  skipIfContains: "id: \"users.profile.menu.surface-switch\"",
251
284
  value:
252
- "\naddPlacement({\n id: \"users.profile.menu.surface-switch\",\n host: \"auth-profile-menu\",\n position: \"primary-menu\",\n surfaces: [\"*\"],\n order: 100,\n componentToken: \"users.web.profile.menu.surface-switch-item\",\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
285
+ "\naddPlacement({\n id: \"users.profile.menu.surface-switch\",\n target: \"auth-profile-menu:primary-menu\",\n surfaces: [\"*\"],\n order: 100,\n componentToken: \"users.web.profile.menu.surface-switch-item\",\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
253
286
  reason: "Append users-web profile surface switch placement into app-owned placement registry.",
254
287
  category: "users-web",
255
288
  id: "users-web-profile-surface-switch-placement"
@@ -260,21 +293,43 @@ export default Object.freeze({
260
293
  position: "bottom",
261
294
  skipIfContains: "id: \"users.profile.menu.settings\"",
262
295
  value:
263
- "\naddPlacement({\n id: \"users.profile.menu.settings\",\n host: \"auth-profile-menu\",\n position: \"primary-menu\",\n surfaces: [\"*\"],\n order: 500,\n componentToken: \"users.web.shell.menu-link-item\",\n props: {\n label: \"Settings\",\n to: \"/account\"\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
296
+ "\naddPlacement({\n id: \"users.profile.menu.settings\",\n target: \"auth-profile-menu:primary-menu\",\n surfaces: [\"*\"],\n order: 500,\n componentToken: \"auth.web.profile.menu.link-item\",\n props: {\n label: \"Settings\",\n to: \"/account\"\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
264
297
  reason: "Append users-web profile settings menu placement into app-owned placement registry.",
265
298
  category: "users-web",
266
299
  id: "users-web-profile-settings-placement"
267
300
  },
301
+ {
302
+ op: "append-text",
303
+ file: "src/placement.js",
304
+ position: "bottom",
305
+ skipIfContains: "id: \"users.home.menu.home\"",
306
+ value:
307
+ "\naddPlacement({\n id: \"users.home.menu.home\",\n target: \"shell-layout:primary-menu\",\n surfaces: [\"*\"],\n order: 50,\n componentToken: \"local.main.ui.surface-aware-menu-link-item\",\n props: {\n label: \"Home\",\n surface: \"home\",\n workspaceSuffix: \"/\",\n nonWorkspaceSuffix: \"/\",\n exact: true\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
308
+ reason: "Append users-web home shell menu placement into app-owned placement registry.",
309
+ category: "users-web",
310
+ id: "users-web-home-shell-menu-placement"
311
+ },
268
312
  {
269
313
  op: "append-text",
270
314
  file: "src/placement.js",
271
315
  position: "bottom",
272
316
  skipIfContains: "id: \"users.console.menu.settings\"",
273
317
  value:
274
- "\naddPlacement({\n id: \"users.console.menu.settings\",\n host: \"shell-layout\",\n position: \"primary-menu\",\n surfaces: [\"console\"],\n order: 100,\n componentToken: \"users.web.shell.menu-link-item\",\n props: {\n label: \"Settings\",\n to: \"/console/settings\",\n icon: \"mdi-cog-outline\"\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
318
+ "\naddPlacement({\n id: \"users.console.menu.settings\",\n target: \"shell-layout:primary-menu\",\n surfaces: [\"console\"],\n order: 100,\n componentToken: \"local.main.ui.menu-link-item\",\n props: {\n label: \"Settings\",\n to: \"/console/settings\",\n icon: \"mdi-cog-outline\"\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
275
319
  reason: "Append users-web console settings menu placement into app-owned placement registry.",
276
320
  category: "users-web",
277
321
  id: "users-web-console-settings-placement"
322
+ },
323
+ {
324
+ op: "append-text",
325
+ file: "src/placement.js",
326
+ position: "bottom",
327
+ skipIfContains: "id: \"users.home.tools.widget\"",
328
+ value:
329
+ "\naddPlacement({\n id: \"users.home.tools.widget\",\n target: \"shell-layout:top-right\",\n surfaces: [\"home\"],\n order: 900,\n componentToken: \"users.web.home.tools.widget\",\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n\naddPlacement({\n id: \"users.home.menu.settings\",\n target: \"home-tools:primary-menu\",\n surfaces: [\"home\"],\n order: 100,\n componentToken: \"local.main.ui.surface-aware-menu-link-item\",\n props: {\n label: \"Settings\",\n surface: \"home\",\n workspaceSuffix: \"/settings\",\n nonWorkspaceSuffix: \"/settings\"\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n});\n",
330
+ reason: "Append users-web home tools widget and settings menu placements into app-owned placement registry.",
331
+ category: "users-web",
332
+ id: "users-web-home-tools-placement"
278
333
  }
279
334
  ]
280
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -8,8 +8,6 @@
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
10
10
  "./client/providers/UsersWorkspacesClientProvider": "./src/client/providers/UsersWorkspacesClientProvider.js",
11
- "./client/components/ConsoleSettingsClientElement": "./src/client/components/ConsoleSettingsClientElement.vue",
12
- "./client/components/WorkspaceSettingsClientElement": "./src/client/components/WorkspaceSettingsClientElement.vue",
13
11
  "./client/components/WorkspaceMembersClientElement": "./src/client/components/WorkspaceMembersClientElement.vue",
14
12
  "./client/composables/useAddEdit": "./src/client/composables/records/useAddEdit.js",
15
13
  "./client/composables/useCrudAddEdit": "./src/client/composables/records/useCrudAddEdit.js",
@@ -28,12 +26,12 @@
28
26
  "dependencies": {
29
27
  "@tanstack/vue-query": "5.92.12",
30
28
  "@mdi/js": "^7.4.47",
31
- "@jskit-ai/http-runtime": "0.1.31",
32
- "@jskit-ai/kernel": "0.1.32",
33
- "@jskit-ai/realtime": "0.1.31",
34
- "@jskit-ai/shell-web": "0.1.31",
35
- "@jskit-ai/uploads-image-web": "0.1.10",
36
- "@jskit-ai/users-core": "0.1.42",
29
+ "@jskit-ai/http-runtime": "0.1.33",
30
+ "@jskit-ai/kernel": "0.1.34",
31
+ "@jskit-ai/realtime": "0.1.33",
32
+ "@jskit-ai/shell-web": "0.1.33",
33
+ "@jskit-ai/uploads-image-web": "0.1.12",
34
+ "@jskit-ai/users-core": "0.1.44",
37
35
  "vuetify": "^4.0.0"
38
36
  }
39
37
  }
@@ -168,6 +168,7 @@
168
168
  <script setup>
169
169
  import { computed, toRefs, unref } from "vue";
170
170
  import { formatDateTime as formatKernelDateTime } from "@jskit-ai/kernel/shared/support";
171
+ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
171
172
  import { requireBoolean, requireFunction, requireRecord } from "../support/contractGuards.js";
172
173
 
173
174
  const props = defineProps({
@@ -188,11 +189,11 @@ const props = defineProps({
188
189
  required: true
189
190
  },
190
191
  revokeInviteId: {
191
- type: Number,
192
+ type: String,
192
193
  required: true
193
194
  },
194
195
  removeMemberUserId: {
195
- type: Number,
196
+ type: String,
196
197
  required: true
197
198
  },
198
199
  status: {
@@ -352,11 +353,11 @@ function isMemberRemoveLocked(member) {
352
353
  }
353
354
 
354
355
  function isRevokeInviteLoading(inviteId) {
355
- return isRevokingInvite.value && revokeInviteId.value === Number(inviteId || 0);
356
+ return isRevokingInvite.value && revokeInviteId.value === normalizeRecordId(inviteId, { fallback: "" });
356
357
  }
357
358
 
358
359
  function isRemoveMemberLoading(memberUserId) {
359
- return isRemovingMember.value && removeMemberUserId.value === Number(memberUserId || 0);
360
+ return isRemovingMember.value && removeMemberUserId.value === normalizeRecordId(memberUserId, { fallback: "" });
360
361
  }
361
362
 
362
363
  async function onSubmitInvite() {
@@ -0,0 +1,12 @@
1
+ <script setup>
2
+ import ShellOutletMenuWidget from "@jskit-ai/shell-web/client/components/ShellOutletMenuWidget";
3
+ import { HOME_TOOLS_OUTLET } from "../../shared/toolsOutletContracts.js";
4
+ </script>
5
+
6
+ <template>
7
+ <ShellOutletMenuWidget
8
+ :target="HOME_TOOLS_OUTLET.target"
9
+ :default-link-component-token="HOME_TOOLS_OUTLET.defaultLinkComponentToken"
10
+ :aria-label="HOME_TOOLS_OUTLET.ariaLabel"
11
+ />
12
+ </template>
@@ -1,23 +1,12 @@
1
1
  <script setup>
2
- import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";
3
- import { mdiCogOutline } from "@mdi/js";
2
+ import ShellOutletMenuWidget from "@jskit-ai/shell-web/client/components/ShellOutletMenuWidget";
3
+ import { WORKSPACE_TOOLS_OUTLET } from "../../shared/toolsOutletContracts.js";
4
4
  </script>
5
5
 
6
6
  <template>
7
- <v-menu location="bottom end" offset="10" eager>
8
- <template #activator="{ props: activatorProps }">
9
- <v-btn
10
- v-bind="activatorProps"
11
- icon
12
- variant="text"
13
- aria-label="Workspace tools"
14
- >
15
- <v-icon :icon="mdiCogOutline" />
16
- </v-btn>
17
- </template>
18
-
19
- <v-list min-width="220" density="comfortable" class="py-1">
20
- <ShellOutlet host="workspace-tools" position="primary-menu" />
21
- </v-list>
22
- </v-menu>
7
+ <ShellOutletMenuWidget
8
+ :target="WORKSPACE_TOOLS_OUTLET.target"
9
+ :default-link-component-token="WORKSPACE_TOOLS_OUTLET.defaultLinkComponentToken"
10
+ :aria-label="WORKSPACE_TOOLS_OUTLET.ariaLabel"
11
+ />
23
12
  </template>
@@ -21,6 +21,7 @@
21
21
  <script setup>
22
22
  import { computed, reactive, ref, watch } from "vue";
23
23
  import { formatDateTime } from "@jskit-ai/kernel/shared/support";
24
+ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
24
25
  import MembersAdminClientElement from "./MembersAdminClientElement.vue";
25
26
  import { useCommand } from "../composables/useCommand.js";
26
27
  import { useList } from "../composables/records/useList.js";
@@ -61,8 +62,8 @@ const collections = reactive({
61
62
  const inviteFeedback = useUiFeedback();
62
63
  const membersFeedback = useUiFeedback();
63
64
  const teamFeedback = useUiFeedback();
64
- const revokeInviteId = ref(0);
65
- const removeMemberUserId = ref(0);
65
+ const revokeInviteId = ref("");
66
+ const removeMemberUserId = ref("");
66
67
 
67
68
  const { route, currentSurfaceId, workspaceSlugFromRoute, mergePlacementContext } =
68
69
  useWorkspaceRouteContext();
@@ -83,7 +84,8 @@ const workspaceInvitesApiPath = computed(() =>
83
84
  );
84
85
 
85
86
  function workspaceMembersPath(memberId) {
86
- return `${workspaceMembersApiPath.value}/${Number(memberId || 0)}`;
87
+ const normalizedMemberId = encodeURIComponent(String(memberId || "").trim());
88
+ return `${workspaceMembersApiPath.value}/${normalizedMemberId}`;
87
89
  }
88
90
 
89
91
  function workspaceInvitePath(inviteId) {
@@ -145,8 +147,8 @@ function resetViewState() {
145
147
  collections.members = [];
146
148
  collections.invites = [];
147
149
  clearRoleOptions();
148
- revokeInviteId.value = 0;
149
- removeMemberUserId.value = 0;
150
+ revokeInviteId.value = "";
151
+ removeMemberUserId.value = "";
150
152
  }
151
153
 
152
154
  function toRoleTitle(roleSid) {
@@ -223,7 +225,7 @@ function normalizeMembers(entries) {
223
225
  return source.map((entry) => {
224
226
  const value = entry && typeof entry === "object" ? entry : {};
225
227
  return {
226
- userId: Number(value.userId || 0),
228
+ userId: normalizeRecordId(value.userId, { fallback: "" }),
227
229
  roleSid: String(value.roleSid || "").trim().toLowerCase(),
228
230
  status: String(value.status || "").trim().toLowerCase(),
229
231
  displayName: String(value.displayName || "").trim(),
@@ -238,12 +240,12 @@ function normalizeInvites(entries) {
238
240
  return source.map((entry) => {
239
241
  const value = entry && typeof entry === "object" ? entry : {};
240
242
  return {
241
- id: Number(value.id || 0),
243
+ id: normalizeRecordId(value.id, { fallback: "" }),
242
244
  email: String(value.email || "").trim().toLowerCase(),
243
245
  roleSid: String(value.roleSid || "").trim().toLowerCase(),
244
246
  status: String(value.status || "").trim().toLowerCase(),
245
247
  expiresAt: value.expiresAt || "",
246
- invitedByUserId: value.invitedByUserId == null ? null : Number(value.invitedByUserId)
248
+ invitedByUserId: normalizeRecordId(value.invitedByUserId, { fallback: null })
247
249
  };
248
250
  });
249
251
  }
@@ -576,7 +578,7 @@ async function submitRevokeInvite(inviteId) {
576
578
  return;
577
579
  }
578
580
 
579
- revokeInviteId.value = Number(inviteId || 0);
581
+ revokeInviteId.value = normalizeRecordId(inviteId, { fallback: "" });
580
582
  teamFeedback.clear();
581
583
 
582
584
  try {
@@ -591,7 +593,7 @@ async function submitRevokeInvite(inviteId) {
591
593
  } catch (error) {
592
594
  teamFeedback.error(error, "Unable to revoke invite.");
593
595
  } finally {
594
- revokeInviteId.value = 0;
596
+ revokeInviteId.value = "";
595
597
  }
596
598
  }
597
599
 
@@ -603,8 +605,8 @@ async function submitMemberRoleUpdate(member, roleSid) {
603
605
  membersFeedback.clear();
604
606
 
605
607
  try {
606
- const memberUserId = Number(member?.userId || 0);
607
- if (!Number.isInteger(memberUserId) || memberUserId < 1) {
608
+ const memberUserId = normalizeRecordId(member?.userId, { fallback: null });
609
+ if (!memberUserId) {
608
610
  throw new Error("Member user id is invalid.");
609
611
  }
610
612
 
@@ -630,12 +632,12 @@ async function submitRemoveMember(member) {
630
632
  membersFeedback.clear();
631
633
 
632
634
  try {
633
- const memberUserId = Number(member?.userId || 0);
634
- if (!Number.isInteger(memberUserId) || memberUserId < 1) {
635
+ const memberUserId = normalizeRecordId(member?.userId, { fallback: null });
636
+ if (!memberUserId) {
635
637
  throw new Error("Member user id is invalid.");
636
638
  }
637
639
 
638
- removeMemberUserId.value = memberUserId;
640
+ removeMemberUserId.value = normalizeRecordId(memberUserId, { fallback: "" });
639
641
  await memberRemoveCommand.run({
640
642
  memberUserId
641
643
  });
@@ -647,7 +649,7 @@ async function submitRemoveMember(member) {
647
649
  } catch (error) {
648
650
  membersFeedback.error(error, "Unable to remove member.");
649
651
  } finally {
650
- removeMemberUserId.value = 0;
652
+ removeMemberUserId.value = "";
651
653
  }
652
654
  }
653
655
  </script>
@@ -3,6 +3,7 @@ import {
3
3
  normalizeReturnToPath as normalizeSharedReturnToPath,
4
4
  resolveAllowedOriginsFromPlacementContext
5
5
  } from "@jskit-ai/kernel/shared/support";
6
+ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
6
7
  import { normalizeRecord } from "../../support/runtimeNormalization.js";
7
8
 
8
9
  function normalizeReturnToPath(value, { fallback = "/", accountSettingsPath = "/account", allowedOrigins = [] } = {}) {
@@ -27,9 +28,9 @@ function normalizePendingInvite(entry) {
27
28
  return null;
28
29
  }
29
30
 
30
- const id = Number(entry.id);
31
- const workspaceId = Number(entry.workspaceId);
32
- if (!Number.isInteger(id) || id < 1 || !Number.isInteger(workspaceId) || workspaceId < 1) {
31
+ const id = normalizeRecordId(entry.id, { fallback: null });
32
+ const workspaceId = normalizeRecordId(entry.workspaceId, { fallback: null });
33
+ if (!id || !workspaceId) {
33
34
  return null;
34
35
  }
35
36
 
@@ -2,8 +2,6 @@ import { UsersWebClientProvider } from "./providers/UsersWebClientProvider.js";
2
2
 
3
3
  export { UsersWebClientProvider } from "./providers/UsersWebClientProvider.js";
4
4
  export { UsersWorkspacesClientProvider } from "./providers/UsersWorkspacesClientProvider.js";
5
- export { default as ConsoleSettingsClientElement } from "./components/ConsoleSettingsClientElement.vue";
6
- export { default as WorkspaceSettingsClientElement } from "./components/WorkspaceSettingsClientElement.vue";
7
5
 
8
6
  const clientProviders = Object.freeze([UsersWebClientProvider]);
9
7
 
@@ -1,3 +1,5 @@
1
+ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
2
+
1
3
  function buildBootstrapApiPath(workspaceSlug = "") {
2
4
  const normalizedWorkspaceSlug = String(workspaceSlug || "").trim();
3
5
  if (!normalizedWorkspaceSlug) {
@@ -15,9 +17,9 @@ function normalizeWorkspaceEntry(entry) {
15
17
  return null;
16
18
  }
17
19
 
18
- const id = Number(entry.id);
20
+ const id = normalizeRecordId(entry.id, { fallback: null });
19
21
  const slug = String(entry.slug || "").trim();
20
- if (!Number.isInteger(id) || id < 1 || !slug) {
22
+ if (!id || !slug) {
21
23
  return null;
22
24
  }
23
25
 
@@ -69,8 +71,8 @@ function resolvePlacementUserFromBootstrapPayload(payload = {}, currentUser = nu
69
71
  const fallbackUser = currentUser && typeof currentUser === "object" ? currentUser : {};
70
72
  const nextUser = {};
71
73
 
72
- const userId = Number(session.userId || fallbackUser.id || 0);
73
- if (Number.isInteger(userId) && userId > 0) {
74
+ const userId = normalizeRecordId(session.userId || fallbackUser.id, { fallback: null });
75
+ if (userId) {
74
76
  nextUser.id = userId;
75
77
  }
76
78
 
@@ -1,6 +1,5 @@
1
- import UsersShellMenuLinkItem from "../components/UsersShellMenuLinkItem.vue";
2
- import UsersSurfaceAwareMenuLinkItem from "../components/UsersSurfaceAwareMenuLinkItem.vue";
3
1
  import UsersProfileSurfaceSwitchMenuItem from "../components/UsersProfileSurfaceSwitchMenuItem.vue";
2
+ import UsersHomeToolsWidget from "../components/UsersHomeToolsWidget.vue";
4
3
  import ProfileClientElement from "../components/ProfileClientElement.vue";
5
4
  import {
6
5
  createBootstrapPlacementRuntime
@@ -15,9 +14,8 @@ class UsersWebClientProvider {
15
14
  throw new Error("UsersWebClientProvider requires application singleton().");
16
15
  }
17
16
 
18
- app.singleton("users.web.shell.menu-link-item", () => UsersShellMenuLinkItem);
19
- app.singleton("users.web.shell.surface-aware-menu-link-item", () => UsersSurfaceAwareMenuLinkItem);
20
17
  app.singleton("users.web.profile.menu.surface-switch-item", () => UsersProfileSurfaceSwitchMenuItem);
18
+ app.singleton("users.web.home.tools.widget", () => UsersHomeToolsWidget);
21
19
  app.singleton("users.web.profile.element", () => ProfileClientElement);
22
20
  app.singleton("users.web.bootstrap-placement.runtime", (scope) => createBootstrapPlacementRuntime({ app: scope }));
23
21
  }
@@ -3,7 +3,6 @@ import UsersWorkspaceToolsWidget from "../components/UsersWorkspaceToolsWidget.v
3
3
  import UsersWorkspaceSettingsMenuItem from "../components/UsersWorkspaceSettingsMenuItem.vue";
4
4
  import UsersWorkspaceMembersMenuItem from "../components/UsersWorkspaceMembersMenuItem.vue";
5
5
  import MembersAdminClientElement from "../components/MembersAdminClientElement.vue";
6
- import WorkspaceSettingsClientElement from "../components/WorkspaceSettingsClientElement.vue";
7
6
 
8
7
  class UsersWorkspacesClientProvider {
9
8
  static id = "workspaces.web.client";
@@ -19,7 +18,6 @@ class UsersWorkspacesClientProvider {
19
18
  app.singleton("users.web.workspace-settings.menu-item", () => UsersWorkspaceSettingsMenuItem);
20
19
  app.singleton("users.web.workspace-members.menu-item", () => UsersWorkspaceMembersMenuItem);
21
20
  app.singleton("users.web.members-admin.element", () => MembersAdminClientElement);
22
- app.singleton("users.web.workspace-settings.element", () => WorkspaceSettingsClientElement);
23
21
  }
24
22
  }
25
23
 
@@ -0,0 +1,19 @@
1
+ const DEFAULT_TOOLS_LINK_COMPONENT_TOKEN = "local.main.ui.surface-aware-menu-link-item";
2
+
3
+ const HOME_TOOLS_OUTLET = Object.freeze({
4
+ target: "home-tools:primary-menu",
5
+ defaultLinkComponentToken: DEFAULT_TOOLS_LINK_COMPONENT_TOKEN,
6
+ ariaLabel: "Home tools"
7
+ });
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
+ export {
16
+ DEFAULT_TOOLS_LINK_COMPONENT_TOKEN,
17
+ HOME_TOOLS_OUTLET,
18
+ WORKSPACE_TOOLS_OUTLET
19
+ };
@@ -1,7 +1,8 @@
1
1
  <script setup>
2
- import ConsoleSettingsClientElement from "@jskit-ai/users-web/client/components/ConsoleSettingsClientElement";
2
+ // To redirect this settings shell to a child page, uncomment and edit the example below.
3
+ // definePage({
4
+ // redirect: (to) => `${to.path}/your_child_segment`
5
+ // });
3
6
  </script>
4
7
 
5
- <template>
6
- <ConsoleSettingsClientElement />
7
- </template>
8
+ <template />
@@ -15,7 +15,10 @@ import { RouterView } from "vue-router";
15
15
  <v-row no-gutters>
16
16
  <v-col cols="12" md="3" lg="2" class="pr-md-4 mb-4 mb-md-0">
17
17
  <v-list nav density="comfortable" rounded="lg" border>
18
- <ShellOutlet host="console-settings" position="primary-menu" />
18
+ <ShellOutlet
19
+ target="console-settings:primary-menu"
20
+ default-link-component-token="local.main.ui.surface-aware-menu-link-item"
21
+ />
19
22
  </v-list>
20
23
  </v-col>
21
24
 
@@ -17,7 +17,7 @@ test("resolvePlacementUserFromBootstrapPayload maps profile fields used by place
17
17
  const user = resolvePlacementUserFromBootstrapPayload({
18
18
  session: {
19
19
  authenticated: true,
20
- userId: 42
20
+ userId: "42"
21
21
  },
22
22
  profile: {
23
23
  displayName: "Ada Lovelace",
@@ -29,7 +29,7 @@ test("resolvePlacementUserFromBootstrapPayload maps profile fields used by place
29
29
  });
30
30
 
31
31
  assert.deepEqual(user, {
32
- id: 42,
32
+ id: "42",
33
33
  displayName: "Ada Lovelace",
34
34
  name: "Ada Lovelace",
35
35
  email: "ada@example.com",
@@ -245,7 +245,7 @@ test("bootstrap placement runtime writes user/workspace/permissions into placeme
245
245
  return {
246
246
  session: {
247
247
  authenticated: true,
248
- userId: 7
248
+ userId: "7"
249
249
  },
250
250
  profile: {
251
251
  displayName: "Ada Lovelace",
@@ -260,10 +260,10 @@ test("bootstrap placement runtime writes user/workspace/permissions into placeme
260
260
  }
261
261
  },
262
262
  pendingInvites: [
263
- { id: 1, workspaceId: 1, token: "a" },
264
- { id: 2, workspaceId: 2, token: "b" }
263
+ { id: "1", workspaceId: "1", token: "a" },
264
+ { id: "2", workspaceId: "2", token: "b" }
265
265
  ],
266
- workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
266
+ workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
267
267
  permissions: ["workspace.settings.view"]
268
268
  };
269
269
  }
@@ -280,7 +280,7 @@ test("bootstrap placement runtime writes user/workspace/permissions into placeme
280
280
  assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
281
281
  assert.equal(context.workspaceBootstrapStatuses?.acme, WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
282
282
  assert.deepEqual(context.user, {
283
- id: 7,
283
+ id: "7",
284
284
  displayName: "Ada Lovelace",
285
285
  name: "Ada Lovelace",
286
286
  email: "ada@example.com",
@@ -305,7 +305,7 @@ test("bootstrap placement runtime resolves workspace slug from pathname when sur
305
305
  return {
306
306
  session: {
307
307
  authenticated: true,
308
- userId: 1
308
+ userId: "1"
309
309
  },
310
310
  profile: {
311
311
  displayName: "User",
@@ -314,7 +314,7 @@ test("bootstrap placement runtime resolves workspace slug from pathname when sur
314
314
  effectiveUrl: ""
315
315
  }
316
316
  },
317
- workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
317
+ workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
318
318
  permissions: ["workspace.settings.view"]
319
319
  };
320
320
  }
@@ -348,7 +348,7 @@ test("bootstrap placement runtime does not mutate placement auth context", async
348
348
  return {
349
349
  session: {
350
350
  authenticated: true,
351
- userId: 9
351
+ userId: "9"
352
352
  },
353
353
  profile: {
354
354
  displayName: "User",
@@ -357,7 +357,7 @@ test("bootstrap placement runtime does not mutate placement auth context", async
357
357
  effectiveUrl: ""
358
358
  }
359
359
  },
360
- workspaces: [{ id: 1, slug: "acme", name: "Workspace" }],
360
+ workspaces: [{ id: "1", slug: "acme", name: "Workspace" }],
361
361
  permissions: []
362
362
  };
363
363
  }
@@ -387,7 +387,7 @@ test("bootstrap placement runtime refetches on route changes and users.bootstrap
387
387
  return {
388
388
  session: {
389
389
  authenticated: true,
390
- userId: 1
390
+ userId: "1"
391
391
  },
392
392
  profile: {
393
393
  displayName: "User",
@@ -396,7 +396,7 @@ test("bootstrap placement runtime refetches on route changes and users.bootstrap
396
396
  effectiveUrl: ""
397
397
  }
398
398
  },
399
- workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
399
+ workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
400
400
  permissions: []
401
401
  };
402
402
  }
@@ -436,7 +436,7 @@ test("bootstrap placement runtime refetches when auth context changes", async ()
436
436
  return {
437
437
  session: {
438
438
  authenticated: true,
439
- userId: 1
439
+ userId: "1"
440
440
  },
441
441
  profile: {
442
442
  displayName: "User",
@@ -445,7 +445,7 @@ test("bootstrap placement runtime refetches when auth context changes", async ()
445
445
  effectiveUrl: ""
446
446
  }
447
447
  },
448
- workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
448
+ workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
449
449
  permissions: []
450
450
  };
451
451
  }
@@ -535,7 +535,7 @@ test("bootstrap placement runtime reapplies theme when bootstrap payload changes
535
535
  return {
536
536
  session: {
537
537
  authenticated: true,
538
- userId: 1
538
+ userId: "1"
539
539
  },
540
540
  profile: {
541
541
  displayName: "User",
@@ -547,7 +547,7 @@ test("bootstrap placement runtime reapplies theme when bootstrap payload changes
547
547
  userSettings: {
548
548
  theme: fetchCount === 1 ? "dark" : "light"
549
549
  },
550
- workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
550
+ workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
551
551
  permissions: []
552
552
  };
553
553
  }
@@ -575,7 +575,7 @@ test("bootstrap placement runtime applies workspace palette via Vuetify workspac
575
575
  return {
576
576
  session: {
577
577
  authenticated: true,
578
- userId: 1
578
+ userId: "1"
579
579
  },
580
580
  workspaceSettings: {
581
581
  lightPrimaryColor: "#CC3344",
@@ -589,7 +589,7 @@ test("bootstrap placement runtime applies workspace palette via Vuetify workspac
589
589
  },
590
590
  workspaces: [
591
591
  {
592
- id: 1,
592
+ id: "1",
593
593
  slug: "acme",
594
594
  name: "Acme Workspace"
595
595
  }
@@ -630,8 +630,8 @@ test("bootstrap placement runtime marks workspace slug as not_found and clears w
630
630
  const placementRuntime = createPlacementRuntimeStub();
631
631
  placementRuntime.setContext(
632
632
  {
633
- workspace: { id: 1, slug: "acme", name: "Acme Workspace" },
634
- workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
633
+ workspace: { id: "1", slug: "acme", name: "Acme Workspace" },
634
+ workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
635
635
  permissions: ["workspace.settings.view"]
636
636
  },
637
637
  { source: "test.seed" }
@@ -680,7 +680,7 @@ test("bootstrap placement runtime updates status per workspace slug across route
680
680
  return {
681
681
  session: {
682
682
  authenticated: true,
683
- userId: 1
683
+ userId: "1"
684
684
  },
685
685
  profile: {
686
686
  displayName: "User",
@@ -689,7 +689,7 @@ test("bootstrap placement runtime updates status per workspace slug across route
689
689
  effectiveUrl: ""
690
690
  }
691
691
  },
692
- workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
692
+ workspaces: [{ id: "1", slug: workspaceSlug || "acme", name: "Workspace" }],
693
693
  permissions: []
694
694
  };
695
695
  }
@@ -721,7 +721,7 @@ test("bootstrap placement runtime uses requestedWorkspace status and keeps globa
721
721
  return {
722
722
  session: {
723
723
  authenticated: true,
724
- userId: 4
724
+ userId: "4"
725
725
  },
726
726
  profile: {
727
727
  displayName: "Chiara",
@@ -730,7 +730,7 @@ test("bootstrap placement runtime uses requestedWorkspace status and keeps globa
730
730
  effectiveUrl: ""
731
731
  }
732
732
  },
733
- workspaces: [{ id: 3, slug: "chiaramobily", name: "Chiara Workspace" }],
733
+ workspaces: [{ id: "3", slug: "chiaramobily", name: "Chiara Workspace" }],
734
734
  requestedWorkspace: {
735
735
  slug: "tonymobily",
736
736
  status: "forbidden"
@@ -763,7 +763,7 @@ test("bootstrap placement runtime uses requestedWorkspace=not_found without forc
763
763
  return {
764
764
  session: {
765
765
  authenticated: true,
766
- userId: 1
766
+ userId: "1"
767
767
  },
768
768
  profile: {
769
769
  displayName: "User",
@@ -772,7 +772,7 @@ test("bootstrap placement runtime uses requestedWorkspace=not_found without forc
772
772
  effectiveUrl: ""
773
773
  }
774
774
  },
775
- workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
775
+ workspaces: [{ id: "1", slug: "acme", name: "Acme Workspace" }],
776
776
  requestedWorkspace: {
777
777
  slug: "missing",
778
778
  status: "not_found"
@@ -811,9 +811,9 @@ test("bootstrap placement runtime guard wrapper preserves delegated deny outcome
811
811
  return {
812
812
  session: {
813
813
  authenticated: true,
814
- userId: 1
814
+ userId: "1"
815
815
  },
816
- workspaces: [{ id: 1, slug: "acme", name: "Acme" }],
816
+ workspaces: [{ id: "1", slug: "acme", name: "Acme" }],
817
817
  permissions: []
818
818
  };
819
819
  }
@@ -854,7 +854,7 @@ test("bootstrap placement runtime guard wrapper blocks forbidden workspace route
854
854
  return {
855
855
  session: {
856
856
  authenticated: true,
857
- userId: 1
857
+ userId: "1"
858
858
  },
859
859
  workspaces: [],
860
860
  permissions: []
@@ -1032,7 +1032,7 @@ test("bootstrap placement runtime enforces surface access policies after bootstr
1032
1032
  return {
1033
1033
  session: {
1034
1034
  authenticated: true,
1035
- userId: 1
1035
+ userId: "1"
1036
1036
  },
1037
1037
  workspaces: [],
1038
1038
  permissions: [],
@@ -1059,9 +1059,9 @@ test("bootstrap placement runtime captures guard evaluator assignments after ini
1059
1059
  return {
1060
1060
  session: {
1061
1061
  authenticated: true,
1062
- userId: 1
1062
+ userId: "1"
1063
1063
  },
1064
- workspaces: [{ id: 1, slug: "acme", name: "Acme" }],
1064
+ workspaces: [{ id: "1", slug: "acme", name: "Acme" }],
1065
1065
  permissions: []
1066
1066
  };
1067
1067
  }
@@ -8,30 +8,217 @@ import descriptor from "../package.descriptor.mjs";
8
8
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
9
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
10
 
11
- function readSettingsOutlets() {
11
+ function readOutlets(host = "") {
12
12
  const outlets = descriptor?.metadata?.ui?.placements?.outlets;
13
+ const normalizedTarget = String(host || "").trim();
13
14
  return Array.isArray(outlets)
14
- ? outlets.filter((entry) => String(entry?.host || "").trim() === "console-settings")
15
+ ? outlets.filter((entry) => String(entry?.target || "").trim() === normalizedTarget)
15
16
  : [];
16
17
  }
17
18
 
19
+ function findContribution(id) {
20
+ const contributions = descriptor?.metadata?.ui?.placements?.contributions;
21
+ return Array.isArray(contributions)
22
+ ? contributions.find((entry) => String(entry?.id || "").trim() === id) || null
23
+ : null;
24
+ }
25
+
26
+ function findTextMutation(id) {
27
+ const textMutations = descriptor?.mutations?.text;
28
+ return Array.isArray(textMutations)
29
+ ? textMutations.find((entry) => String(entry?.id || "").trim() === id) || null
30
+ : null;
31
+ }
32
+
33
+ function findFileMutation(id) {
34
+ const fileMutations = descriptor?.mutations?.files;
35
+ return Array.isArray(fileMutations)
36
+ ? fileMutations.find((entry) => String(entry?.id || "").trim() === id) || null
37
+ : null;
38
+ }
39
+
40
+ function expectContribution(id, expected = {}) {
41
+ const contribution = findContribution(id);
42
+ assert.ok(contribution, `Expected contribution "${id}".`);
43
+
44
+ for (const [key, value] of Object.entries(expected)) {
45
+ assert.deepEqual(contribution[key], value);
46
+ }
47
+ }
48
+
49
+ function expectTextMutation(id, { reason = "", category = "", skipIfContains = "", snippets = [] } = {}) {
50
+ const mutation = findTextMutation(id);
51
+ assert.ok(mutation, `Expected text mutation "${id}".`);
52
+ assert.equal(mutation.op, "append-text");
53
+ assert.equal(mutation.file, "src/placement.js");
54
+ assert.equal(mutation.position, "bottom");
55
+ assert.equal(mutation.id, id);
56
+
57
+ if (reason) {
58
+ assert.equal(mutation.reason, reason);
59
+ }
60
+
61
+ if (category) {
62
+ assert.equal(mutation.category, category);
63
+ }
64
+
65
+ if (skipIfContains) {
66
+ assert.equal(mutation.skipIfContains, skipIfContains);
67
+ }
68
+
69
+ for (const snippet of snippets) {
70
+ assert.ok(mutation.value.includes(snippet), `Expected mutation "${id}" to include "${snippet}".`);
71
+ }
72
+ }
73
+
18
74
  test("users-web console settings template exposes surface-derived settings outlets", async () => {
19
75
  const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "console", "settings.vue"), "utf8");
20
76
 
21
- assert.match(source, /<ShellOutlet host="console-settings" position="primary-menu" \/>/);
77
+ assert.match(source, /target="console-settings:primary-menu"/);
78
+ assert.match(source, /default-link-component-token="local\.main\.ui\.surface-aware-menu-link-item"/);
79
+ assert.match(source, /<RouterView \/>/);
80
+ });
81
+
82
+ test("users-web console settings index template is a simple developer-owned stub", async () => {
83
+ const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "console", "settings", "index.vue"), "utf8");
84
+
85
+ assert.match(source, /definePage/);
86
+ assert.match(source, /your_child_segment/);
22
87
  });
23
88
 
24
89
  test("users-web descriptor metadata advertises console settings outlets with standard positions", () => {
25
- const outlets = readSettingsOutlets();
90
+ const outlets = readOutlets("console-settings:primary-menu");
26
91
  assert.deepEqual(
27
92
  outlets,
28
93
  [
29
94
  {
30
- host: "console-settings",
31
- position: "primary-menu",
95
+ target: "console-settings:primary-menu",
96
+ defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
32
97
  surfaces: ["console"],
33
98
  source: "templates/src/pages/console/settings.vue"
34
99
  }
35
100
  ]
36
101
  );
102
+ assert.deepEqual(findFileMutation("users-web-page-console-settings"), {
103
+ from: "templates/src/pages/console/settings/index.vue",
104
+ toSurface: "console",
105
+ toSurfacePath: "settings/index.vue",
106
+ reason: "Install console settings index stub scaffold for app-owned landing or redirect behavior.",
107
+ category: "users-web",
108
+ id: "users-web-page-console-settings"
109
+ });
110
+ });
111
+
112
+ test("users-web home tools widget exposes home-tools outlet", async () => {
113
+ const source = await readFile(path.join(PACKAGE_DIR, "src", "client", "components", "UsersHomeToolsWidget.vue"), "utf8");
114
+
115
+ assert.match(source, /import \{ HOME_TOOLS_OUTLET \} from "\.\.\/\.\.\/shared\/toolsOutletContracts\.js";/);
116
+ assert.match(source, /<ShellOutletMenuWidget/);
117
+ assert.match(source, /:target="HOME_TOOLS_OUTLET\.target"/);
118
+ assert.match(source, /:default-link-component-token="HOME_TOOLS_OUTLET\.defaultLinkComponentToken"/);
119
+ });
120
+
121
+ test("users-web workspace tools widget exposes workspace-tools outlet", async () => {
122
+ const source = await readFile(path.join(PACKAGE_DIR, "src", "client", "components", "UsersWorkspaceToolsWidget.vue"), "utf8");
123
+
124
+ assert.match(source, /import \{ WORKSPACE_TOOLS_OUTLET \} from "\.\.\/\.\.\/shared\/toolsOutletContracts\.js";/);
125
+ assert.match(source, /<ShellOutletMenuWidget/);
126
+ assert.match(source, /:target="WORKSPACE_TOOLS_OUTLET\.target"/);
127
+ assert.match(source, /:default-link-component-token="WORKSPACE_TOOLS_OUTLET\.defaultLinkComponentToken"/);
128
+ });
129
+
130
+ test("users-web descriptor metadata advertises home tools outlet and standard home settings placements", () => {
131
+ assert.deepEqual(
132
+ readOutlets("home-tools:primary-menu"),
133
+ [
134
+ {
135
+ target: "home-tools:primary-menu",
136
+ defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
137
+ surfaces: ["home"],
138
+ source: "src/client/components/UsersHomeToolsWidget.vue"
139
+ }
140
+ ]
141
+ );
142
+
143
+ expectContribution("users.home.menu.home", {
144
+ target: "shell-layout:primary-menu",
145
+ surfaces: ["*"],
146
+ order: 50,
147
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
148
+ when: "auth.authenticated === true",
149
+ source: "mutations.text#users-web-home-shell-menu-placement"
150
+ });
151
+
152
+ expectContribution("users.profile.menu.settings", {
153
+ target: "auth-profile-menu:primary-menu",
154
+ surfaces: ["*"],
155
+ order: 500,
156
+ componentToken: "auth.web.profile.menu.link-item",
157
+ when: "auth.authenticated === true",
158
+ source: "mutations.text#users-web-profile-settings-placement"
159
+ });
160
+
161
+ expectContribution("users.home.tools.widget", {
162
+ target: "shell-layout:top-right",
163
+ surfaces: ["home"],
164
+ order: 900,
165
+ componentToken: "users.web.home.tools.widget",
166
+ when: "auth.authenticated === true",
167
+ source: "mutations.text#users-web-home-tools-placement"
168
+ });
169
+
170
+ expectContribution("users.home.menu.settings", {
171
+ target: "home-tools:primary-menu",
172
+ surfaces: ["home"],
173
+ order: 100,
174
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
175
+ when: "auth.authenticated === true",
176
+ source: "mutations.text#users-web-home-tools-placement"
177
+ });
178
+ assert.equal(findContribution("users.home.settings.general"), null);
179
+
180
+ expectTextMutation("users-web-home-tools-placement", {
181
+ reason: "Append users-web home tools widget and settings menu placements into app-owned placement registry.",
182
+ category: "users-web",
183
+ skipIfContains: 'id: "users.home.tools.widget"',
184
+ snippets: [
185
+ 'id: "users.home.tools.widget"',
186
+ 'componentToken: "users.web.home.tools.widget"',
187
+ 'id: "users.home.menu.settings"',
188
+ 'target: "home-tools:primary-menu"',
189
+ 'componentToken: "local.main.ui.surface-aware-menu-link-item"',
190
+ 'workspaceSuffix: "/settings"',
191
+ 'nonWorkspaceSuffix: "/settings"'
192
+ ]
193
+ });
194
+
195
+ expectTextMutation("users-web-profile-settings-placement", {
196
+ reason: "Append users-web profile settings menu placement into app-owned placement registry.",
197
+ category: "users-web",
198
+ skipIfContains: 'id: "users.profile.menu.settings"',
199
+ snippets: [
200
+ 'id: "users.profile.menu.settings"',
201
+ 'target: "auth-profile-menu:primary-menu"',
202
+ 'componentToken: "auth.web.profile.menu.link-item"',
203
+ 'label: "Settings"',
204
+ 'to: "/account"'
205
+ ]
206
+ });
207
+
208
+ expectTextMutation("users-web-home-shell-menu-placement", {
209
+ reason: "Append users-web home shell menu placement into app-owned placement registry.",
210
+ category: "users-web",
211
+ skipIfContains: 'id: "users.home.menu.home"',
212
+ snippets: [
213
+ 'id: "users.home.menu.home"',
214
+ 'target: "shell-layout:primary-menu"',
215
+ 'componentToken: "local.main.ui.surface-aware-menu-link-item"',
216
+ 'label: "Home"',
217
+ 'surface: "home"',
218
+ 'workspaceSuffix: "/"',
219
+ 'nonWorkspaceSuffix: "/"',
220
+ 'exact: true'
221
+ ]
222
+ });
223
+
37
224
  });
@@ -1,140 +0,0 @@
1
- <script setup>
2
- import { computed } from "vue";
3
- import { useRoute } from "vue-router";
4
- import { appendQueryString } from "@jskit-ai/kernel/shared/support";
5
- import { isExternalLinkTarget, splitPathQueryHash } from "@jskit-ai/kernel/shared/support/linkPath";
6
- import {
7
- useWebPlacementContext,
8
- resolveSurfaceNavigationTargetFromPlacementContext
9
- } from "@jskit-ai/shell-web/client/placement";
10
- import { resolveAccountSettingsPathFromPlacementContext } from "../lib/workspaceSurfacePaths.js";
11
- import { resolveMenuLinkIcon } from "../lib/menuIcons.js";
12
-
13
- const props = defineProps({
14
- label: {
15
- type: String,
16
- default: ""
17
- },
18
- to: {
19
- type: String,
20
- default: ""
21
- },
22
- icon: {
23
- type: String,
24
- default: ""
25
- },
26
- disabled: {
27
- type: Boolean,
28
- default: false
29
- }
30
- });
31
-
32
- const route = useRoute();
33
- const { context: placementContext } = useWebPlacementContext();
34
-
35
- function resolveFallbackReturnTo() {
36
- if (typeof window !== "object" || !window || !window.location) {
37
- return "/";
38
- }
39
- const pathname = String(window.location.pathname || "").trim() || "/";
40
- const search = String(window.location.search || "").trim();
41
- const hash = String(window.location.hash || "").trim();
42
- return `${pathname}${search}${hash}`;
43
- }
44
-
45
- function resolveFallbackReturnToHref() {
46
- if (typeof window !== "object" || !window || !window.location) {
47
- return "/";
48
- }
49
- return String(window.location.href || "").trim() || resolveFallbackReturnTo();
50
- }
51
-
52
- function resolvePathnameFromLinkTarget(target = "") {
53
- const normalizedTarget = String(target || "").trim();
54
- if (!normalizedTarget) {
55
- return "";
56
- }
57
-
58
- if (isExternalLinkTarget(normalizedTarget)) {
59
- try {
60
- const parsed = new URL(normalizedTarget);
61
- return String(parsed.pathname || "").trim();
62
- } catch {
63
- return "";
64
- }
65
- }
66
-
67
- return splitPathQueryHash(normalizedTarget).pathname;
68
- }
69
-
70
- const accountSettingsPathname = computed(() => {
71
- const settingsPath = resolveAccountSettingsPathFromPlacementContext(placementContext.value);
72
- return resolvePathnameFromLinkTarget(settingsPath);
73
- });
74
-
75
- const resolvedTo = computed(() => {
76
- const target = String(props.to || "").trim();
77
- if (!target) {
78
- return "";
79
- }
80
-
81
- const targetPathname = resolvePathnameFromLinkTarget(target);
82
- if (!targetPathname || targetPathname !== accountSettingsPathname.value) {
83
- return target;
84
- }
85
- if (target.includes("returnTo=")) {
86
- return target;
87
- }
88
-
89
- const accountSettingsTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
90
- path: target,
91
- surfaceId: "account"
92
- });
93
- const routeFullPath = String(route?.fullPath || "").trim();
94
- const routePath = String(route?.path || "").trim();
95
- const returnTo = accountSettingsTarget.sameOrigin
96
- ? routeFullPath || routePath || resolveFallbackReturnTo()
97
- : resolveFallbackReturnToHref();
98
- const queryParams = new URLSearchParams({
99
- returnTo
100
- });
101
-
102
- return appendQueryString(target, queryParams.toString());
103
- });
104
-
105
- const resolvedNavigationTarget = computed(() => {
106
- const target = String(resolvedTo.value || "").trim();
107
- if (!target) {
108
- return {
109
- href: "",
110
- sameOrigin: true
111
- };
112
- }
113
-
114
- const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
115
- path: target
116
- });
117
- return {
118
- href: navigationTarget.href,
119
- sameOrigin: navigationTarget.sameOrigin
120
- };
121
- });
122
-
123
- const resolvedIcon = computed(() =>
124
- resolveMenuLinkIcon({
125
- icon: props.icon,
126
- label: props.label,
127
- to: resolvedTo.value
128
- })
129
- );
130
- </script>
131
-
132
- <template>
133
- <v-list-item
134
- :title="props.label"
135
- :to="resolvedNavigationTarget.sameOrigin ? resolvedNavigationTarget.href : undefined"
136
- :href="resolvedNavigationTarget.sameOrigin ? undefined : resolvedNavigationTarget.href"
137
- :prepend-icon="resolvedIcon || undefined"
138
- :disabled="props.disabled"
139
- />
140
- </template>
@@ -1,76 +0,0 @@
1
- <script setup>
2
- import { computed } from "vue";
3
- import { useRoute } from "vue-router";
4
- import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
5
- import { usePaths } from "../composables/usePaths.js";
6
- import { resolveMenuLinkIcon } from "../lib/menuIcons.js";
7
- import { resolveMenuLinkTarget } from "../support/menuLinkTarget.js";
8
-
9
- const props = defineProps({
10
- label: {
11
- type: String,
12
- default: ""
13
- },
14
- to: {
15
- type: String,
16
- default: ""
17
- },
18
- icon: {
19
- type: String,
20
- default: ""
21
- },
22
- surface: {
23
- type: String,
24
- default: ""
25
- },
26
- workspaceSuffix: {
27
- type: String,
28
- default: "/"
29
- },
30
- nonWorkspaceSuffix: {
31
- type: String,
32
- default: "/"
33
- },
34
- disabled: {
35
- type: Boolean,
36
- default: false
37
- }
38
- });
39
-
40
- const route = useRoute();
41
- const paths = usePaths();
42
- const { context: placementContext } = useWebPlacementContext();
43
-
44
- const resolvedTo = computed(() => {
45
- return resolveMenuLinkTarget({
46
- to: props.to,
47
- surface: props.surface,
48
- currentSurfaceId: paths.currentSurfaceId.value,
49
- placementContext: placementContext.value,
50
- workspaceSuffix: props.workspaceSuffix,
51
- nonWorkspaceSuffix: props.nonWorkspaceSuffix,
52
- routeParams: route.params || {},
53
- resolvePagePath(relativePath, options = {}) {
54
- return paths.page(relativePath, options);
55
- }
56
- });
57
- });
58
-
59
- const resolvedIcon = computed(() =>
60
- resolveMenuLinkIcon({
61
- icon: props.icon,
62
- label: props.label,
63
- to: resolvedTo.value
64
- })
65
- );
66
- </script>
67
-
68
- <template>
69
- <v-list-item
70
- v-if="resolvedTo"
71
- :title="props.label"
72
- :to="resolvedTo"
73
- :prepend-icon="resolvedIcon || undefined"
74
- :disabled="props.disabled"
75
- />
76
- </template>