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