@open-mercato/core 0.6.8-develop.6918.1.7b3f32b0c8 → 0.6.8-develop.6925.1.cefa200fa4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/bootstrap.js +2 -1
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/role/index.js +2 -0
  5. package/dist/generated/entities/role/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +1 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/api.js +4 -0
  9. package/dist/helpers/integration/api.js.map +2 -2
  10. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js +5 -0
  11. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
  12. package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js +5 -0
  13. package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js.map +2 -2
  14. package/dist/modules/audit_logs/backend/audit-logs/page.js +4 -4
  15. package/dist/modules/audit_logs/backend/audit-logs/page.js.map +2 -2
  16. package/dist/modules/audit_logs/components/AuditLogsActions.js +9 -2
  17. package/dist/modules/audit_logs/components/AuditLogsActions.js.map +2 -2
  18. package/dist/modules/auth/api/login.js +5 -2
  19. package/dist/modules/auth/api/login.js.map +2 -2
  20. package/dist/modules/auth/api/users/route.js +8 -5
  21. package/dist/modules/auth/api/users/route.js.map +2 -2
  22. package/dist/modules/auth/backend/users/[id]/edit/page.js +15 -1
  23. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  24. package/dist/modules/auth/backend/users/page.js +7 -1
  25. package/dist/modules/auth/backend/users/page.js.map +2 -2
  26. package/dist/modules/auth/commands/users.js +175 -76
  27. package/dist/modules/auth/commands/users.js.map +2 -2
  28. package/dist/modules/auth/data/entities.js +4 -0
  29. package/dist/modules/auth/data/entities.js.map +2 -2
  30. package/dist/modules/auth/lib/sessionIntegrity.js +1 -1
  31. package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
  32. package/dist/modules/auth/lib/setup-app.js +1 -1
  33. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  34. package/dist/modules/auth/migrations/Migration20260728134212_auth.js +14 -0
  35. package/dist/modules/auth/migrations/Migration20260728134212_auth.js.map +7 -0
  36. package/dist/modules/catalog/search.js +8 -0
  37. package/dist/modules/catalog/search.js.map +2 -2
  38. package/dist/modules/customer_accounts/search.js +2 -0
  39. package/dist/modules/customer_accounts/search.js.map +2 -2
  40. package/dist/modules/customers/commands/deals.js +55 -8
  41. package/dist/modules/customers/commands/deals.js.map +2 -2
  42. package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js +1 -1
  43. package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js.map +2 -2
  44. package/dist/modules/customers/components/formConfig.js +2 -0
  45. package/dist/modules/customers/components/formConfig.js.map +2 -2
  46. package/dist/modules/inbox_ops/search.js +1 -0
  47. package/dist/modules/inbox_ops/search.js.map +2 -2
  48. package/dist/modules/messages/search.js +2 -1
  49. package/dist/modules/messages/search.js.map +2 -2
  50. package/dist/modules/planner/search.js +1 -0
  51. package/dist/modules/planner/search.js.map +2 -2
  52. package/dist/modules/resources/search.js +2 -0
  53. package/dist/modules/resources/search.js.map +2 -2
  54. package/dist/modules/sales/search.js +24 -2
  55. package/dist/modules/sales/search.js.map +2 -2
  56. package/dist/modules/staff/search.js +4 -0
  57. package/dist/modules/staff/search.js.map +2 -2
  58. package/dist/modules/wms/search.js +4 -0
  59. package/dist/modules/wms/search.js.map +2 -2
  60. package/generated/entities/role/index.ts +1 -0
  61. package/generated/entity-fields-registry.ts +1 -0
  62. package/package.json +7 -7
  63. package/src/bootstrap.ts +9 -2
  64. package/src/helpers/integration/api.ts +13 -0
  65. package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +7 -0
  66. package/src/modules/audit_logs/api/audit-logs/actions/undo/route.ts +9 -0
  67. package/src/modules/audit_logs/backend/audit-logs/page.tsx +4 -4
  68. package/src/modules/audit_logs/components/AuditLogsActions.tsx +20 -4
  69. package/src/modules/auth/api/login.ts +9 -2
  70. package/src/modules/auth/api/users/route.ts +8 -4
  71. package/src/modules/auth/backend/users/[id]/edit/page.tsx +17 -1
  72. package/src/modules/auth/backend/users/page.tsx +11 -0
  73. package/src/modules/auth/commands/users.ts +251 -85
  74. package/src/modules/auth/data/entities.ts +3 -0
  75. package/src/modules/auth/i18n/de.json +6 -0
  76. package/src/modules/auth/i18n/en.json +6 -0
  77. package/src/modules/auth/i18n/es.json +6 -0
  78. package/src/modules/auth/i18n/ko.json +6 -0
  79. package/src/modules/auth/i18n/pl.json +6 -0
  80. package/src/modules/auth/lib/sessionIntegrity.ts +1 -1
  81. package/src/modules/auth/lib/setup-app.ts +1 -1
  82. package/src/modules/auth/migrations/.snapshot-open-mercato.json +16 -0
  83. package/src/modules/auth/migrations/Migration20260728134212_auth.ts +14 -0
  84. package/src/modules/catalog/search.ts +8 -0
  85. package/src/modules/customer_accounts/search.ts +2 -0
  86. package/src/modules/customers/commands/deals.ts +86 -14
  87. package/src/modules/customers/components/detail/create/dealCustomFieldControl.tsx +1 -1
  88. package/src/modules/customers/components/formConfig.tsx +2 -0
  89. package/src/modules/inbox_ops/search.ts +1 -0
  90. package/src/modules/messages/search.ts +2 -1
  91. package/src/modules/planner/search.ts +1 -0
  92. package/src/modules/resources/search.ts +2 -0
  93. package/src/modules/sales/search.ts +24 -2
  94. package/src/modules/staff/search.ts +4 -0
  95. package/src/modules/wms/search.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.6918.1.7b3f32b0c8",
