@open-mercato/core 0.6.8-develop.7101.1.bd9356828a → 0.7.1-develop.7102.1.b41f7e3e51

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 (159) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/login.js +19 -0
  3. package/dist/modules/auth/api/login.js.map +2 -2
  4. package/dist/modules/auth/api/routeError.js +11 -0
  5. package/dist/modules/auth/api/routeError.js.map +7 -0
  6. package/dist/modules/auth/api/users/acl/route.js +23 -7
  7. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  8. package/dist/modules/business_rules/backend/rules/[id]/page.meta.js +6 -1
  9. package/dist/modules/business_rules/backend/rules/[id]/page.meta.js.map +2 -2
  10. package/dist/modules/business_rules/backend/rules/create/page.meta.js +5 -1
  11. package/dist/modules/business_rules/backend/rules/create/page.meta.js.map +2 -2
  12. package/dist/modules/business_rules/backend/sets/[id]/page.meta.js +6 -1
  13. package/dist/modules/business_rules/backend/sets/[id]/page.meta.js.map +2 -2
  14. package/dist/modules/business_rules/backend/sets/create/page.meta.js +5 -1
  15. package/dist/modules/business_rules/backend/sets/create/page.meta.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +84 -0
  17. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +7 -0
  18. package/dist/modules/customer_accounts/backend/customer_accounts/settings/page.js +7 -81
  19. package/dist/modules/customer_accounts/backend/customer_accounts/settings/page.js.map +2 -2
  20. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +525 -0
  21. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +7 -0
  22. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js +731 -0
  23. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js.map +7 -0
  24. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/page.js +7 -727
  25. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/page.js.map +2 -2
  26. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +7 -521
  27. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  28. package/dist/modules/customer_accounts/extension-points.js +1 -1
  29. package/dist/modules/customer_accounts/extension-points.js.map +2 -2
  30. package/dist/modules/customer_accounts/lib/portalUrl.js +69 -0
  31. package/dist/modules/customer_accounts/lib/portalUrl.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/companies/page.js +2 -2
  33. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  34. package/dist/modules/customers/backend/customers/deals/page.js +2 -2
  35. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  36. package/dist/modules/customers/backend/customers/people/page.js +2 -2
  37. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  38. package/dist/modules/customers/components/detail/AssignRoleDialog.js +33 -28
  39. package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
  40. package/dist/modules/eudr/backend/eudr/evidence-submissions/[id]/page.meta.js +3 -1
  41. package/dist/modules/eudr/backend/eudr/evidence-submissions/[id]/page.meta.js.map +2 -2
  42. package/dist/modules/eudr/backend/eudr/evidence-submissions/create/page.meta.js +1 -0
  43. package/dist/modules/eudr/backend/eudr/evidence-submissions/create/page.meta.js.map +2 -2
  44. package/dist/modules/eudr/backend/eudr/evidence-submissions/page.meta.js +4 -1
  45. package/dist/modules/eudr/backend/eudr/evidence-submissions/page.meta.js.map +2 -2
  46. package/dist/modules/eudr/backend/eudr/page.meta.js +2 -2
  47. package/dist/modules/eudr/backend/eudr/page.meta.js.map +2 -2
  48. package/dist/modules/eudr/backend/eudr/plots/[id]/page.js +8 -2
  49. package/dist/modules/eudr/backend/eudr/plots/[id]/page.js.map +2 -2
  50. package/dist/modules/eudr/backend/eudr/plots/[id]/page.meta.js +3 -1
  51. package/dist/modules/eudr/backend/eudr/plots/[id]/page.meta.js.map +2 -2
  52. package/dist/modules/eudr/backend/eudr/plots/create/page.js +8 -2
  53. package/dist/modules/eudr/backend/eudr/plots/create/page.js.map +2 -2
  54. package/dist/modules/eudr/backend/eudr/plots/create/page.meta.js +1 -0
  55. package/dist/modules/eudr/backend/eudr/plots/create/page.meta.js.map +2 -2
  56. package/dist/modules/eudr/backend/eudr/plots/page.meta.js +4 -1
  57. package/dist/modules/eudr/backend/eudr/plots/page.meta.js.map +2 -2
  58. package/dist/modules/eudr/backend/eudr/product-mappings/[id]/page.meta.js +3 -1
  59. package/dist/modules/eudr/backend/eudr/product-mappings/[id]/page.meta.js.map +2 -2
  60. package/dist/modules/eudr/backend/eudr/product-mappings/create/page.meta.js +1 -0
  61. package/dist/modules/eudr/backend/eudr/product-mappings/create/page.meta.js.map +2 -2
  62. package/dist/modules/eudr/backend/eudr/product-mappings/page.meta.js +4 -1
  63. package/dist/modules/eudr/backend/eudr/product-mappings/page.meta.js.map +2 -2
  64. package/dist/modules/eudr/backend/eudr/risk-assessments/[id]/page.meta.js +3 -1
  65. package/dist/modules/eudr/backend/eudr/risk-assessments/[id]/page.meta.js.map +2 -2
  66. package/dist/modules/eudr/backend/eudr/risk-assessments/create/page.meta.js +1 -0
  67. package/dist/modules/eudr/backend/eudr/risk-assessments/create/page.meta.js.map +2 -2
  68. package/dist/modules/eudr/backend/eudr/risk-assessments/page.meta.js +4 -1
  69. package/dist/modules/eudr/backend/eudr/risk-assessments/page.meta.js.map +2 -2
  70. package/dist/modules/eudr/backend/eudr/statements/[id]/page.js +2 -0
  71. package/dist/modules/eudr/backend/eudr/statements/[id]/page.js.map +2 -2
  72. package/dist/modules/eudr/backend/eudr/statements/[id]/page.meta.js +3 -1
  73. package/dist/modules/eudr/backend/eudr/statements/[id]/page.meta.js.map +2 -2
  74. package/dist/modules/eudr/backend/eudr/statements/create/page.meta.js +1 -0
  75. package/dist/modules/eudr/backend/eudr/statements/create/page.meta.js.map +2 -2
  76. package/dist/modules/eudr/backend/eudr/statements/page.js +30 -25
  77. package/dist/modules/eudr/backend/eudr/statements/page.js.map +2 -2
  78. package/dist/modules/eudr/backend/eudr/statements/page.meta.js +4 -1
  79. package/dist/modules/eudr/backend/eudr/statements/page.meta.js.map +2 -2
  80. package/dist/modules/eudr/commands/statements.js +5 -3
  81. package/dist/modules/eudr/commands/statements.js.map +2 -2
  82. package/dist/modules/eudr/components/crudErrorI18n.js +21 -1
  83. package/dist/modules/eudr/components/crudErrorI18n.js.map +2 -2
  84. package/dist/modules/eudr/components/formConfig.js +6 -2
  85. package/dist/modules/eudr/components/formConfig.js.map +2 -2
  86. package/dist/modules/eudr/lib/statement-lifecycle.js +9 -1
  87. package/dist/modules/eudr/lib/statement-lifecycle.js.map +2 -2
  88. package/dist/modules/sales/components/documents/LineItemDialog.js +1 -1
  89. package/dist/modules/sales/components/documents/LineItemDialog.js.map +1 -1
  90. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +44 -2
  91. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  92. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js +20 -16
  93. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js.map +2 -2
  94. package/dist/modules/wms/components/backend/WmsLotDetailPage.js +10 -6
  95. package/dist/modules/wms/components/backend/WmsLotDetailPage.js.map +2 -2
  96. package/dist/modules/wms/components/backend/WmsOperationalDashboardPage.js +11 -7
  97. package/dist/modules/wms/components/backend/WmsOperationalDashboardPage.js.map +2 -2
  98. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js +10 -6
  99. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js.map +2 -2
  100. package/package.json +8 -8
  101. package/src/modules/auth/api/login.ts +23 -0
  102. package/src/modules/auth/api/routeError.ts +30 -0
  103. package/src/modules/auth/api/users/acl/route.ts +49 -9
  104. package/src/modules/business_rules/backend/rules/[id]/page.meta.ts +5 -0
  105. package/src/modules/business_rules/backend/rules/create/page.meta.ts +5 -1
  106. package/src/modules/business_rules/backend/sets/[id]/page.meta.ts +5 -0
  107. package/src/modules/business_rules/backend/sets/create/page.meta.ts +5 -1
  108. package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +138 -0
  109. package/src/modules/customer_accounts/backend/customer_accounts/settings/page.tsx +8 -132
  110. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +601 -0
  111. package/src/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.tsx +838 -0
  112. package/src/modules/customer_accounts/backend/customer_accounts/users/[id]/page.tsx +6 -838
  113. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +6 -590
  114. package/src/modules/customer_accounts/extension-points.ts +1 -1
  115. package/src/modules/customer_accounts/lib/portalUrl.ts +112 -0
  116. package/src/modules/customers/backend/customers/companies/page.tsx +2 -2
  117. package/src/modules/customers/backend/customers/deals/page.tsx +2 -2
  118. package/src/modules/customers/backend/customers/people/page.tsx +2 -2
  119. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +12 -7
  120. package/src/modules/eudr/backend/eudr/evidence-submissions/[id]/page.meta.ts +2 -0
  121. package/src/modules/eudr/backend/eudr/evidence-submissions/create/page.meta.ts +1 -0
  122. package/src/modules/eudr/backend/eudr/evidence-submissions/page.meta.ts +4 -1
  123. package/src/modules/eudr/backend/eudr/page.meta.ts +2 -2
  124. package/src/modules/eudr/backend/eudr/plots/[id]/page.meta.ts +2 -0
  125. package/src/modules/eudr/backend/eudr/plots/[id]/page.tsx +8 -2
  126. package/src/modules/eudr/backend/eudr/plots/create/page.meta.ts +1 -0
  127. package/src/modules/eudr/backend/eudr/plots/create/page.tsx +8 -2
  128. package/src/modules/eudr/backend/eudr/plots/page.meta.ts +4 -1
  129. package/src/modules/eudr/backend/eudr/product-mappings/[id]/page.meta.ts +2 -0
  130. package/src/modules/eudr/backend/eudr/product-mappings/create/page.meta.ts +1 -0
  131. package/src/modules/eudr/backend/eudr/product-mappings/page.meta.ts +4 -1
  132. package/src/modules/eudr/backend/eudr/risk-assessments/[id]/page.meta.ts +2 -0
  133. package/src/modules/eudr/backend/eudr/risk-assessments/create/page.meta.ts +1 -0
  134. package/src/modules/eudr/backend/eudr/risk-assessments/page.meta.ts +4 -1
  135. package/src/modules/eudr/backend/eudr/statements/[id]/page.meta.ts +2 -0
  136. package/src/modules/eudr/backend/eudr/statements/[id]/page.tsx +2 -0
  137. package/src/modules/eudr/backend/eudr/statements/create/page.meta.ts +1 -0
  138. package/src/modules/eudr/backend/eudr/statements/page.meta.ts +4 -1
  139. package/src/modules/eudr/backend/eudr/statements/page.tsx +32 -26
  140. package/src/modules/eudr/commands/statements.ts +4 -2
  141. package/src/modules/eudr/components/crudErrorI18n.ts +31 -0
  142. package/src/modules/eudr/components/formConfig.tsx +9 -1
  143. package/src/modules/eudr/i18n/de.json +3 -2
  144. package/src/modules/eudr/i18n/en.json +3 -2
  145. package/src/modules/eudr/i18n/es.json +3 -2
  146. package/src/modules/eudr/i18n/ko.json +3 -2
  147. package/src/modules/eudr/i18n/pl.json +3 -2
  148. package/src/modules/eudr/lib/statement-lifecycle.ts +8 -0
  149. package/src/modules/sales/components/documents/LineItemDialog.tsx +1 -1
  150. package/src/modules/sales/i18n/de.json +37 -0
  151. package/src/modules/sales/i18n/en.json +37 -0
  152. package/src/modules/sales/i18n/es.json +37 -0
  153. package/src/modules/sales/i18n/ko.json +37 -0
  154. package/src/modules/sales/i18n/pl.json +37 -0
  155. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +42 -2
  156. package/src/modules/wms/components/backend/WmsLocationDetailPage.tsx +30 -26
  157. package/src/modules/wms/components/backend/WmsLotDetailPage.tsx +21 -17
  158. package/src/modules/wms/components/backend/WmsOperationalDashboardPage.tsx +17 -13
  159. package/src/modules/wms/components/backend/WmsSkuDetailPage.tsx +16 -12
