@ngbplatform/ui 1.3.0

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 (217) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +44 -0
  3. package/package.json +61 -0
  4. package/public/favicon.svg +6 -0
  5. package/public/silent-check-sso.html +8 -0
  6. package/src/assets/ngb_logo.svg +17 -0
  7. package/src/assets/ngb_logo_color.svg +17 -0
  8. package/src/index.ts +1023 -0
  9. package/src/ngb/README.md +27 -0
  10. package/src/ngb/accounting/NgbAccountingPeriodClosingPage.vue +1157 -0
  11. package/src/ngb/accounting/NgbChartOfAccountEditor.vue +590 -0
  12. package/src/ngb/accounting/NgbChartOfAccountsPage.vue +420 -0
  13. package/src/ngb/accounting/NgbGeneralJournalEntryEditPage.vue +796 -0
  14. package/src/ngb/accounting/NgbGeneralJournalEntryLinesEditor.vue +454 -0
  15. package/src/ngb/accounting/NgbGeneralJournalEntryListPage.vue +186 -0
  16. package/src/ngb/accounting/api.ts +79 -0
  17. package/src/ngb/accounting/generalJournalEntry.ts +127 -0
  18. package/src/ngb/accounting/generalJournalEntryApi.ts +112 -0
  19. package/src/ngb/accounting/generalJournalEntryTypes.ts +155 -0
  20. package/src/ngb/accounting/navigation.ts +83 -0
  21. package/src/ngb/accounting/periodClosing.ts +75 -0
  22. package/src/ngb/accounting/periodClosingApi.ts +60 -0
  23. package/src/ngb/accounting/periodClosingTypes.ts +73 -0
  24. package/src/ngb/accounting/types.ts +57 -0
  25. package/src/ngb/api/audit.ts +23 -0
  26. package/src/ngb/api/catalogs.ts +50 -0
  27. package/src/ngb/api/contracts.ts +285 -0
  28. package/src/ngb/api/documents.ts +170 -0
  29. package/src/ngb/api/http.ts +446 -0
  30. package/src/ngb/api/lookups.ts +14 -0
  31. package/src/ngb/api/types.ts +6 -0
  32. package/src/ngb/auth/index.ts +13 -0
  33. package/src/ngb/auth/keycloak.ts +374 -0
  34. package/src/ngb/auth/router.ts +35 -0
  35. package/src/ngb/auth/types.ts +12 -0
  36. package/src/ngb/auth/useAuthStore.ts +209 -0
  37. package/src/ngb/command-palette/NgbCommandPaletteDialog.vue +342 -0
  38. package/src/ngb/command-palette/NgbCommandPaletteEmptyState.vue +55 -0
  39. package/src/ngb/command-palette/NgbCommandPaletteFooterHints.vue +29 -0
  40. package/src/ngb/command-palette/api.ts +10 -0
  41. package/src/ngb/command-palette/config.ts +16 -0
  42. package/src/ngb/command-palette/presentation.ts +85 -0
  43. package/src/ngb/command-palette/search.ts +130 -0
  44. package/src/ngb/command-palette/staticItems.ts +243 -0
  45. package/src/ngb/command-palette/storage.ts +45 -0
  46. package/src/ngb/command-palette/store.ts +716 -0
  47. package/src/ngb/command-palette/types.ts +120 -0
  48. package/src/ngb/command-palette/useCommandPaletteHotkeys.ts +27 -0
  49. package/src/ngb/command-palette/useCommandPalettePageContext.ts +18 -0
  50. package/src/ngb/components/NgbConfirmDialog.vue +63 -0
  51. package/src/ngb/components/NgbDialog.vue +44 -0
  52. package/src/ngb/components/NgbDrawer.vue +156 -0
  53. package/src/ngb/components/NgbHeaderActionCluster.vue +140 -0
  54. package/src/ngb/components/NgbModalShell.vue +99 -0
  55. package/src/ngb/components/NgbToolbar.vue +14 -0
  56. package/src/ngb/components/forms/NgbFormLayout.vue +13 -0
  57. package/src/ngb/components/forms/NgbFormRow.vue +21 -0
  58. package/src/ngb/components/forms/NgbFormSection.vue +16 -0
  59. package/src/ngb/components/forms/NgbValidationSummary.vue +18 -0
  60. package/src/ngb/components/navigation/NgbNavigationTree.vue +68 -0
  61. package/src/ngb/components/navigation/NgbTreeNode.vue +94 -0
  62. package/src/ngb/components/register/NgbColumnChooser.vue +101 -0
  63. package/src/ngb/components/register/NgbRegisterGrid.vue +473 -0
  64. package/src/ngb/components/register/registerTypes.ts +51 -0
  65. package/src/ngb/components/register/useRegisterColumnResize.ts +99 -0
  66. package/src/ngb/components/register/useRegisterColumnState.ts +220 -0
  67. package/src/ngb/components/register/useRegisterRows.ts +209 -0
  68. package/src/ngb/components/register/useRegisterViewport.ts +56 -0
  69. package/src/ngb/editor/NgbDocumentEffectsPage.vue +512 -0
  70. package/src/ngb/editor/NgbDocumentFlowPage.vue +409 -0
  71. package/src/ngb/editor/NgbDocumentPrintPage.vue +712 -0
  72. package/src/ngb/editor/NgbEditorDiscardDialog.vue +40 -0
  73. package/src/ngb/editor/NgbEntityAuditSidebar.vue +288 -0
  74. package/src/ngb/editor/NgbEntityEditor.vue +250 -0
  75. package/src/ngb/editor/NgbEntityEditorDrawerActions.vue +88 -0
  76. package/src/ngb/editor/NgbEntityEditorHeader.vue +114 -0
  77. package/src/ngb/editor/catalogNavigation.ts +13 -0
  78. package/src/ngb/editor/config.ts +252 -0
  79. package/src/ngb/editor/documentCopyDraft.ts +245 -0
  80. package/src/ngb/editor/documentEffects.ts +43 -0
  81. package/src/ngb/editor/documentEffectsDateFormatting.ts +18 -0
  82. package/src/ngb/editor/documentNavigation.ts +115 -0
  83. package/src/ngb/editor/documentStatus.ts +45 -0
  84. package/src/ngb/editor/entityEditorErrors.ts +162 -0
  85. package/src/ngb/editor/entityEditorPersistence.ts +241 -0
  86. package/src/ngb/editor/extensions.ts +40 -0
  87. package/src/ngb/editor/types.ts +295 -0
  88. package/src/ngb/editor/useConfiguredEntityEditorDocumentActions.ts +183 -0
  89. package/src/ngb/editor/useDocumentEditorDrawerState.ts +152 -0
  90. package/src/ngb/editor/useEditorDrawerState.ts +88 -0
  91. package/src/ngb/editor/useEntityEditorBusinessContext.ts +97 -0
  92. package/src/ngb/editor/useEntityEditorCapabilities.ts +190 -0
  93. package/src/ngb/editor/useEntityEditorCommandPalette.ts +154 -0
  94. package/src/ngb/editor/useEntityEditorCommitHandlers.ts +102 -0
  95. package/src/ngb/editor/useEntityEditorHeaderActions.ts +253 -0
  96. package/src/ngb/editor/useEntityEditorLeaveGuard.ts +66 -0
  97. package/src/ngb/editor/useEntityEditorNavigationActions.ts +235 -0
  98. package/src/ngb/editor/useEntityEditorOutputs.ts +59 -0
  99. package/src/ngb/editor/useEntityEditorPageActions.ts +57 -0
  100. package/src/ngb/editor/useRouteQueryEditorDrawer.ts +173 -0
  101. package/src/ngb/env/runtimeConfig.ts +25 -0
  102. package/src/ngb/lookup/config.ts +42 -0
  103. package/src/ngb/lookup/defaultConfig.ts +136 -0
  104. package/src/ngb/lookup/navigation.ts +76 -0
  105. package/src/ngb/lookup/prefetch.ts +40 -0
  106. package/src/ngb/lookup/store.ts +261 -0
  107. package/src/ngb/metadata/NgbDocumentListFiltersDrawer.vue +78 -0
  108. package/src/ngb/metadata/NgbDocumentPeriodFilter.vue +53 -0
  109. package/src/ngb/metadata/NgbEntityForm.vue +84 -0
  110. package/src/ngb/metadata/NgbEntityFormFieldsBlock.vue +110 -0
  111. package/src/ngb/metadata/NgbEntityListPageHeader.vue +80 -0
  112. package/src/ngb/metadata/NgbFilterFieldControl.vue +104 -0
  113. package/src/ngb/metadata/NgbMetadataCatalogEditPage.vue +42 -0
  114. package/src/ngb/metadata/NgbMetadataCatalogListPage.vue +269 -0
  115. package/src/ngb/metadata/NgbMetadataDocumentEditPage.vue +68 -0
  116. package/src/ngb/metadata/NgbMetadataDocumentListPage.vue +353 -0
  117. package/src/ngb/metadata/NgbMetadataFieldRenderer.vue +124 -0
  118. package/src/ngb/metadata/NgbMetadataLookupControl.vue +92 -0
  119. package/src/ngb/metadata/NgbRecycleBinFilter.vue +53 -0
  120. package/src/ngb/metadata/NgbRegisterPageLayout.vue +143 -0
  121. package/src/ngb/metadata/config.ts +28 -0
  122. package/src/ngb/metadata/dataTypes.ts +27 -0
  123. package/src/ngb/metadata/entityForm.ts +114 -0
  124. package/src/ngb/metadata/entityModel.ts +34 -0
  125. package/src/ngb/metadata/entityValueFormatting.ts +83 -0
  126. package/src/ngb/metadata/fieldRendererState.ts +84 -0
  127. package/src/ngb/metadata/filtering.ts +153 -0
  128. package/src/ngb/metadata/lookup.ts +25 -0
  129. package/src/ngb/metadata/monthPagedListQuery.ts +108 -0
  130. package/src/ngb/metadata/normalization.ts +100 -0
  131. package/src/ngb/metadata/referenceHydration.ts +99 -0
  132. package/src/ngb/metadata/register.ts +135 -0
  133. package/src/ngb/metadata/routePages.ts +86 -0
  134. package/src/ngb/metadata/store.ts +35 -0
  135. package/src/ngb/metadata/types.ts +276 -0
  136. package/src/ngb/metadata/useMetadataListFilters.ts +306 -0
  137. package/src/ngb/metadata/useMetadataRegisterPageData.ts +246 -0
  138. package/src/ngb/metadata/useValidationFocus.ts +49 -0
  139. package/src/ngb/primitives/NgbBadge.vue +31 -0
  140. package/src/ngb/primitives/NgbButton.vue +53 -0
  141. package/src/ngb/primitives/NgbCheckbox.vue +33 -0
  142. package/src/ngb/primitives/NgbDatePicker.vue +141 -0
  143. package/src/ngb/primitives/NgbIcon.vue +545 -0
  144. package/src/ngb/primitives/NgbInput.vue +57 -0
  145. package/src/ngb/primitives/NgbLookup.vue +264 -0
  146. package/src/ngb/primitives/NgbMonthPicker.vue +132 -0
  147. package/src/ngb/primitives/NgbMultiSelect.vue +179 -0
  148. package/src/ngb/primitives/NgbPeriodPicker.vue +125 -0
  149. package/src/ngb/primitives/NgbPickerNavButton.vue +31 -0
  150. package/src/ngb/primitives/NgbPickerPopover.vue +79 -0
  151. package/src/ngb/primitives/NgbRadioGroup.vue +53 -0
  152. package/src/ngb/primitives/NgbSelect.vue +108 -0
  153. package/src/ngb/primitives/NgbStatusIcon.vue +63 -0
  154. package/src/ngb/primitives/NgbSwitch.vue +42 -0
  155. package/src/ngb/primitives/NgbTabs.vue +116 -0
  156. package/src/ngb/primitives/NgbToastHost.vue +49 -0
  157. package/src/ngb/primitives/NgbTooltip.vue +27 -0
  158. package/src/ngb/primitives/iconNames.ts +83 -0
  159. package/src/ngb/primitives/toast.ts +66 -0
  160. package/src/ngb/primitives/useAsyncComboboxQuery.ts +85 -0
  161. package/src/ngb/primitives/useFloatingDropdownPosition.ts +129 -0
  162. package/src/ngb/reporting/NgbReportComposerCollectionSection.vue +106 -0
  163. package/src/ngb/reporting/NgbReportComposerPanel.vue +1187 -0
  164. package/src/ngb/reporting/NgbReportDateRangeFilter.vue +54 -0
  165. package/src/ngb/reporting/NgbReportPage.vue +1124 -0
  166. package/src/ngb/reporting/NgbReportSheet.vue +467 -0
  167. package/src/ngb/reporting/api.ts +35 -0
  168. package/src/ngb/reporting/composer.ts +904 -0
  169. package/src/ngb/reporting/config.ts +120 -0
  170. package/src/ngb/reporting/defaultConfig.ts +15 -0
  171. package/src/ngb/reporting/lookupFilters.ts +43 -0
  172. package/src/ngb/reporting/navigation.ts +181 -0
  173. package/src/ngb/reporting/pageHelpers.ts +73 -0
  174. package/src/ngb/reporting/pageSession.ts +92 -0
  175. package/src/ngb/reporting/paging.ts +66 -0
  176. package/src/ngb/reporting/types.ts +333 -0
  177. package/src/ngb/router/backNavigation.ts +122 -0
  178. package/src/ngb/router/queryParams.ts +129 -0
  179. package/src/ngb/router/queryState.ts +121 -0
  180. package/src/ngb/router/routeAliases.ts +66 -0
  181. package/src/ngb/router/routeParams.ts +15 -0
  182. package/src/ngb/router/shareLink.ts +39 -0
  183. package/src/ngb/security/NgbAccessAuditPanel.vue +32 -0
  184. package/src/ngb/security/NgbAccessDeniedState.vue +27 -0
  185. package/src/ngb/security/NgbEffectiveAccessPanel.vue +60 -0
  186. package/src/ngb/security/NgbPermissionMatrix.vue +160 -0
  187. package/src/ngb/security/NgbRoleEditorPage.vue +319 -0
  188. package/src/ngb/security/NgbRolesPage.vue +125 -0
  189. package/src/ngb/security/NgbUserEditorPage.vue +615 -0
  190. package/src/ngb/security/NgbUsersPage.vue +125 -0
  191. package/src/ngb/security/api.ts +91 -0
  192. package/src/ngb/security/audit.ts +21 -0
  193. package/src/ngb/security/permissions.ts +65 -0
  194. package/src/ngb/security/types.ts +157 -0
  195. package/src/ngb/security/useAccessStore.ts +69 -0
  196. package/src/ngb/site/NgbDashboardAsOfToolbar.vue +100 -0
  197. package/src/ngb/site/NgbDashboardStatusBanner.vue +91 -0
  198. package/src/ngb/site/NgbLogo.vue +44 -0
  199. package/src/ngb/site/NgbPageHeader.vue +49 -0
  200. package/src/ngb/site/NgbSiteShell.vue +286 -0
  201. package/src/ngb/site/NgbSiteSidebar.vue +270 -0
  202. package/src/ngb/site/NgbTopBar.vue +425 -0
  203. package/src/ngb/site/NgbTrendChart.vue +224 -0
  204. package/src/ngb/site/dashboardData.ts +324 -0
  205. package/src/ngb/site/mainMenuStore.ts +49 -0
  206. package/src/ngb/site/types.ts +31 -0
  207. package/src/ngb/site/useDashboardPageState.ts +98 -0
  208. package/src/ngb/site/useTheme.ts +95 -0
  209. package/src/ngb/utils/clone.ts +43 -0
  210. package/src/ngb/utils/dateValues.ts +107 -0
  211. package/src/ngb/utils/errorMessage.ts +14 -0
  212. package/src/ngb/utils/guid.ts +21 -0
  213. package/src/ngb/utils/stableValue.ts +21 -0
  214. package/src/ngb/utils/storage.ts +135 -0
  215. package/src/styles/tailwind.css +165 -0
  216. package/src/vite/publicAssets.ts +104 -0
  217. package/vite-public-assets.js +68 -0
