@open-mercato/core 0.6.8-develop.6876.1.c514f2eb37 → 0.6.8-develop.6878.1.ab817e58ac
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/dist/modules/customer_accounts/api/admin/users-invite.js +109 -1
- package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +31 -3
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customer_accounts/api/admin/users-invite.ts +129 -1
- package/src/modules/customer_accounts/i18n/de.json +3 -0
- package/src/modules/customer_accounts/i18n/en.json +3 -0
- package/src/modules/customer_accounts/i18n/es.json +3 -0
- package/src/modules/customer_accounts/i18n/ko.json +3 -0
- package/src/modules/customer_accounts/i18n/pl.json +3 -0
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +52 -3
|
@@ -14,12 +14,83 @@ import {
|
|
|
14
14
|
import { readNormalizedEmailFromJsonRequest } from "@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier";
|
|
15
15
|
import { sendCustomerInvitationEmail } from "@open-mercato/core/modules/customer_accounts/lib/invitationEmail";
|
|
16
16
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
17
|
+
import { CustomerUserInvitation } from "@open-mercato/core/modules/customer_accounts/data/entities";
|
|
18
|
+
import { findAndCountWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
19
|
+
import { lookupHashCandidates } from "@open-mercato/shared/lib/encryption/aes";
|
|
17
20
|
const logger = createLogger("customer_accounts").child({ component: "admin-users-invite" });
|
|
21
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
22
|
const metadata = {};
|
|
19
23
|
function resolveInvitedByUserId(auth) {
|
|
20
24
|
if (auth.isApiKey) return auth.userId ?? null;
|
|
21
25
|
return auth.sub;
|
|
22
26
|
}
|
|
27
|
+
function parsePositiveInt(value, fallback) {
|
|
28
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
29
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
30
|
+
}
|
|
31
|
+
async function GET(req) {
|
|
32
|
+
const auth = await getAuthFromRequest(req);
|
|
33
|
+
if (!auth) {
|
|
34
|
+
return NextResponse.json({ ok: false, error: "Authentication required" }, { status: 401 });
|
|
35
|
+
}
|
|
36
|
+
const container = await createRequestContainer();
|
|
37
|
+
const rbacService = container.resolve("rbacService");
|
|
38
|
+
const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ["customer_accounts.view"], { tenantId: auth.tenantId, organizationId: auth.orgId });
|
|
39
|
+
if (!hasAccess) {
|
|
40
|
+
return NextResponse.json({ ok: false, error: "Insufficient permissions" }, { status: 403 });
|
|
41
|
+
}
|
|
42
|
+
const url = new URL(req.url);
|
|
43
|
+
const page = Math.max(1, parsePositiveInt(url.searchParams.get("page"), 1));
|
|
44
|
+
const pageSize = Math.min(100, Math.max(1, parsePositiveInt(url.searchParams.get("pageSize"), 25)));
|
|
45
|
+
const personEntityId = url.searchParams.get("personEntityId");
|
|
46
|
+
const customerEntityId = url.searchParams.get("customerEntityId");
|
|
47
|
+
const email = url.searchParams.get("email");
|
|
48
|
+
for (const value of [personEntityId, customerEntityId]) {
|
|
49
|
+
if (value && !UUID_PATTERN.test(value)) {
|
|
50
|
+
return NextResponse.json({ ok: false, error: "Validation failed" }, { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const where = {
|
|
54
|
+
tenantId: auth.tenantId,
|
|
55
|
+
organizationId: auth.orgId,
|
|
56
|
+
acceptedAt: null,
|
|
57
|
+
cancelledAt: null,
|
|
58
|
+
expiresAt: { $gt: /* @__PURE__ */ new Date() }
|
|
59
|
+
};
|
|
60
|
+
if (personEntityId) where.personEntityId = personEntityId;
|
|
61
|
+
if (customerEntityId) where.customerEntityId = customerEntityId;
|
|
62
|
+
if (email) where.emailHash = { $in: lookupHashCandidates(email) };
|
|
63
|
+
const em = container.resolve("em");
|
|
64
|
+
const [invitations, total] = await findAndCountWithDecryption(
|
|
65
|
+
em,
|
|
66
|
+
CustomerUserInvitation,
|
|
67
|
+
where,
|
|
68
|
+
{
|
|
69
|
+
orderBy: { createdAt: "DESC" },
|
|
70
|
+
limit: pageSize,
|
|
71
|
+
offset: (page - 1) * pageSize
|
|
72
|
+
},
|
|
73
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId }
|
|
74
|
+
);
|
|
75
|
+
const items = invitations.map((invitation) => ({
|
|
76
|
+
id: invitation.id,
|
|
77
|
+
email: invitation.email,
|
|
78
|
+
displayName: invitation.displayName || null,
|
|
79
|
+
customerEntityId: invitation.customerEntityId || null,
|
|
80
|
+
personEntityId: invitation.personEntityId || null,
|
|
81
|
+
roleIds: Array.isArray(invitation.roleIdsJson) ? invitation.roleIdsJson : [],
|
|
82
|
+
invitedByUserId: invitation.invitedByUserId || null,
|
|
83
|
+
expiresAt: invitation.expiresAt,
|
|
84
|
+
createdAt: invitation.createdAt
|
|
85
|
+
}));
|
|
86
|
+
return NextResponse.json({
|
|
87
|
+
ok: true,
|
|
88
|
+
items,
|
|
89
|
+
total,
|
|
90
|
+
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
|
91
|
+
page
|
|
92
|
+
});
|
|
93
|
+
}
|
|
23
94
|
async function POST(req) {
|
|
24
95
|
const auth = await getAuthFromRequest(req);
|
|
25
96
|
if (!auth) {
|
|
@@ -150,11 +221,48 @@ const methodDoc = {
|
|
|
150
221
|
{ status: 502, description: "Invitation email could not be sent", schema: errorSchema }
|
|
151
222
|
]
|
|
152
223
|
};
|
|
224
|
+
const listQuerySchema = z.object({
|
|
225
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
226
|
+
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
227
|
+
personEntityId: z.string().uuid().optional(),
|
|
228
|
+
customerEntityId: z.string().uuid().optional(),
|
|
229
|
+
email: z.string().optional()
|
|
230
|
+
});
|
|
231
|
+
const listSuccessSchema = z.object({
|
|
232
|
+
ok: z.literal(true),
|
|
233
|
+
items: z.array(z.object({
|
|
234
|
+
id: z.string().uuid(),
|
|
235
|
+
email: z.string(),
|
|
236
|
+
displayName: z.string().nullable(),
|
|
237
|
+
customerEntityId: z.string().uuid().nullable(),
|
|
238
|
+
personEntityId: z.string().uuid().nullable(),
|
|
239
|
+
roleIds: z.array(z.string().uuid()),
|
|
240
|
+
invitedByUserId: z.string().uuid().nullable(),
|
|
241
|
+
expiresAt: z.string().datetime(),
|
|
242
|
+
createdAt: z.string().datetime()
|
|
243
|
+
})),
|
|
244
|
+
total: z.number(),
|
|
245
|
+
totalPages: z.number(),
|
|
246
|
+
page: z.number()
|
|
247
|
+
});
|
|
248
|
+
const listMethodDoc = {
|
|
249
|
+
summary: "List pending customer invitations (admin)",
|
|
250
|
+
description: "Lists invitations that are still pending \u2014 not accepted, not cancelled, and not expired \u2014 for the caller's tenant and organization. The invitation token is never returned.",
|
|
251
|
+
tags: ["Customer Accounts Admin"],
|
|
252
|
+
query: listQuerySchema,
|
|
253
|
+
responses: [{ status: 200, description: "Pending invitations", schema: listSuccessSchema }],
|
|
254
|
+
errors: [
|
|
255
|
+
{ status: 400, description: "Validation failed", schema: errorSchema },
|
|
256
|
+
{ status: 401, description: "Not authenticated", schema: errorSchema },
|
|
257
|
+
{ status: 403, description: "Insufficient permissions", schema: errorSchema }
|
|
258
|
+
]
|
|
259
|
+
};
|
|
153
260
|
const openApi = {
|
|
154
261
|
summary: "Invite customer user (admin)",
|
|
155
|
-
methods: { POST: methodDoc }
|
|
262
|
+
methods: { GET: listMethodDoc, POST: methodDoc }
|
|
156
263
|
};
|
|
157
264
|
export {
|
|
265
|
+
GET,
|
|
158
266
|
POST,
|
|
159
267
|
metadata,
|
|
160
268
|
openApi
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customer_accounts/api/admin/users-invite.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'\nimport { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { isOwnedCompanyEntity, isOwnedPersonEntity, resolveOwnedCompanyForPerson } from '@open-mercato/core/modules/customer_accounts/lib/customerEntityOwnership'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport {\n checkAuthRateLimit,\n customerInviteRateLimitConfig,\n customerInviteIpRateLimitConfig,\n} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'\nimport { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'\nimport { sendCustomerInvitationEmail } from '@open-mercato/core/modules/customer_accounts/lib/invitationEmail'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customer_accounts').child({ component: 'admin-users-invite' })\n\nexport const metadata = {}\n\nfunction resolveInvitedByUserId(auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>): string | null {\n if (auth.isApiKey) return auth.userId ?? null\n return auth.sub\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const rateLimitEmail = await readNormalizedEmailFromJsonRequest(req)\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: customerInviteIpRateLimitConfig,\n compoundConfig: customerInviteRateLimitConfig,\n compoundIdentifier: rateLimitEmail,\n })\n if (rateLimitError) return rateLimitError\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.invite'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = inviteUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n // Reject a customerEntityId the caller does not own. customerEntityId is the\n // CRM company FK; without this check a non-company (e.g. a person) entity id\n // or a company from another org poisons the invitation and every later user\n // edit fails with \"Company not found\" (#4362, #2693).\n if (parsed.data.customerEntityId) {\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const owned = await isOwnedCompanyEntity(em, parsed.data.customerEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Company not found' }, { status: 400 })\n }\n }\n\n // Same guard for the person FK: it is copied onto the customer user on accept\n // and makes `autoLinkCrm` short-circuit, so an unowned id would permanently\n // cross-link the portal user to another org's CRM person.\n let resolvedCustomerEntityId = parsed.data.customerEntityId || null\n if (parsed.data.personEntityId) {\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const owned = await isOwnedPersonEntity(em, parsed.data.personEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Person not found' }, { status: 400 })\n }\n\n // An invitation raised from a person card carries only the person FK, and the\n // accepted user then short-circuits `autoLinkCrm`. Resolve the person's company\n // here so the user lands with a company scope key: the portal Users page, portal\n // invitations, and the company detail \"Portal users\" group all filter on it.\n if (!resolvedCustomerEntityId) {\n resolvedCustomerEntityId = await resolveOwnedCompanyForPerson(em, parsed.data.personEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n }\n }\n\n const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService\n\n const { invitation, rawToken, rollbackState } = await customerInvitationService.createInvitation(\n parsed.data.email,\n { tenantId: auth.tenantId!, organizationId: auth.orgId! },\n {\n customerEntityId: resolvedCustomerEntityId,\n personEntityId: parsed.data.personEntityId || null,\n roleIds: parsed.data.roleIds,\n invitedByUserId: resolveInvitedByUserId(auth),\n displayName: parsed.data.displayName || null,\n },\n )\n\n try {\n await sendCustomerInvitationEmail({\n container,\n organizationId: auth.orgId!,\n email: invitation.email,\n rawToken,\n })\n } catch (error) {\n logger.error('Invitation email failed', { err: error })\n try {\n await customerInvitationService.rollbackInvitation(invitation, rollbackState)\n } catch (rollbackError) {\n logger.error('Invitation rollback failed', { err: rollbackError })\n }\n return NextResponse.json({ ok: false, error: 'Invitation email could not be sent' }, { status: 502 })\n }\n\n // Emit only after the email is sent, so a subscriber observing \"invited\" can\n // assume the recipient was actually notified (no event fires on the 502 path).\n void emitCustomerAccountsEvent('customer_accounts.user.invited', {\n invitationId: invitation.id,\n email: invitation.email,\n customerEntityId: invitation.customerEntityId || null,\n invitedByType: 'staff',\n tenantId: auth.tenantId!,\n organizationId: auth.orgId!,\n }).catch(() => undefined)\n\n return NextResponse.json({\n ok: true,\n invitation: {\n id: invitation.id,\n email: invitation.email,\n // Echo the stored CRM links so the caller can see which company the person\n // invite resolved to. The token stays unexposed.\n customerEntityId: invitation.customerEntityId || null,\n personEntityId: invitation.personEntityId || null,\n expiresAt: invitation.expiresAt,\n },\n }, { status: 201 })\n}\n\nconst successSchema = z.object({\n ok: z.literal(true),\n invitation: z.object({\n id: z.string().uuid(),\n email: z.string(),\n customerEntityId: z.string().uuid().nullable(),\n personEntityId: z.string().uuid().nullable(),\n expiresAt: z.string().datetime(),\n }),\n})\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst methodDoc: OpenApiMethodDoc = {\n summary: 'Invite customer user (admin)',\n description: 'Creates a staff-initiated invitation for a new customer user. The invitedByUserId is set from the staff auth context.',\n tags: ['Customer Accounts Admin'],\n requestBody: { schema: inviteUserSchema },\n responses: [{ status: 201, description: 'Invitation created', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },\n { status: 502, description: 'Invitation email could not be sent', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Invite customer user (admin)',\n methods: { POST: methodDoc },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAGvC,SAAS,iCAAiC;AAC1C,SAAS,wBAAwB;AACjC,SAAS,sBAAsB,qBAAqB,oCAAoC;AACxF,SAAS,4BAA4B;AACrC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,0CAA0C;AACnD,SAAS,mCAAmC;AAC5C,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'\nimport { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { isOwnedCompanyEntity, isOwnedPersonEntity, resolveOwnedCompanyForPerson } from '@open-mercato/core/modules/customer_accounts/lib/customerEntityOwnership'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport {\n checkAuthRateLimit,\n customerInviteRateLimitConfig,\n customerInviteIpRateLimitConfig,\n} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'\nimport { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'\nimport { sendCustomerInvitationEmail } from '@open-mercato/core/modules/customer_accounts/lib/invitationEmail'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { CustomerUserInvitation } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { findAndCountWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\n\nconst logger = createLogger('customer_accounts').child({ component: 'admin-users-invite' })\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nexport const metadata = {}\n\nfunction resolveInvitedByUserId(auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>): string | null {\n if (auth.isApiKey) return auth.userId ?? null\n return auth.sub\n}\n\nfunction parsePositiveInt(value: string | null, fallback: number): number {\n const parsed = Number.parseInt(value ?? '', 10)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback\n}\n\n// Pending invitations are the only trace a not-yet-accepted invite leaves: the\n// customer user row is created on accept, so every users-backed surface (admin\n// users list, the CRM person \"account status\" widget) shows the same empty\n// state right after a successful invite (#4950). This read surface makes that\n// pending state queryable. The one-time token stays server-side.\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.view'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n const url = new URL(req.url)\n // A non-numeric page/pageSize must not reach the query as NaN.\n const page = Math.max(1, parsePositiveInt(url.searchParams.get('page'), 1))\n const pageSize = Math.min(100, Math.max(1, parsePositiveInt(url.searchParams.get('pageSize'), 25)))\n const personEntityId = url.searchParams.get('personEntityId')\n const customerEntityId = url.searchParams.get('customerEntityId')\n const email = url.searchParams.get('email')\n\n for (const value of [personEntityId, customerEntityId]) {\n if (value && !UUID_PATTERN.test(value)) {\n return NextResponse.json({ ok: false, error: 'Validation failed' }, { status: 400 })\n }\n }\n\n // Pending means: still open (not accepted, not cancelled) and not expired.\n const where: Record<string, unknown> = {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n acceptedAt: null,\n cancelledAt: null,\n expiresAt: { $gt: new Date() },\n }\n if (personEntityId) where.personEntityId = personEntityId\n if (customerEntityId) where.customerEntityId = customerEntityId\n // email is stored encrypted, so match on the deterministic lookup hash.\n if (email) where.emailHash = { $in: lookupHashCandidates(email) }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const [invitations, total] = await findAndCountWithDecryption(\n em,\n CustomerUserInvitation,\n where as any,\n {\n orderBy: { createdAt: 'DESC' },\n limit: pageSize,\n offset: (page - 1) * pageSize,\n },\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n\n const items = invitations.map((invitation) => ({\n id: invitation.id,\n email: invitation.email,\n displayName: invitation.displayName || null,\n customerEntityId: invitation.customerEntityId || null,\n personEntityId: invitation.personEntityId || null,\n roleIds: Array.isArray(invitation.roleIdsJson) ? invitation.roleIdsJson : [],\n invitedByUserId: invitation.invitedByUserId || null,\n expiresAt: invitation.expiresAt,\n createdAt: invitation.createdAt,\n }))\n\n return NextResponse.json({\n ok: true,\n items,\n total,\n totalPages: Math.max(1, Math.ceil(total / pageSize)),\n page,\n })\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const rateLimitEmail = await readNormalizedEmailFromJsonRequest(req)\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: customerInviteIpRateLimitConfig,\n compoundConfig: customerInviteRateLimitConfig,\n compoundIdentifier: rateLimitEmail,\n })\n if (rateLimitError) return rateLimitError\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.invite'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = inviteUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n // Reject a customerEntityId the caller does not own. customerEntityId is the\n // CRM company FK; without this check a non-company (e.g. a person) entity id\n // or a company from another org poisons the invitation and every later user\n // edit fails with \"Company not found\" (#4362, #2693).\n if (parsed.data.customerEntityId) {\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const owned = await isOwnedCompanyEntity(em, parsed.data.customerEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Company not found' }, { status: 400 })\n }\n }\n\n // Same guard for the person FK: it is copied onto the customer user on accept\n // and makes `autoLinkCrm` short-circuit, so an unowned id would permanently\n // cross-link the portal user to another org's CRM person.\n let resolvedCustomerEntityId = parsed.data.customerEntityId || null\n if (parsed.data.personEntityId) {\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const owned = await isOwnedPersonEntity(em, parsed.data.personEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Person not found' }, { status: 400 })\n }\n\n // An invitation raised from a person card carries only the person FK, and the\n // accepted user then short-circuits `autoLinkCrm`. Resolve the person's company\n // here so the user lands with a company scope key: the portal Users page, portal\n // invitations, and the company detail \"Portal users\" group all filter on it.\n if (!resolvedCustomerEntityId) {\n resolvedCustomerEntityId = await resolveOwnedCompanyForPerson(em, parsed.data.personEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n }\n }\n\n const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService\n\n const { invitation, rawToken, rollbackState } = await customerInvitationService.createInvitation(\n parsed.data.email,\n { tenantId: auth.tenantId!, organizationId: auth.orgId! },\n {\n customerEntityId: resolvedCustomerEntityId,\n personEntityId: parsed.data.personEntityId || null,\n roleIds: parsed.data.roleIds,\n invitedByUserId: resolveInvitedByUserId(auth),\n displayName: parsed.data.displayName || null,\n },\n )\n\n try {\n await sendCustomerInvitationEmail({\n container,\n organizationId: auth.orgId!,\n email: invitation.email,\n rawToken,\n })\n } catch (error) {\n logger.error('Invitation email failed', { err: error })\n try {\n await customerInvitationService.rollbackInvitation(invitation, rollbackState)\n } catch (rollbackError) {\n logger.error('Invitation rollback failed', { err: rollbackError })\n }\n return NextResponse.json({ ok: false, error: 'Invitation email could not be sent' }, { status: 502 })\n }\n\n // Emit only after the email is sent, so a subscriber observing \"invited\" can\n // assume the recipient was actually notified (no event fires on the 502 path).\n void emitCustomerAccountsEvent('customer_accounts.user.invited', {\n invitationId: invitation.id,\n email: invitation.email,\n customerEntityId: invitation.customerEntityId || null,\n invitedByType: 'staff',\n tenantId: auth.tenantId!,\n organizationId: auth.orgId!,\n }).catch(() => undefined)\n\n return NextResponse.json({\n ok: true,\n invitation: {\n id: invitation.id,\n email: invitation.email,\n // Echo the stored CRM links so the caller can see which company the person\n // invite resolved to. The token stays unexposed.\n customerEntityId: invitation.customerEntityId || null,\n personEntityId: invitation.personEntityId || null,\n expiresAt: invitation.expiresAt,\n },\n }, { status: 201 })\n}\n\nconst successSchema = z.object({\n ok: z.literal(true),\n invitation: z.object({\n id: z.string().uuid(),\n email: z.string(),\n customerEntityId: z.string().uuid().nullable(),\n personEntityId: z.string().uuid().nullable(),\n expiresAt: z.string().datetime(),\n }),\n})\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst methodDoc: OpenApiMethodDoc = {\n summary: 'Invite customer user (admin)',\n description: 'Creates a staff-initiated invitation for a new customer user. The invitedByUserId is set from the staff auth context.',\n tags: ['Customer Accounts Admin'],\n requestBody: { schema: inviteUserSchema },\n responses: [{ status: 201, description: 'Invitation created', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },\n { status: 502, description: 'Invitation email could not be sent', schema: errorSchema },\n ],\n}\n\nconst listQuerySchema = z.object({\n page: z.coerce.number().int().min(1).optional(),\n pageSize: z.coerce.number().int().min(1).max(100).optional(),\n personEntityId: z.string().uuid().optional(),\n customerEntityId: z.string().uuid().optional(),\n email: z.string().optional(),\n})\n\nconst listSuccessSchema = z.object({\n ok: z.literal(true),\n items: z.array(z.object({\n id: z.string().uuid(),\n email: z.string(),\n displayName: z.string().nullable(),\n customerEntityId: z.string().uuid().nullable(),\n personEntityId: z.string().uuid().nullable(),\n roleIds: z.array(z.string().uuid()),\n invitedByUserId: z.string().uuid().nullable(),\n expiresAt: z.string().datetime(),\n createdAt: z.string().datetime(),\n })),\n total: z.number(),\n totalPages: z.number(),\n page: z.number(),\n})\n\nconst listMethodDoc: OpenApiMethodDoc = {\n summary: 'List pending customer invitations (admin)',\n description: 'Lists invitations that are still pending \u2014 not accepted, not cancelled, and not expired \u2014 for the caller\\'s tenant and organization. The invitation token is never returned.',\n tags: ['Customer Accounts Admin'],\n query: listQuerySchema,\n responses: [{ status: 200, description: 'Pending invitations', schema: listSuccessSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Invite customer user (admin)',\n methods: { GET: listMethodDoc, POST: methodDoc },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -202,9 +202,24 @@ function AccountStatusWidget({ context }) {
|
|
|
202
202
|
},
|
|
203
203
|
enabled: !!personEntityId
|
|
204
204
|
});
|
|
205
|
+
const { data: pendingInvitation, isLoading: isLoadingInvitation } = useQuery({
|
|
206
|
+
queryKey: ["customer-account-pending-invitation", personEntityId],
|
|
207
|
+
queryFn: async () => {
|
|
208
|
+
if (!personEntityId) return null;
|
|
209
|
+
const result = await apiCall(
|
|
210
|
+
`/api/customer_accounts/admin/users-invite?personEntityId=${encodeURIComponent(personEntityId)}&pageSize=1`
|
|
211
|
+
);
|
|
212
|
+
if (!result.ok) return null;
|
|
213
|
+
const json = result.result;
|
|
214
|
+
const items = json?.items;
|
|
215
|
+
return items?.[0] || null;
|
|
216
|
+
},
|
|
217
|
+
enabled: !!personEntityId && !isLoading && !data
|
|
218
|
+
});
|
|
205
219
|
function handleInviteSuccess() {
|
|
206
220
|
setShowInviteForm(false);
|
|
207
221
|
queryClient.invalidateQueries({ queryKey: ["customer-account-status", personEntityId] });
|
|
222
|
+
queryClient.invalidateQueries({ queryKey: ["customer-account-pending-invitation", personEntityId] });
|
|
208
223
|
}
|
|
209
224
|
if (isLoading) {
|
|
210
225
|
return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: t("common.loading", "Loading...") });
|
|
@@ -212,15 +227,28 @@ function AccountStatusWidget({ context }) {
|
|
|
212
227
|
if (!data) {
|
|
213
228
|
return /* @__PURE__ */ jsxs("div", { className: "rounded-md border p-3", children: [
|
|
214
229
|
/* @__PURE__ */ jsx("div", { className: "text-sm font-medium mb-1", children: t("customer_accounts.widgets.accountStatus", "Portal Account") }),
|
|
215
|
-
/* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: t("
|
|
216
|
-
|
|
230
|
+
isLoadingInvitation ? /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: t("common.loading", "Loading...") }) : pendingInvitation ? /* @__PURE__ */ jsxs("div", { className: "space-y-1 text-sm", children: [
|
|
231
|
+
/* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
|
|
232
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: t("common.status", "Status") }),
|
|
233
|
+
/* @__PURE__ */ jsx(StatusBadge, { variant: "warning", dot: true, children: t("customer_accounts.widgets.invitationPending", "Invitation pending") })
|
|
234
|
+
] }),
|
|
235
|
+
/* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
|
|
236
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: t("common.email", "Email") }),
|
|
237
|
+
/* @__PURE__ */ jsx("span", { children: pendingInvitation.email })
|
|
238
|
+
] }),
|
|
239
|
+
/* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
|
|
240
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: t("customer_accounts.widgets.invitationExpires", "Invitation expires") }),
|
|
241
|
+
/* @__PURE__ */ jsx("span", { children: new Date(pendingInvitation.expiresAt).toLocaleDateString() })
|
|
242
|
+
] })
|
|
243
|
+
] }) : /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: t("customer_accounts.widgets.noAccount", "No portal account linked") }),
|
|
244
|
+
!showInviteForm && personEntityId && !isLoadingInvitation && /* @__PURE__ */ jsx("div", { className: "mt-2", children: /* @__PURE__ */ jsx(
|
|
217
245
|
Button,
|
|
218
246
|
{
|
|
219
247
|
type: "button",
|
|
220
248
|
variant: "outline",
|
|
221
249
|
size: "sm",
|
|
222
250
|
onClick: () => setShowInviteForm(true),
|
|
223
|
-
children: t("customer_accounts.widgets.invite.button", "Invite to Portal")
|
|
251
|
+
children: pendingInvitation ? t("customer_accounts.widgets.invite.resend", "Resend invitation") : t("customer_accounts.widgets.invite.button", "Invite to Portal")
|
|
224
252
|
}
|
|
225
253
|
) }),
|
|
226
254
|
showInviteForm && personEntityId && /* @__PURE__ */ jsx(InviteForm, { personEntityId, onSuccess: handleInviteSuccess })
|
package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { EmailInput } from '@open-mercato/ui/primitives/email-input'\nimport { StatusBadge } from '@open-mercato/ui/primitives/status-badge'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\n\ninterface AccountStatusData {\n id: string\n email: string\n isActive: boolean\n emailVerified: boolean\n lastLoginAt: string | null\n}\n\ninterface AccountStatusProps {\n context?: {\n entityId?: string\n recordId?: string\n }\n}\n\ninterface RoleOption {\n id: string\n name: string\n}\n\ninterface PersonData {\n person?: {\n primaryEmail?: string | null\n displayName?: string | null\n }\n profile?: {\n firstName?: string | null\n lastName?: string | null\n } | null\n}\n\nfunction InviteForm({ personEntityId, onSuccess }: { personEntityId: string; onSuccess: () => void }) {\n const t = useT()\n const [isLoadingPerson, setIsLoadingPerson] = React.useState(true)\n const [email, setEmail] = React.useState('')\n const [displayName, setDisplayName] = React.useState('')\n const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])\n const [availableRoles, setAvailableRoles] = React.useState<RoleOption[]>([])\n const [isLoadingRoles, setIsLoadingRoles] = React.useState(true)\n const [isSubmitting, setIsSubmitting] = React.useState(false)\n\n const { runMutation } = useGuardedMutation<{ entityType: string }>({\n contextId: 'customer_accounts:account-status-invite',\n })\n\n React.useEffect(() => {\n let cancelled = false\n async function loadPerson() {\n try {\n const call = await apiCall<PersonData>(\n `/api/customers/people/${encodeURIComponent(personEntityId)}`,\n )\n if (cancelled) return\n if (call.ok && call.result) {\n const person = call.result.person\n const profile = call.result.profile\n if (person?.primaryEmail) {\n setEmail(person.primaryEmail)\n }\n const nameParts = [profile?.firstName, profile?.lastName].filter(Boolean)\n if (nameParts.length > 0) {\n setDisplayName(nameParts.join(' '))\n } else if (person?.displayName) {\n setDisplayName(person.displayName)\n }\n }\n } catch {\n /* ignore - fields will remain empty for manual entry */\n } finally {\n if (!cancelled) setIsLoadingPerson(false)\n }\n }\n loadPerson()\n return () => { cancelled = true }\n }, [personEntityId])\n\n React.useEffect(() => {\n let cancelled = false\n async function loadRoles() {\n try {\n const call = await apiCall<{ items?: RoleOption[] }>(\n '/api/customer_accounts/admin/roles?pageSize=100',\n )\n if (cancelled) return\n if (call.ok && call.result) {\n const items = Array.isArray(call.result.items) ? call.result.items : []\n setAvailableRoles(items.map((role) => ({ id: role.id, name: role.name })))\n }\n } catch {\n /* ignore */\n } finally {\n if (!cancelled) setIsLoadingRoles(false)\n }\n }\n loadRoles()\n return () => { cancelled = true }\n }, [])\n\n function toggleRole(roleId: string) {\n setSelectedRoleIds((prev) =>\n prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId],\n )\n }\n\n async function handleSubmit() {\n const trimmedEmail = email.trim()\n if (!trimmedEmail) {\n flash(t('customer_accounts.widgets.invite.error.emailRequired', 'Email is required'), 'error')\n return\n }\n if (selectedRoleIds.length === 0) {\n flash(t('customer_accounts.widgets.invite.error.roleRequired', 'At least one role must be selected'), 'error')\n return\n }\n\n setIsSubmitting(true)\n try {\n await runMutation({\n context: { entityType: 'customer_accounts:user' },\n mutationPayload: { personEntityId, roleIds: selectedRoleIds },\n operation: async () => {\n // optimistic-lock-exempt: creates a new portal invitation, not a concurrent record edit\n const call = await apiCall<{ ok: boolean; error?: string }>(\n '/api/customer_accounts/admin/users-invite',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n email: trimmedEmail,\n roleIds: selectedRoleIds,\n displayName: displayName.trim() || undefined,\n personEntityId,\n }),\n },\n )\n\n if (!call.ok) {\n const errorMessage = (call.result as Record<string, unknown> | null)?.error as string | undefined\n flash(errorMessage || t('customer_accounts.widgets.invite.error.failed', 'Failed to send invitation'), 'error')\n return\n }\n\n flash(t('customer_accounts.widgets.invite.success', 'Invitation sent successfully'), 'success')\n onSuccess()\n },\n })\n } catch {\n flash(t('customer_accounts.widgets.invite.error.failed', 'Failed to send invitation'), 'error')\n } finally {\n setIsSubmitting(false)\n }\n }\n\n const isLoading = isLoadingPerson || isLoadingRoles\n\n if (isLoading) {\n return (\n <div className=\"text-sm text-muted-foreground py-2\">\n {t('common.loading', 'Loading...')}\n </div>\n )\n }\n\n return (\n <div className=\"space-y-3 mt-2\">\n <div>\n <label htmlFor=\"invite-email\" className=\"block text-xs font-medium text-muted-foreground mb-1\">\n {t('common.email', 'Email')}\n </label>\n <EmailInput\n id=\"invite-email\"\n size=\"sm\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n required\n disabled={isSubmitting}\n />\n </div>\n\n <div>\n <label htmlFor=\"invite-display-name\" className=\"block text-xs font-medium text-muted-foreground mb-1\">\n {t('customer_accounts.widgets.invite.displayName', 'Display Name')}\n </label>\n <Input\n id=\"invite-display-name\"\n type=\"text\"\n size=\"sm\"\n value={displayName}\n onChange={(event) => setDisplayName(event.target.value)}\n disabled={isSubmitting}\n />\n </div>\n\n <div>\n <div className=\"text-xs font-medium text-muted-foreground mb-1.5\">\n {t('customer_accounts.widgets.invite.roles', 'Roles')}\n </div>\n {availableRoles.length === 0 ? (\n <div className=\"text-xs text-muted-foreground\">\n {t('customer_accounts.widgets.invite.noRoles', 'No roles available')}\n </div>\n ) : (\n <div className=\"flex flex-wrap gap-1.5\">\n {availableRoles.map((role) => (\n <Button\n key={role.id}\n type=\"button\"\n variant={selectedRoleIds.includes(role.id) ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => toggleRole(role.id)}\n disabled={isSubmitting}\n className=\"text-xs h-7\"\n >\n {role.name}\n </Button>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex justify-end gap-2 pt-1\">\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={handleSubmit}\n disabled={isSubmitting || !email.trim() || selectedRoleIds.length === 0}\n >\n {isSubmitting\n ? t('common.loading', 'Loading...')\n : t('customer_accounts.widgets.invite.submit', 'Send Invitation')}\n </Button>\n </div>\n </div>\n )\n}\n\nexport default function AccountStatusWidget({ context }: AccountStatusProps) {\n const t = useT()\n const queryClient = useQueryClient()\n const personEntityId = context?.recordId\n const [showInviteForm, setShowInviteForm] = React.useState(false)\n\n const { data, isLoading } = useQuery({\n queryKey: ['customer-account-status', personEntityId],\n queryFn: async (): Promise<AccountStatusData | null> => {\n if (!personEntityId) return null\n const result = await apiCall(`/api/customer_accounts/admin/users?personEntityId=${personEntityId}&pageSize=1`)\n if (!result.ok) return null\n const json = result.result as Record<string, unknown> | null\n const items = json?.items as AccountStatusData[] | undefined\n return items?.[0] || null\n },\n enabled: !!personEntityId,\n })\n\n function handleInviteSuccess() {\n setShowInviteForm(false)\n queryClient.invalidateQueries({ queryKey: ['customer-account-status', personEntityId] })\n }\n\n if (isLoading) {\n return <div className=\"text-sm text-muted-foreground\">{t('common.loading', 'Loading...')}</div>\n }\n\n if (!data) {\n return (\n <div className=\"rounded-md border p-3\">\n <div className=\"text-sm font-medium mb-1\">{t('customer_accounts.widgets.accountStatus', 'Portal Account')}</div>\n <div className=\"text-sm text-muted-foreground\">{t('customer_accounts.widgets.noAccount', 'No portal account linked')}</div>\n {!showInviteForm && personEntityId && (\n <div className=\"mt-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setShowInviteForm(true)}\n >\n {t('customer_accounts.widgets.invite.button', 'Invite to Portal')}\n </Button>\n </div>\n )}\n {showInviteForm && personEntityId && (\n <InviteForm personEntityId={personEntityId} onSuccess={handleInviteSuccess} />\n )}\n </div>\n )\n }\n\n return (\n <div className=\"rounded-md border p-3\">\n <div className=\"text-sm font-medium mb-2\">{t('customer_accounts.widgets.accountStatus', 'Portal Account')}</div>\n <div className=\"space-y-1 text-sm\">\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('common.status', 'Status')}</span>\n <StatusBadge variant={data.isActive ? 'success' : 'error'} dot>\n {data.isActive ? t('common.active', 'Active') : t('common.inactive', 'Inactive')}\n </StatusBadge>\n </div>\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('common.email', 'Email')}</span>\n <span>{data.email}</span>\n </div>\n {data.emailVerified !== undefined && (\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('customer_accounts.widgets.emailVerified', 'Email Verified')}</span>\n <span>{data.emailVerified ? '\u2713' : '\u2717'}</span>\n </div>\n )}\n {data.lastLoginAt && (\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('customer_accounts.widgets.lastLogin', 'Last Login')}</span>\n <span>{new Date(data.lastLoginAt).toLocaleDateString()}</span>\n </div>\n )}\n </div>\n <div className=\"mt-2\">\n <a\n href={`/backend/customer_accounts/users/${data.id}`}\n className=\"text-xs text-primary hover:underline\"\n >\n {t('customer_accounts.widgets.viewAccount', 'View account details \u2192')}\n </a>\n </div>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { EmailInput } from '@open-mercato/ui/primitives/email-input'\nimport { StatusBadge } from '@open-mercato/ui/primitives/status-badge'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\n\ninterface AccountStatusData {\n id: string\n email: string\n isActive: boolean\n emailVerified: boolean\n lastLoginAt: string | null\n}\n\ninterface PendingInvitationData {\n id: string\n email: string\n expiresAt: string\n}\n\ninterface AccountStatusProps {\n context?: {\n entityId?: string\n recordId?: string\n }\n}\n\ninterface RoleOption {\n id: string\n name: string\n}\n\ninterface PersonData {\n person?: {\n primaryEmail?: string | null\n displayName?: string | null\n }\n profile?: {\n firstName?: string | null\n lastName?: string | null\n } | null\n}\n\nfunction InviteForm({ personEntityId, onSuccess }: { personEntityId: string; onSuccess: () => void }) {\n const t = useT()\n const [isLoadingPerson, setIsLoadingPerson] = React.useState(true)\n const [email, setEmail] = React.useState('')\n const [displayName, setDisplayName] = React.useState('')\n const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])\n const [availableRoles, setAvailableRoles] = React.useState<RoleOption[]>([])\n const [isLoadingRoles, setIsLoadingRoles] = React.useState(true)\n const [isSubmitting, setIsSubmitting] = React.useState(false)\n\n const { runMutation } = useGuardedMutation<{ entityType: string }>({\n contextId: 'customer_accounts:account-status-invite',\n })\n\n React.useEffect(() => {\n let cancelled = false\n async function loadPerson() {\n try {\n const call = await apiCall<PersonData>(\n `/api/customers/people/${encodeURIComponent(personEntityId)}`,\n )\n if (cancelled) return\n if (call.ok && call.result) {\n const person = call.result.person\n const profile = call.result.profile\n if (person?.primaryEmail) {\n setEmail(person.primaryEmail)\n }\n const nameParts = [profile?.firstName, profile?.lastName].filter(Boolean)\n if (nameParts.length > 0) {\n setDisplayName(nameParts.join(' '))\n } else if (person?.displayName) {\n setDisplayName(person.displayName)\n }\n }\n } catch {\n /* ignore - fields will remain empty for manual entry */\n } finally {\n if (!cancelled) setIsLoadingPerson(false)\n }\n }\n loadPerson()\n return () => { cancelled = true }\n }, [personEntityId])\n\n React.useEffect(() => {\n let cancelled = false\n async function loadRoles() {\n try {\n const call = await apiCall<{ items?: RoleOption[] }>(\n '/api/customer_accounts/admin/roles?pageSize=100',\n )\n if (cancelled) return\n if (call.ok && call.result) {\n const items = Array.isArray(call.result.items) ? call.result.items : []\n setAvailableRoles(items.map((role) => ({ id: role.id, name: role.name })))\n }\n } catch {\n /* ignore */\n } finally {\n if (!cancelled) setIsLoadingRoles(false)\n }\n }\n loadRoles()\n return () => { cancelled = true }\n }, [])\n\n function toggleRole(roleId: string) {\n setSelectedRoleIds((prev) =>\n prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId],\n )\n }\n\n async function handleSubmit() {\n const trimmedEmail = email.trim()\n if (!trimmedEmail) {\n flash(t('customer_accounts.widgets.invite.error.emailRequired', 'Email is required'), 'error')\n return\n }\n if (selectedRoleIds.length === 0) {\n flash(t('customer_accounts.widgets.invite.error.roleRequired', 'At least one role must be selected'), 'error')\n return\n }\n\n setIsSubmitting(true)\n try {\n await runMutation({\n context: { entityType: 'customer_accounts:user' },\n mutationPayload: { personEntityId, roleIds: selectedRoleIds },\n operation: async () => {\n // optimistic-lock-exempt: creates a new portal invitation, not a concurrent record edit\n const call = await apiCall<{ ok: boolean; error?: string }>(\n '/api/customer_accounts/admin/users-invite',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n email: trimmedEmail,\n roleIds: selectedRoleIds,\n displayName: displayName.trim() || undefined,\n personEntityId,\n }),\n },\n )\n\n if (!call.ok) {\n const errorMessage = (call.result as Record<string, unknown> | null)?.error as string | undefined\n flash(errorMessage || t('customer_accounts.widgets.invite.error.failed', 'Failed to send invitation'), 'error')\n return\n }\n\n flash(t('customer_accounts.widgets.invite.success', 'Invitation sent successfully'), 'success')\n onSuccess()\n },\n })\n } catch {\n flash(t('customer_accounts.widgets.invite.error.failed', 'Failed to send invitation'), 'error')\n } finally {\n setIsSubmitting(false)\n }\n }\n\n const isLoading = isLoadingPerson || isLoadingRoles\n\n if (isLoading) {\n return (\n <div className=\"text-sm text-muted-foreground py-2\">\n {t('common.loading', 'Loading...')}\n </div>\n )\n }\n\n return (\n <div className=\"space-y-3 mt-2\">\n <div>\n <label htmlFor=\"invite-email\" className=\"block text-xs font-medium text-muted-foreground mb-1\">\n {t('common.email', 'Email')}\n </label>\n <EmailInput\n id=\"invite-email\"\n size=\"sm\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n required\n disabled={isSubmitting}\n />\n </div>\n\n <div>\n <label htmlFor=\"invite-display-name\" className=\"block text-xs font-medium text-muted-foreground mb-1\">\n {t('customer_accounts.widgets.invite.displayName', 'Display Name')}\n </label>\n <Input\n id=\"invite-display-name\"\n type=\"text\"\n size=\"sm\"\n value={displayName}\n onChange={(event) => setDisplayName(event.target.value)}\n disabled={isSubmitting}\n />\n </div>\n\n <div>\n <div className=\"text-xs font-medium text-muted-foreground mb-1.5\">\n {t('customer_accounts.widgets.invite.roles', 'Roles')}\n </div>\n {availableRoles.length === 0 ? (\n <div className=\"text-xs text-muted-foreground\">\n {t('customer_accounts.widgets.invite.noRoles', 'No roles available')}\n </div>\n ) : (\n <div className=\"flex flex-wrap gap-1.5\">\n {availableRoles.map((role) => (\n <Button\n key={role.id}\n type=\"button\"\n variant={selectedRoleIds.includes(role.id) ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => toggleRole(role.id)}\n disabled={isSubmitting}\n className=\"text-xs h-7\"\n >\n {role.name}\n </Button>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex justify-end gap-2 pt-1\">\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={handleSubmit}\n disabled={isSubmitting || !email.trim() || selectedRoleIds.length === 0}\n >\n {isSubmitting\n ? t('common.loading', 'Loading...')\n : t('customer_accounts.widgets.invite.submit', 'Send Invitation')}\n </Button>\n </div>\n </div>\n )\n}\n\nexport default function AccountStatusWidget({ context }: AccountStatusProps) {\n const t = useT()\n const queryClient = useQueryClient()\n const personEntityId = context?.recordId\n const [showInviteForm, setShowInviteForm] = React.useState(false)\n\n const { data, isLoading } = useQuery({\n queryKey: ['customer-account-status', personEntityId],\n queryFn: async (): Promise<AccountStatusData | null> => {\n if (!personEntityId) return null\n const result = await apiCall(`/api/customer_accounts/admin/users?personEntityId=${personEntityId}&pageSize=1`)\n if (!result.ok) return null\n const json = result.result as Record<string, unknown> | null\n const items = json?.items as AccountStatusData[] | undefined\n return items?.[0] || null\n },\n enabled: !!personEntityId,\n })\n\n // A portal account only exists once the invitation is accepted, so the users\n // query above stays empty right after a successful invite. Without this the\n // widget renders the identical \"no account\" state and the invite looks like a\n // no-op (#4950).\n const { data: pendingInvitation, isLoading: isLoadingInvitation } = useQuery({\n queryKey: ['customer-account-pending-invitation', personEntityId],\n queryFn: async (): Promise<PendingInvitationData | null> => {\n if (!personEntityId) return null\n const result = await apiCall(\n `/api/customer_accounts/admin/users-invite?personEntityId=${encodeURIComponent(personEntityId)}&pageSize=1`,\n )\n if (!result.ok) return null\n const json = result.result as Record<string, unknown> | null\n const items = json?.items as PendingInvitationData[] | undefined\n return items?.[0] || null\n },\n enabled: !!personEntityId && !isLoading && !data,\n })\n\n function handleInviteSuccess() {\n setShowInviteForm(false)\n queryClient.invalidateQueries({ queryKey: ['customer-account-status', personEntityId] })\n queryClient.invalidateQueries({ queryKey: ['customer-account-pending-invitation', personEntityId] })\n }\n\n if (isLoading) {\n return <div className=\"text-sm text-muted-foreground\">{t('common.loading', 'Loading...')}</div>\n }\n\n if (!data) {\n return (\n <div className=\"rounded-md border p-3\">\n <div className=\"text-sm font-medium mb-1\">{t('customer_accounts.widgets.accountStatus', 'Portal Account')}</div>\n {isLoadingInvitation ? (\n <div className=\"text-sm text-muted-foreground\">{t('common.loading', 'Loading...')}</div>\n ) : pendingInvitation ? (\n <div className=\"space-y-1 text-sm\">\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('common.status', 'Status')}</span>\n <StatusBadge variant=\"warning\" dot>\n {t('customer_accounts.widgets.invitationPending', 'Invitation pending')}\n </StatusBadge>\n </div>\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('common.email', 'Email')}</span>\n <span>{pendingInvitation.email}</span>\n </div>\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('customer_accounts.widgets.invitationExpires', 'Invitation expires')}</span>\n <span>{new Date(pendingInvitation.expiresAt).toLocaleDateString()}</span>\n </div>\n </div>\n ) : (\n <div className=\"text-sm text-muted-foreground\">{t('customer_accounts.widgets.noAccount', 'No portal account linked')}</div>\n )}\n {!showInviteForm && personEntityId && !isLoadingInvitation && (\n <div className=\"mt-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setShowInviteForm(true)}\n >\n {pendingInvitation\n ? t('customer_accounts.widgets.invite.resend', 'Resend invitation')\n : t('customer_accounts.widgets.invite.button', 'Invite to Portal')}\n </Button>\n </div>\n )}\n {showInviteForm && personEntityId && (\n <InviteForm personEntityId={personEntityId} onSuccess={handleInviteSuccess} />\n )}\n </div>\n )\n }\n\n return (\n <div className=\"rounded-md border p-3\">\n <div className=\"text-sm font-medium mb-2\">{t('customer_accounts.widgets.accountStatus', 'Portal Account')}</div>\n <div className=\"space-y-1 text-sm\">\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('common.status', 'Status')}</span>\n <StatusBadge variant={data.isActive ? 'success' : 'error'} dot>\n {data.isActive ? t('common.active', 'Active') : t('common.inactive', 'Inactive')}\n </StatusBadge>\n </div>\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('common.email', 'Email')}</span>\n <span>{data.email}</span>\n </div>\n {data.emailVerified !== undefined && (\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('customer_accounts.widgets.emailVerified', 'Email Verified')}</span>\n <span>{data.emailVerified ? '\u2713' : '\u2717'}</span>\n </div>\n )}\n {data.lastLoginAt && (\n <div className=\"flex justify-between\">\n <span className=\"text-muted-foreground\">{t('customer_accounts.widgets.lastLogin', 'Last Login')}</span>\n <span>{new Date(data.lastLoginAt).toLocaleDateString()}</span>\n </div>\n )}\n </div>\n <div className=\"mt-2\">\n <a\n href={`/backend/customer_accounts/users/${data.id}`}\n className=\"text-xs text-primary hover:underline\"\n >\n {t('customer_accounts.widgets.viewAccount', 'View account details \u2192')}\n </a>\n </div>\n </div>\n )\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.6878.1.ab817e58ac",
|
|
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.6878.1.ab817e58ac",
|
|
258
|
+
"@open-mercato/shared": "0.6.8-develop.6878.1.ab817e58ac",
|
|
259
|
+
"@open-mercato/ui": "0.6.8-develop.6878.1.ab817e58ac",
|
|
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.6878.1.ab817e58ac",
|
|
265
|
+
"@open-mercato/shared": "0.6.8-develop.6878.1.ab817e58ac",
|
|
266
|
+
"@open-mercato/ui": "0.6.8-develop.6878.1.ab817e58ac",
|
|
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",
|
|
@@ -17,9 +17,14 @@ import {
|
|
|
17
17
|
import { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'
|
|
18
18
|
import { sendCustomerInvitationEmail } from '@open-mercato/core/modules/customer_accounts/lib/invitationEmail'
|
|
19
19
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
20
|
+
import { CustomerUserInvitation } from '@open-mercato/core/modules/customer_accounts/data/entities'
|
|
21
|
+
import { findAndCountWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
22
|
+
import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
20
23
|
|
|
21
24
|
const logger = createLogger('customer_accounts').child({ component: 'admin-users-invite' })
|
|
22
25
|
|
|
26
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
27
|
+
|
|
23
28
|
export const metadata = {}
|
|
24
29
|
|
|
25
30
|
function resolveInvitedByUserId(auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>): string | null {
|
|
@@ -27,6 +32,90 @@ function resolveInvitedByUserId(auth: NonNullable<Awaited<ReturnType<typeof getA
|
|
|
27
32
|
return auth.sub
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
function parsePositiveInt(value: string | null, fallback: number): number {
|
|
36
|
+
const parsed = Number.parseInt(value ?? '', 10)
|
|
37
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pending invitations are the only trace a not-yet-accepted invite leaves: the
|
|
41
|
+
// customer user row is created on accept, so every users-backed surface (admin
|
|
42
|
+
// users list, the CRM person "account status" widget) shows the same empty
|
|
43
|
+
// state right after a successful invite (#4950). This read surface makes that
|
|
44
|
+
// pending state queryable. The one-time token stays server-side.
|
|
45
|
+
export async function GET(req: Request) {
|
|
46
|
+
const auth = await getAuthFromRequest(req)
|
|
47
|
+
if (!auth) {
|
|
48
|
+
return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const container = await createRequestContainer()
|
|
52
|
+
const rbacService = container.resolve('rbacService') as RbacService
|
|
53
|
+
const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.view'], { tenantId: auth.tenantId, organizationId: auth.orgId })
|
|
54
|
+
if (!hasAccess) {
|
|
55
|
+
return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const url = new URL(req.url)
|
|
59
|
+
// A non-numeric page/pageSize must not reach the query as NaN.
|
|
60
|
+
const page = Math.max(1, parsePositiveInt(url.searchParams.get('page'), 1))
|
|
61
|
+
const pageSize = Math.min(100, Math.max(1, parsePositiveInt(url.searchParams.get('pageSize'), 25)))
|
|
62
|
+
const personEntityId = url.searchParams.get('personEntityId')
|
|
63
|
+
const customerEntityId = url.searchParams.get('customerEntityId')
|
|
64
|
+
const email = url.searchParams.get('email')
|
|
65
|
+
|
|
66
|
+
for (const value of [personEntityId, customerEntityId]) {
|
|
67
|
+
if (value && !UUID_PATTERN.test(value)) {
|
|
68
|
+
return NextResponse.json({ ok: false, error: 'Validation failed' }, { status: 400 })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Pending means: still open (not accepted, not cancelled) and not expired.
|
|
73
|
+
const where: Record<string, unknown> = {
|
|
74
|
+
tenantId: auth.tenantId,
|
|
75
|
+
organizationId: auth.orgId,
|
|
76
|
+
acceptedAt: null,
|
|
77
|
+
cancelledAt: null,
|
|
78
|
+
expiresAt: { $gt: new Date() },
|
|
79
|
+
}
|
|
80
|
+
if (personEntityId) where.personEntityId = personEntityId
|
|
81
|
+
if (customerEntityId) where.customerEntityId = customerEntityId
|
|
82
|
+
// email is stored encrypted, so match on the deterministic lookup hash.
|
|
83
|
+
if (email) where.emailHash = { $in: lookupHashCandidates(email) }
|
|
84
|
+
|
|
85
|
+
const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager
|
|
86
|
+
const [invitations, total] = await findAndCountWithDecryption(
|
|
87
|
+
em,
|
|
88
|
+
CustomerUserInvitation,
|
|
89
|
+
where as any,
|
|
90
|
+
{
|
|
91
|
+
orderBy: { createdAt: 'DESC' },
|
|
92
|
+
limit: pageSize,
|
|
93
|
+
offset: (page - 1) * pageSize,
|
|
94
|
+
},
|
|
95
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const items = invitations.map((invitation) => ({
|
|
99
|
+
id: invitation.id,
|
|
100
|
+
email: invitation.email,
|
|
101
|
+
displayName: invitation.displayName || null,
|
|
102
|
+
customerEntityId: invitation.customerEntityId || null,
|
|
103
|
+
personEntityId: invitation.personEntityId || null,
|
|
104
|
+
roleIds: Array.isArray(invitation.roleIdsJson) ? invitation.roleIdsJson : [],
|
|
105
|
+
invitedByUserId: invitation.invitedByUserId || null,
|
|
106
|
+
expiresAt: invitation.expiresAt,
|
|
107
|
+
createdAt: invitation.createdAt,
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
return NextResponse.json({
|
|
111
|
+
ok: true,
|
|
112
|
+
items,
|
|
113
|
+
total,
|
|
114
|
+
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
|
115
|
+
page,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
30
119
|
export async function POST(req: Request) {
|
|
31
120
|
const auth = await getAuthFromRequest(req)
|
|
32
121
|
if (!auth) {
|
|
@@ -185,7 +274,46 @@ const methodDoc: OpenApiMethodDoc = {
|
|
|
185
274
|
],
|
|
186
275
|
}
|
|
187
276
|
|
|
277
|
+
const listQuerySchema = z.object({
|
|
278
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
279
|
+
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
280
|
+
personEntityId: z.string().uuid().optional(),
|
|
281
|
+
customerEntityId: z.string().uuid().optional(),
|
|
282
|
+
email: z.string().optional(),
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const listSuccessSchema = z.object({
|
|
286
|
+
ok: z.literal(true),
|
|
287
|
+
items: z.array(z.object({
|
|
288
|
+
id: z.string().uuid(),
|
|
289
|
+
email: z.string(),
|
|
290
|
+
displayName: z.string().nullable(),
|
|
291
|
+
customerEntityId: z.string().uuid().nullable(),
|
|
292
|
+
personEntityId: z.string().uuid().nullable(),
|
|
293
|
+
roleIds: z.array(z.string().uuid()),
|
|
294
|
+
invitedByUserId: z.string().uuid().nullable(),
|
|
295
|
+
expiresAt: z.string().datetime(),
|
|
296
|
+
createdAt: z.string().datetime(),
|
|
297
|
+
})),
|
|
298
|
+
total: z.number(),
|
|
299
|
+
totalPages: z.number(),
|
|
300
|
+
page: z.number(),
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const listMethodDoc: OpenApiMethodDoc = {
|
|
304
|
+
summary: 'List pending customer invitations (admin)',
|
|
305
|
+
description: 'Lists invitations that are still pending — not accepted, not cancelled, and not expired — for the caller\'s tenant and organization. The invitation token is never returned.',
|
|
306
|
+
tags: ['Customer Accounts Admin'],
|
|
307
|
+
query: listQuerySchema,
|
|
308
|
+
responses: [{ status: 200, description: 'Pending invitations', schema: listSuccessSchema }],
|
|
309
|
+
errors: [
|
|
310
|
+
{ status: 400, description: 'Validation failed', schema: errorSchema },
|
|
311
|
+
{ status: 401, description: 'Not authenticated', schema: errorSchema },
|
|
312
|
+
{ status: 403, description: 'Insufficient permissions', schema: errorSchema },
|
|
313
|
+
],
|
|
314
|
+
}
|
|
315
|
+
|
|
188
316
|
export const openApi: OpenApiRouteDoc = {
|
|
189
317
|
summary: 'Invite customer user (admin)',
|
|
190
|
-
methods: { POST: methodDoc },
|
|
318
|
+
methods: { GET: listMethodDoc, POST: methodDoc },
|
|
191
319
|
}
|
|
@@ -337,12 +337,15 @@
|
|
|
337
337
|
"customer_accounts.widgets.accountStatus": "Kontostatus",
|
|
338
338
|
"customer_accounts.widgets.accountStatus.description": "Zeigt den Status des Kundenportal-Kontos in der CRM-Personendetailansicht",
|
|
339
339
|
"customer_accounts.widgets.emailVerified": "E-Mail verifiziert",
|
|
340
|
+
"customer_accounts.widgets.invitationExpires": "Einladung läuft ab",
|
|
341
|
+
"customer_accounts.widgets.invitationPending": "Einladung ausstehend",
|
|
340
342
|
"customer_accounts.widgets.invite.button": "Zum Portal einladen",
|
|
341
343
|
"customer_accounts.widgets.invite.displayName": "Anzeigename",
|
|
342
344
|
"customer_accounts.widgets.invite.error.emailRequired": "E-Mail-Adresse ist erforderlich",
|
|
343
345
|
"customer_accounts.widgets.invite.error.failed": "Einladung konnte nicht gesendet werden",
|
|
344
346
|
"customer_accounts.widgets.invite.error.roleRequired": "Mindestens eine Rolle muss ausgewählt werden",
|
|
345
347
|
"customer_accounts.widgets.invite.noRoles": "Keine Rollen verfügbar",
|
|
348
|
+
"customer_accounts.widgets.invite.resend": "Einladung erneut senden",
|
|
346
349
|
"customer_accounts.widgets.invite.roles": "Rollen",
|
|
347
350
|
"customer_accounts.widgets.invite.submit": "Einladung senden",
|
|
348
351
|
"customer_accounts.widgets.invite.success": "Einladung erfolgreich gesendet",
|
|
@@ -337,12 +337,15 @@
|
|
|
337
337
|
"customer_accounts.widgets.accountStatus": "Account status",
|
|
338
338
|
"customer_accounts.widgets.accountStatus.description": "Shows customer portal account status on CRM person detail",
|
|
339
339
|
"customer_accounts.widgets.emailVerified": "Email verified",
|
|
340
|
+
"customer_accounts.widgets.invitationExpires": "Invitation expires",
|
|
341
|
+
"customer_accounts.widgets.invitationPending": "Invitation pending",
|
|
340
342
|
"customer_accounts.widgets.invite.button": "Invite to Portal",
|
|
341
343
|
"customer_accounts.widgets.invite.displayName": "Display Name",
|
|
342
344
|
"customer_accounts.widgets.invite.error.emailRequired": "Email is required",
|
|
343
345
|
"customer_accounts.widgets.invite.error.failed": "Failed to send invitation",
|
|
344
346
|
"customer_accounts.widgets.invite.error.roleRequired": "At least one role must be selected",
|
|
345
347
|
"customer_accounts.widgets.invite.noRoles": "No roles available",
|
|
348
|
+
"customer_accounts.widgets.invite.resend": "Resend invitation",
|
|
346
349
|
"customer_accounts.widgets.invite.roles": "Roles",
|
|
347
350
|
"customer_accounts.widgets.invite.submit": "Send Invitation",
|
|
348
351
|
"customer_accounts.widgets.invite.success": "Invitation sent successfully",
|
|
@@ -337,12 +337,15 @@
|
|
|
337
337
|
"customer_accounts.widgets.accountStatus": "Estado de la cuenta",
|
|
338
338
|
"customer_accounts.widgets.accountStatus.description": "Muestra el estado de la cuenta del portal del cliente en el detalle de la persona del CRM",
|
|
339
339
|
"customer_accounts.widgets.emailVerified": "Correo verificado",
|
|
340
|
+
"customer_accounts.widgets.invitationExpires": "La invitación caduca",
|
|
341
|
+
"customer_accounts.widgets.invitationPending": "Invitación pendiente",
|
|
340
342
|
"customer_accounts.widgets.invite.button": "Invitar al portal",
|
|
341
343
|
"customer_accounts.widgets.invite.displayName": "Nombre visible",
|
|
342
344
|
"customer_accounts.widgets.invite.error.emailRequired": "El correo electrónico es obligatorio",
|
|
343
345
|
"customer_accounts.widgets.invite.error.failed": "No se pudo enviar la invitación",
|
|
344
346
|
"customer_accounts.widgets.invite.error.roleRequired": "Debe seleccionar al menos un rol",
|
|
345
347
|
"customer_accounts.widgets.invite.noRoles": "No hay roles disponibles",
|
|
348
|
+
"customer_accounts.widgets.invite.resend": "Reenviar invitación",
|
|
346
349
|
"customer_accounts.widgets.invite.roles": "Roles",
|
|
347
350
|
"customer_accounts.widgets.invite.submit": "Enviar invitación",
|
|
348
351
|
"customer_accounts.widgets.invite.success": "Invitación enviada correctamente",
|
|
@@ -337,12 +337,15 @@
|
|
|
337
337
|
"customer_accounts.widgets.accountStatus": "계정 상태",
|
|
338
338
|
"customer_accounts.widgets.accountStatus.description": "CRM 담당자 세부정보에 고객 포털 계정 상태 표시",
|
|
339
339
|
"customer_accounts.widgets.emailVerified": "이메일 확인됨",
|
|
340
|
+
"customer_accounts.widgets.invitationExpires": "초대 만료",
|
|
341
|
+
"customer_accounts.widgets.invitationPending": "초대 대기 중",
|
|
340
342
|
"customer_accounts.widgets.invite.button": "포털로 초대",
|
|
341
343
|
"customer_accounts.widgets.invite.displayName": "표시 이름",
|
|
342
344
|
"customer_accounts.widgets.invite.error.emailRequired": "이메일은 필수입니다",
|
|
343
345
|
"customer_accounts.widgets.invite.error.failed": "초대 전송에 실패했습니다",
|
|
344
346
|
"customer_accounts.widgets.invite.error.roleRequired": "역할을 하나 이상 선택해야 합니다",
|
|
345
347
|
"customer_accounts.widgets.invite.noRoles": "사용 가능한 역할이 없습니다",
|
|
348
|
+
"customer_accounts.widgets.invite.resend": "초대 다시 보내기",
|
|
346
349
|
"customer_accounts.widgets.invite.roles": "역할",
|
|
347
350
|
"customer_accounts.widgets.invite.submit": "초대 보내기",
|
|
348
351
|
"customer_accounts.widgets.invite.success": "초대가 성공적으로 전송되었습니다",
|
|
@@ -337,12 +337,15 @@
|
|
|
337
337
|
"customer_accounts.widgets.accountStatus": "Status konta",
|
|
338
338
|
"customer_accounts.widgets.accountStatus.description": "Pokazuje status konta w portalu klienta na szczegółach osoby CRM",
|
|
339
339
|
"customer_accounts.widgets.emailVerified": "E-mail zweryfikowany",
|
|
340
|
+
"customer_accounts.widgets.invitationExpires": "Zaproszenie wygasa",
|
|
341
|
+
"customer_accounts.widgets.invitationPending": "Zaproszenie oczekuje",
|
|
340
342
|
"customer_accounts.widgets.invite.button": "Zaproś do portalu",
|
|
341
343
|
"customer_accounts.widgets.invite.displayName": "Nazwa wyświetlana",
|
|
342
344
|
"customer_accounts.widgets.invite.error.emailRequired": "Adres e-mail jest wymagany",
|
|
343
345
|
"customer_accounts.widgets.invite.error.failed": "Nie udało się wysłać zaproszenia",
|
|
344
346
|
"customer_accounts.widgets.invite.error.roleRequired": "Musisz wybrać co najmniej jedną rolę",
|
|
345
347
|
"customer_accounts.widgets.invite.noRoles": "Brak dostępnych ról",
|
|
348
|
+
"customer_accounts.widgets.invite.resend": "Wyślij zaproszenie ponownie",
|
|
346
349
|
"customer_accounts.widgets.invite.roles": "Role",
|
|
347
350
|
"customer_accounts.widgets.invite.submit": "Wyślij zaproszenie",
|
|
348
351
|
"customer_accounts.widgets.invite.success": "Zaproszenie zostało wysłane",
|
|
@@ -19,6 +19,12 @@ interface AccountStatusData {
|
|
|
19
19
|
lastLoginAt: string | null
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface PendingInvitationData {
|
|
23
|
+
id: string
|
|
24
|
+
email: string
|
|
25
|
+
expiresAt: string
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
interface AccountStatusProps {
|
|
23
29
|
context?: {
|
|
24
30
|
entityId?: string
|
|
@@ -266,9 +272,29 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
|
|
|
266
272
|
enabled: !!personEntityId,
|
|
267
273
|
})
|
|
268
274
|
|
|
275
|
+
// A portal account only exists once the invitation is accepted, so the users
|
|
276
|
+
// query above stays empty right after a successful invite. Without this the
|
|
277
|
+
// widget renders the identical "no account" state and the invite looks like a
|
|
278
|
+
// no-op (#4950).
|
|
279
|
+
const { data: pendingInvitation, isLoading: isLoadingInvitation } = useQuery({
|
|
280
|
+
queryKey: ['customer-account-pending-invitation', personEntityId],
|
|
281
|
+
queryFn: async (): Promise<PendingInvitationData | null> => {
|
|
282
|
+
if (!personEntityId) return null
|
|
283
|
+
const result = await apiCall(
|
|
284
|
+
`/api/customer_accounts/admin/users-invite?personEntityId=${encodeURIComponent(personEntityId)}&pageSize=1`,
|
|
285
|
+
)
|
|
286
|
+
if (!result.ok) return null
|
|
287
|
+
const json = result.result as Record<string, unknown> | null
|
|
288
|
+
const items = json?.items as PendingInvitationData[] | undefined
|
|
289
|
+
return items?.[0] || null
|
|
290
|
+
},
|
|
291
|
+
enabled: !!personEntityId && !isLoading && !data,
|
|
292
|
+
})
|
|
293
|
+
|
|
269
294
|
function handleInviteSuccess() {
|
|
270
295
|
setShowInviteForm(false)
|
|
271
296
|
queryClient.invalidateQueries({ queryKey: ['customer-account-status', personEntityId] })
|
|
297
|
+
queryClient.invalidateQueries({ queryKey: ['customer-account-pending-invitation', personEntityId] })
|
|
272
298
|
}
|
|
273
299
|
|
|
274
300
|
if (isLoading) {
|
|
@@ -279,8 +305,29 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
|
|
|
279
305
|
return (
|
|
280
306
|
<div className="rounded-md border p-3">
|
|
281
307
|
<div className="text-sm font-medium mb-1">{t('customer_accounts.widgets.accountStatus', 'Portal Account')}</div>
|
|
282
|
-
|
|
283
|
-
|
|
308
|
+
{isLoadingInvitation ? (
|
|
309
|
+
<div className="text-sm text-muted-foreground">{t('common.loading', 'Loading...')}</div>
|
|
310
|
+
) : pendingInvitation ? (
|
|
311
|
+
<div className="space-y-1 text-sm">
|
|
312
|
+
<div className="flex justify-between">
|
|
313
|
+
<span className="text-muted-foreground">{t('common.status', 'Status')}</span>
|
|
314
|
+
<StatusBadge variant="warning" dot>
|
|
315
|
+
{t('customer_accounts.widgets.invitationPending', 'Invitation pending')}
|
|
316
|
+
</StatusBadge>
|
|
317
|
+
</div>
|
|
318
|
+
<div className="flex justify-between">
|
|
319
|
+
<span className="text-muted-foreground">{t('common.email', 'Email')}</span>
|
|
320
|
+
<span>{pendingInvitation.email}</span>
|
|
321
|
+
</div>
|
|
322
|
+
<div className="flex justify-between">
|
|
323
|
+
<span className="text-muted-foreground">{t('customer_accounts.widgets.invitationExpires', 'Invitation expires')}</span>
|
|
324
|
+
<span>{new Date(pendingInvitation.expiresAt).toLocaleDateString()}</span>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
) : (
|
|
328
|
+
<div className="text-sm text-muted-foreground">{t('customer_accounts.widgets.noAccount', 'No portal account linked')}</div>
|
|
329
|
+
)}
|
|
330
|
+
{!showInviteForm && personEntityId && !isLoadingInvitation && (
|
|
284
331
|
<div className="mt-2">
|
|
285
332
|
<Button
|
|
286
333
|
type="button"
|
|
@@ -288,7 +335,9 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
|
|
|
288
335
|
size="sm"
|
|
289
336
|
onClick={() => setShowInviteForm(true)}
|
|
290
337
|
>
|
|
291
|
-
{
|
|
338
|
+
{pendingInvitation
|
|
339
|
+
? t('customer_accounts.widgets.invite.resend', 'Resend invitation')
|
|
340
|
+
: t('customer_accounts.widgets.invite.button', 'Invite to Portal')}
|
|
292
341
|
</Button>
|
|
293
342
|
</div>
|
|
294
343
|
)}
|