@open-mercato/core 0.6.8-develop.6876.1.c514f2eb37 → 0.6.8-develop.6877.1.c95a715a00

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.
@@ -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": "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",
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("customer_accounts.widgets.noAccount", "No portal account linked") }),
216
- !showInviteForm && personEntityId && /* @__PURE__ */ jsx("div", { className: "mt-2", children: /* @__PURE__ */ jsx(
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 })
@@ -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": ";AA0KM,cAQA,YARA;AAxKN,OAAO,WAAW;AAClB,SAAS,UAAU,sBAAsB;AACzC,SAAS,eAAe;AACxB,SAAS,0BAA0B;AACnC,SAAS,YAAY;AACrB,SAAS,cAAc;AACvB,SAAS,aAAa;AACtB,SAAS,kBAAkB;AAC3B,SAAS,mBAAmB;AAC5B,SAAS,aAAa;AAiCtB,SAAS,WAAW,EAAE,gBAAgB,UAAU,GAAsD;AACpG,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,MAAM,SAAS,IAAI;AACjE,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,EAAE;AAC3C,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,EAAE;AACvD,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,MAAM,SAAmB,CAAC,CAAC;AACzE,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM,SAAuB,CAAC,CAAC;AAC3E,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM,SAAS,IAAI;AAC/D,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAS,KAAK;AAE5D,QAAM,EAAE,YAAY,IAAI,mBAA2C;AAAA,IACjE,WAAW;AAAA,EACb,CAAC;AAED,QAAM,UAAU,MAAM;AACpB,QAAI,YAAY;AAChB,mBAAe,aAAa;AAC1B,UAAI;AACF,cAAM,OAAO,MAAM;AAAA,UACjB,yBAAyB,mBAAmB,cAAc,CAAC;AAAA,QAC7D;AACA,YAAI,UAAW;AACf,YAAI,KAAK,MAAM,KAAK,QAAQ;AAC1B,gBAAM,SAAS,KAAK,OAAO;AAC3B,gBAAM,UAAU,KAAK,OAAO;AAC5B,cAAI,QAAQ,cAAc;AACxB,qBAAS,OAAO,YAAY;AAAA,UAC9B;AACA,gBAAM,YAAY,CAAC,SAAS,WAAW,SAAS,QAAQ,EAAE,OAAO,OAAO;AACxE,cAAI,UAAU,SAAS,GAAG;AACxB,2BAAe,UAAU,KAAK,GAAG,CAAC;AAAA,UACpC,WAAW,QAAQ,aAAa;AAC9B,2BAAe,OAAO,WAAW;AAAA,UACnC;AAAA,QACF;AAAA,MACF,QAAQ;AAAA,MAER,UAAE;AACA,YAAI,CAAC,UAAW,oBAAmB,KAAK;AAAA,MAC1C;AAAA,IACF;AACA,eAAW;AACX,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,cAAc,CAAC;AAEnB,QAAM,UAAU,MAAM;AACpB,QAAI,YAAY;AAChB,mBAAe,YAAY;AACzB,UAAI;AACF,cAAM,OAAO,MAAM;AAAA,UACjB;AAAA,QACF;AACA,YAAI,UAAW;AACf,YAAI,KAAK,MAAM,KAAK,QAAQ;AAC1B,gBAAM,QAAQ,MAAM,QAAQ,KAAK,OAAO,KAAK,IAAI,KAAK,OAAO,QAAQ,CAAC;AACtE,4BAAkB,MAAM,IAAI,CAAC,UAAU,EAAE,IAAI,KAAK,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;AAAA,QAC3E;AAAA,MACF,QAAQ;AAAA,MAER,UAAE;AACA,YAAI,CAAC,UAAW,mBAAkB,KAAK;AAAA,MACzC;AAAA,IACF;AACA,cAAU;AACV,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,CAAC;AAEL,WAAS,WAAW,QAAgB;AAClC;AAAA,MAAmB,CAAC,SAClB,KAAK,SAAS,MAAM,IAAI,KAAK,OAAO,CAAC,OAAO,OAAO,MAAM,IAAI,CAAC,GAAG,MAAM,MAAM;AAAA,IAC/E;AAAA,EACF;AAEA,iBAAe,eAAe;AAC5B,UAAM,eAAe,MAAM,KAAK;AAChC,QAAI,CAAC,cAAc;AACjB,YAAM,EAAE,wDAAwD,mBAAmB,GAAG,OAAO;AAC7F;AAAA,IACF;AACA,QAAI,gBAAgB,WAAW,GAAG;AAChC,YAAM,EAAE,uDAAuD,oCAAoC,GAAG,OAAO;AAC7G;AAAA,IACF;AAEA,oBAAgB,IAAI;AACpB,QAAI;AACF,YAAM,YAAY;AAAA,QAChB,SAAS,EAAE,YAAY,yBAAyB;AAAA,QAChD,iBAAiB,EAAE,gBAAgB,SAAS,gBAAgB;AAAA,QAC5D,WAAW,YAAY;AAErB,gBAAM,OAAO,MAAM;AAAA,YACjB;AAAA,YACA;AAAA,cACE,QAAQ;AAAA,cACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,cAC9C,MAAM,KAAK,UAAU;AAAA,gBACnB,OAAO;AAAA,gBACP,SAAS;AAAA,gBACT,aAAa,YAAY,KAAK,KAAK;AAAA,gBACnC;AAAA,cACF,CAAC;AAAA,YACH;AAAA,UACF;AAEA,cAAI,CAAC,KAAK,IAAI;AACZ,kBAAM,eAAgB,KAAK,QAA2C;AACtE,kBAAM,gBAAgB,EAAE,iDAAiD,2BAA2B,GAAG,OAAO;AAC9G;AAAA,UACF;AAEA,gBAAM,EAAE,4CAA4C,8BAA8B,GAAG,SAAS;AAC9F,oBAAU;AAAA,QACZ;AAAA,MACF,CAAC;AAAA,IACH,QAAQ;AACN,YAAM,EAAE,iDAAiD,2BAA2B,GAAG,OAAO;AAAA,IAChG,UAAE;AACA,sBAAgB,KAAK;AAAA,IACvB;AAAA,EACF;AAEA,QAAM,YAAY,mBAAmB;AAErC,MAAI,WAAW;AACb,WACE,oBAAC,SAAI,WAAU,sCACZ,YAAE,kBAAkB,YAAY,GACnC;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAU,kBACb;AAAA,yBAAC,SACC;AAAA,0BAAC,WAAM,SAAQ,gBAAe,WAAU,wDACrC,YAAE,gBAAgB,OAAO,GAC5B;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,IAAG;AAAA,UACH,MAAK;AAAA,UACL,OAAO;AAAA,UACP,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK;AAAA,UAChD,UAAQ;AAAA,UACR,UAAU;AAAA;AAAA,MACZ;AAAA,OACF;AAAA,IAEA,qBAAC,SACC;AAAA,0BAAC,WAAM,SAAQ,uBAAsB,WAAU,wDAC5C,YAAE,gDAAgD,cAAc,GACnE;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,IAAG;AAAA,UACH,MAAK;AAAA,UACL,MAAK;AAAA,UACL,OAAO;AAAA,UACP,UAAU,CAAC,UAAU,eAAe,MAAM,OAAO,KAAK;AAAA,UACtD,UAAU;AAAA;AAAA,MACZ;AAAA,OACF;AAAA,IAEA,qBAAC,SACC;AAAA,0BAAC,SAAI,WAAU,oDACZ,YAAE,0CAA0C,OAAO,GACtD;AAAA,MACC,eAAe,WAAW,IACzB,oBAAC,SAAI,WAAU,iCACZ,YAAE,4CAA4C,oBAAoB,GACrE,IAEA,oBAAC,SAAI,WAAU,0BACZ,yBAAe,IAAI,CAAC,SACnB;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,SAAS,gBAAgB,SAAS,KAAK,EAAE,IAAI,YAAY;AAAA,UACzD,MAAK;AAAA,UACL,SAAS,MAAM,WAAW,KAAK,EAAE;AAAA,UACjC,UAAU;AAAA,UACV,WAAU;AAAA,UAET,eAAK;AAAA;AAAA,QARD,KAAK;AAAA,MASZ,CACD,GACH;AAAA,OAEJ;AAAA,IAEA,oBAAC,SAAI,WAAU,+BACb;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,MAAK;AAAA,QACL,SAAS;AAAA,QACT,UAAU,gBAAgB,CAAC,MAAM,KAAK,KAAK,gBAAgB,WAAW;AAAA,QAErE,yBACG,EAAE,kBAAkB,YAAY,IAChC,EAAE,2CAA2C,iBAAiB;AAAA;AAAA,IACpE,GACF;AAAA,KACF;AAEJ;AAEe,SAAR,oBAAqC,EAAE,QAAQ,GAAuB;AAC3E,QAAM,IAAI,KAAK;AACf,QAAM,cAAc,eAAe;AACnC,QAAM,iBAAiB,SAAS;AAChC,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM,SAAS,KAAK;AAEhE,QAAM,EAAE,MAAM,UAAU,IAAI,SAAS;AAAA,IACnC,UAAU,CAAC,2BAA2B,cAAc;AAAA,IACpD,SAAS,YAA+C;AACtD,UAAI,CAAC,eAAgB,QAAO;AAC5B,YAAM,SAAS,MAAM,QAAQ,qDAAqD,cAAc,aAAa;AAC7G,UAAI,CAAC,OAAO,GAAI,QAAO;AACvB,YAAM,OAAO,OAAO;AACpB,YAAM,QAAQ,MAAM;AACpB,aAAO,QAAQ,CAAC,KAAK;AAAA,IACvB;AAAA,IACA,SAAS,CAAC,CAAC;AAAA,EACb,CAAC;AAED,WAAS,sBAAsB;AAC7B,sBAAkB,KAAK;AACvB,gBAAY,kBAAkB,EAAE,UAAU,CAAC,2BAA2B,cAAc,EAAE,CAAC;AAAA,EACzF;AAEA,MAAI,WAAW;AACb,WAAO,oBAAC,SAAI,WAAU,iCAAiC,YAAE,kBAAkB,YAAY,GAAE;AAAA,EAC3F;AAEA,MAAI,CAAC,MAAM;AACT,WACE,qBAAC,SAAI,WAAU,yBACb;AAAA,0BAAC,SAAI,WAAU,4BAA4B,YAAE,2CAA2C,gBAAgB,GAAE;AAAA,MAC1G,oBAAC,SAAI,WAAU,iCAAiC,YAAE,uCAAuC,0BAA0B,GAAE;AAAA,MACpH,CAAC,kBAAkB,kBAClB,oBAAC,SAAI,WAAU,QACb;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAQ;AAAA,UACR,MAAK;AAAA,UACL,SAAS,MAAM,kBAAkB,IAAI;AAAA,UAEpC,YAAE,2CAA2C,kBAAkB;AAAA;AAAA,MAClE,GACF;AAAA,MAED,kBAAkB,kBACjB,oBAAC,cAAW,gBAAgC,WAAW,qBAAqB;AAAA,OAEhF;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAU,yBACb;AAAA,wBAAC,SAAI,WAAU,4BAA4B,YAAE,2CAA2C,gBAAgB,GAAE;AAAA,IAC1G,qBAAC,SAAI,WAAU,qBACb;AAAA,2BAAC,SAAI,WAAU,wBACb;AAAA,4BAAC,UAAK,WAAU,yBAAyB,YAAE,iBAAiB,QAAQ,GAAE;AAAA,QACtE,oBAAC,eAAY,SAAS,KAAK,WAAW,YAAY,SAAS,KAAG,MAC3D,eAAK,WAAW,EAAE,iBAAiB,QAAQ,IAAI,EAAE,mBAAmB,UAAU,GACjF;AAAA,SACF;AAAA,MACA,qBAAC,SAAI,WAAU,wBACb;AAAA,4BAAC,UAAK,WAAU,yBAAyB,YAAE,gBAAgB,OAAO,GAAE;AAAA,QACpE,oBAAC,UAAM,eAAK,OAAM;AAAA,SACpB;AAAA,MACC,KAAK,kBAAkB,UACtB,qBAAC,SAAI,WAAU,wBACb;AAAA,4BAAC,UAAK,WAAU,yBAAyB,YAAE,2CAA2C,gBAAgB,GAAE;AAAA,QACxG,oBAAC,UAAM,eAAK,gBAAgB,WAAM,UAAI;AAAA,SACxC;AAAA,MAED,KAAK,eACJ,qBAAC,SAAI,WAAU,wBACb;AAAA,4BAAC,UAAK,WAAU,yBAAyB,YAAE,uCAAuC,YAAY,GAAE;AAAA,QAChG,oBAAC,UAAM,cAAI,KAAK,KAAK,WAAW,EAAE,mBAAmB,GAAE;AAAA,SACzD;AAAA,OAEJ;AAAA,IACA,oBAAC,SAAI,WAAU,QACb;AAAA,MAAC;AAAA;AAAA,QACC,MAAM,oCAAoC,KAAK,EAAE;AAAA,QACjD,WAAU;AAAA,QAET,YAAE,yCAAyC,6BAAwB;AAAA;AAAA,IACtE,GACF;AAAA,KACF;AAEJ;",
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.6876.1.c514f2eb37",
3
+ "version": "0.6.8-develop.6877.1.c95a715a00",
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.6876.1.c514f2eb37",
258
- "@open-mercato/shared": "0.6.8-develop.6876.1.c514f2eb37",
259
- "@open-mercato/ui": "0.6.8-develop.6876.1.c514f2eb37",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.6877.1.c95a715a00",
258
+ "@open-mercato/shared": "0.6.8-develop.6877.1.c95a715a00",
259
+ "@open-mercato/ui": "0.6.8-develop.6877.1.c95a715a00",
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.6876.1.c514f2eb37",
265
- "@open-mercato/shared": "0.6.8-develop.6876.1.c514f2eb37",
266
- "@open-mercato/ui": "0.6.8-develop.6876.1.c514f2eb37",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.6877.1.c95a715a00",
265
+ "@open-mercato/shared": "0.6.8-develop.6877.1.c95a715a00",
266
+ "@open-mercato/ui": "0.6.8-develop.6877.1.c95a715a00",
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
- <div className="text-sm text-muted-foreground">{t('customer_accounts.widgets.noAccount', 'No portal account linked')}</div>
283
- {!showInviteForm && personEntityId && (
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
- {t('customer_accounts.widgets.invite.button', 'Invite to Portal')}
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
  )}