@@ -0,0 +1,615 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { ApiError } from '../api/http'
5
+ import NgbDrawer from '../components/NgbDrawer.vue'
6
+ import NgbValidationSummary from '../components/forms/NgbValidationSummary.vue'
7
+ import NgbBadge from '../primitives/NgbBadge.vue'
8
+ import NgbButton from '../primitives/NgbButton.vue'
9
+ import NgbConfirmDialog from '../components/NgbConfirmDialog.vue'
10
+ import NgbIcon from '../primitives/NgbIcon.vue'
11
+ import NgbInput from '../primitives/NgbInput.vue'
12
+ import NgbStatusIcon from '../primitives/NgbStatusIcon.vue'
13
+ import NgbSwitch from '../primitives/NgbSwitch.vue'
14
+ import { useOptionalToasts } from '../primitives/toast'
15
+ import NgbEntityAuditSidebar from '../editor/NgbEntityAuditSidebar.vue'
16
+ import NgbPageHeader from '../site/NgbPageHeader.vue'
17
+ import { toErrorMessage } from '../utils/errorMessage'
18
+ import NgbAccessDeniedState from './NgbAccessDeniedState.vue'
19
+ import NgbEffectiveAccessPanel from './NgbEffectiveAccessPanel.vue'
20
+ import { USER_AUDIT_BEHAVIOR } from './audit'
21
+ import {
22
+ createUser,
23
+ deactivateUser,
24
+ getRoles,
25
+ getUser,
26
+ getUserEffectiveAccess,
27
+ reactivateUser,
28
+ updateUser,
29
+ } from './api'
30
+ import { useAccessStore } from './useAccessStore'
31
+ import type { EffectiveAccessDto, RoleListItemDto, UserDetailsDto } from './types'
32
+
33
+ type UserForm = {
34
+ email: string
35
+ displayName: string
36
+ password: string
37
+ confirmPassword: string
38
+ requirePasswordUpdate: boolean
39
+ }
40
+
41
+ type UserFieldErrors = Partial<Record<keyof Omit<UserForm, 'requirePasswordUpdate'>, string>>
42
+
43
+ const AUDIT_ENTITY_KIND_SECURITY_USER = 8
44
+
45
+ const route = useRoute()
46
+ const router = useRouter()
47
+ const access = useAccessStore()
48
+
49
+ const loading = ref(false)
50
+ const saving = ref(false)
51
+ const activating = ref(false)
52
+ const effectiveLoading = ref(false)
53
+ const error = ref<string | null>(null)
54
+ const effectiveError = ref<string | null>(null)
55
+ const serverValidationMessages = ref<string[]>([])
56
+ const accessDenied = ref(false)
57
+ const user = ref<UserDetailsDto | null>(null)
58
+ const roles = ref<RoleListItemDto[]>([])
59
+ const selectedRoleIds = ref<string[]>([])
60
+ const effectiveAccess = ref<EffectiveAccessDto | null>(null)
61
+ const confirmMode = ref<'deactivate' | 'reactivate' | null>(null)
62
+ const auditOpen = ref(false)
63
+ const attemptedSave = ref(false)
64
+ const showPassword = ref(false)
65
+ const showConfirmPassword = ref(false)
66
+ const changePasswordMode = ref(false)
67
+ const toasts = useOptionalToasts()
68
+
69
+ const form = ref<UserForm>({
70
+ email: '',
71
+ displayName: '',
72
+ password: '',
73
+ confirmPassword: '',
74
+ requirePasswordUpdate: true,
75
+ })
76
+
77
+ const userId = computed(() => String(route.params.userId ?? 'new'))
78
+ const isNew = computed(() => userId.value === 'new')
79
+ const canEdit = computed(() => access.canManageUsers)
80
+ const canOpenAudit = computed(() => !isNew.value && !!user.value)
81
+ const shouldShowPasswordFields = computed(() => isNew.value || changePasswordMode.value)
82
+ const title = computed(() => {
83
+ if (isNew.value) return 'New user'
84
+ return user.value?.displayName?.trim() || user.value?.email?.trim() || 'User'
85
+ })
86
+ const activeRoles = computed(() => roles.value.filter((role) => role.isActive || selectedRoleIds.value.includes(role.roleId)))
87
+ const auditEntityTitle = computed(() => title.value)
88
+ const fieldErrors = computed<UserFieldErrors>(() => {
89
+ if (!attemptedSave.value) return {}
90
+
91
+ const errors: UserFieldErrors = {}
92
+ const values = form.value
93
+ const passwordRequired = shouldShowPasswordFields.value
94
+
95
+ if (!values.email.trim()) errors.email = 'Email is required.'
96
+ else if (!isEmail(values.email)) errors.email = 'Enter a valid email address.'
97
+
98
+ if (!values.displayName.trim()) errors.displayName = 'Display name is required.'
99
+
100
+ if (passwordRequired && values.password.length === 0) errors.password = 'Password is required.'
101
+ if (passwordRequired && values.confirmPassword.length === 0) errors.confirmPassword = 'Confirm password is required.'
102
+
103
+ if (passwordRequired && values.password.length > 0 && values.confirmPassword.length > 0 && values.password !== values.confirmPassword) {
104
+ errors.confirmPassword = 'Passwords do not match.'
105
+ }
106
+
107
+ return errors
108
+ })
109
+ const validationMessages = computed(() => uniqueMessages([
110
+ ...Object.values(fieldErrors.value).filter((message): message is string => !!message),
111
+ ...serverValidationMessages.value,
112
+ ]))
113
+
114
+ function resetForm(): void {
115
+ user.value = null
116
+ selectedRoleIds.value = []
117
+ effectiveAccess.value = null
118
+ auditOpen.value = false
119
+ attemptedSave.value = false
120
+ serverValidationMessages.value = []
121
+ showPassword.value = false
122
+ showConfirmPassword.value = false
123
+ changePasswordMode.value = false
124
+ form.value = {
125
+ email: '',
126
+ displayName: '',
127
+ password: '',
128
+ confirmPassword: '',
129
+ requirePasswordUpdate: true,
130
+ }
131
+ }
132
+
133
+ function applyUser(next: UserDetailsDto): void {
134
+ user.value = next
135
+ selectedRoleIds.value = next.roles.map((role) => role.roleId)
136
+ form.value = {
137
+ email: next.email ?? '',
138
+ displayName: next.displayName ?? '',
139
+ password: '',
140
+ confirmPassword: '',
141
+ requirePasswordUpdate: true,
142
+ }
143
+ changePasswordMode.value = false
144
+ showPassword.value = false
145
+ showConfirmPassword.value = false
146
+ }
147
+
148
+ async function loadEffectiveAccess(): Promise<void> {
149
+ if (isNew.value || !user.value) return
150
+
151
+ effectiveLoading.value = true
152
+ effectiveError.value = null
153
+
154
+ try {
155
+ effectiveAccess.value = await getUserEffectiveAccess(user.value.userId)
156
+ } catch (cause) {
157
+ effectiveAccess.value = null
158
+ effectiveError.value = toErrorMessage(cause, 'Failed to load effective access')
159
+ } finally {
160
+ effectiveLoading.value = false
161
+ }
162
+ }
163
+
164
+ async function load(): Promise<void> {
165
+ loading.value = true
166
+ error.value = null
167
+ accessDenied.value = false
168
+ resetForm()
169
+
170
+ try {
171
+ await access.load()
172
+ if (isNew.value && !access.canManageUsers) {
173
+ accessDenied.value = true
174
+ return
175
+ }
176
+
177
+ roles.value = await getRoles()
178
+ if (!isNew.value) {
179
+ const nextUser = await getUser(userId.value)
180
+ applyUser(nextUser)
181
+ await loadEffectiveAccess()
182
+ }
183
+ } catch (cause) {
184
+ accessDenied.value = cause instanceof ApiError && cause.status === 403
185
+ error.value = accessDenied.value ? null : toErrorMessage(cause, 'Failed to load user')
186
+ } finally {
187
+ loading.value = false
188
+ }
189
+ }
190
+
191
+ function isRoleSelected(roleId: string): boolean {
192
+ return selectedRoleIds.value.includes(roleId)
193
+ }
194
+
195
+ function setRoleSelected(roleId: string, selected: boolean): void {
196
+ if (!canEdit.value) return
197
+ const next = new Set(selectedRoleIds.value)
198
+ if (selected) next.add(roleId)
199
+ else next.delete(roleId)
200
+ selectedRoleIds.value = Array.from(next)
201
+ }
202
+
203
+ function isEmail(value: string): boolean {
204
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
205
+ }
206
+
207
+ function cleanOptional(value: string): string | null {
208
+ const trimmed = value.trim()
209
+ return trimmed.length > 0 ? trimmed : null
210
+ }
211
+
212
+ function uniqueMessages(messages: string[]): string[] {
213
+ const seen = new Set<string>()
214
+ const result: string[] = []
215
+ for (const message of messages) {
216
+ const text = String(message ?? '').trim()
217
+ if (!text || seen.has(text)) continue
218
+ seen.add(text)
219
+ result.push(text)
220
+ }
221
+ return result
222
+ }
223
+
224
+ function mapApiIssueMessage(path: string, message: string): string {
225
+ const normalizedPath = path.toLowerCase()
226
+ const normalizedMessage = message.trim()
227
+ if (normalizedPath.includes('email') && /valid email|email/i.test(normalizedMessage)) return 'Enter a valid email address.'
228
+ if (normalizedPath.includes('password') && /policy|invalid|weak|length/i.test(normalizedMessage)) return 'Password does not meet the password policy.'
229
+ return normalizedMessage
230
+ }
231
+
232
+ function mapKeycloakError(cause: ApiError): string | null {
233
+ if (cause.errorCode !== 'ngb.keycloak.admin_request_failed') return null
234
+
235
+ const body = typeof cause.context?.keycloakErrorBody === 'string' ? cause.context.keycloakErrorBody : ''
236
+ const text = body.toLowerCase()
237
+ const statusCode = typeof cause.context?.statusCode === 'number' ? cause.context.statusCode : cause.status
238
+
239
+ if (statusCode === 409 || text.includes('already exists') || text.includes('duplicate')) {
240
+ return 'A user with this email already exists.'
241
+ }
242
+
243
+ if ((text.includes('email') && text.includes('invalid')) || text.includes('invalid email')) {
244
+ return 'Enter a valid email address.'
245
+ }
246
+
247
+ if (text.includes('password')) {
248
+ return 'Password does not meet the password policy.'
249
+ }
250
+
251
+ return 'The identity provider rejected the user data. Check the email and password, then try again.'
252
+ }
253
+
254
+ function mapUserSaveError(cause: unknown): string[] {
255
+ if (!(cause instanceof ApiError)) return []
256
+
257
+ const keycloak = mapKeycloakError(cause)
258
+ if (keycloak) return [keycloak]
259
+
260
+ const issueMessages = (cause.issues ?? [])
261
+ .map((issue) => mapApiIssueMessage(issue.path, issue.message))
262
+ .filter((message) => message.length > 0)
263
+
264
+ const errorMessages = Object.entries(cause.errors ?? {})
265
+ .flatMap(([path, messages]) => messages.map((message) => mapApiIssueMessage(path, message)))
266
+ .filter((message) => message.length > 0)
267
+
268
+ return uniqueMessages([...issueMessages, ...errorMessages])
269
+ }
270
+
271
+ function validateBeforeSave(): boolean {
272
+ attemptedSave.value = true
273
+ serverValidationMessages.value = []
274
+ return Object.keys(fieldErrors.value).length === 0
275
+ }
276
+
277
+ function startChangePassword(): void {
278
+ if (!canEdit.value) return
279
+ changePasswordMode.value = true
280
+ form.value.password = ''
281
+ form.value.confirmPassword = ''
282
+ serverValidationMessages.value = []
283
+ attemptedSave.value = false
284
+ showPassword.value = false
285
+ showConfirmPassword.value = false
286
+ }
287
+
288
+ function cancelChangePassword(): void {
289
+ changePasswordMode.value = false
290
+ form.value.password = ''
291
+ form.value.confirmPassword = ''
292
+ serverValidationMessages.value = []
293
+ attemptedSave.value = false
294
+ showPassword.value = false
295
+ showConfirmPassword.value = false
296
+ }
297
+
298
+ async function save(): Promise<void> {
299
+ if (!canEdit.value || saving.value) return
300
+
301
+ if (!validateBeforeSave()) {
302
+ error.value = null
303
+ return
304
+ }
305
+
306
+ saving.value = true
307
+ error.value = null
308
+
309
+ try {
310
+ if (isNew.value) {
311
+ const created = await createUser({
312
+ email: form.value.email.trim(),
313
+ firstName: null,
314
+ lastName: null,
315
+ displayName: cleanOptional(form.value.displayName),
316
+ enabled: true,
317
+ temporaryPassword: form.value.password,
318
+ requirePasswordUpdate: form.value.requirePasswordUpdate,
319
+ roleIds: selectedRoleIds.value,
320
+ })
321
+ applyUser(created)
322
+ toasts?.push({ title: 'User created', message: 'User was saved.', tone: 'success' })
323
+ await router.replace(`/admin/security/users/${encodeURIComponent(created.userId)}`)
324
+ await loadEffectiveAccess()
325
+ return
326
+ }
327
+
328
+ const password = changePasswordMode.value ? form.value.password : null
329
+ const updated = await updateUser(userId.value, {
330
+ email: cleanOptional(form.value.email),
331
+ firstName: null,
332
+ lastName: null,
333
+ displayName: cleanOptional(form.value.displayName),
334
+ enabled: user.value?.keycloakEnabled ?? user.value?.isActive ?? true,
335
+ temporaryPassword: password,
336
+ requirePasswordUpdate: false,
337
+ roleIds: selectedRoleIds.value,
338
+ })
339
+ applyUser(updated)
340
+ toasts?.push({ title: 'User saved', message: 'Changes were saved.', tone: 'success' })
341
+ await loadEffectiveAccess()
342
+ } catch (cause) {
343
+ const messages = mapUserSaveError(cause)
344
+ if (messages.length > 0) serverValidationMessages.value = messages
345
+ else error.value = toErrorMessage(cause, 'Failed to save user')
346
+ } finally {
347
+ saving.value = false
348
+ }
349
+ }
350
+
351
+ async function confirmActivationChange(): Promise<void> {
352
+ if (!user.value || !confirmMode.value) return
353
+
354
+ activating.value = true
355
+ error.value = null
356
+
357
+ try {
358
+ if (confirmMode.value === 'deactivate') await deactivateUser(user.value.userId)
359
+ else await reactivateUser(user.value.userId)
360
+ confirmMode.value = null
361
+ await load()
362
+ } catch (cause) {
363
+ error.value = toErrorMessage(cause, 'Failed to update user status')
364
+ } finally {
365
+ activating.value = false
366
+ }
367
+ }
368
+
369
+ function goBack(): void {
370
+ void router.push('/admin/security/users')
371
+ }
372
+
373
+ function openRole(roleId: string): void {
374
+ void router.push(`/admin/security/roles/${encodeURIComponent(roleId)}`)
375
+ }
376
+
377
+ function openAuditLog(): void {
378
+ if (!canOpenAudit.value) return
379
+ auditOpen.value = true
380
+ }
381
+
382
+ function closeAuditLog(): void {
383
+ auditOpen.value = false
384
+ }
385
+
386
+ watch(
387
+ () => route.params.userId,
388
+ () => {
389
+ void load()
390
+ },
391
+ { immediate: true },
392
+ )
393
+ </script>
394
+
395
+ <template>
396
+ <NgbAccessDeniedState v-if="accessDenied" />
397
+
398
+ <div v-else class="flex h-full min-h-0 flex-col">
399
+ <NgbPageHeader :title="title" :can-back="true" :breadcrumbs="['Users']" @back="goBack">
400
+ <template #secondary>
401
+ <div class="flex min-w-0 items-center gap-2">
402
+ <NgbBadge v-if="user" :tone="user.isActive ? 'success' : 'danger'">{{ user.isActive ? 'Active' : 'Inactive' }}</NgbBadge>
403
+ </div>
404
+ </template>
405
+ <template #actions>
406
+ <button v-if="canOpenAudit" type="button" class="ngb-iconbtn" title="Audit log" :disabled="loading || saving" @click="openAuditLog">
407
+ <NgbIcon name="history" />
408
+ </button>
409
+ <button
410
+ v-if="user && canEdit && user.isActive"
411
+ type="button"
412
+ class="ngb-iconbtn"
413
+ title="Deactivate"
414
+ aria-label="Deactivate"
415
+ :disabled="saving || activating"
416
+ @click="confirmMode = 'deactivate'"
417
+ >
418
+ <NgbStatusIcon status="marked" title="Deactivate" />
419
+ </button>
420
+ <button
421
+ v-if="user && canEdit && !user.isActive"
422
+ type="button"
423
+ class="ngb-iconbtn"
424
+ title="Reactivate"
425
+ aria-label="Reactivate"
426
+ :disabled="saving || activating"
427
+ @click="confirmMode = 'reactivate'"
428
+ >
429
+ <NgbStatusIcon status="posted" title="Reactivate" />
430
+ </button>
431
+ <button
432
+ type="button"
433
+ class="ngb-iconbtn"
434
+ title="Save"
435
+ aria-label="Save"
436
+ :disabled="!canEdit || loading || saving"
437
+ @click="save"
438
+ >
439
+ <NgbIcon name="save" />
440
+ </button>
441
+ </template>
442
+ </NgbPageHeader>
443
+
444
+ <main class="flex-1 min-h-0 overflow-auto p-6 pb-8">
445
+ <div v-if="loading" class="text-sm text-ngb-muted">Loading...</div>
446
+
447
+ <div v-else class="grid min-h-[calc(100vh-270px)] items-stretch gap-6 xl:grid-cols-[minmax(0,1fr),420px]">
448
+ <div class="flex min-w-0 flex-col gap-6">
449
+ <section class="rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card p-4 shadow-card">
450
+ <NgbValidationSummary v-if="validationMessages.length > 0" class="mb-4" :messages="validationMessages" />
451
+
452
+ <div class="grid gap-4 md:grid-cols-2">
453
+ <div>
454
+ <NgbInput v-model="form.email" label="Email" type="email" :disabled="!canEdit" />
455
+ <div v-if="fieldErrors.email" class="mt-1 text-xs text-ngb-danger">{{ fieldErrors.email }}</div>
456
+ </div>
457
+ <div>
458
+ <NgbInput v-model="form.displayName" label="Display name" :disabled="!canEdit" />
459
+ <div v-if="fieldErrors.displayName" class="mt-1 text-xs text-ngb-danger">{{ fieldErrors.displayName }}</div>
460
+ </div>
461
+ </div>
462
+
463
+ <div v-if="shouldShowPasswordFields" class="mt-4 grid gap-4 md:grid-cols-2">
464
+ <div>
465
+ <label class="mb-1 block text-xs font-semibold text-ngb-muted">Password</label>
466
+ <div class="relative">
467
+ <input
468
+ :type="showPassword ? 'text' : 'password'"
469
+ :value="form.password"
470
+ :disabled="!canEdit"
471
+ autocomplete="new-password"
472
+ class="h-9 w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-3 pr-10 text-sm text-ngb-text placeholder:text-ngb-muted/70 ngb-focus"
473
+ :class="!canEdit ? 'cursor-not-allowed opacity-60' : ''"
474
+ @input="form.password = ($event.target as HTMLInputElement).value"
475
+ />
476
+ <button
477
+ type="button"
478
+ class="ngb-iconbtn absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
479
+ :disabled="!canEdit"
480
+ :title="showPassword ? 'Hide password' : 'Show password'"
481
+ @click="showPassword = !showPassword"
482
+ >
483
+ <NgbIcon :name="showPassword ? 'eye-off' : 'eye'" :size="16" />
484
+ </button>
485
+ </div>
486
+ <div v-if="fieldErrors.password" class="mt-1 text-xs text-ngb-danger">{{ fieldErrors.password }}</div>
487
+ </div>
488
+
489
+ <div>
490
+ <label class="mb-1 block text-xs font-semibold text-ngb-muted">Confirm password</label>
491
+ <div class="relative">
492
+ <input
493
+ :type="showConfirmPassword ? 'text' : 'password'"
494
+ :value="form.confirmPassword"
495
+ :disabled="!canEdit"
496
+ autocomplete="new-password"
497
+ class="h-9 w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-3 pr-10 text-sm text-ngb-text placeholder:text-ngb-muted/70 ngb-focus"
498
+ :class="!canEdit ? 'cursor-not-allowed opacity-60' : ''"
499
+ @input="form.confirmPassword = ($event.target as HTMLInputElement).value"
500
+ />
501
+ <button
502
+ type="button"
503
+ class="ngb-iconbtn absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
504
+ :disabled="!canEdit"
505
+ :title="showConfirmPassword ? 'Hide password' : 'Show password'"
506
+ @click="showConfirmPassword = !showConfirmPassword"
507
+ >
508
+ <NgbIcon :name="showConfirmPassword ? 'eye-off' : 'eye'" :size="16" />
509
+ </button>
510
+ </div>
511
+ <div v-if="fieldErrors.confirmPassword" class="mt-1 text-xs text-ngb-danger">{{ fieldErrors.confirmPassword }}</div>
512
+ </div>
513
+ </div>
514
+
515
+ <div v-if="isNew" class="mt-4">
516
+ <NgbSwitch v-model="form.requirePasswordUpdate" label="Require password update" :disabled="!canEdit" />
517
+ </div>
518
+
519
+ <div v-else class="mt-4 flex flex-wrap items-center gap-2">
520
+ <NgbButton v-if="!changePasswordMode" size="sm" variant="secondary" :disabled="!canEdit" @click="startChangePassword">
521
+ <NgbIcon name="shield" :size="15" />
522
+ Change password
523
+ </NgbButton>
524
+ <NgbButton v-else size="sm" variant="ghost" :disabled="!canEdit" @click="cancelChangePassword">
525
+ Cancel
526
+ </NgbButton>
527
+ </div>
528
+
529
+ <div
530
+ v-if="error"
531
+ class="mt-4 rounded-[var(--ngb-radius)] border border-red-200 bg-red-50 p-3 text-sm text-red-900 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-100"
532
+ >
533
+ {{ error }}
534
+ </div>
535
+ </section>
536
+
537
+ <NgbEffectiveAccessPanel
538
+ v-if="!isNew"
539
+ class="min-h-[560px] flex-1"
540
+ :access="effectiveAccess"
541
+ :loading="effectiveLoading"
542
+ :error="effectiveError"
543
+ :show-refresh="false"
544
+ @refresh="loadEffectiveAccess"
545
+ />
546
+ </div>
547
+
548
+ <section class="flex min-h-full flex-col rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card">
549
+ <div class="border-b border-ngb-border px-4 py-3">
550
+ <h2 class="text-sm font-semibold text-ngb-text">Roles</h2>
551
+ </div>
552
+ <div class="min-h-0 flex-1 overflow-auto divide-y divide-ngb-border">
553
+ <div
554
+ v-for="role in activeRoles"
555
+ :key="role.roleId"
556
+ class="grid grid-cols-[minmax(0,1fr),2rem] items-start gap-2 px-4 py-3 hover:bg-[var(--ngb-row-hover)]"
557
+ :class="!canEdit ? 'opacity-70' : ''"
558
+ >
559
+ <label class="grid min-w-0 cursor-pointer grid-cols-[1.25rem,minmax(0,1fr)] gap-3" :class="!canEdit ? 'cursor-not-allowed' : ''">
560
+ <input
561
+ type="checkbox"
562
+ class="mt-1 h-4 w-4"
563
+ :checked="isRoleSelected(role.roleId)"
564
+ :disabled="!canEdit"
565
+ @change="setRoleSelected(role.roleId, ($event.target as HTMLInputElement).checked)"
566
+ />
567
+ <span class="min-w-0">
568
+ <span class="flex min-w-0 items-center gap-2">
569
+ <span class="truncate text-sm font-medium text-ngb-text">{{ role.name }}</span>
570
+ <NgbBadge v-if="!role.isActive" tone="danger">Inactive</NgbBadge>
571
+ <NgbBadge v-if="role.isSystem" tone="neutral">System</NgbBadge>
572
+ </span>
573
+ <span class="mt-0.5 block truncate font-mono text-xs text-ngb-muted">{{ role.code }}</span>
574
+ </span>
575
+ </label>
576
+ <button
577
+ type="button"
578
+ class="ngb-iconbtn"
579
+ :title="`Open ${role.name}`"
580
+ :aria-label="`Open role ${role.name}`"
581
+ @click.stop="openRole(role.roleId)"
582
+ >
583
+ <NgbIcon name="open-in-new" :size="16" />
584
+ </button>
585
+ </div>
586
+ <div v-if="activeRoles.length === 0" class="px-4 py-6 text-sm text-ngb-muted">No roles available.</div>
587
+ </div>
588
+ </section>
589
+ </div>
590
+ </main>
591
+
592
+ <NgbConfirmDialog
593
+ :open="confirmMode !== null"
594
+ :title="confirmMode === 'reactivate' ? 'Reactivate user?' : 'Deactivate user?'"
595
+ :message="confirmMode === 'reactivate' ? 'This enables the NGB application user and attempts to enable the linked Keycloak user.' : 'This disables access without deleting history, audit records, or ownership links.'"
596
+ :confirm-text="confirmMode === 'reactivate' ? 'Reactivate' : 'Deactivate'"
597
+ :danger="confirmMode === 'deactivate'"
598
+ :confirm-loading="activating"
599
+ @update:open="(value) => { if (!value) confirmMode = null }"
600
+ @confirm="confirmActivationChange"
601
+ />
602
+
603
+ <NgbDrawer v-model:open="auditOpen" title="Audit Log" hide-header flush-body>
604
+ <NgbEntityAuditSidebar
605
+ :open="auditOpen"
606
+ :entity-kind="AUDIT_ENTITY_KIND_SECURITY_USER"
607
+ :entity-id="user?.userId ?? null"
608
+ :entity-title="auditEntityTitle"
609
+ :behavior="USER_AUDIT_BEHAVIOR"
610
+ @back="closeAuditLog"
611
+ @close="closeAuditLog"
612
+ />
613
+ </NgbDrawer>
614
+ </div>
615
+ </template>