@@ -0,0 +1,838 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import { useRouter } from 'next/navigation'
6
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { Input } from '@open-mercato/ui/primitives/input'
9
+ import { PasswordInput } from '@open-mercato/ui/primitives/password-input'
10
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
11
+ import { SwitchField } from '@open-mercato/ui/primitives/switch-field'
12
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
13
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
14
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
15
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
16
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
17
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
18
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
19
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
20
+ import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
21
+ import { buildPortalUrlPattern } from '../../../../lib/portalUrl'
22
+
23
+ type UserDetail = {
24
+ id: string
25
+ displayName: string
26
+ email: string
27
+ emailVerifiedAt: string | null
28
+ isActive: boolean
29
+ lastLoginAt: string | null
30
+ personEntityId: string | null
31
+ customerEntityId: string | null
32
+ createdAt: string
33
+ updatedAt: string | null
34
+ roles: Array<{ id: string; name: string; slug: string }>
35
+ sessions: Array<{
36
+ id: string
37
+ ipAddress: string | null
38
+ userAgent: string | null
39
+ lastUsedAt: string | null
40
+ createdAt: string
41
+ expiresAt: string
42
+ }>
43
+ }
44
+
45
+ function formatDate(value: string | null | undefined, fallback: string): string {
46
+ if (!value) return fallback
47
+ const date = new Date(value)
48
+ if (Number.isNaN(date.getTime())) return fallback
49
+ return date.toLocaleString()
50
+ }
51
+
52
+ function ResetPasswordDialog({
53
+ open,
54
+ onOpenChange,
55
+ userId,
56
+ onRunMutation,
57
+ }: {
58
+ open: boolean
59
+ onOpenChange: (next: boolean) => void
60
+ userId: string
61
+ onRunMutation: <T>(operation: () => Promise<T>) => Promise<T>
62
+ }) {
63
+ const t = useT()
64
+ const [newPassword, setNewPassword] = React.useState('')
65
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
66
+
67
+ const handleSubmit = React.useCallback(async (event: React.FormEvent) => {
68
+ event.preventDefault()
69
+ if (!newPassword.trim() || newPassword.length < 8) {
70
+ flash(t('customer_accounts.admin.detail.resetPassword.error.minLength', 'Password must be at least 8 characters'), 'error')
71
+ return
72
+ }
73
+ setIsSubmitting(true)
74
+ try {
75
+ await onRunMutation(async () => {
76
+ const call = await apiCall<{ ok: boolean; error?: string }>(
77
+ `/api/customer_accounts/admin/users/${encodeURIComponent(userId)}/reset-password`,
78
+ {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ newPassword }),
82
+ },
83
+ )
84
+ if (!call.ok) {
85
+ flash(call.result?.error || t('customer_accounts.admin.detail.resetPassword.error.save', 'Failed to reset password'), 'error')
86
+ return
87
+ }
88
+ flash(t('customer_accounts.admin.detail.resetPassword.flash.success', 'Password reset successfully'), 'success')
89
+ setNewPassword('')
90
+ onOpenChange(false)
91
+ })
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.resetPassword.error.save', 'Failed to reset password')
94
+ flash(message, 'error')
95
+ } finally {
96
+ setIsSubmitting(false)
97
+ }
98
+ }, [newPassword, onOpenChange, onRunMutation, t, userId])
99
+
100
+ const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
101
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
102
+ event.preventDefault()
103
+ const form = (event.target as HTMLElement).closest('form')
104
+ if (form) form.requestSubmit()
105
+ }
106
+ }, [])
107
+
108
+ return (
109
+ <Dialog open={open} onOpenChange={(next) => { if (!next) setNewPassword(''); onOpenChange(next) }}>
110
+ <DialogContent className="sm:max-w-md">
111
+ <DialogHeader>
112
+ <DialogTitle>{t('customer_accounts.admin.detail.resetPassword.title', 'Reset Password')}</DialogTitle>
113
+ </DialogHeader>
114
+ <form onSubmit={(event) => { void handleSubmit(event) }} onKeyDown={handleKeyDown} className="space-y-4">
115
+ <div className="space-y-2">
116
+ <label className="text-sm font-medium" htmlFor="reset-password">
117
+ {t('customer_accounts.admin.detail.resetPassword.fields.newPassword', 'New Password')}
118
+ </label>
119
+ <PasswordInput
120
+ id="reset-password"
121
+ required
122
+ minLength={8}
123
+ value={newPassword}
124
+ onChange={(event) => setNewPassword(event.target.value)}
125
+ placeholder={t('customer_accounts.admin.detail.resetPassword.fields.placeholder', 'Min. 8 characters')}
126
+ autoComplete="new-password"
127
+ />
128
+ </div>
129
+ <div className="flex justify-end gap-2 pt-2">
130
+ <Button type="button" variant="outline" onClick={() => { setNewPassword(''); onOpenChange(false) }}>
131
+ {t('customer_accounts.admin.detail.resetPassword.actions.cancel', 'Cancel')}
132
+ </Button>
133
+ <Button type="submit" disabled={isSubmitting}>
134
+ {isSubmitting
135
+ ? t('customer_accounts.admin.detail.resetPassword.actions.resetting', 'Resetting...')
136
+ : t('customer_accounts.admin.detail.resetPassword.actions.reset', 'Reset Password')}
137
+ </Button>
138
+ </div>
139
+ </form>
140
+ </DialogContent>
141
+ </Dialog>
142
+ )
143
+ }
144
+
145
+ export type PortalUserDetailPageClientProps = {
146
+ params?: { id?: string }
147
+ portalOrigin: string
148
+ }
149
+
150
+ export function PortalUserDetailPageClient({ params, portalOrigin }: PortalUserDetailPageClientProps) {
151
+ const id = params?.id
152
+ const t = useT()
153
+ const router = useRouter()
154
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
155
+ const [data, setData] = React.useState<UserDetail | null>(null)
156
+ const [isLoading, setIsLoading] = React.useState(true)
157
+ const [error, setError] = React.useState<string | null>(null)
158
+ const [isNotFound, setIsNotFound] = React.useState(false)
159
+ const [isSaving, setIsSaving] = React.useState(false)
160
+ const [editActive, setEditActive] = React.useState<boolean | null>(null)
161
+ const [editDisplayName, setEditDisplayName] = React.useState('')
162
+ const [availableRoles, setAvailableRoles] = React.useState<Array<{ id: string; name: string }>>([])
163
+ const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])
164
+ const [resetPasswordOpen, setResetPasswordOpen] = React.useState(false)
165
+ const [isVerifying, setIsVerifying] = React.useState(false)
166
+ const [editPersonEntityId, setEditPersonEntityId] = React.useState<string | null>(null)
167
+ const [editCustomerEntityId, setEditCustomerEntityId] = React.useState<string | null>(null)
168
+ const [personSearchQuery, setPersonSearchQuery] = React.useState('')
169
+ const [companySearchQuery, setCompanySearchQuery] = React.useState('')
170
+ const [personResults, setPersonResults] = React.useState<Array<{ id: string; label: string }>>([])
171
+ const [companyResults, setCompanyResults] = React.useState<Array<{ id: string; label: string }>>([])
172
+ const [personName, setPersonName] = React.useState<string | null>(null)
173
+ const [companyName, setCompanyName] = React.useState<string | null>(null)
174
+ const [isSendingResetLink, setIsSendingResetLink] = React.useState(false)
175
+ const [resetLinkUrl, setResetLinkUrl] = React.useState<string | null>(null)
176
+
177
+ const mutationContextId = `customer_accounts:user:${id ?? 'pending'}`
178
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
179
+ entityType: string
180
+ entityId?: string
181
+ }>({
182
+ contextId: mutationContextId,
183
+ })
184
+
185
+ const runMutationWithContext = React.useCallback(
186
+ async <T,>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>): Promise<T> => {
187
+ return runMutation({
188
+ operation,
189
+ mutationPayload,
190
+ context: { entityType: 'customer_accounts:user', entityId: id },
191
+ })
192
+ },
193
+ [id, runMutation],
194
+ )
195
+
196
+ React.useEffect(() => {
197
+ if (!id) {
198
+ setIsNotFound(true)
199
+ setIsLoading(false)
200
+ return
201
+ }
202
+ let cancelled = false
203
+ async function load() {
204
+ setIsLoading(true)
205
+ setError(null)
206
+ setIsNotFound(false)
207
+ try {
208
+ const payload = await readApiResultOrThrow<UserDetail>(
209
+ `/api/customer_accounts/admin/users/${encodeURIComponent(id!)}`,
210
+ undefined,
211
+ { errorMessage: t('customer_accounts.admin.detail.error.load', 'Failed to load user') },
212
+ )
213
+ if (cancelled) return
214
+ setData(payload)
215
+ setEditActive(payload.isActive)
216
+ setEditDisplayName(payload.displayName)
217
+ setSelectedRoleIds(payload.roles.map((role) => role.id))
218
+ setEditPersonEntityId(payload.personEntityId)
219
+ setEditCustomerEntityId(payload.customerEntityId)
220
+ } catch (err) {
221
+ if (cancelled) return
222
+ if ((err as { status?: number }).status === 404) {
223
+ setIsNotFound(true)
224
+ } else {
225
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.error.load', 'Failed to load user')
226
+ setError(message)
227
+ }
228
+ } finally {
229
+ if (!cancelled) setIsLoading(false)
230
+ }
231
+ }
232
+ load()
233
+ return () => { cancelled = true }
234
+ }, [id, t])
235
+
236
+ React.useEffect(() => {
237
+ let cancelled = false
238
+ async function loadRoles() {
239
+ try {
240
+ const call = await apiCall<{ items?: Array<{ id: string; name: string }> }>(
241
+ '/api/customer_accounts/admin/roles?pageSize=100',
242
+ )
243
+ if (cancelled || !call.ok) return
244
+ const items = Array.isArray(call.result?.items) ? call.result!.items : []
245
+ setAvailableRoles(
246
+ items.filter((item) => typeof item?.id === 'string' && typeof item?.name === 'string'),
247
+ )
248
+ } catch {
249
+ // silently ignore role loading failures
250
+ }
251
+ }
252
+ loadRoles()
253
+ return () => { cancelled = true }
254
+ }, [])
255
+
256
+ React.useEffect(() => {
257
+ if (!data) return
258
+ let cancelled = false
259
+ async function loadPersonName() {
260
+ if (!data!.personEntityId) return
261
+ try {
262
+ const call = await apiCall<{ id?: string; firstName?: string; lastName?: string }>(`/api/customers/people/${encodeURIComponent(data!.personEntityId)}`)
263
+ if (!cancelled && call.ok && call.result) {
264
+ setPersonName([call.result.firstName, call.result.lastName].filter(Boolean).join(' ') || call.result.id || null)
265
+ }
266
+ } catch { /* ignore */ }
267
+ }
268
+ async function loadCompanyName() {
269
+ if (!data!.customerEntityId) return
270
+ try {
271
+ const call = await apiCall<{ id?: string; name?: string }>(`/api/customers/${encodeURIComponent(data!.customerEntityId)}`)
272
+ if (!cancelled && call.ok && call.result) {
273
+ setCompanyName(call.result.name || call.result.id || null)
274
+ }
275
+ } catch { /* ignore */ }
276
+ }
277
+ void Promise.all([loadPersonName(), loadCompanyName()])
278
+ return () => { cancelled = true }
279
+ }, [data])
280
+
281
+ const handleSearchPeople = React.useCallback(async (query: string) => {
282
+ setPersonSearchQuery(query)
283
+ if (query.trim().length < 2) { setPersonResults([]); return }
284
+ try {
285
+ const call = await apiCall<{ items?: Array<{ id: string; firstName?: string; lastName?: string; email?: string }> }>(
286
+ `/api/customers/people?search=${encodeURIComponent(query.trim())}&pageSize=10`,
287
+ )
288
+ if (call.ok && Array.isArray(call.result?.items)) {
289
+ setPersonResults(call.result!.items.map((person) => ({
290
+ id: person.id,
291
+ label: [person.firstName, person.lastName].filter(Boolean).join(' ') || person.email || person.id,
292
+ })))
293
+ }
294
+ } catch { /* ignore */ }
295
+ }, [])
296
+
297
+ const handleSearchCompanies = React.useCallback(async (query: string) => {
298
+ setCompanySearchQuery(query)
299
+ if (query.trim().length < 2) { setCompanyResults([]); return }
300
+ try {
301
+ const call = await apiCall<{ items?: Array<{ id: string; name?: string }> }>(
302
+ `/api/customers?search=${encodeURIComponent(query.trim())}&pageSize=10`,
303
+ )
304
+ if (call.ok && Array.isArray(call.result?.items)) {
305
+ setCompanyResults(call.result!.items.map((company) => ({
306
+ id: company.id,
307
+ label: company.name || company.id,
308
+ })))
309
+ }
310
+ } catch { /* ignore */ }
311
+ }, [])
312
+
313
+ const handleSendResetLink = React.useCallback(async () => {
314
+ if (!id) return
315
+ setIsSendingResetLink(true)
316
+ try {
317
+ await runMutationWithContext(async () => {
318
+ const call = await apiCall<{ ok: boolean; resetLink?: string; error?: string }>(
319
+ `/api/customer_accounts/admin/users/${encodeURIComponent(id)}/send-reset-link`,
320
+ { method: 'POST' },
321
+ )
322
+ if (!call.ok || !call.result?.resetLink) {
323
+ flash(call.result?.error || t('customer_accounts.admin.detail.sendResetLink.error', 'Failed to generate reset link'), 'error')
324
+ return
325
+ }
326
+ setResetLinkUrl(call.result.resetLink)
327
+ flash(t('customer_accounts.admin.detail.sendResetLink.flash.success', 'Reset link generated'), 'success')
328
+ }, { id })
329
+ } catch (err) {
330
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.sendResetLink.error', 'Failed to generate reset link')
331
+ flash(message, 'error')
332
+ } finally {
333
+ setIsSendingResetLink(false)
334
+ }
335
+ }, [id, runMutationWithContext, t])
336
+
337
+ const handleSave = React.useCallback(async () => {
338
+ if (!data || !id) return
339
+ setIsSaving(true)
340
+ try {
341
+ await runMutationWithContext(async () => {
342
+ const call = await withScopedApiRequestHeaders(
343
+ buildOptimisticLockHeader(data.updatedAt),
344
+ () => apiCall<{ ok: boolean; error?: string; updatedAt?: string | null }>(
345
+ `/api/customer_accounts/admin/users/${encodeURIComponent(id)}`,
346
+ {
347
+ method: 'PUT',
348
+ headers: { 'content-type': 'application/json' },
349
+ body: JSON.stringify({
350
+ displayName: editDisplayName.trim() || undefined,
351
+ isActive: editActive,
352
+ roleIds: selectedRoleIds,
353
+ personEntityId: editPersonEntityId,
354
+ customerEntityId: editCustomerEntityId,
355
+ }),
356
+ },
357
+ ),
358
+ )
359
+ if (!call.ok) {
360
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
361
+ flash(call.result?.error || t('customer_accounts.admin.detail.error.save', 'Failed to save user'), 'error')
362
+ return
363
+ }
364
+ flash(t('customer_accounts.admin.detail.flash.saved', 'User updated'), 'success')
365
+ setData((prev) => prev ? {
366
+ ...prev,
367
+ isActive: editActive ?? prev.isActive,
368
+ displayName: editDisplayName.trim() || prev.displayName,
369
+ personEntityId: editPersonEntityId,
370
+ customerEntityId: editCustomerEntityId,
371
+ updatedAt: call.result?.updatedAt ?? prev.updatedAt,
372
+ } : prev)
373
+ }, { displayName: editDisplayName, isActive: editActive, roleIds: selectedRoleIds, personEntityId: editPersonEntityId, customerEntityId: editCustomerEntityId })
374
+ } catch (err) {
375
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.error.save', 'Failed to save user')
376
+ flash(message, 'error')
377
+ } finally {
378
+ setIsSaving(false)
379
+ }
380
+ }, [data, editActive, editCustomerEntityId, editDisplayName, editPersonEntityId, id, runMutationWithContext, selectedRoleIds, t])
381
+
382
+ const handleDelete = React.useCallback(async () => {
383
+ if (!data || !id) return
384
+ const confirmed = await confirm({
385
+ title: t('customer_accounts.admin.confirm.delete', 'Delete user "{{name}}"?', {
386
+ name: data.displayName || data.email,
387
+ }),
388
+ variant: 'destructive',
389
+ })
390
+ if (!confirmed) return
391
+ try {
392
+ await runMutationWithContext(async () => {
393
+ const call = await withScopedApiRequestHeaders(
394
+ buildOptimisticLockHeader(data.updatedAt),
395
+ () => apiCall(
396
+ `/api/customer_accounts/admin/users/${encodeURIComponent(id)}`,
397
+ { method: 'DELETE' },
398
+ ),
399
+ )
400
+ if (!call.ok) {
401
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
402
+ flash(t('customer_accounts.admin.error.delete', 'Failed to delete user'), 'error')
403
+ return
404
+ }
405
+ flash(t('customer_accounts.admin.flash.deleted', 'User deleted'), 'success')
406
+ router.push('/backend/customer_accounts/users')
407
+ }, { id })
408
+ } catch (err) {
409
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.error.delete', 'Failed to delete user')
410
+ flash(message, 'error')
411
+ }
412
+ }, [confirm, data, id, router, runMutationWithContext, t])
413
+
414
+ const handleVerifyEmail = React.useCallback(async () => {
415
+ if (!data || !id) return
416
+ const confirmed = await confirm({
417
+ title: t('customer_accounts.admin.detail.verifyEmail.confirm', 'Mark email as verified for "{{name}}"?', {
418
+ name: data.displayName || data.email,
419
+ }),
420
+ })
421
+ if (!confirmed) return
422
+ setIsVerifying(true)
423
+ try {
424
+ await runMutationWithContext(async () => {
425
+ const call = await apiCall<{ ok: boolean; error?: string }>(
426
+ `/api/customer_accounts/admin/users/${encodeURIComponent(id)}/verify-email`,
427
+ { method: 'POST' },
428
+ )
429
+ if (!call.ok) {
430
+ flash(call.result?.error || t('customer_accounts.admin.detail.verifyEmail.error', 'Failed to verify email'), 'error')
431
+ return
432
+ }
433
+ flash(t('customer_accounts.admin.detail.verifyEmail.flash.success', 'Email marked as verified'), 'success')
434
+ setData((prev) => prev ? { ...prev, emailVerifiedAt: new Date().toISOString() } : prev)
435
+ }, { id })
436
+ } catch (err) {
437
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.verifyEmail.error', 'Failed to verify email')
438
+ flash(message, 'error')
439
+ } finally {
440
+ setIsVerifying(false)
441
+ }
442
+ }, [confirm, data, id, runMutationWithContext, t])
443
+
444
+ const handleRevokeSession = React.useCallback(async (sessionId: string) => {
445
+ if (!id) return
446
+ try {
447
+ await runMutationWithContext(async () => {
448
+ const call = await apiCall(
449
+ `/api/customer_accounts/admin/users/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`,
450
+ { method: 'DELETE' },
451
+ )
452
+ if (!call.ok) {
453
+ flash(t('customer_accounts.admin.detail.error.revokeSession', 'Failed to revoke session'), 'error')
454
+ return
455
+ }
456
+ flash(t('customer_accounts.admin.detail.flash.sessionRevoked', 'Session revoked'), 'success')
457
+ setData((prev) => {
458
+ if (!prev) return prev
459
+ return { ...prev, sessions: prev.sessions.filter((session) => session.id !== sessionId) }
460
+ })
461
+ }, { sessionId })
462
+ } catch (err) {
463
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.error.revokeSession', 'Failed to revoke session')
464
+ flash(message, 'error')
465
+ }
466
+ }, [id, runMutationWithContext, t])
467
+
468
+ const handleRoleToggle = React.useCallback((roleId: string) => {
469
+ setSelectedRoleIds((prev) =>
470
+ prev.includes(roleId)
471
+ ? prev.filter((existingId) => existingId !== roleId)
472
+ : [...prev, roleId],
473
+ )
474
+ }, [])
475
+
476
+ if (isLoading) {
477
+ return (
478
+ <div className="flex h-[50vh] flex-col items-center justify-center gap-2 text-muted-foreground">
479
+ <Spinner className="h-6 w-6" />
480
+ <span>{t('customer_accounts.admin.detail.loading', 'Loading user...')}</span>
481
+ </div>
482
+ )
483
+ }
484
+
485
+ if (isNotFound) {
486
+ return (
487
+ <RecordNotFoundState
488
+ label={t('customer_accounts.admin.detail.error.notFound', 'User not found')}
489
+ backHref="/backend/customer_accounts/users"
490
+ backLabel={t('customer_accounts.admin.detail.actions.backToList', 'Back to list')}
491
+ />
492
+ )
493
+ }
494
+
495
+ if (error || !data) {
496
+ return (
497
+ <ErrorMessage
498
+ label={error ?? t('customer_accounts.admin.detail.error.notFound', 'User not found')}
499
+ action={
500
+ <Button asChild variant="outline" size="sm">
501
+ <Link href="/backend/customer_accounts/users">
502
+ {t('customer_accounts.admin.detail.actions.backToList', 'Back to list')}
503
+ </Link>
504
+ </Button>
505
+ }
506
+ />
507
+ )
508
+ }
509
+
510
+ return (
511
+ <>
512
+ <FormHeader
513
+ mode="detail"
514
+ backHref="/backend/customer_accounts/users"
515
+ backLabel={t('customer_accounts.admin.detail.actions.backToList', 'Back to list')}
516
+ title={data.displayName}
517
+ subtitle={data.email}
518
+ onDelete={() => { void handleDelete() }}
519
+ deleteLabel={t('customer_accounts.admin.detail.actions.delete', 'Delete')}
520
+ />
521
+
522
+ <div className="rounded-lg border border-status-info-border bg-status-info-bg p-4">
523
+ <div className="flex items-start gap-3">
524
+ <div className="flex-1">
525
+ <h3 className="text-sm font-medium text-status-info-text">
526
+ {t('customer_accounts.admin.detail.portalAccess.title', 'Customer Portal Access')}
527
+ </h3>
528
+ <p className="mt-1 text-sm text-status-info-text">
529
+ {t('customer_accounts.admin.detail.portalAccess.description', 'This user can access the customer portal at the URL below. The portal provides self-service access to orders, invoices, quotes, and account management.')}
530
+ </p>
531
+ <p className="mt-2 text-xs text-status-info-text">
532
+ {t('customer_accounts.admin.detail.portalAccess.url', 'Portal URL: {url}', {
533
+ url: buildPortalUrlPattern(portalOrigin),
534
+ })}
535
+ </p>
536
+ </div>
537
+ </div>
538
+ </div>
539
+
540
+ <div className="grid gap-6 md:grid-cols-2">
541
+ <div className="rounded-lg border p-4 space-y-3">
542
+ <h2 className="text-sm font-semibold">{t('customer_accounts.admin.detail.sections.info', 'User Information')}</h2>
543
+ <dl className="space-y-2 text-sm">
544
+ <div className="flex justify-between">
545
+ <dt className="text-muted-foreground">{t('customer_accounts.admin.detail.fields.email', 'Email')}</dt>
546
+ <dd>{data.email}</dd>
547
+ </div>
548
+ <div className="flex justify-between items-center">
549
+ <dt className="text-muted-foreground">{t('customer_accounts.admin.detail.fields.emailVerified', 'Email Verified')}</dt>
550
+ <dd className="flex items-center gap-2">
551
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
552
+ data.emailVerifiedAt
553
+ ? 'bg-status-success-bg text-status-success-text'
554
+ : 'bg-status-warning-bg text-status-warning-text'
555
+ }`}>
556
+ {data.emailVerifiedAt
557
+ ? t('customer_accounts.admin.verified', 'Yes')
558
+ : t('customer_accounts.admin.unverified', 'No')}
559
+ </span>
560
+ {!data.emailVerifiedAt && (
561
+ <Button
562
+ variant="outline"
563
+ size="sm"
564
+ onClick={() => { void handleVerifyEmail() }}
565
+ disabled={isVerifying}
566
+ >
567
+ {isVerifying
568
+ ? t('customer_accounts.admin.detail.verifyEmail.actions.verifying', 'Verifying...')
569
+ : t('customer_accounts.admin.detail.verifyEmail.actions.verify', 'Mark Verified')}
570
+ </Button>
571
+ )}
572
+ </dd>
573
+ </div>
574
+ <div className="flex justify-between">
575
+ <dt className="text-muted-foreground">{t('customer_accounts.admin.detail.fields.lastLogin', 'Last Login')}</dt>
576
+ <dd>{formatDate(data.lastLoginAt, '-')}</dd>
577
+ </div>
578
+ <div className="flex justify-between">
579
+ <dt className="text-muted-foreground">{t('customer_accounts.admin.detail.fields.createdAt', 'Created')}</dt>
580
+ <dd>{formatDate(data.createdAt, '-')}</dd>
581
+ </div>
582
+ </dl>
583
+ </div>
584
+
585
+ <div className="rounded-lg border p-4 space-y-3">
586
+ <h2 className="text-sm font-semibold">{t('customer_accounts.admin.detail.sections.crmLinks', 'CRM Links')}</h2>
587
+ <div className="space-y-3 text-sm">
588
+ <div className="space-y-1.5">
589
+ <p className="text-muted-foreground">{t('customer_accounts.admin.detail.fields.personEntity', 'Linked Person')}</p>
590
+ {editPersonEntityId ? (
591
+ <div className="flex items-center gap-2">
592
+ <Link href={`/backend/customers/people/${editPersonEntityId}`} className="text-primary hover:underline">
593
+ {personName || editPersonEntityId}
594
+ </Link>
595
+ <Button type="button" variant="outline" size="sm" onClick={() => { setEditPersonEntityId(null); setPersonName(null) }}>
596
+ {t('customer_accounts.admin.detail.actions.unlink', 'Unlink')}
597
+ </Button>
598
+ </div>
599
+ ) : (
600
+ <div className="space-y-1">
601
+ <div className="relative">
602
+ <Input
603
+ type="text"
604
+ value={personSearchQuery}
605
+ onChange={(event) => { void handleSearchPeople(event.target.value) }}
606
+ placeholder={t('customer_accounts.admin.detail.fields.searchPerson', 'Search people by name...')}
607
+ />
608
+ {personResults.length > 0 && (
609
+ <div className="absolute z-10 mt-1 w-full rounded-md border bg-background shadow-lg max-h-40 overflow-y-auto">
610
+ {personResults.map((person) => (
611
+ <button
612
+ key={person.id}
613
+ type="button"
614
+ className="w-full px-3 py-2 text-left text-sm hover:bg-muted"
615
+ onClick={() => {
616
+ setEditPersonEntityId(person.id)
617
+ setPersonName(person.label)
618
+ setPersonSearchQuery('')
619
+ setPersonResults([])
620
+ }}
621
+ >
622
+ {person.label}
623
+ </button>
624
+ ))}
625
+ </div>
626
+ )}
627
+ </div>
628
+ </div>
629
+ )}
630
+ </div>
631
+ <div className="space-y-1.5">
632
+ <p className="text-muted-foreground">{t('customer_accounts.admin.detail.fields.customerEntity', 'Linked Company')}</p>
633
+ {editCustomerEntityId ? (
634
+ <div className="flex items-center gap-2">
635
+ <Link href={`/backend/customers/companies/${editCustomerEntityId}`} className="text-primary hover:underline">
636
+ {companyName || editCustomerEntityId}
637
+ </Link>
638
+ <Button type="button" variant="outline" size="sm" onClick={() => { setEditCustomerEntityId(null); setCompanyName(null) }}>
639
+ {t('customer_accounts.admin.detail.actions.unlink', 'Unlink')}
640
+ </Button>
641
+ </div>
642
+ ) : (
643
+ <div className="space-y-1">
644
+ <div className="relative">
645
+ <Input
646
+ type="text"
647
+ value={companySearchQuery}
648
+ onChange={(event) => { void handleSearchCompanies(event.target.value) }}
649
+ placeholder={t('customer_accounts.admin.detail.fields.searchCompany', 'Search companies by name...')}
650
+ />
651
+ {companyResults.length > 0 && (
652
+ <div className="absolute z-10 mt-1 w-full rounded-md border bg-background shadow-lg max-h-40 overflow-y-auto">
653
+ {companyResults.map((company) => (
654
+ <button
655
+ key={company.id}
656
+ type="button"
657
+ className="w-full px-3 py-2 text-left text-sm hover:bg-muted"
658
+ onClick={() => {
659
+ setEditCustomerEntityId(company.id)
660
+ setCompanyName(company.label)
661
+ setCompanySearchQuery('')
662
+ setCompanyResults([])
663
+ }}
664
+ >
665
+ {company.label}
666
+ </button>
667
+ ))}
668
+ </div>
669
+ )}
670
+ </div>
671
+ </div>
672
+ )}
673
+ </div>
674
+ <p className="text-xs text-muted-foreground">
675
+ {t('customer_accounts.admin.detail.crmLinks.hint', 'Changes to CRM links are saved when you click Save Changes below.')}
676
+ </p>
677
+ </div>
678
+ </div>
679
+ </div>
680
+
681
+ <div className="rounded-lg border p-4 space-y-4">
682
+ <h2 className="text-sm font-semibold">{t('customer_accounts.admin.detail.sections.settings', 'Account Settings')}</h2>
683
+ <div className="grid gap-4 md:grid-cols-2">
684
+ <div className="space-y-3">
685
+ <div className="space-y-2">
686
+ <label className="text-sm font-medium" htmlFor="user-display-name">
687
+ {t('customer_accounts.admin.detail.fields.displayName', 'Display Name')}
688
+ </label>
689
+ <Input
690
+ id="user-display-name"
691
+ type="text"
692
+ value={editDisplayName}
693
+ onChange={(event) => setEditDisplayName(event.target.value)}
694
+ />
695
+ </div>
696
+
697
+ <SwitchField
698
+ id="user-active-toggle"
699
+ label={t('customer_accounts.admin.detail.fields.isActive', 'Active')}
700
+ checked={editActive ?? data.isActive}
701
+ onCheckedChange={(next) => setEditActive(next)}
702
+ />
703
+ </div>
704
+
705
+ <div className="space-y-2">
706
+ <p className="text-sm font-medium">{t('customer_accounts.admin.detail.fields.roles', 'Roles')}</p>
707
+ <div className="flex flex-wrap gap-2">
708
+ {availableRoles.map((role) => {
709
+ const isSelected = selectedRoleIds.includes(role.id)
710
+ return (
711
+ <Button
712
+ key={role.id}
713
+ type="button"
714
+ variant="outline"
715
+ size="sm"
716
+ onClick={() => handleRoleToggle(role.id)}
717
+ className={`rounded-full ${
718
+ isSelected
719
+ ? 'border-primary bg-primary/10 text-primary'
720
+ : ''
721
+ }`}
722
+ >
723
+ {role.name}
724
+ </Button>
725
+ )
726
+ })}
727
+ {availableRoles.length === 0 && (
728
+ <span className="text-sm text-muted-foreground">
729
+ {t('customer_accounts.admin.detail.noRolesAvailable', 'No roles available')}
730
+ </span>
731
+ )}
732
+ </div>
733
+ </div>
734
+ </div>
735
+ <div className="pt-2">
736
+ <Button onClick={() => { void handleSave() }} disabled={isSaving}>
737
+ {isSaving
738
+ ? t('customer_accounts.admin.detail.actions.saving', 'Saving...')
739
+ : t('customer_accounts.admin.detail.actions.save', 'Save Changes')}
740
+ </Button>
741
+ </div>
742
+ </div>
743
+
744
+ <div className="rounded-lg border p-4 space-y-3">
745
+ <div className="flex items-center justify-between">
746
+ <h2 className="text-sm font-semibold">{t('customer_accounts.admin.detail.sections.security', 'Security')}</h2>
747
+ </div>
748
+ <div className="flex flex-wrap gap-2">
749
+ <Button variant="outline" onClick={() => setResetPasswordOpen(true)}>
750
+ {t('customer_accounts.admin.detail.resetPassword.actions.open', 'Reset Password')}
751
+ </Button>
752
+ <Button
753
+ variant="outline"
754
+ onClick={() => { void handleSendResetLink() }}
755
+ disabled={isSendingResetLink}
756
+ >
757
+ {isSendingResetLink
758
+ ? t('customer_accounts.admin.detail.sendResetLink.actions.sending', 'Generating...')
759
+ : t('customer_accounts.admin.detail.sendResetLink.actions.send', 'Send Reset Link')}
760
+ </Button>
761
+ </div>
762
+ {resetLinkUrl && (
763
+ <div className="rounded-md border border-status-info-border bg-status-info-bg p-3">
764
+ <p className="mb-1.5 text-sm font-medium text-status-info-text">
765
+ {t('customer_accounts.admin.detail.sendResetLink.linkLabel', 'Password reset link (valid for 60 minutes):')}
766
+ </p>
767
+ <div className="flex items-center gap-2">
768
+ <code className="flex-1 break-all rounded bg-status-info-bg px-2 py-1 text-xs text-status-info-text">
769
+ {resetLinkUrl}
770
+ </code>
771
+ <Button
772
+ type="button"
773
+ variant="outline"
774
+ size="sm"
775
+ onClick={() => {
776
+ void navigator.clipboard.writeText(resetLinkUrl)
777
+ flash(t('customer_accounts.admin.detail.sendResetLink.flash.copied', 'Link copied to clipboard'), 'success')
778
+ }}
779
+ >
780
+ {t('customer_accounts.admin.detail.sendResetLink.actions.copy', 'Copy')}
781
+ </Button>
782
+ </div>
783
+ <p className="mt-1.5 text-xs text-status-info-text">
784
+ {t('customer_accounts.admin.detail.sendResetLink.hint', 'Share this link with the customer to let them set a new password.')}
785
+ </p>
786
+ </div>
787
+ )}
788
+ </div>
789
+
790
+ <div className="rounded-lg border p-4 space-y-3">
791
+ <h2 className="text-sm font-semibold">
792
+ {t('customer_accounts.admin.detail.sections.sessions', 'Active Sessions')}
793
+ {data.sessions.length > 0 && (
794
+ <span className="ml-2 text-xs font-normal text-muted-foreground">({data.sessions.length})</span>
795
+ )}
796
+ </h2>
797
+ {data.sessions.length === 0 ? (
798
+ <p className="text-sm text-muted-foreground">
799
+ {t('customer_accounts.admin.detail.noSessions', 'No active sessions')}
800
+ </p>
801
+ ) : (
802
+ <div className="divide-y">
803
+ {data.sessions.map((session) => (
804
+ <div key={session.id} className="flex items-center justify-between py-2 text-sm">
805
+ <div className="space-y-0.5">
806
+ <p className="font-medium">
807
+ {session.ipAddress || t('customer_accounts.admin.detail.unknownIp', 'Unknown IP')}
808
+ </p>
809
+ <p className="text-xs text-muted-foreground truncate max-w-xs">
810
+ {session.userAgent || t('customer_accounts.admin.detail.unknownDevice', 'Unknown device')}
811
+ </p>
812
+ <p className="text-xs text-muted-foreground">
813
+ {t('customer_accounts.admin.detail.fields.lastUsed', 'Last used')}: {formatDate(session.lastUsedAt, '-')}
814
+ </p>
815
+ </div>
816
+ <Button
817
+ variant="outline"
818
+ size="sm"
819
+ onClick={() => { void handleRevokeSession(session.id) }}
820
+ >
821
+ {t('customer_accounts.admin.detail.actions.revoke', 'Revoke')}
822
+ </Button>
823
+ </div>
824
+ ))}
825
+ </div>
826
+ )}
827
+ </div>
828
+
829
+ <ResetPasswordDialog
830
+ open={resetPasswordOpen}
831
+ onOpenChange={setResetPasswordOpen}
832
+ userId={id!}
833
+ onRunMutation={runMutationWithContext}
834
+ />
835
+ {ConfirmDialogElement}
836
+ </>
837
+ )
838
+ }