3
+ "version": "0.6.8-develop.6925.1.cefa200fa4",
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.6918.1.7b3f32b0c8",
258
- "@open-mercato/shared": "0.6.8-develop.6918.1.7b3f32b0c8",
259
- "@open-mercato/ui": "0.6.8-develop.6918.1.7b3f32b0c8",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.6925.1.cefa200fa4",
258
+ "@open-mercato/shared": "0.6.8-develop.6925.1.cefa200fa4",
259
+ "@open-mercato/ui": "0.6.8-develop.6925.1.cefa200fa4",
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.6918.1.7b3f32b0c8",
265
- "@open-mercato/shared": "0.6.8-develop.6918.1.7b3f32b0c8",
266
- "@open-mercato/ui": "0.6.8-develop.6918.1.7b3f32b0c8",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.6925.1.cefa200fa4",
265
+ "@open-mercato/shared": "0.6.8-develop.6925.1.cefa200fa4",
266
+ "@open-mercato/ui": "0.6.8-develop.6925.1.cefa200fa4",
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",
package/src/bootstrap.ts CHANGED
@@ -136,7 +136,9 @@ export async function bootstrap(container: AwilixContainer) {
136
136
  try {
137
137
  eventBus = createEventBus({ resolve: container.resolve.bind(container) as any, queueStrategy: 'local' })
138
138
  } catch {
139
- // In extreme cases, provide a no-op bus to avoid crashes
139
+ // In extreme cases, provide a no-op bus to avoid crashes. It deliberately
140
+ // omits `dispatchQueued` so the events worker fails its job loudly instead
141
+ // of quietly completing it with zero subscribers dispatched.
140
142
  eventBus = {
141
143
  emit: async () => {},
142
144
  on: () => {},
@@ -154,7 +156,12 @@ export async function bootstrap(container: AwilixContainer) {
154
156
  try {
155
157
  const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
156
158
  loadedModules = getModules()
157
- } catch {}
159
+ } catch (err) {
160
+ // The events worker dispatches persistent subscribers through this bus, so
161
+ // an empty registry here means queued events silently run nothing. Swallowing
162
+ // it made that failure invisible; log it.
163
+ logger.warn('Module registry unavailable; event bus starts with no module subscribers', { err })
164
+ }
158
165
  const subs = loadedModules.flatMap((m) =>
159
166
  (m.subscribers || []).map((subscriber: ModuleSubscriber) => ({
160
167
  ...subscriber,
@@ -13,6 +13,19 @@ function resolveUrl(path: string): string {
13
13
  const tokenCache = new Map<string, { token: string; mintedAt: number }>();
14
14
  const TOKEN_TTL_MS = 45 * 60 * 1000; // 45 min; well under the default 2h session TTL.
15
15
 
16
+ /**
17
+ * Drops cached tokens so the next getAuthToken() mints a fresh session.
18
+ *
19
+ * A cached token points at a `sessions` row. Anything that deletes those rows —
20
+ * deactivating the user, a password change, an explicit session purge — leaves the
21
+ * cached token resolving to 401 for the rest of the TTL, and `apiRequest` neither
22
+ * retries nor re-mints. A spec that revokes sessions for an account other specs also
23
+ * use MUST call this afterwards.
24
+ */
25
+ export function clearAuthTokenCache(): void {
26
+ tokenCache.clear();
27
+ }
28
+
16
29
  export async function getAuthToken(
17
30
  request: APIRequestContext,
18
31
  roleOrEmail: Role | string = 'admin',
@@ -4,6 +4,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
5
5
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
6
6
  import { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
8
  import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
8
9
  import type { CommandRuntimeContext, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
9
10
  import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
@@ -160,6 +161,12 @@ export async function POST(req: Request) {
160
161
  }
161
162
  return response
162
163
  } catch (err) {
164
+ // Same contract as undo: a domain refusal carries an already-localized body the
165
+ // operator can act on, so surface it instead of a generic message.
166
+ if (isCrudHttpError(err)) {
167
+ logger.warn('Redo rejected', { err })
168
+ return NextResponse.json(err.body, { status: err.status })
169
+ }
163
170
  logger.error('Redo failed', { err })
164
171
  return NextResponse.json({ error: 'Redo failed' }, { status: 400 })
165
172
  }
@@ -4,6 +4,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
5
5
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
6
6
  import { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
8
  import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
8
9
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
9
10
  import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
@@ -121,6 +122,14 @@ export async function POST(req: Request) {
121
122
  await commandBus.undo(undoToken, ctx)
122
123
  return NextResponse.json({ ok: true, logId: target.id })
123
124
  } catch (err) {
125
+ // A command can refuse an undo for a domain reason the operator can act on — e.g.
126
+ // reverting a role grant would drop a tenant below a protected role's active-holder
127
+ // floor. Those arrive as CrudHttpError with an already-localized body, so pass them
128
+ // through verbatim instead of flattening every failure to a generic message.
129
+ if (isCrudHttpError(err)) {
130
+ logger.warn('Undo rejected', { err, logId: target.id })
131
+ return NextResponse.json(err.body, { status: err.status })
132
+ }
124
133
  // A beforeUndo interceptor that blocked with an explicit status is a deliberate business
125
134
  // rejection, not an undo failure — surface its status and message (issue #5045).
126
135
  const interceptorRejection = getCommandInterceptorHttpRejection(err)
@@ -146,11 +146,11 @@ export default function AuditLogsPage() {
146
146
  </Button>
147
147
  ), [loadWithState, actionsPage, actionsPageSize, accessPage, accessPageSize, loading, t])
148
148
 
149
- const handleUndoError = React.useCallback(() => {
150
- setError(t('audit_logs.error.undo'))
149
+ const handleUndoError = React.useCallback((reason?: string) => {
150
+ setError(reason || t('audit_logs.error.undo'))
151
151
  }, [t])
152
- const handleRedoError = React.useCallback(() => {
153
- setError(t('audit_logs.error.redo'))
152
+ const handleRedoError = React.useCallback((reason?: string) => {
153
+ setError(reason || t('audit_logs.error.redo'))
154
154
  }, [t])
155
155
 
156
156
  const handleActionsPageChange = React.useCallback((nextPage: number) => {
@@ -16,6 +16,21 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
16
16
 
17
17
  const logger = createLogger('audit_logs').child({ component: 'AuditLogsActions' })
18
18
 
19
+ /**
20
+ * `raiseCrudError` prefers the server's `error` body over the fallback message and stamps
21
+ * `status` on anything it builds from an HTTP response, so a numeric `status` is what
22
+ * distinguishes a route's refusal from a client-side failure. Without that check an
23
+ * offline or aborted request would put a raw "Failed to fetch" into the operator's
24
+ * banner; those keep the generic fallback instead.
25
+ */
26
+ function resolveServerReason(err: unknown): string | undefined {
27
+ if (!(err instanceof Error)) return undefined
28
+ const status = (err as Error & { status?: unknown }).status
29
+ if (typeof status !== 'number') return undefined
30
+ const message = err.message?.trim()
31
+ return message ? message : undefined
32
+ }
33
+
19
34
  export type ActionLogItem = {
20
35
  id: string
21
36
  commandId: string
@@ -51,8 +66,9 @@ export function AuditLogsActions({
51
66
  onRefresh: () => Promise<void>
52
67
  isLoading?: boolean
53
68
  headerExtras?: React.ReactNode
54
- onUndoError?: () => void
55
- onRedoError?: () => void
69
+ /** Receives the server's reason when it sent one, so the page can show why the undo was refused. */
70
+ onUndoError?: (reason?: string) => void
71
+ onRedoError?: (reason?: string) => void
56
72
  pagination?: PaginationProps
57
73
  }) {
58
74
  const t = useT()
@@ -109,7 +125,7 @@ export function AuditLogsActions({
109
125
  await onRefresh()
110
126
  } catch (err) {
111
127
  logger.error('Undo action failed', { err })
112
- onUndoError?.()
128
+ onUndoError?.(resolveServerReason(err))
113
129
  } finally {
114
130
  setUndoingToken(null)
115
131
  }
@@ -128,7 +144,7 @@ export function AuditLogsActions({
128
144
  await onRefresh()
129
145
  } catch (err) {
130
146
  logger.error('Redo action failed', { err })
131
- onRedoError?.()
147
+ onRedoError?.(resolveServerReason(err))
132
148
  } finally {
133
149
  setRedoingId(null)
134
150
  }
@@ -120,8 +120,15 @@ export async function POST(req: Request) {
120
120
  // wrong-password, and multi-tenant cases return an identical 401 with
121
121
  // identical latency.
122
122
  const ok = await auth.verifyPassword(user, parsed.data.password)
123
- if (!user || !ok) {
124
- const reason = user?.passwordHash ? 'invalid_password' : 'invalid_credentials'
123
+ if (!user || !ok || user.isConfirmed === false) {
124
+ // The 401 body stays identical for every branch so the response never reveals
125
+ // which one fired. `reason` goes to the audit stream instead, where separating a
126
+ // deactivated account from a mistyped password is the whole point — otherwise
127
+ // repeated attempts against a disabled account look like ordinary fat-fingering.
128
+ let reason: string
129
+ if (user && user.isConfirmed === false) reason = 'account_deactivated'
130
+ else if (user?.passwordHash) reason = 'invalid_password'
131
+ else reason = 'invalid_credentials'
125
132
  void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason }).catch(() => undefined)
126
133
  return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })
127
134
  }
@@ -72,6 +72,7 @@ const userUpdateSchema = z.object({
72
72
  password: passwordSchema.optional(),
73
73
  organizationId: z.string().uuid().optional(),
74
74
  roles: z.array(z.string()).optional(),
75
+ isConfirmed: z.boolean().optional(),
75
76
  })
76
77
 
77
78
  const userListItemSchema = z.object({
@@ -85,6 +86,7 @@ const userListItemSchema = z.object({
85
86
  roles: z.array(z.string()),
86
87
  roleIds: z.array(z.string().uuid()).optional(),
87
88
  hasPassword: z.boolean().optional(),
89
+ isConfirmed: z.boolean(),
88
90
  updatedAt: z.string().nullable().optional(),
89
91
  })
90
92
 
@@ -476,6 +478,7 @@ export async function GET(req: Request) {
476
478
  roles: roleMap[uid] || [],
477
479
  roleIds: roleIdMap[uid] || [],
478
480
  ...(id ? { hasPassword: !!u.passwordHash } : {}),
481
+ isConfirmed: u.isConfirmed !== false,
479
482
  updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : null,
480
483
  ...(cfByUser[uid] || {}),
481
484
  }
@@ -662,7 +665,8 @@ export const openApi: OpenApiRouteDoc = {
662
665
  },
663
666
  PUT: {
664
667
  summary: 'Update user',
665
- description: 'Updates profile fields including display name, organization assignment, credentials, or role memberships.',
668
+ description:
669
+ 'Updates profile fields including display name, organization assignment, credentials, or role memberships. Setting isConfirmed=false deactivates the account: the user can no longer sign in and every active session is revoked; isConfirmed=true reactivates it. A tenant cannot drop below a protected role\'s minimum active holder count, so revoking the role from, deactivating, moving, or deleting the last active administrator is rejected.',
666
670
  requestBody: {
667
671
  contentType: 'application/json',
668
672
  schema: userUpdateSchema,
@@ -671,7 +675,7 @@ export const openApi: OpenApiRouteDoc = {
671
675
  { status: 200, description: 'User updated', schema: okResponseSchema },
672
676
  ],
673
677
  errors: [
674
- { status: 400, description: 'Invalid payload', schema: errorResponseSchema },
678
+ { status: 400, description: 'Invalid payload, duplicate email, or the update would remove the last active holder of a protected role', schema: errorResponseSchema },
675
679
  { status: 401, description: 'Unauthorized', schema: errorResponseSchema },
676
680
  { status: 403, description: 'Attempted to assign privileged roles', schema: errorResponseSchema },
677
681
  { status: 404, description: 'User not found', schema: errorResponseSchema },
@@ -679,13 +683,13 @@ export const openApi: OpenApiRouteDoc = {
679
683
  },
680
684
  DELETE: {
681
685
  summary: 'Delete user',
682
- description: 'Deletes a user by identifier. Undo support is provided via the command bus.',
686
+ description: 'Deletes a user by identifier. Rejected when the target is the last active holder of a protected role in the tenant. Undo support is provided via the command bus.',
683
687
  query: z.object({ id: z.string().uuid().describe('User identifier') }),
684
688
  responses: [
685
689
  { status: 200, description: 'User deleted', schema: okResponseSchema },
686
690
  ],
687
691
  errors: [
688
- { status: 400, description: 'User cannot be deleted', schema: errorResponseSchema },
692
+ { status: 400, description: 'User cannot be deleted, or is the last active holder of a protected role', schema: errorResponseSchema },
689
693
  { status: 401, description: 'Unauthorized', schema: errorResponseSchema },
690
694
  { status: 404, description: 'User not found', schema: errorResponseSchema },
691
695
  ],
@@ -47,6 +47,7 @@ type LoadedUser = {
47
47
  roles: string[]
48
48
  roleIds: string[]
49
49
  hasPassword: boolean
50
+ isConfirmed: boolean
50
51
  updatedAt: string | null
51
52
  }
52
53
 
@@ -61,6 +62,7 @@ type UserApiItem = {
61
62
  roles?: unknown
62
63
  roleIds?: unknown
63
64
  hasPassword?: boolean
65
+ isConfirmed?: boolean
64
66
  updatedAt?: string | null
65
67
  updated_at?: string | null
66
68
  }
@@ -268,6 +270,7 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
268
270
  roles: roleNames,
269
271
  roleIds: roleIds.length > 0 ? roleIds : roleNames,
270
272
  hasPassword: item.hasPassword !== false,
273
+ isConfirmed: item.isConfirmed !== false,
271
274
  updatedAt: typeof item.updatedAt === 'string'
272
275
  ? item.updatedAt
273
276
  : typeof item.updated_at === 'string'
@@ -399,11 +402,20 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
399
402
  options: initialRoleOptions,
400
403
  loadOptions: loadRoleOptions,
401
404
  })
405
+ items.push({
406
+ id: 'isConfirmed',
407
+ label: t('auth.users.form.field.active', 'Active'),
408
+ type: 'checkbox',
409
+ description: t(
410
+ 'auth.users.form.field.activeHint',
411
+ 'Clearing this deactivates the account: the user can no longer sign in and every active session is revoked.',
412
+ ),
413
+ })
402
414
  return items
403
415
  }, [actorIsSuperAdmin, initialRoleOptions, loadRoleOptions, passwordDescription, preloadedTenants, selectedOrgId, selectedTenantId, t, userHasPassword])
404
416
 
405
417
  const detailFieldIds = React.useMemo(() => {
406
- const base: string[] = ['email', 'name', 'password', 'organizationId', 'roles']
418
+ const base: string[] = ['email', 'name', 'password', 'organizationId', 'roles', 'isConfirmed']
407
419
  if (actorIsSuperAdmin) base.splice(2, 0, 'tenantId')
408
420
  return base
409
421
  }, [actorIsSuperAdmin])
@@ -464,6 +476,7 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
464
476
  tenantId: initialUser.tenantId,
465
477
  organizationId: initialUser.organizationId,
466
478
  roles: initialUser.roleIds,
479
+ isConfirmed: initialUser.isConfirmed,
467
480
  updatedAt: initialUser.updatedAt,
468
481
  ...customFieldValues,
469
482
  }
@@ -555,6 +568,9 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
555
568
  password: values.password && values.password.trim() ? values.password : undefined,
556
569
  organizationId: values.organizationId ? values.organizationId : undefined,
557
570
  roles: Array.isArray(values.roles) ? values.roles : [],
571
+ // Only sent when the checkbox actually resolved to a boolean — never
572
+ // default a missing value to `false`, which would silently deactivate.
573
+ ...(typeof values.isConfirmed === 'boolean' ? { isConfirmed: values.isConfirmed } : {}),
558
574
  ...(Object.keys(customFields).length ? { customFields } : {}),
559
575
  }
560
576
  const userOptimisticLockHeader = buildOptimisticLockHeader(initialUser?.updatedAt)
@@ -9,6 +9,7 @@ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
9
9
  import type { SortingState } from '@tanstack/react-table'
10
10
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
11
11
  import { Button } from '@open-mercato/ui/primitives/button'
12
+ import { Badge } from '@open-mercato/ui/primitives/badge'
12
13
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
13
14
  import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
14
15
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
@@ -30,6 +31,7 @@ type Row = {
30
31
  tenantId: string | null
31
32
  tenantName?: string | null
32
33
  roles: string[]
34
+ isConfirmed?: boolean
33
35
  updatedAt?: string | null
34
36
  }
35
37
 
@@ -378,6 +380,15 @@ export default function UsersListPage() {
378
380
  { accessorKey: 'name', header: t('auth.users.list.columns.name', 'Display name') },
379
381
  { accessorKey: 'organizationName', header: 'Organization' },
380
382
  { accessorKey: 'roles', header: 'Roles', cell: ({ row }) => (row.original.roles || []).join(', ') },
383
+ {
384
+ accessorKey: 'isConfirmed',
385
+ header: t('auth.users.list.columns.status', 'Status'),
386
+ cell: ({ row }) => (
387
+ row.original.isConfirmed === false
388
+ ? <Badge variant="neutral" dot>{t('auth.users.list.status.deactivated', 'Deactivated')}</Badge>
389
+ : <Badge variant="success" dot>{t('auth.users.list.status.active', 'Active')}</Badge>
390
+ ),
391
+ },
381
392
  ]
382
393
  if (showTenantColumn) {
383
394
  base.splice(1, 0, { accessorKey: 'tenantName', header: 'Tenant' })