@open-mercato/core 0.6.8-develop.6944.1.eef6a0ee1d → 0.6.8-develop.6948.1.8369fc4c97

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 (51) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/authFixtures.js +3 -0
  3. package/dist/helpers/integration/authFixtures.js.map +2 -2
  4. package/dist/modules/auth/api/users/acl/route.js +15 -2
  5. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  6. package/dist/modules/auth/api/users/consents/route.js +8 -1
  7. package/dist/modules/auth/api/users/consents/route.js.map +2 -2
  8. package/dist/modules/auth/api/users/resend-invite/route.js +8 -1
  9. package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
  10. package/dist/modules/auth/api/users/route.js +80 -6
  11. package/dist/modules/auth/api/users/route.js.map +2 -2
  12. package/dist/modules/auth/commands/users.js +49 -2
  13. package/dist/modules/auth/commands/users.js.map +2 -2
  14. package/dist/modules/auth/lib/grantChecks.js +86 -5
  15. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  16. package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
  17. package/dist/modules/customer_accounts/lib/customerAuth.js +19 -11
  18. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  19. package/dist/modules/customer_accounts/lib/customerAuthServer.js +14 -7
  20. package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
  21. package/dist/modules/customer_accounts/services/customerSessionService.js +14 -0
  22. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  23. package/dist/modules/customers/api/interactions/route.js +16 -5
  24. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  25. package/dist/modules/customers/components/calendar/CalendarScreen.js +5 -5
  26. package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
  27. package/dist/modules/customers/components/calendar/editor/hooks.js +2 -2
  28. package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
  29. package/dist/modules/customers/components/calendar/useCalendarItems.js +33 -7
  30. package/dist/modules/customers/components/calendar/useCalendarItems.js.map +2 -2
  31. package/package.json +7 -7
  32. package/src/helpers/integration/authFixtures.ts +5 -2
  33. package/src/modules/auth/api/users/acl/route.ts +13 -0
  34. package/src/modules/auth/api/users/consents/route.ts +7 -0
  35. package/src/modules/auth/api/users/resend-invite/route.ts +7 -0
  36. package/src/modules/auth/api/users/route.ts +80 -4
  37. package/src/modules/auth/commands/users.ts +54 -2
  38. package/src/modules/auth/i18n/de.json +4 -0
  39. package/src/modules/auth/i18n/en.json +4 -0
  40. package/src/modules/auth/i18n/es.json +4 -0
  41. package/src/modules/auth/i18n/ko.json +4 -0
  42. package/src/modules/auth/i18n/pl.json +4 -0
  43. package/src/modules/auth/lib/grantChecks.ts +121 -5
  44. package/src/modules/auth/lib/sessionIntegrity.ts +5 -4
  45. package/src/modules/customer_accounts/lib/customerAuth.ts +31 -12
  46. package/src/modules/customer_accounts/lib/customerAuthServer.ts +19 -7
  47. package/src/modules/customer_accounts/services/customerSessionService.ts +20 -0
  48. package/src/modules/customers/api/interactions/route.ts +16 -5
  49. package/src/modules/customers/components/calendar/CalendarScreen.tsx +5 -5
  50. package/src/modules/customers/components/calendar/editor/hooks.ts +2 -2
  51. package/src/modules/customers/components/calendar/useCalendarItems.ts +43 -6
@@ -9,7 +9,7 @@ import {
9
9
  } from "./types.js";
10
10
  const PAGE_LIMIT = 100;
11
11
  const MAX_WINDOW_ITEMS = 500;
