@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,601 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { extensionPoints } from '@open-mercato/core/modules/customer_accounts/extension-points'
5
+ import Link from 'next/link'
6
+ import { useRouter } from 'next/navigation'
7
+ import { Globe, Settings } from 'lucide-react'
8
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
9
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
10
+ import { RowActions } from '@open-mercato/ui/backend/RowActions'
11
+ import { Button } from '@open-mercato/ui/primitives/button'
12
+ import { Input } from '@open-mercato/ui/primitives/input'
13
+ import { EmailInput } from '@open-mercato/ui/primitives/email-input'
14
+ import { PasswordInput } from '@open-mercato/ui/primitives/password-input'
15
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
16
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
17
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
18
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
19
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
20
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
21
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
22
+ import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
23
+ import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
24
+ import { buildPortalRootUrl, buildPortalUrlPattern } from '../../../lib/portalUrl'
25
+
26
+ type UserRow = {
27
+ id: string
28
+ displayName: string
29
+ email: string
30
+ emailVerified: boolean
31
+ isActive: boolean
32
+ lastLoginAt: string | null
33
+ roles: Array<{ id: string; name: string; slug: string }>
34
+ createdAt: string
35
+ updatedAt?: string | null
36
+ personEntityId: string | null
37
+ customerEntityId: string | null
38
+ }
39
+
40
+ type UsersResponse = {
41
+ items?: UserRow[]
42
+ total?: number
43
+ totalPages?: number
44
+ totalIsCapped?: boolean
45
+ }
46
+
47
+ function formatDate(value: string | null | undefined, fallback: string): string {
48
+ if (!value) return fallback
49
+ const date = new Date(value)
50
+ if (Number.isNaN(date.getTime())) return fallback
51
+ return date.toLocaleDateString()
52
+ }
53
+
54
+ async function fetchRoleFilterOptions(): Promise<Array<{ value: string; label: string; id: string }>> {
55
+ try {
56
+ const call = await apiCall<{ items?: Array<{ id: string; name: string }> }>(
57
+ '/api/customer_accounts/admin/roles?pageSize=100',
58
+ )
59
+ if (!call.ok) return []
60
+ const items = Array.isArray(call.result?.items) ? call.result!.items : []
61
+ return items
62
+ .filter((item) => typeof item?.id === 'string' && typeof item?.name === 'string')
63
+ .map((item) => ({ value: item.id, label: item.name, id: item.id }))
64
+ } catch {
65
+ return []
66
+ }
67
+ }
68
+
69
+ function CreateUserDialog({
70
+ open,
71
+ onOpenChange,
72
+ roleOptions,
73
+ onCreated,
74
+ onRunMutation,
75
+ }: {
76
+ open: boolean
77
+ onOpenChange: (next: boolean) => void
78
+ roleOptions: Array<{ id: string; label: string }>
79
+ onCreated: () => void
80
+ onRunMutation: <T>(operation: () => Promise<T>) => Promise<T>
81
+ }) {
82
+ const t = useT()
83
+ const [email, setEmail] = React.useState('')
84
+ const [displayName, setDisplayName] = React.useState('')
85
+ const [password, setPassword] = React.useState('')
86
+ const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])
87
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
88
+
89
+ const resetForm = React.useCallback(() => {
90
+ setEmail('')
91
+ setDisplayName('')
92
+ setPassword('')
93
+ setSelectedRoleIds([])
94
+ }, [])
95
+
96
+ const handleSubmit = React.useCallback(async (event: React.FormEvent) => {
97
+ event.preventDefault()
98
+ if (!email.trim() || !displayName.trim() || !password.trim()) {
99
+ flash(t('customer_accounts.admin.createUser.error.required', 'Email, name, and password are required'), 'error')
100
+ return
101
+ }
102
+ setIsSubmitting(true)
103
+ try {
104
+ await onRunMutation(async () => {
105
+ const call = await apiCall<{ ok: boolean; error?: string }>(
106
+ '/api/customer_accounts/admin/users',
107
+ {
108
+ method: 'POST',
109
+ headers: { 'content-type': 'application/json' },
110
+ body: JSON.stringify({
111
+ email: email.trim(),
112
+ displayName: displayName.trim(),
113
+ password,
114
+ roleIds: selectedRoleIds.length > 0 ? selectedRoleIds : undefined,
115
+ }),
116
+ },
117
+ )
118
+ if (!call.ok) {
119
+ flash(call.result?.error || t('customer_accounts.admin.createUser.error.save', 'Failed to create user'), 'error')
120
+ return
121
+ }
122
+ flash(t('customer_accounts.admin.createUser.flash.created', 'User created'), 'success')
123
+ resetForm()
124
+ onOpenChange(false)
125
+ onCreated()
126
+ })
127
+ } catch (err) {
128
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.createUser.error.save', 'Failed to create user')
129
+ flash(message, 'error')
130
+ } finally {
131
+ setIsSubmitting(false)
132
+ }
133
+ }, [displayName, email, onCreated, onOpenChange, onRunMutation, password, resetForm, selectedRoleIds, t])
134
+
135
+ const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
136
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
137
+ event.preventDefault()
138
+ const form = (event.target as HTMLElement).closest('form')
139
+ if (form) form.requestSubmit()
140
+ }
141
+ }, [])
142
+
143
+ return (
144
+ <Dialog open={open} onOpenChange={(next) => { if (!next) resetForm(); onOpenChange(next) }}>
145
+ <DialogContent className="sm:max-w-lg">
146
+ <DialogHeader>
147
+ <DialogTitle>{t('customer_accounts.admin.createUser.title', 'Create Customer User')}</DialogTitle>
148
+ </DialogHeader>
149
+ <form onSubmit={(event) => { void handleSubmit(event) }} onKeyDown={handleKeyDown} className="space-y-4">
150
+ <div className="space-y-2">
151
+ <label className="text-sm font-medium" htmlFor="create-email">
152
+ {t('customer_accounts.admin.createUser.fields.email', 'Email')}
153
+ </label>
154
+ <EmailInput
155
+ id="create-email"
156
+ required
157
+ value={email}
158
+ onChange={(event) => setEmail(event.target.value)}
159
+ placeholder={t('customer_accounts.admin.createUser.fields.emailPlaceholder', 'user@example.com')}
160
+ />
161
+ </div>
162
+ <div className="space-y-2">
163
+ <label className="text-sm font-medium" htmlFor="create-name">
164
+ {t('customer_accounts.admin.createUser.fields.displayName', 'Display Name')}
165
+ </label>
166
+ <Input
167
+ id="create-name"
168
+ type="text"
169
+ required
170
+ value={displayName}
171
+ onChange={(event) => setDisplayName(event.target.value)}
172
+ placeholder={t('customer_accounts.admin.createUser.fields.displayNamePlaceholder', 'John Doe')}
173
+ />
174
+ </div>
175
+ <div className="space-y-2">
176
+ <label className="text-sm font-medium" htmlFor="create-password">
177
+ {t('customer_accounts.admin.createUser.fields.password', 'Password')}
178
+ </label>
179
+ <PasswordInput
180
+ id="create-password"
181
+ required
182
+ minLength={8}
183
+ value={password}
184
+ onChange={(event) => setPassword(event.target.value)}
185
+ placeholder={t('customer_accounts.admin.createUser.fields.passwordPlaceholder', 'Min. 8 characters')}
186
+ autoComplete="new-password"
187
+ />
188
+ </div>
189
+ {roleOptions.length > 0 && (
190
+ <div className="space-y-2">
191
+ <p className="text-sm font-medium">{t('customer_accounts.admin.createUser.fields.roles', 'Roles')}</p>
192
+ <div className="flex flex-wrap gap-2">
193
+ {roleOptions.map((role) => {
194
+ const isSelected = selectedRoleIds.includes(role.id)
195
+ return (
196
+ <Button
197
+ key={role.id}
198
+ type="button"
199
+ size="2xs"
200
+ variant={isSelected ? 'secondary' : 'outline'}
201
+ aria-pressed={isSelected}
202
+ onClick={() => setSelectedRoleIds((prev) =>
203
+ prev.includes(role.id) ? prev.filter((rid) => rid !== role.id) : [...prev, role.id],
204
+ )}
205
+ className="rounded-full"
206
+ >
207
+ {role.label}
208
+ </Button>
209
+ )
210
+ })}
211
+ </div>
212
+ </div>
213
+ )}
214
+ <div className="flex justify-end gap-2 pt-2">
215
+ <Button type="button" variant="outline" onClick={() => { resetForm(); onOpenChange(false) }}>
216
+ {t('customer_accounts.admin.createUser.actions.cancel', 'Cancel')}
217
+ </Button>
218
+ <Button type="submit" disabled={isSubmitting}>
219
+ {isSubmitting
220
+ ? t('customer_accounts.admin.createUser.actions.creating', 'Creating...')
221
+ : t('customer_accounts.admin.createUser.actions.create', 'Create User')}
222
+ </Button>
223
+ </div>
224
+ </form>
225
+ </DialogContent>
226
+ </Dialog>
227
+ )
228
+ }
229
+
230
+ export type PortalUsersPageClientProps = {
231
+ portalOrigin: string
232
+ }
233
+
234
+ export function PortalUsersPageClient({ portalOrigin }: PortalUsersPageClientProps) {
235
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
236
+ const t = useT()
237
+ const router = useRouter()
238
+ const [rows, setRows] = React.useState<UserRow[]>([])
239
+ const [page, setPage] = React.useState(1)
240
+ const [pageSize] = React.useState(50)
241
+ const [total, setTotal] = React.useState(0)
242
+ const [totalPages, setTotalPages] = React.useState(1)
243
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
244
+ const [search, setSearch] = React.useState('')
245
+ const [filterValues, setFilterValues] = React.useState<FilterValues>({})
246
+ const [isLoading, setIsLoading] = React.useState(true)
247
+ const [reloadToken, setReloadToken] = React.useState(0)
248
+ const [roleOptions, setRoleOptions] = React.useState<Array<{ value: string; label: string; id: string }>>([])
249
+ const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
250
+
251
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
252
+ entityType: string
253
+ entityId?: string
254
+ }>({
255
+ contextId: 'customer_accounts:users-list',
256
+ })
257
+
258
+ const runMutationWithContext = React.useCallback(
259
+ async <T,>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>): Promise<T> => {
260
+ return runMutation({
261
+ operation,
262
+ mutationPayload,
263
+ context: { entityType: 'customer_accounts:user' },
264
+ })
265
+ },
266
+ [runMutation],
267
+ )
268
+
269
+ React.useEffect(() => {
270
+ let cancelled = false
271
+ fetchRoleFilterOptions().then((opts) => {
272
+ if (!cancelled) setRoleOptions(opts)
273
+ })
274
+ return () => { cancelled = true }
275
+ }, [])
276
+
277
+ const filters = React.useMemo<FilterDef[]>(() => [
278
+ {
279
+ id: 'status',
280
+ label: t('customer_accounts.admin.filters.status', 'Status'),
281
+ type: 'select',
282
+ options: [
283
+ { value: 'active', label: t('customer_accounts.admin.filters.active', 'Active') },
284
+ { value: 'inactive', label: t('customer_accounts.admin.filters.inactive', 'Inactive') },
285
+ ],
286
+ },
287
+ {
288
+ id: 'roleId',
289
+ label: t('customer_accounts.admin.filters.role', 'Role'),
290
+ type: 'select',
291
+ options: roleOptions,
292
+ },
293
+ ], [roleOptions, t])
294
+
295
+ const queryParams = React.useMemo(() => {
296
+ const params = new URLSearchParams()
297
+ params.set('page', String(page))
298
+ params.set('pageSize', String(pageSize))
299
+ if (search.trim()) params.set('search', search.trim())
300
+ const status = filterValues.status
301
+ if (typeof status === 'string' && status.trim()) params.set('status', status)
302
+ const roleId = filterValues.roleId
303
+ if (typeof roleId === 'string' && roleId.trim()) params.set('roleId', roleId)
304
+ return params.toString()
305
+ }, [filterValues, page, pageSize, search])
306
+
307
+ React.useEffect(() => {
308
+ let cancelled = false
309
+ async function load() {
310
+ setIsLoading(true)
311
+ try {
312
+ const fallback: UsersResponse = { items: [], total: 0, totalPages: 1 }
313
+ const payload = await readApiResultOrThrow<UsersResponse>(
314
+ `/api/customer_accounts/admin/users?${queryParams}`,
315
+ undefined,
316
+ { errorMessage: t('customer_accounts.admin.error.loadUsers', 'Failed to load customer users'), fallback },
317
+ )
318
+ if (cancelled) return
319
+ const items = Array.isArray(payload?.items) ? payload.items : []
320
+ setRows(items)
321
+ setTotal(typeof payload?.total === 'number' ? payload.total : items.length)
322
+ setTotalPages(typeof payload?.totalPages === 'number' ? payload.totalPages : 1)
323
+ setTotalIsCapped(payload?.totalIsCapped === true)
324
+ } catch (err) {
325
+ if (!cancelled) {
326
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.error.loadUsers', 'Failed to load customer users')
327
+ flash(message, 'error')
328
+ }
329
+ } finally {
330
+ if (!cancelled) setIsLoading(false)
331
+ }
332
+ }
333
+ load()
334
+ return () => { cancelled = true }
335
+ }, [queryParams, reloadToken, t])
336
+
337
+ const handleToggleActive = React.useCallback(async (user: UserRow) => {
338
+ const nextActive = !user.isActive
339
+ const actionLabel = nextActive
340
+ ? t('customer_accounts.admin.actions.activate', 'Activate')
341
+ : t('customer_accounts.admin.actions.deactivate', 'Deactivate')
342
+ const confirmed = await confirm({
343
+ title: t('customer_accounts.admin.confirm.toggleActive', '{{action}} user "{{name}}"?', {
344
+ action: actionLabel,
345
+ name: user.displayName || user.email,
346
+ }),
347
+ variant: nextActive ? 'default' : 'destructive',
348
+ })
349
+ if (!confirmed) return
350
+ try {
351
+ await runMutationWithContext(async () => {
352
+ const call = await withScopedApiRequestHeaders(
353
+ buildOptimisticLockHeader(user.updatedAt),
354
+ () => apiCall(
355
+ `/api/customer_accounts/admin/users/${encodeURIComponent(user.id)}`,
356
+ {
357
+ method: 'PUT',
358
+ headers: { 'content-type': 'application/json' },
359
+ body: JSON.stringify({ isActive: nextActive }),
360
+ },
361
+ ),
362
+ )
363
+ if (!call.ok) {
364
+ flash(t('customer_accounts.admin.error.toggleActive', 'Failed to update user status'), 'error')
365
+ return
366
+ }
367
+ flash(
368
+ nextActive
369
+ ? t('customer_accounts.admin.flash.activated', 'User activated')
370
+ : t('customer_accounts.admin.flash.deactivated', 'User deactivated'),
371
+ 'success',
372
+ )
373
+ setReloadToken((token) => token + 1)
374
+ }, { userId: user.id, isActive: nextActive })
375
+ } catch (err) {
376
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.error.toggleActive', 'Failed to update user status')
377
+ flash(message, 'error')
378
+ }
379
+ }, [confirm, runMutationWithContext, t])
380
+
381
+ const handleDelete = React.useCallback(async (user: UserRow) => {
382
+ const confirmed = await confirm({
383
+ title: t('customer_accounts.admin.confirm.delete', 'Delete user "{{name}}"?', {
384
+ name: user.displayName || user.email,
385
+ }),
386
+ variant: 'destructive',
387
+ })
388
+ if (!confirmed) return
389
+ try {
390
+ await runMutationWithContext(async () => {
391
+ const call = await withScopedApiRequestHeaders(
392
+ buildOptimisticLockHeader(user.updatedAt),
393
+ () => apiCall(
394
+ `/api/customer_accounts/admin/users/${encodeURIComponent(user.id)}`,
395
+ { method: 'DELETE' },
396
+ ),
397
+ )
398
+ if (!call.ok) {
399
+ flash(t('customer_accounts.admin.error.delete', 'Failed to delete user'), 'error')
400
+ return
401
+ }
402
+ flash(t('customer_accounts.admin.flash.deleted', 'User deleted'), 'success')
403
+ setReloadToken((token) => token + 1)
404
+ }, { id: user.id })
405
+ } catch (err) {
406
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.error.delete', 'Failed to delete user')
407
+ flash(message, 'error')
408
+ }
409
+ }, [confirm, runMutationWithContext, t])
410
+
411
+ const handleFiltersApply = React.useCallback((values: FilterValues) => {
412
+ setFilterValues(values)
413
+ setPage(1)
414
+ }, [])
415
+
416
+ const handleFiltersClear = React.useCallback(() => {
417
+ setFilterValues({})
418
+ setPage(1)
419
+ }, [])
420
+
421
+ const columns = React.useMemo<ColumnDef<UserRow>[]>(() => {
422
+ const noValue = <span className="text-muted-foreground text-sm">-</span>
423
+ return [
424
+ {
425
+ accessorKey: 'displayName',
426
+ header: t('customer_accounts.admin.columns.displayName', 'Name'),
427
+ cell: ({ row }) => (
428
+ <Link
429
+ href={`/backend/customer_accounts/users/${row.original.id}`}
430
+ className="font-medium hover:underline"
431
+ >
432
+ {row.original.displayName || row.original.email}
433
+ </Link>
434
+ ),
435
+ },
436
+ {
437
+ accessorKey: 'email',
438
+ header: t('customer_accounts.admin.columns.email', 'Email'),
439
+ },
440
+ {
441
+ accessorKey: 'emailVerified',
442
+ header: t('customer_accounts.admin.columns.emailVerified', 'Verified'),
443
+ cell: ({ row }) => (
444
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
445
+ row.original.emailVerified
446
+ ? 'bg-status-success-bg text-status-success-text'
447
+ : 'bg-status-warning-bg text-status-warning-text'
448
+ }`}>
449
+ {row.original.emailVerified
450
+ ? t('customer_accounts.admin.verified', 'Yes')
451
+ : t('customer_accounts.admin.unverified', 'No')}
452
+ </span>
453
+ ),
454
+ },
455
+ {
456
+ accessorKey: 'isActive',
457
+ header: t('customer_accounts.admin.columns.status', 'Status'),
458
+ cell: ({ row }) => (
459
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
460
+ row.original.isActive
461
+ ? 'bg-status-success-bg text-status-success-text'
462
+ : 'bg-status-error-bg text-status-error-text'
463
+ }`}>
464
+ {row.original.isActive
465
+ ? t('customer_accounts.admin.active', 'Active')
466
+ : t('customer_accounts.admin.inactive', 'Inactive')}
467
+ </span>
468
+ ),
469
+ },
470
+ {
471
+ accessorKey: 'lastLoginAt',
472
+ header: t('customer_accounts.admin.columns.lastLogin', 'Last Login'),
473
+ cell: ({ row }) => formatDate(row.original.lastLoginAt, '-') || noValue,
474
+ },
475
+ {
476
+ accessorKey: 'roles',
477
+ header: t('customer_accounts.admin.columns.roles', 'Roles'),
478
+ cell: ({ row }) => {
479
+ const roles = row.original.roles
480
+ if (!roles || !roles.length) return noValue
481
+ return <span className="text-sm">{roles.map((r) => r.name).join(', ')}</span>
482
+ },
483
+ },
484
+ {
485
+ accessorKey: 'createdAt',
486
+ header: t('customer_accounts.admin.columns.createdAt', 'Created'),
487
+ cell: ({ row }) => formatDate(row.original.createdAt, '-'),
488
+ },
489
+ ]
490
+ }, [t])
491
+
492
+ return (
493
+ <>
494
+ <div className="rounded-lg border border-status-info-border bg-status-info-bg p-4">
495
+ <div className="flex items-start justify-between gap-4">
496
+ <div>
497
+ <h3 className="text-sm font-medium text-status-info-text">
498
+ {t('customer_accounts.admin.portalInfo.title', 'Customer Portal')}
499
+ </h3>
500
+ <p className="mt-1 text-sm text-status-info-text">
501
+ {t('customer_accounts.admin.portalInfo.description', 'Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.')}
502
+ </p>
503
+ <p className="mt-1.5 text-xs text-status-info-text">
504
+ {t('customer_accounts.admin.portalInfo.url', 'Portal URL: {url}', {
505
+ url: buildPortalUrlPattern(portalOrigin),
506
+ })}
507
+ </p>
508
+ <p className="mt-0.5 text-xs text-status-info-text">
509
+ {t('customer_accounts.admin.portalInfo.credentials', 'Demo credentials: alice.johnson@example.com / Password123!')}
510
+ </p>
511
+ </div>
512
+ <div className="flex shrink-0 flex-col gap-2">
513
+ <Button
514
+ type="button"
515
+ variant="outline"
516
+ size="sm"
517
+ asChild
518
+ >
519
+ <Link href="/backend/customer_accounts/settings">
520
+ <Settings className="size-4" />
521
+ {t('customer_accounts.admin.portalInfo.openConfiguration', 'Open Configuration')}
522
+ </Link>
523
+ </Button>
524
+ <Button
525
+ type="button"
526
+ variant="outline"
527
+ size="sm"
528
+ asChild
529
+ >
530
+ <a href={buildPortalRootUrl(portalOrigin)} target="_blank" rel="noopener noreferrer">
531
+ <Globe className="size-4" />
532
+ {t('customer_accounts.admin.portalInfo.open', 'Open Portal')}
533
+ </a>
534
+ </Button>
535
+ </div>
536
+ </div>
537
+ </div>
538
+ <DataTable<UserRow>
539
+ stickyActionsColumn
540
+ title={t('customer_accounts.admin.title', 'Users')}
541
+ actions={(
542
+ <Button onClick={() => setCreateDialogOpen(true)}>
543
+ {t('customer_accounts.admin.actions.createUser', 'Create User')}
544
+ </Button>
545
+ )}
546
+ columns={columns}
547
+ data={rows}
548
+ searchValue={search}
549
+ onSearchChange={(value) => { setSearch(value); setPage(1) }}
550
+ searchPlaceholder={t('customer_accounts.admin.searchPlaceholder', 'Search by name or email...')}
551
+ filters={filters}
552
+ filterValues={filterValues}
553
+ onFiltersApply={handleFiltersApply}
554
+ onFiltersClear={handleFiltersClear}
555
+ perspective={{ tableId: extensionPoints.hosts.usersTable.tableId }}
556
+ emptyState={(
557
+ <ListEmptyState
558
+ entityName={t('customer_accounts.admin.title', 'Users')}
559
+ onCreate={() => setCreateDialogOpen(true)}
560
+ createLabel={t('customer_accounts.admin.actions.createUser', 'Create User')}
561
+ />
562
+ )}
563
+ onRowClick={(row) => router.push(`/backend/customer_accounts/users/${row.id}`)}
564
+ rowActions={(row) => (
565
+ <RowActions
566
+ items={[
567
+ {
568
+ id: 'view',
569
+ label: t('customer_accounts.admin.actions.view', 'View'),
570
+ onSelect: () => { router.push(`/backend/customer_accounts/users/${row.id}`) },
571
+ },
572
+ {
573
+ id: 'toggle-active',
574
+ label: row.isActive
575
+ ? t('customer_accounts.admin.actions.deactivate', 'Deactivate')
576
+ : t('customer_accounts.admin.actions.activate', 'Activate'),
577
+ onSelect: () => { void handleToggleActive(row) },
578
+ },
579
+ {
580
+ id: 'delete',
581
+ label: t('customer_accounts.admin.actions.delete', 'Delete'),
582
+ destructive: true,
583
+ onSelect: () => { void handleDelete(row) },
584
+ },
585
+ ]}
586
+ />
587
+ )}
588
+ pagination={{ page, pageSize, total, totalPages, totalIsCapped, onPageChange: setPage }}
589
+ isLoading={isLoading}
590
+ />
591
+ <CreateUserDialog
592
+ open={createDialogOpen}
593
+ onOpenChange={setCreateDialogOpen}
594
+ roleOptions={roleOptions}
595
+ onCreated={() => setReloadToken((token) => token + 1)}
596
+ onRunMutation={runMutationWithContext}
597
+ />
598
+ {ConfirmDialogElement}
599
+ </>
600
+ )
601
+ }