@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/authFixtures.js +3 -0
- package/dist/helpers/integration/authFixtures.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +15 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/consents/route.js +8 -1
- package/dist/modules/auth/api/users/consents/route.js.map +2 -2
- package/dist/modules/auth/api/users/resend-invite/route.js +8 -1
- package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +80 -6
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/commands/users.js +49 -2
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +86 -5
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuth.js +19 -11
- package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuthServer.js +14 -7
- package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerSessionService.js +14 -0
- package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +16 -5
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarScreen.js +5 -5
- package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
- package/dist/modules/customers/components/calendar/useCalendarItems.js +33 -7
- package/dist/modules/customers/components/calendar/useCalendarItems.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/authFixtures.ts +5 -2
- package/src/modules/auth/api/users/acl/route.ts +13 -0
- package/src/modules/auth/api/users/consents/route.ts +7 -0
- package/src/modules/auth/api/users/resend-invite/route.ts +7 -0
- package/src/modules/auth/api/users/route.ts +80 -4
- package/src/modules/auth/commands/users.ts +54 -2
- package/src/modules/auth/i18n/de.json +4 -0
- package/src/modules/auth/i18n/en.json +4 -0
- package/src/modules/auth/i18n/es.json +4 -0
- package/src/modules/auth/i18n/ko.json +4 -0
- package/src/modules/auth/i18n/pl.json +4 -0
- package/src/modules/auth/lib/grantChecks.ts +121 -5
- package/src/modules/auth/lib/sessionIntegrity.ts +5 -4
- package/src/modules/customer_accounts/lib/customerAuth.ts +31 -12
- package/src/modules/customer_accounts/lib/customerAuthServer.ts +19 -7
- package/src/modules/customer_accounts/services/customerSessionService.ts +20 -0
- package/src/modules/customers/api/interactions/route.ts +16 -5
- package/src/modules/customers/components/calendar/CalendarScreen.tsx +5 -5
- package/src/modules/customers/components/calendar/editor/hooks.ts +2 -2
- 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
|
|
95
|
-
fetchWindow,
|
|
96
|
-
controller.signal
|
|
97
|
-
);
|
|
121
|
+
const result = await fetchCalendarCandidates(fetchWindow, controller.signal);
|
|
98
122
|
if (cancelled) return;
|
|
99
|
-
setPayloads(
|
|
100
|
-
setTruncated(
|
|
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
|
|
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;
|
|
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": "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",
|
|
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.
|
|
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.
|
|
258
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
259
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
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.
|
|
265
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
266
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
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
|
-
|
|
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: '
|
|
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)
|
|
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",
|