12
- async function fetchInteractionWindow(window, signal) {
12
+ async function fetchInteractionWindow(window, signal, options = {}) {
13
13
  const collected = [];
14
14
  let cursor;
15
15
  let truncated = false;
@@ -19,6 +19,7 @@ async function fetchInteractionWindow(window, signal) {
19
19
  to: window.to.toISOString(),
20
20
  limit: String(PAGE_LIMIT)
21
21
  });
22
+ if (options.recurrenceMasters) params.set("recurrenceMasters", "true");
22
23
  if (cursor) params.set("cursor", cursor);
23
24
  const call = await apiCall(
24
25
  `/api/customers/interactions?${params.toString()}`,
@@ -38,6 +39,32 @@ async function fetchInteractionWindow(window, signal) {
38
39
  } while (cursor);
39
40
  return { payloads: collected.slice(0, MAX_WINDOW_ITEMS), truncated };
40
41
  }
42
+ function mergeInteractionPayloads(windowPayloads, recurringPayloads) {
43
+ const windowById = new Map(windowPayloads.map((payload) => [payload.id, payload]));
44
+ const byId = /* @__PURE__ */ new Map();
45
+ for (const payload of recurringPayloads) {
46
+ byId.set(payload.id, windowById.get(payload.id) ?? payload);
47
+ }
48
+ for (const payload of windowPayloads) {
49
+ if (!byId.has(payload.id)) byId.set(payload.id, payload);
50
+ }
51
+ const payloads = Array.from(byId.values());
52
+ return {
53
+ payloads: payloads.slice(0, MAX_WINDOW_ITEMS),
54
+ truncated: payloads.length > MAX_WINDOW_ITEMS
55
+ };
56
+ }
57
+ async function fetchCalendarCandidates(window, signal) {
58
+ const [windowResult, recurringResult] = await Promise.all([
59
+ fetchInteractionWindow(window, signal),
60
+ fetchInteractionWindow(window, signal, { recurrenceMasters: true })
61
+ ]);
62
+ const merged = mergeInteractionPayloads(windowResult.payloads, recurringResult.payloads);
63
+ return {
64
+ payloads: merged.payloads,
65
+ truncated: windowResult.truncated || recurringResult.truncated || merged.truncated
66
+ };
67
+ }
41
68
  function useCalendarItems(range) {
42
69
  const [payloads, setPayloads] = React.useState([]);
43
70
  const [isLoading, setIsLoading] = React.useState(true);
@@ -91,13 +118,10 @@ function useCalendarItems(range) {
91
118
  setError(null);
92
119
  try {
93
120
  const fetchWindow = getFetchWindow({ from: new Date(fromTime), to: new Date(toTime) });
94
- const { payloads: collected, truncated: windowTruncated } = await fetchInteractionWindow(
95
- fetchWindow,
96
- controller.signal
97
- );
121
+ const result = await fetchCalendarCandidates(fetchWindow, controller.signal);
98
122
  if (cancelled) return;
99
- setPayloads(collected);
100
- setTruncated(windowTruncated);
123
+ setPayloads(result.payloads);
124
+ setTruncated(result.truncated);
101
125
  } catch (err) {
102
126
  if (cancelled || controller.signal.aborted) return;
103
127
  setPayloads([]);
@@ -130,7 +154,9 @@ function useCalendarItems(range) {
130
154
  }
131
155
  export {
132
156
  MAX_WINDOW_ITEMS,
157
+ fetchCalendarCandidates,
133
158
  fetchInteractionWindow,
159
+ mergeInteractionPayloads,
134
160
  useCalendarItems
135
161
  };
136
162
  //# sourceMappingURL=useCalendarItems.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/customers/components/calendar/useCalendarItems.ts"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { expandOccurrences } from '../../lib/calendar/recurrence'\nimport { getFetchWindow } from '../../lib/calendar/range'\nimport { mapInteractionToCalendarItem } from '../../lib/calendar/mapItem'\nimport {\n calendarInteractionPayloadSchema,\n type CalendarInteractionPayload,\n type CalendarItem,\n type CalendarRange,\n} from './types'\n\nconst PAGE_LIMIT = 100\nexport const MAX_WINDOW_ITEMS = 500\n\n/**\n * Cursor-follows `/api/customers/interactions` across the given window (already\n * padded by the caller) up to `MAX_WINDOW_ITEMS`. Shared by the grid data hook\n * and the editor's conflict probe so both see the exact same candidate set.\n */\nexport async function fetchInteractionWindow(\n window: CalendarRange,\n signal?: AbortSignal,\n): Promise<{ payloads: CalendarInteractionPayload[]; truncated: boolean }> {\n const collected: CalendarInteractionPayload[] = []\n let cursor: string | undefined\n let truncated = false\n do {\n const params = new URLSearchParams({\n from: window.from.toISOString(),\n to: window.to.toISOString(),\n limit: String(PAGE_LIMIT),\n })\n if (cursor) params.set('cursor', cursor)\n const call = await apiCall<{ items?: unknown[]; nextCursor?: string }>(\n `/api/customers/interactions?${params.toString()}`,\n { signal },\n )\n if (!call.ok) throw new Error(`[internal] calendar interactions fetch failed (${call.status})`)\n const pageItems = Array.isArray(call.result?.items) ? call.result.items : []\n for (const rawItem of pageItems) {\n const parsed = calendarInteractionPayloadSchema.safeParse(rawItem)\n if (parsed.success) collected.push(parsed.data)\n }\n cursor = typeof call.result?.nextCursor === 'string' ? call.result.nextCursor : undefined\n if (cursor && collected.length >= MAX_WINDOW_ITEMS) {\n truncated = true\n cursor = undefined\n }\n } while (cursor)\n return { payloads: collected.slice(0, MAX_WINDOW_ITEMS), truncated }\n}\n\ntype ActivityTypeDictionaryEntry = {\n value?: unknown\n label?: unknown\n color?: unknown\n icon?: unknown\n}\n\nexport type UseCalendarItemsResult = {\n items: CalendarItem[]\n isLoading: boolean\n error: string | null\n truncated: boolean\n typeLabels: Record<string, string>\n typeColors: Record<string, string | null>\n typeIcons: Record<string, string | null>\n refetch: () => void\n}\n\nexport function useCalendarItems(range: CalendarRange): UseCalendarItemsResult {\n const [payloads, setPayloads] = React.useState<CalendarInteractionPayload[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [truncated, setTruncated] = React.useState(false)\n const [typeLabels, setTypeLabels] = React.useState<Record<string, string>>({})\n const [typeColors, setTypeColors] = React.useState<Record<string, string | null>>({})\n const [typeIcons, setTypeIcons] = React.useState<Record<string, string | null>>({})\n const [reloadToken, setReloadToken] = React.useState(0)\n\n const fromTime = range.from.getTime()\n const toTime = range.to.getTime()\n\n React.useEffect(() => {\n const controller = new AbortController()\n let cancelled = false\n async function loadActivityTypes() {\n const call = await apiCall<{ items?: ActivityTypeDictionaryEntry[] }>(\n '/api/customers/dictionaries/activity-types',\n { signal: controller.signal },\n )\n if (cancelled || !call.ok) return\n const entries = Array.isArray(call.result?.items) ? call.result.items : []\n const labels: Record<string, string> = {}\n const colors: Record<string, string | null> = {}\n const icons: Record<string, string | null> = {}\n for (const entry of entries) {\n if (typeof entry?.value !== 'string' || entry.value.length === 0) continue\n labels[entry.value] = typeof entry.label === 'string' && entry.label.length > 0 ? entry.label : entry.value\n colors[entry.value] = typeof entry.color === 'string' && entry.color.length > 0 ? entry.color : null\n icons[entry.value] = typeof entry.icon === 'string' && entry.icon.length > 0 ? entry.icon : null\n }\n setTypeLabels(labels)\n setTypeColors(colors)\n setTypeIcons(icons)\n }\n loadActivityTypes().catch(() => {\n if (cancelled || controller.signal.aborted) return\n setTypeLabels({})\n setTypeColors({})\n setTypeIcons({})\n })\n return () => {\n cancelled = true\n controller.abort()\n }\n }, [])\n\n React.useEffect(() => {\n const controller = new AbortController()\n let cancelled = false\n async function loadInteractions() {\n setIsLoading(true)\n setError(null)\n try {\n const fetchWindow = getFetchWindow({ from: new Date(fromTime), to: new Date(toTime) })\n const { payloads: collected, truncated: windowTruncated } = await fetchInteractionWindow(\n fetchWindow,\n controller.signal,\n )\n if (cancelled) return\n setPayloads(collected)\n setTruncated(windowTruncated)\n } catch (err) {\n if (cancelled || controller.signal.aborted) return\n setPayloads([])\n setTruncated(false)\n setError(err instanceof Error ? err.message : '[internal] calendar interactions fetch failed')\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n void loadInteractions()\n return () => {\n cancelled = true\n controller.abort()\n }\n }, [fromTime, toTime, reloadToken])\n\n const items = React.useMemo(() => {\n const expansionWindow = getFetchWindow({ from: new Date(fromTime), to: new Date(toTime) })\n const mapped: CalendarItem[] = []\n for (const payload of payloads) {\n const item = mapInteractionToCalendarItem(payload, typeColors)\n if (!item) continue\n mapped.push(...expandOccurrences(item, expansionWindow))\n }\n return mapped\n }, [payloads, typeColors, fromTime, toTime])\n\n const refetch = React.useCallback(() => {\n setReloadToken((token) => token + 1)\n }, [])\n\n return { items, isLoading, error, truncated, typeLabels, typeColors, typeIcons, refetch }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { expandOccurrences } from '../../lib/calendar/recurrence'\nimport { getFetchWindow } from '../../lib/calendar/range'\nimport { mapInteractionToCalendarItem } from '../../lib/calendar/mapItem'\nimport {\n calendarInteractionPayloadSchema,\n type CalendarInteractionPayload,\n type CalendarItem,\n type CalendarRange,\n} from './types'\n\nconst PAGE_LIMIT = 100\nexport const MAX_WINDOW_ITEMS = 500\n\ntype FetchInteractionWindowOptions = {\n recurrenceMasters?: boolean\n}\n\n/**\n * Cursor-follows `/api/customers/interactions` across the given window (already\n * padded by the caller) up to `MAX_WINDOW_ITEMS`. Shared by the grid data hook\n * and the editor's conflict probe so both see the exact same candidate set.\n */\nexport async function fetchInteractionWindow(\n window: CalendarRange,\n signal?: AbortSignal,\n options: FetchInteractionWindowOptions = {},\n): Promise<{ payloads: CalendarInteractionPayload[]; truncated: boolean }> {\n const collected: CalendarInteractionPayload[] = []\n let cursor: string | undefined\n let truncated = false\n do {\n const params = new URLSearchParams({\n from: window.from.toISOString(),\n to: window.to.toISOString(),\n limit: String(PAGE_LIMIT),\n })\n if (options.recurrenceMasters) params.set('recurrenceMasters', 'true')\n if (cursor) params.set('cursor', cursor)\n const call = await apiCall<{ items?: unknown[]; nextCursor?: string }>(\n `/api/customers/interactions?${params.toString()}`,\n { signal },\n )\n if (!call.ok) throw new Error(`[internal] calendar interactions fetch failed (${call.status})`)\n const pageItems = Array.isArray(call.result?.items) ? call.result.items : []\n for (const rawItem of pageItems) {\n const parsed = calendarInteractionPayloadSchema.safeParse(rawItem)\n if (parsed.success) collected.push(parsed.data)\n }\n cursor = typeof call.result?.nextCursor === 'string' ? call.result.nextCursor : undefined\n if (cursor && collected.length >= MAX_WINDOW_ITEMS) {\n truncated = true\n cursor = undefined\n }\n } while (cursor)\n return { payloads: collected.slice(0, MAX_WINDOW_ITEMS), truncated }\n}\n\nexport function mergeInteractionPayloads(\n windowPayloads: CalendarInteractionPayload[],\n recurringPayloads: CalendarInteractionPayload[],\n): { payloads: CalendarInteractionPayload[]; truncated: boolean } {\n const windowById = new Map(windowPayloads.map((payload) => [payload.id, payload]))\n const byId = new Map<string, CalendarInteractionPayload>()\n for (const payload of recurringPayloads) {\n byId.set(payload.id, windowById.get(payload.id) ?? payload)\n }\n for (const payload of windowPayloads) {\n if (!byId.has(payload.id)) byId.set(payload.id, payload)\n }\n const payloads = Array.from(byId.values())\n return {\n payloads: payloads.slice(0, MAX_WINDOW_ITEMS),\n truncated: payloads.length > MAX_WINDOW_ITEMS,\n }\n}\n\nexport async function fetchCalendarCandidates(\n window: CalendarRange,\n signal?: AbortSignal,\n): Promise<{ payloads: CalendarInteractionPayload[]; truncated: boolean }> {\n const [windowResult, recurringResult] = await Promise.all([\n fetchInteractionWindow(window, signal),\n fetchInteractionWindow(window, signal, { recurrenceMasters: true }),\n ])\n const merged = mergeInteractionPayloads(windowResult.payloads, recurringResult.payloads)\n return {\n payloads: merged.payloads,\n truncated: windowResult.truncated || recurringResult.truncated || merged.truncated,\n }\n}\n\ntype ActivityTypeDictionaryEntry = {\n value?: unknown\n label?: unknown\n color?: unknown\n icon?: unknown\n}\n\nexport type UseCalendarItemsResult = {\n items: CalendarItem[]\n isLoading: boolean\n error: string | null\n truncated: boolean\n typeLabels: Record<string, string>\n typeColors: Record<string, string | null>\n typeIcons: Record<string, string | null>\n refetch: () => void\n}\n\nexport function useCalendarItems(range: CalendarRange): UseCalendarItemsResult {\n const [payloads, setPayloads] = React.useState<CalendarInteractionPayload[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [truncated, setTruncated] = React.useState(false)\n const [typeLabels, setTypeLabels] = React.useState<Record<string, string>>({})\n const [typeColors, setTypeColors] = React.useState<Record<string, string | null>>({})\n const [typeIcons, setTypeIcons] = React.useState<Record<string, string | null>>({})\n const [reloadToken, setReloadToken] = React.useState(0)\n\n const fromTime = range.from.getTime()\n const toTime = range.to.getTime()\n\n React.useEffect(() => {\n const controller = new AbortController()\n let cancelled = false\n async function loadActivityTypes() {\n const call = await apiCall<{ items?: ActivityTypeDictionaryEntry[] }>(\n '/api/customers/dictionaries/activity-types',\n { signal: controller.signal },\n )\n if (cancelled || !call.ok) return\n const entries = Array.isArray(call.result?.items) ? call.result.items : []\n const labels: Record<string, string> = {}\n const colors: Record<string, string | null> = {}\n const icons: Record<string, string | null> = {}\n for (const entry of entries) {\n if (typeof entry?.value !== 'string' || entry.value.length === 0) continue\n labels[entry.value] = typeof entry.label === 'string' && entry.label.length > 0 ? entry.label : entry.value\n colors[entry.value] = typeof entry.color === 'string' && entry.color.length > 0 ? entry.color : null\n icons[entry.value] = typeof entry.icon === 'string' && entry.icon.length > 0 ? entry.icon : null\n }\n setTypeLabels(labels)\n setTypeColors(colors)\n setTypeIcons(icons)\n }\n loadActivityTypes().catch(() => {\n if (cancelled || controller.signal.aborted) return\n setTypeLabels({})\n setTypeColors({})\n setTypeIcons({})\n })\n return () => {\n cancelled = true\n controller.abort()\n }\n }, [])\n\n React.useEffect(() => {\n const controller = new AbortController()\n let cancelled = false\n async function loadInteractions() {\n setIsLoading(true)\n setError(null)\n try {\n const fetchWindow = getFetchWindow({ from: new Date(fromTime), to: new Date(toTime) })\n const result = await fetchCalendarCandidates(fetchWindow, controller.signal)\n if (cancelled) return\n setPayloads(result.payloads)\n setTruncated(result.truncated)\n } catch (err) {\n if (cancelled || controller.signal.aborted) return\n setPayloads([])\n setTruncated(false)\n setError(err instanceof Error ? err.message : '[internal] calendar interactions fetch failed')\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n void loadInteractions()\n return () => {\n cancelled = true\n controller.abort()\n }\n }, [fromTime, toTime, reloadToken])\n\n const items = React.useMemo(() => {\n const expansionWindow = getFetchWindow({ from: new Date(fromTime), to: new Date(toTime) })\n const mapped: CalendarItem[] = []\n for (const payload of payloads) {\n const item = mapInteractionToCalendarItem(payload, typeColors)\n if (!item) continue\n mapped.push(...expandOccurrences(item, expansionWindow))\n }\n return mapped\n }, [payloads, typeColors, fromTime, toTime])\n\n const refetch = React.useCallback(() => {\n setReloadToken((token) => token + 1)\n }, [])\n\n return { items, isLoading, error, truncated, typeLabels, typeColors, typeIcons, refetch }\n}\n"],
5
+ "mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,eAAe;AACxB,SAAS,yBAAyB;AAClC,SAAS,sBAAsB;AAC/B,SAAS,oCAAoC;AAC7C;AAAA,EACE;AAAA,OAIK;AAEP,MAAM,aAAa;AACZ,MAAM,mBAAmB;AAWhC,eAAsB,uBACpB,QACA,QACA,UAAyC,CAAC,GAC+B;AACzE,QAAM,YAA0C,CAAC;AACjD,MAAI;AACJ,MAAI,YAAY;AAChB,KAAG;AACD,UAAM,SAAS,IAAI,gBAAgB;AAAA,MACjC,MAAM,OAAO,KAAK,YAAY;AAAA,MAC9B,IAAI,OAAO,GAAG,YAAY;AAAA,MAC1B,OAAO,OAAO,UAAU;AAAA,IAC1B,CAAC;AACD,QAAI,QAAQ,kBAAmB,QAAO,IAAI,qBAAqB,MAAM;AACrE,QAAI,OAAQ,QAAO,IAAI,UAAU,MAAM;AACvC,UAAM,OAAO,MAAM;AAAA,MACjB,+BAA+B,OAAO,SAAS,CAAC;AAAA,MAChD,EAAE,OAAO;AAAA,IACX;AACA,QAAI,CAAC,KAAK,GAAI,OAAM,IAAI,MAAM,kDAAkD,KAAK,MAAM,GAAG;AAC9F,UAAM,YAAY,MAAM,QAAQ,KAAK,QAAQ,KAAK,IAAI,KAAK,OAAO,QAAQ,CAAC;AAC3E,eAAW,WAAW,WAAW;AAC/B,YAAM,SAAS,iCAAiC,UAAU,OAAO;AACjE,UAAI,OAAO,QAAS,WAAU,KAAK,OAAO,IAAI;AAAA,IAChD;AACA,aAAS,OAAO,KAAK,QAAQ,eAAe,WAAW,KAAK,OAAO,aAAa;AAChF,QAAI,UAAU,UAAU,UAAU,kBAAkB;AAClD,kBAAY;AACZ,eAAS;AAAA,IACX;AAAA,EACF,SAAS;AACT,SAAO,EAAE,UAAU,UAAU,MAAM,GAAG,gBAAgB,GAAG,UAAU;AACrE;AAEO,SAAS,yBACd,gBACA,mBACgE;AAChE,QAAM,aAAa,IAAI,IAAI,eAAe,IAAI,CAAC,YAAY,CAAC,QAAQ,IAAI,OAAO,CAAC,CAAC;AACjF,QAAM,OAAO,oBAAI,IAAwC;AACzD,aAAW,WAAW,mBAAmB;AACvC,SAAK,IAAI,QAAQ,IAAI,WAAW,IAAI,QAAQ,EAAE,KAAK,OAAO;AAAA,EAC5D;AACA,aAAW,WAAW,gBAAgB;AACpC,QAAI,CAAC,KAAK,IAAI,QAAQ,EAAE,EAAG,MAAK,IAAI,QAAQ,IAAI,OAAO;AAAA,EACzD;AACA,QAAM,WAAW,MAAM,KAAK,KAAK,OAAO,CAAC;AACzC,SAAO;AAAA,IACL,UAAU,SAAS,MAAM,GAAG,gBAAgB;AAAA,IAC5C,WAAW,SAAS,SAAS;AAAA,EAC/B;AACF;AAEA,eAAsB,wBACpB,QACA,QACyE;AACzE,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,QAAQ,IAAI;AAAA,IACxD,uBAAuB,QAAQ,MAAM;AAAA,IACrC,uBAAuB,QAAQ,QAAQ,EAAE,mBAAmB,KAAK,CAAC;AAAA,EACpE,CAAC;AACD,QAAM,SAAS,yBAAyB,aAAa,UAAU,gBAAgB,QAAQ;AACvF,SAAO;AAAA,IACL,UAAU,OAAO;AAAA,IACjB,WAAW,aAAa,aAAa,gBAAgB,aAAa,OAAO;AAAA,EAC3E;AACF;AAoBO,SAAS,iBAAiB,OAA8C;AAC7E,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAuC,CAAC,CAAC;AAC/E,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAC5D,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,KAAK;AACtD,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAiC,CAAC,CAAC;AAC7E,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAwC,CAAC,CAAC;AACpF,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAwC,CAAC,CAAC;AAClF,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,CAAC;AAEtD,QAAM,WAAW,MAAM,KAAK,QAAQ;AACpC,QAAM,SAAS,MAAM,GAAG,QAAQ;AAEhC,QAAM,UAAU,MAAM;AACpB,UAAM,aAAa,IAAI,gBAAgB;AACvC,QAAI,YAAY;AAChB,mBAAe,oBAAoB;AACjC,YAAM,OAAO,MAAM;AAAA,QACjB;AAAA,QACA,EAAE,QAAQ,WAAW,OAAO;AAAA,MAC9B;AACA,UAAI,aAAa,CAAC,KAAK,GAAI;AAC3B,YAAM,UAAU,MAAM,QAAQ,KAAK,QAAQ,KAAK,IAAI,KAAK,OAAO,QAAQ,CAAC;AACzE,YAAM,SAAiC,CAAC;AACxC,YAAM,SAAwC,CAAC;AAC/C,YAAM,QAAuC,CAAC;AAC9C,iBAAW,SAAS,SAAS;AAC3B,YAAI,OAAO,OAAO,UAAU,YAAY,MAAM,MAAM,WAAW,EAAG;AAClE,eAAO,MAAM,KAAK,IAAI,OAAO,MAAM,UAAU,YAAY,MAAM,MAAM,SAAS,IAAI,MAAM,QAAQ,MAAM;AACtG,eAAO,MAAM,KAAK,IAAI,OAAO,MAAM,UAAU,YAAY,MAAM,MAAM,SAAS,IAAI,MAAM,QAAQ;AAChG,cAAM,MAAM,KAAK,IAAI,OAAO,MAAM,SAAS,YAAY,MAAM,KAAK,SAAS,IAAI,MAAM,OAAO;AAAA,MAC9F;AACA,oBAAc,MAAM;AACpB,oBAAc,MAAM;AACpB,mBAAa,KAAK;AAAA,IACpB;AACA,sBAAkB,EAAE,MAAM,MAAM;AAC9B,UAAI,aAAa,WAAW,OAAO,QAAS;AAC5C,oBAAc,CAAC,CAAC;AAChB,oBAAc,CAAC,CAAC;AAChB,mBAAa,CAAC,CAAC;AAAA,IACjB,CAAC;AACD,WAAO,MAAM;AACX,kBAAY;AACZ,iBAAW,MAAM;AAAA,IACnB;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,UAAU,MAAM;AACpB,UAAM,aAAa,IAAI,gBAAgB;AACvC,QAAI,YAAY;AAChB,mBAAe,mBAAmB;AAChC,mBAAa,IAAI;AACjB,eAAS,IAAI;AACb,UAAI;AACF,cAAM,cAAc,eAAe,EAAE,MAAM,IAAI,KAAK,QAAQ,GAAG,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;AACrF,cAAM,SAAS,MAAM,wBAAwB,aAAa,WAAW,MAAM;AAC3E,YAAI,UAAW;AACf,oBAAY,OAAO,QAAQ;AAC3B,qBAAa,OAAO,SAAS;AAAA,MAC/B,SAAS,KAAK;AACZ,YAAI,aAAa,WAAW,OAAO,QAAS;AAC5C,oBAAY,CAAC,CAAC;AACd,qBAAa,KAAK;AAClB,iBAAS,eAAe,QAAQ,IAAI,UAAU,+CAA+C;AAAA,MAC/F,UAAE;AACA,YAAI,CAAC,UAAW,cAAa,KAAK;AAAA,MACpC;AAAA,IACF;AACA,SAAK,iBAAiB;AACtB,WAAO,MAAM;AACX,kBAAY;AACZ,iBAAW,MAAM;AAAA,IACnB;AAAA,EACF,GAAG,CAAC,UAAU,QAAQ,WAAW,CAAC;AAElC,QAAM,QAAQ,MAAM,QAAQ,MAAM;AAChC,UAAM,kBAAkB,eAAe,EAAE,MAAM,IAAI,KAAK,QAAQ,GAAG,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;AACzF,UAAM,SAAyB,CAAC;AAChC,eAAW,WAAW,UAAU;AAC9B,YAAM,OAAO,6BAA6B,SAAS,UAAU;AAC7D,UAAI,CAAC,KAAM;AACX,aAAO,KAAK,GAAG,kBAAkB,MAAM,eAAe,CAAC;AAAA,IACzD;AACA,WAAO;AAAA,EACT,GAAG,CAAC,UAAU,YAAY,UAAU,MAAM,CAAC;AAE3C,QAAM,UAAU,MAAM,YAAY,MAAM;AACtC,mBAAe,CAAC,UAAU,QAAQ,CAAC;AAAA,EACrC,GAAG,CAAC,CAAC;AAEL,SAAO,EAAE,OAAO,WAAW,OAAO,WAAW,YAAY,YAAY,WAAW,QAAQ;AAC1F;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.6944.1.eef6a0ee1d",
3
+ "version": "0.6.8-develop.6948.1.8369fc4c97",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.8-develop.6944.1.eef6a0ee1d",
258
- "@open-mercato/shared": "0.6.8-develop.6944.1.eef6a0ee1d",
259
- "@open-mercato/ui": "0.6.8-develop.6944.1.eef6a0ee1d",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.6948.1.8369fc4c97",
258
+ "@open-mercato/shared": "0.6.8-develop.6948.1.8369fc4c97",
259
+ "@open-mercato/ui": "0.6.8-develop.6948.1.8369fc4c97",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.8-develop.6944.1.eef6a0ee1d",
265
- "@open-mercato/shared": "0.6.8-develop.6944.1.eef6a0ee1d",
266
- "@open-mercato/ui": "0.6.8-develop.6944.1.eef6a0ee1d",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.6948.1.8369fc4c97",
265
+ "@open-mercato/shared": "0.6.8-develop.6948.1.8369fc4c97",
266
+ "@open-mercato/ui": "0.6.8-develop.6948.1.8369fc4c97",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^7.0.0",
269
269
  "@testing-library/react": "^16.3.1",
@@ -86,12 +86,15 @@ export async function deleteUserIfExists(
86
86
  export async function createOrganizationFixture(
87
87
  request: APIRequestContext,
88
88
  token: string,
89
- input: { name: string; tenantId?: string },
89
+ input: { name: string; tenantId?: string; parentId?: string },
90
90
  ): Promise<string> {
91
- const payload: { name: string; tenantId?: string } = { name: input.name };
91
+ const payload: { name: string; tenantId?: string; parentId?: string } = { name: input.name };
92
92
  if (typeof input.tenantId === 'string' && input.tenantId.length > 0) {
93
93
  payload.tenantId = input.tenantId;
94
94
  }
95
+ if (typeof input.parentId === 'string' && input.parentId.length > 0) {
96
+ payload.parentId = input.parentId;
97
+ }
95
98
  const response = await apiRequest(request, 'POST', '/api/directory/organizations', {
96
99
  token,
97
100
  data: payload,
@@ -16,6 +16,7 @@ import {
16
16
  } from '@open-mercato/core/modules/auth/lib/grantChecks'
17
17
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
18
18
  import type { EntityManager } from '@mikro-orm/postgresql'
19
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
19
20
 
20
21
  const getSchema = z.object({ userId: z.string().uuid() })
21
22
  const putSchema = z.object({
@@ -76,6 +77,12 @@ export async function GET(req: Request) {
76
77
  organizationId: auth.orgId ?? null,
77
78
  targetUserId: parsed.data.userId,
78
79
  actorIsSuperAdmin: false,
80
+ organizationScope: await resolveOrganizationScopeForRequest({
81
+ container,
82
+ auth,
83
+ request: req,
84
+ tenantId: auth.tenantId ?? null,
85
+ }),
79
86
  })
80
87
  } catch (err) {
81
88
  if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
@@ -143,6 +150,12 @@ export async function PUT(req: Request) {
143
150
  organizationId: auth.orgId ?? null,
144
151
  targetUserId: parsed.data.userId,
145
152
  actorIsSuperAdmin: false,
153
+ organizationScope: await resolveOrganizationScopeForRequest({
154
+ container,
155
+ auth,
156
+ request: req,
157
+ tenantId: auth.tenantId ?? null,
158
+ }),
146
159
  })
147
160
  } catch (err) {
148
161
  if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
@@ -11,6 +11,7 @@ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
11
11
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
12
  import type { ConsentItem } from '@open-mercato/core/modules/auth/lib/consentTypes'
13
13
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
14
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
14
15
 
15
16
  export const metadata = {
16
17
  path: '/auth/users/consents',
@@ -50,6 +51,12 @@ export async function GET(req: Request) {
50
51
  tenantId,
51
52
  organizationId,
52
53
  targetUserId: parsed.data.userId,
54
+ organizationScope: await resolveOrganizationScopeForRequest({
55
+ container,
56
+ auth,
57
+ request: req,
58
+ tenantId,
59
+ }),
53
60
  })
54
61
  } catch (err) {
55
62
  if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
@@ -17,6 +17,7 @@ import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato
17
17
  import { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'
18
18
  import { assertActorCanAccessUserTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
19
19
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
20
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
20
21
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
21
22
  import type { EntityManager } from '@mikro-orm/postgresql'
22
23
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -96,6 +97,12 @@ export async function POST(req: Request) {
96
97
  organizationId: auth.orgId ?? null,
97
98
  targetUserId: parsed.data.id,
98
99
  actorIsSuperAdmin: isSuperAdmin,
100
+ organizationScope: await resolveOrganizationScopeForRequest({
101
+ container,
102
+ auth,
103
+ request: req,
104
+ tenantId: auth.tenantId ?? null,
105
+ }),
99
106
  })
100
107
  } catch (err) {
101
108
  if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
@@ -15,9 +15,12 @@ import type { EntityManager } from '@mikro-orm/postgresql'
15
15
  import { userCrudEvents, userCrudIndexer } from '@open-mercato/core/modules/auth/commands/users'
16
16
  import {
17
17
  assertActorCanAccessUserTarget,
18
+ assertActorCanAssignUserDestination,
18
19
  assertActorCanGrantRoleTokens,
19
20
  assertActorCanModifySuperAdminUserTarget,
20
21
  listSuperAdminUserIds,
22
+ resolveUserDestinationRoles,
23
+ throwUserDestinationOrganizationNotFound,
21
24
  } from '@open-mercato/core/modules/auth/lib/grantChecks'
22
25
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
23
26
  import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
@@ -168,7 +171,14 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
168
171
  await assertCanModifySuperAdminTarget(ctx.request, parsed.id)
169
172
  await assertCanAccessUserTarget(ctx.request, parsed.id)
170
173
  }
171
- await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
174
+ if (typeof parsed.organizationId === 'string' && parsed.organizationId.length) {
175
+ const destinationChanged = await assertCanAssignUserDestination(ctx.request, parsed)
176
+ if (!destinationChanged) {
177
+ await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
178
+ }
179
+ } else {
180
+ await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
181
+ }
172
182
  }
173
183
  return parsed
174
184
  },
@@ -559,6 +569,12 @@ async function assertCanAccessUserTarget(req: Request, targetUserId: string) {
559
569
  if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
560
570
  const container = await createRequestContainer()
561
571
  const em = container.resolve('em') as EntityManager
572
+ const organizationScope = await resolveOrganizationScopeForRequest({
573
+ container,
574
+ auth,
575
+ request: req,
576
+ tenantId: auth.tenantId ?? null,
577
+ })
562
578
  await assertActorCanAccessUserTarget({
563
579
  em,
564
580
  rbacService: container.resolve('rbacService') as RbacService,
@@ -566,6 +582,7 @@ async function assertCanAccessUserTarget(req: Request, targetUserId: string) {
566
582
  tenantId: auth.tenantId ?? null,
567
583
  organizationId: auth.orgId ?? null,
568
584
  targetUserId,
585
+ organizationScope,
569
586
  })
570
587
  }
571
588
 
@@ -598,6 +615,65 @@ async function assertCanAssignRoles(req: Request, roles: unknown, payload: Recor
598
615
  })
599
616
  }
600
617
 
618
+ async function assertCanAssignUserDestination(req: Request, payload: Record<string, unknown>): Promise<boolean> {
619
+ const organizationId = typeof payload.organizationId === 'string' ? payload.organizationId : null
620
+ const targetUserId = typeof payload.id === 'string' ? payload.id : null
621
+ if (!organizationId || !targetUserId) return false
622
+
623
+ const auth = await getAuthFromRequest(req)
624
+ if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
625
+ const container = await createRequestContainer()
626
+ const em = container.resolve('em') as EntityManager
627
+ const targetUser = await findOneWithDecryption(
628
+ em,
629
+ User,
630
+ { id: targetUserId, deletedAt: null },
631
+ {},
632
+ { tenantId: null, organizationId: null },
633
+ )
634
+ if (!targetUser) return false
635
+ const organization = await findOneWithDecryption(
636
+ em,
637
+ Organization,
638
+ { id: organizationId },
639
+ { populate: ['tenant'] },
640
+ { tenantId: null, organizationId },
641
+ )
642
+ if (!organization) return throwUserDestinationOrganizationNotFound(400)
643
+ const destinationTenantId = organization.tenant?.id ? String(organization.tenant.id) : null
644
+ if (!destinationTenantId) return throwUserDestinationOrganizationNotFound(400)
645
+ const currentOrganizationId = targetUser.organizationId ? String(targetUser.organizationId) : null
646
+ const currentTenantId = targetUser.tenantId ? String(targetUser.tenantId) : null
647
+ if (currentOrganizationId === organizationId && currentTenantId === destinationTenantId) {
648
+ return false
649
+ }
650
+ const roles = await resolveUserDestinationRoles({
651
+ em,
652
+ targetUserId,
653
+ destinationTenantId,
654
+ roleTokens: payload.roles,
655
+ })
656
+ const organizationScope = await resolveOrganizationScopeForRequest({
657
+ container,
658
+ auth,
659
+ request: req,
660
+ tenantId: destinationTenantId,
661
+ })
662
+ await assertActorCanAssignUserDestination({
663
+ em,
664
+ rbacService: container.resolve('rbacService') as RbacService,
665
+ actorUserId: auth.sub,
666
+ actorIsSuperAdmin: auth.isSuperAdmin === true,
667
+ tenantId: auth.tenantId ?? null,
668
+ organizationId: auth.orgId ?? null,
669
+ allowedOrganizationIds: organizationScope.allowedIds,
670
+ destinationTenantId,
671
+ destinationOrganizationId: organizationId,
672
+ roles,
673
+ })
674
+ return true
675
+ }
676
+
601
677
  async function resolveTargetTenantIdForRoleGrant(
602
678
  em: EntityManager,
603
679
  payload: Record<string, unknown>,
@@ -666,7 +742,7 @@ export const openApi: OpenApiRouteDoc = {
666
742
  PUT: {
667
743
  summary: 'Update user',
668
744
  description:
669
- 'Updates profile fields including display name, organization assignment, credentials, or role memberships. Setting isConfirmed=false deactivates the account: the user can no longer sign in and every active session is revoked; isConfirmed=true reactivates it. A tenant cannot drop below a protected role\'s minimum active holder count, so revoking the role from, deactivating, moving, or deleting the last active administrator is rejected.',
745
+ 'Updates profile fields including display name, organization assignment, credentials, or role memberships. A destination organization must be within the caller\'s descendant-expanded organization scope. Retained and newly assigned roles must belong to the destination tenant and be grantable by the caller. Setting isConfirmed=false deactivates the account: the user can no longer sign in and every active session is revoked; isConfirmed=true reactivates it. A tenant cannot drop below a protected role\'s minimum active holder count, so revoking the role from, deactivating, moving, or deleting the last active administrator is rejected.',
670
746
  requestBody: {
671
747
  contentType: 'application/json',
672
748
  schema: userUpdateSchema,
@@ -677,8 +753,8 @@ export const openApi: OpenApiRouteDoc = {
677
753
  errors: [
678
754
  { status: 400, description: 'Invalid payload, duplicate email, or the update would remove the last active holder of a protected role', schema: errorResponseSchema },
679
755
  { status: 401, description: 'Unauthorized', schema: errorResponseSchema },
680
- { status: 403, description: 'Attempted to assign privileged roles', schema: errorResponseSchema },
681
- { status: 404, description: 'User not found', schema: errorResponseSchema },
756
+ { status: 403, description: 'Destination organization is outside caller scope, or a retained or assigned role is not grantable', schema: errorResponseSchema },
757
+ { status: 404, description: 'User or destination organization not found in the caller tenant scope', schema: errorResponseSchema },
682
758
  ],
683
759
  },
684
760
  DELETE: {
@@ -17,6 +17,7 @@ import { UniqueConstraintViolationException, LockMode } from '@mikro-orm/core'
17
17
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
18
18
  import { User, UserRole, Role, UserAcl, Session, PasswordReset } from '@open-mercato/core/modules/auth/data/entities'
19
19
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
20
+ import { resolveOrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
20
21
  import { E } from '#generated/entities.ids.generated'
21
22
  import { z } from 'zod'
22
23
  import {
@@ -41,6 +42,12 @@ import { getSecurityEmailBaseUrl } from '@open-mercato/shared/lib/url'
41
42
  import { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'
42
43
  import { normalizeDisplayNameInput } from '@open-mercato/core/modules/auth/lib/displayName'
43
44
  import { createLogger } from '@open-mercato/shared/lib/logger'
45
+ import {
46
+ assertActorCanAssignUserDestination,
47
+ resolveUserDestinationRoles,
48
+ throwUserDestinationOrganizationNotFound,
49
+ } from '@open-mercato/core/modules/auth/lib/grantChecks'
50
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
44
51
 
45
52
  const logger = createLogger('auth').child({ component: 'users-commands' })
46
53
 
@@ -548,6 +555,7 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
548
555
  : null
549
556
 
550
557
  let tenantId: string | null | undefined
558
+ let destinationChanged = false
551
559
  if (parsed.organizationId !== undefined) {
552
560
  const organization = await findOneWithDecryption(
553
561
  em,
@@ -556,8 +564,52 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
556
564
  { populate: ['tenant'] },
557
565
  { tenantId: null, organizationId: parsed.organizationId ?? null },
558
566
  )
559
- if (!organization) throw new CrudHttpError(400, { error: 'Organization not found' })
567
+ if (!organization) return throwUserDestinationOrganizationNotFound(400)
560
568
  tenantId = organization.tenant?.id ? String(organization.tenant.id) : null
569
+ if (!tenantId) return throwUserDestinationOrganizationNotFound(400)
570
+ const currentUser = await findOneWithDecryption(
571
+ em,
572
+ User,
573
+ { id: parsed.id, deletedAt: null },
574
+ {},
575
+ { tenantId: null, organizationId: null },
576
+ )
577
+ if (!currentUser) throw new CrudHttpError(404, { error: 'User not found' })
578
+ const currentOrganizationId = currentUser.organizationId ? String(currentUser.organizationId) : null
579
+ const currentTenantId = currentUser.tenantId ? String(currentUser.tenantId) : null
580
+ destinationChanged = currentOrganizationId !== parsed.organizationId || currentTenantId !== tenantId
581
+ if (destinationChanged) {
582
+ const rbacService = ctx.container.resolve('rbacService') as RbacService
583
+ const destinationRoles = await resolveUserDestinationRoles({
584
+ em,
585
+ targetUserId: parsed.id,
586
+ destinationTenantId: tenantId,
587
+ roleTokens: parsed.roles,
588
+ })
589
+ const actorIsSuperAdmin = ctx.systemActor === true || ctx.auth?.isSuperAdmin === true
590
+ const organizationScope = ctx.organizationScope?.tenantId === tenantId
591
+ ? ctx.organizationScope
592
+ : !actorIsSuperAdmin && ctx.auth?.sub
593
+ ? await resolveOrganizationScope({
594
+ em,
595
+ rbac: rbacService,
596
+ auth: ctx.auth,
597
+ tenantId,
598
+ })
599
+ : null
600
+ await assertActorCanAssignUserDestination({
601
+ em,
602
+ rbacService,
603
+ actorUserId: ctx.auth?.sub,
604
+ actorIsSuperAdmin,
605
+ tenantId: ctx.auth?.tenantId ?? null,
606
+ organizationId: ctx.auth?.orgId ?? null,
607
+ allowedOrganizationIds: organizationScope?.allowedIds,
608
+ destinationTenantId: tenantId,
609
+ destinationOrganizationId: parsed.organizationId,
610
+ roles: destinationRoles,
611
+ })
612
+ }
561
613
  }
562
614
 
563
615
  const userTenantId = existing.tenantId ? String(existing.tenantId) : null
@@ -655,7 +707,7 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
655
707
  values: custom,
656
708
  })
657
709
  }
658
- ], { transaction: true })
710
+ ], { transaction: true, label: destinationChanged ? 'auth.users.update.destination' : 'auth.users.update' })
659
711
 
660
712
  const identifiers = {
661
713
  id: String(user.id),
@@ -161,8 +161,12 @@
161
161
  "auth.users.consents.loadError": "Einwilligungen konnten nicht geladen werden",
162
162
  "auth.users.consents.loading": "Einwilligungen werden geladen...",
163
163
  "auth.users.consents.withdrawn": "Widerrufen",
164
+ "auth.users.errors.destinationOrganizationOutsideScope": "Der Benutzer kann keiner Zielorganisation außerhalb Ihres Zugriffsbereichs zugewiesen werden.",
164
165
  "auth.users.errors.emailExists": "E-Mail-Adresse wird bereits verwendet",
166
+ "auth.users.errors.invalidRoleAssignment": "Der Benutzer hat eine ungültige Rollenzuweisung.",
165
167
  "auth.users.errors.lastHolderOfCriticalRole": "Der letzte aktive Inhaber der Rolle \"{roleName}\" kann nicht entfernt werden",
168
+ "auth.users.errors.organizationNotFound": "Organisation nicht gefunden",
169
+ "auth.users.errors.roleOutsideDestinationTenant": "Eine Rolle außerhalb des Zielmandanten kann weder beibehalten noch zugewiesen werden.",
166
170
  "auth.users.flash.created": "Benutzer erstellt",
167
171
  "auth.users.flash.createdEmailFailed": "Benutzer erstellt, aber die Einladungs-E-Mail konnte nicht gesendet werden. Sie können sie über die Benutzerseite erneut senden.",
168
172
  "auth.users.flash.createdWithInvite": "Benutzer erstellt und Einladung gesendet",
@@ -161,8 +161,12 @@
161
161
  "auth.users.consents.loadError": "Failed to load consents",
162
162
  "auth.users.consents.loading": "Loading consents...",
163
163
  "auth.users.consents.withdrawn": "Withdrawn",
164
+ "auth.users.errors.destinationOrganizationOutsideScope": "Cannot assign user to a destination organization outside actor scope.",
164
165
  "auth.users.errors.emailExists": "Email already in use",
166
+ "auth.users.errors.invalidRoleAssignment": "User has an invalid role assignment",
165
167
  "auth.users.errors.lastHolderOfCriticalRole": "Cannot remove the last active holder of role \"{roleName}\"",
168
+ "auth.users.errors.organizationNotFound": "Organization not found",
169
+ "auth.users.errors.roleOutsideDestinationTenant": "Cannot retain or assign a role outside the destination tenant.",
166
170
  "auth.users.flash.created": "User created",
167
171
  "auth.users.flash.createdEmailFailed": "User created but invitation email could not be sent. You can resend it from the user page.",
168
172
  "auth.users.flash.createdWithInvite": "User created and invitation sent",
@@ -161,8 +161,12 @@
161
161
  "auth.users.consents.loadError": "No se pudieron cargar los consentimientos",
162
162
  "auth.users.consents.loading": "Cargando consentimientos...",
163
163
  "auth.users.consents.withdrawn": "Retirado",
164
+ "auth.users.errors.destinationOrganizationOutsideScope": "No se puede asignar el usuario a una organización de destino fuera de su ámbito de acceso.",
164
165
  "auth.users.errors.emailExists": "El correo ya está en uso",
166
+ "auth.users.errors.invalidRoleAssignment": "El usuario tiene una asignación de rol no válida.",
165
167
  "auth.users.errors.lastHolderOfCriticalRole": "No se puede eliminar al último titular activo del rol \"{roleName}\"",
168
+ "auth.users.errors.organizationNotFound": "Organización no encontrada",
169
+ "auth.users.errors.roleOutsideDestinationTenant": "No se puede conservar ni asignar un rol fuera del tenant de destino.",
166
170
  "auth.users.flash.created": "Usuario creado",
167
171
  "auth.users.flash.createdEmailFailed": "Usuario creado pero no se pudo enviar el correo de invitación. Puede reenviarlo desde la página del usuario.",
168
172
  "auth.users.flash.createdWithInvite": "Usuario creado e invitación enviada",
@@ -161,8 +161,12 @@
161
161
  "auth.users.consents.loadError": "동의 정보를 불러오지 못했습니다",
162
162
  "auth.users.consents.loading": "동의 정보를 불러오는 중...",
163
163
  "auth.users.consents.withdrawn": "철회함",
164
+ "auth.users.errors.destinationOrganizationOutsideScope": "접근 범위를 벗어난 대상 조직에 사용자를 할당할 수 없습니다.",
164
165
  "auth.users.errors.emailExists": "이미 사용 중인 이메일입니다",
166
+ "auth.users.errors.invalidRoleAssignment": "사용자에게 잘못된 역할이 할당되어 있습니다.",
165
167
  "auth.users.errors.lastHolderOfCriticalRole": "\"{roleName}\" 역할의 마지막 활성 보유자는 제거할 수 없습니다",
168
+ "auth.users.errors.organizationNotFound": "조직을 찾을 수 없습니다",
169
+ "auth.users.errors.roleOutsideDestinationTenant": "대상 테넌트 외부의 역할을 유지하거나 할당할 수 없습니다.",
166
170
  "auth.users.flash.created": "사용자가 생성되었습니다",
167
171
  "auth.users.flash.createdEmailFailed": "사용자가 생성되었지만 초대 이메일을 보낼 수 없었습니다. 사용자 페이지에서 다시 보낼 수 있습니다.",
168
172
  "auth.users.flash.createdWithInvite": "사용자가 생성되고 초대가 전송되었습니다",
@@ -161,8 +161,12 @@
161
161
  "auth.users.consents.loadError": "Nie udało się wczytać zgód",
162
162
  "auth.users.consents.loading": "Ładowanie zgód...",
163
163
  "auth.users.consents.withdrawn": "Wycofana",
164
+ "auth.users.errors.destinationOrganizationOutsideScope": "Nie można przypisać użytkownika do organizacji docelowej poza zakresem dostępu.",
164
165
  "auth.users.errors.emailExists": "Adres e-mail jest już używany",
166
+ "auth.users.errors.invalidRoleAssignment": "Użytkownik ma nieprawidłowo przypisaną rolę.",
165
167
  "auth.users.errors.lastHolderOfCriticalRole": "Nie można usunąć ostatniego aktywnego posiadacza roli \"{roleName}\"",
168
+ "auth.users.errors.organizationNotFound": "Nie znaleziono organizacji",
169
+ "auth.users.errors.roleOutsideDestinationTenant": "Nie można zachować ani przypisać roli spoza docelowego tenantu.",
166
170
  "auth.users.flash.created": "Użytkownik utworzony",
167
171
  "auth.users.flash.createdEmailFailed": "Użytkownik utworzony, ale nie udało się wysłać e-maila z zaproszeniem. Możesz wysłać go ponownie ze strony użytkownika.",
168
172
  "auth.users.flash.createdWithInvite": "Użytkownik utworzony i zaproszenie wysłane",