@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.
- package/LICENSE +201 -0
- package/README.md +44 -0
- package/package.json +61 -0
- package/public/favicon.svg +6 -0
- package/public/silent-check-sso.html +8 -0
- package/src/assets/ngb_logo.svg +17 -0
- package/src/assets/ngb_logo_color.svg +17 -0
- package/src/index.ts +1023 -0
- package/src/ngb/README.md +27 -0
- package/src/ngb/accounting/NgbAccountingPeriodClosingPage.vue +1157 -0
- package/src/ngb/accounting/NgbChartOfAccountEditor.vue +590 -0
- package/src/ngb/accounting/NgbChartOfAccountsPage.vue +420 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryEditPage.vue +796 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryLinesEditor.vue +454 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryListPage.vue +186 -0
- package/src/ngb/accounting/api.ts +79 -0
- package/src/ngb/accounting/generalJournalEntry.ts +127 -0
- package/src/ngb/accounting/generalJournalEntryApi.ts +112 -0
- package/src/ngb/accounting/generalJournalEntryTypes.ts +155 -0
- package/src/ngb/accounting/navigation.ts +83 -0
- package/src/ngb/accounting/periodClosing.ts +75 -0
- package/src/ngb/accounting/periodClosingApi.ts +60 -0
- package/src/ngb/accounting/periodClosingTypes.ts +73 -0
- package/src/ngb/accounting/types.ts +57 -0
- package/src/ngb/api/audit.ts +23 -0
- package/src/ngb/api/catalogs.ts +50 -0
- package/src/ngb/api/contracts.ts +285 -0
- package/src/ngb/api/documents.ts +170 -0
- package/src/ngb/api/http.ts +446 -0
- package/src/ngb/api/lookups.ts +14 -0
- package/src/ngb/api/types.ts +6 -0
- package/src/ngb/auth/index.ts +13 -0
- package/src/ngb/auth/keycloak.ts +374 -0
- package/src/ngb/auth/router.ts +35 -0
- package/src/ngb/auth/types.ts +12 -0
- package/src/ngb/auth/useAuthStore.ts +209 -0
- package/src/ngb/command-palette/NgbCommandPaletteDialog.vue +342 -0
- package/src/ngb/command-palette/NgbCommandPaletteEmptyState.vue +55 -0
- package/src/ngb/command-palette/NgbCommandPaletteFooterHints.vue +29 -0
- package/src/ngb/command-palette/api.ts +10 -0
- package/src/ngb/command-palette/config.ts +16 -0
- package/src/ngb/command-palette/presentation.ts +85 -0
- package/src/ngb/command-palette/search.ts +130 -0
- package/src/ngb/command-palette/staticItems.ts +243 -0
- package/src/ngb/command-palette/storage.ts +45 -0
- package/src/ngb/command-palette/store.ts +716 -0
- package/src/ngb/command-palette/types.ts +120 -0
- package/src/ngb/command-palette/useCommandPaletteHotkeys.ts +27 -0
- package/src/ngb/command-palette/useCommandPalettePageContext.ts +18 -0
- package/src/ngb/components/NgbConfirmDialog.vue +63 -0
- package/src/ngb/components/NgbDialog.vue +44 -0
- package/src/ngb/components/NgbDrawer.vue +156 -0
- package/src/ngb/components/NgbHeaderActionCluster.vue +140 -0
- package/src/ngb/components/NgbModalShell.vue +99 -0
- package/src/ngb/components/NgbToolbar.vue +14 -0
- package/src/ngb/components/forms/NgbFormLayout.vue +13 -0
- package/src/ngb/components/forms/NgbFormRow.vue +21 -0
- package/src/ngb/components/forms/NgbFormSection.vue +16 -0
- package/src/ngb/components/forms/NgbValidationSummary.vue +18 -0
- package/src/ngb/components/navigation/NgbNavigationTree.vue +68 -0
- package/src/ngb/components/navigation/NgbTreeNode.vue +94 -0
- package/src/ngb/components/register/NgbColumnChooser.vue +101 -0
- package/src/ngb/components/register/NgbRegisterGrid.vue +473 -0
- package/src/ngb/components/register/registerTypes.ts +51 -0
- package/src/ngb/components/register/useRegisterColumnResize.ts +99 -0
- package/src/ngb/components/register/useRegisterColumnState.ts +220 -0
- package/src/ngb/components/register/useRegisterRows.ts +209 -0
- package/src/ngb/components/register/useRegisterViewport.ts +56 -0
- package/src/ngb/editor/NgbDocumentEffectsPage.vue +512 -0
- package/src/ngb/editor/NgbDocumentFlowPage.vue +409 -0
- package/src/ngb/editor/NgbDocumentPrintPage.vue +712 -0
- package/src/ngb/editor/NgbEditorDiscardDialog.vue +40 -0
- package/src/ngb/editor/NgbEntityAuditSidebar.vue +288 -0
- package/src/ngb/editor/NgbEntityEditor.vue +250 -0
- package/src/ngb/editor/NgbEntityEditorDrawerActions.vue +88 -0
- package/src/ngb/editor/NgbEntityEditorHeader.vue +114 -0
- package/src/ngb/editor/catalogNavigation.ts +13 -0
- package/src/ngb/editor/config.ts +252 -0
- package/src/ngb/editor/documentCopyDraft.ts +245 -0
- package/src/ngb/editor/documentEffects.ts +43 -0
- package/src/ngb/editor/documentEffectsDateFormatting.ts +18 -0
- package/src/ngb/editor/documentNavigation.ts +115 -0
- package/src/ngb/editor/documentStatus.ts +45 -0
- package/src/ngb/editor/entityEditorErrors.ts +162 -0
- package/src/ngb/editor/entityEditorPersistence.ts +241 -0
- package/src/ngb/editor/extensions.ts +40 -0
- package/src/ngb/editor/types.ts +295 -0
- package/src/ngb/editor/useConfiguredEntityEditorDocumentActions.ts +183 -0
- package/src/ngb/editor/useDocumentEditorDrawerState.ts +152 -0
- package/src/ngb/editor/useEditorDrawerState.ts +88 -0
- package/src/ngb/editor/useEntityEditorBusinessContext.ts +97 -0
- package/src/ngb/editor/useEntityEditorCapabilities.ts +190 -0
- package/src/ngb/editor/useEntityEditorCommandPalette.ts +154 -0
- package/src/ngb/editor/useEntityEditorCommitHandlers.ts +102 -0
- package/src/ngb/editor/useEntityEditorHeaderActions.ts +253 -0
- package/src/ngb/editor/useEntityEditorLeaveGuard.ts +66 -0
- package/src/ngb/editor/useEntityEditorNavigationActions.ts +235 -0
- package/src/ngb/editor/useEntityEditorOutputs.ts +59 -0
- package/src/ngb/editor/useEntityEditorPageActions.ts +57 -0
- package/src/ngb/editor/useRouteQueryEditorDrawer.ts +173 -0
- package/src/ngb/env/runtimeConfig.ts +25 -0
- package/src/ngb/lookup/config.ts +42 -0
- package/src/ngb/lookup/defaultConfig.ts +136 -0
- package/src/ngb/lookup/navigation.ts +76 -0
- package/src/ngb/lookup/prefetch.ts +40 -0
- package/src/ngb/lookup/store.ts +261 -0
- package/src/ngb/metadata/NgbDocumentListFiltersDrawer.vue +78 -0
- package/src/ngb/metadata/NgbDocumentPeriodFilter.vue +53 -0
- package/src/ngb/metadata/NgbEntityForm.vue +84 -0
- package/src/ngb/metadata/NgbEntityFormFieldsBlock.vue +110 -0
- package/src/ngb/metadata/NgbEntityListPageHeader.vue +80 -0
- package/src/ngb/metadata/NgbFilterFieldControl.vue +104 -0
- package/src/ngb/metadata/NgbMetadataCatalogEditPage.vue +42 -0
- package/src/ngb/metadata/NgbMetadataCatalogListPage.vue +269 -0
- package/src/ngb/metadata/NgbMetadataDocumentEditPage.vue +68 -0
- package/src/ngb/metadata/NgbMetadataDocumentListPage.vue +353 -0
- package/src/ngb/metadata/NgbMetadataFieldRenderer.vue +124 -0
- package/src/ngb/metadata/NgbMetadataLookupControl.vue +92 -0
- package/src/ngb/metadata/NgbRecycleBinFilter.vue +53 -0
- package/src/ngb/metadata/NgbRegisterPageLayout.vue +143 -0
- package/src/ngb/metadata/config.ts +28 -0
- package/src/ngb/metadata/dataTypes.ts +27 -0
- package/src/ngb/metadata/entityForm.ts +114 -0
- package/src/ngb/metadata/entityModel.ts +34 -0
- package/src/ngb/metadata/entityValueFormatting.ts +83 -0
- package/src/ngb/metadata/fieldRendererState.ts +84 -0
- package/src/ngb/metadata/filtering.ts +153 -0
- package/src/ngb/metadata/lookup.ts +25 -0
- package/src/ngb/metadata/monthPagedListQuery.ts +108 -0
- package/src/ngb/metadata/normalization.ts +100 -0
- package/src/ngb/metadata/referenceHydration.ts +99 -0
- package/src/ngb/metadata/register.ts +135 -0
- package/src/ngb/metadata/routePages.ts +86 -0
- package/src/ngb/metadata/store.ts +35 -0
- package/src/ngb/metadata/types.ts +276 -0
- package/src/ngb/metadata/useMetadataListFilters.ts +306 -0
- package/src/ngb/metadata/useMetadataRegisterPageData.ts +246 -0
- package/src/ngb/metadata/useValidationFocus.ts +49 -0
- package/src/ngb/primitives/NgbBadge.vue +31 -0
- package/src/ngb/primitives/NgbButton.vue +53 -0
- package/src/ngb/primitives/NgbCheckbox.vue +33 -0
- package/src/ngb/primitives/NgbDatePicker.vue +141 -0
- package/src/ngb/primitives/NgbIcon.vue +545 -0
- package/src/ngb/primitives/NgbInput.vue +57 -0
- package/src/ngb/primitives/NgbLookup.vue +264 -0
- package/src/ngb/primitives/NgbMonthPicker.vue +132 -0
- package/src/ngb/primitives/NgbMultiSelect.vue +179 -0
- package/src/ngb/primitives/NgbPeriodPicker.vue +125 -0
- package/src/ngb/primitives/NgbPickerNavButton.vue +31 -0
- package/src/ngb/primitives/NgbPickerPopover.vue +79 -0
- package/src/ngb/primitives/NgbRadioGroup.vue +53 -0
- package/src/ngb/primitives/NgbSelect.vue +108 -0
- package/src/ngb/primitives/NgbStatusIcon.vue +63 -0
- package/src/ngb/primitives/NgbSwitch.vue +42 -0
- package/src/ngb/primitives/NgbTabs.vue +116 -0
- package/src/ngb/primitives/NgbToastHost.vue +49 -0
- package/src/ngb/primitives/NgbTooltip.vue +27 -0
- package/src/ngb/primitives/iconNames.ts +83 -0
- package/src/ngb/primitives/toast.ts +66 -0
- package/src/ngb/primitives/useAsyncComboboxQuery.ts +85 -0
- package/src/ngb/primitives/useFloatingDropdownPosition.ts +129 -0
- package/src/ngb/reporting/NgbReportComposerCollectionSection.vue +106 -0
- package/src/ngb/reporting/NgbReportComposerPanel.vue +1187 -0
- package/src/ngb/reporting/NgbReportDateRangeFilter.vue +54 -0
- package/src/ngb/reporting/NgbReportPage.vue +1124 -0
- package/src/ngb/reporting/NgbReportSheet.vue +467 -0
- package/src/ngb/reporting/api.ts +35 -0
- package/src/ngb/reporting/composer.ts +904 -0
- package/src/ngb/reporting/config.ts +120 -0
- package/src/ngb/reporting/defaultConfig.ts +15 -0
- package/src/ngb/reporting/lookupFilters.ts +43 -0
- package/src/ngb/reporting/navigation.ts +181 -0
- package/src/ngb/reporting/pageHelpers.ts +73 -0
- package/src/ngb/reporting/pageSession.ts +92 -0
- package/src/ngb/reporting/paging.ts +66 -0
- package/src/ngb/reporting/types.ts +333 -0
- package/src/ngb/router/backNavigation.ts +122 -0
- package/src/ngb/router/queryParams.ts +129 -0
- package/src/ngb/router/queryState.ts +121 -0
- package/src/ngb/router/routeAliases.ts +66 -0
- package/src/ngb/router/routeParams.ts +15 -0
- package/src/ngb/router/shareLink.ts +39 -0
- package/src/ngb/security/NgbAccessAuditPanel.vue +32 -0
- package/src/ngb/security/NgbAccessDeniedState.vue +27 -0
- package/src/ngb/security/NgbEffectiveAccessPanel.vue +60 -0
- package/src/ngb/security/NgbPermissionMatrix.vue +160 -0
- package/src/ngb/security/NgbRoleEditorPage.vue +319 -0
- package/src/ngb/security/NgbRolesPage.vue +125 -0
- package/src/ngb/security/NgbUserEditorPage.vue +615 -0
- package/src/ngb/security/NgbUsersPage.vue +125 -0
- package/src/ngb/security/api.ts +91 -0
- package/src/ngb/security/audit.ts +21 -0
- package/src/ngb/security/permissions.ts +65 -0
- package/src/ngb/security/types.ts +157 -0
- package/src/ngb/security/useAccessStore.ts +69 -0
- package/src/ngb/site/NgbDashboardAsOfToolbar.vue +100 -0
- package/src/ngb/site/NgbDashboardStatusBanner.vue +91 -0
- package/src/ngb/site/NgbLogo.vue +44 -0
- package/src/ngb/site/NgbPageHeader.vue +49 -0
- package/src/ngb/site/NgbSiteShell.vue +286 -0
- package/src/ngb/site/NgbSiteSidebar.vue +270 -0
- package/src/ngb/site/NgbTopBar.vue +425 -0
- package/src/ngb/site/NgbTrendChart.vue +224 -0
- package/src/ngb/site/dashboardData.ts +324 -0
- package/src/ngb/site/mainMenuStore.ts +49 -0
- package/src/ngb/site/types.ts +31 -0
- package/src/ngb/site/useDashboardPageState.ts +98 -0
- package/src/ngb/site/useTheme.ts +95 -0
- package/src/ngb/utils/clone.ts +43 -0
- package/src/ngb/utils/dateValues.ts +107 -0
- package/src/ngb/utils/errorMessage.ts +14 -0
- package/src/ngb/utils/guid.ts +21 -0
- package/src/ngb/utils/stableValue.ts +21 -0
- package/src/ngb/utils/storage.ts +135 -0
- package/src/styles/tailwind.css +165 -0
- package/src/vite/publicAssets.ts +104 -0
- package/vite-public-assets.js +68 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import Keycloak, {
|
|
2
|
+
type KeycloakInitOptions,
|
|
3
|
+
type KeycloakLoginOptions,
|
|
4
|
+
type KeycloakLogoutOptions,
|
|
5
|
+
type KeycloakTokenParsed,
|
|
6
|
+
} from 'keycloak-js'
|
|
7
|
+
import type { AuthSnapshot } from './types'
|
|
8
|
+
import { readAppEnv } from '../env/runtimeConfig'
|
|
9
|
+
|
|
10
|
+
type KeycloakClaims = KeycloakTokenParsed & {
|
|
11
|
+
email?: string
|
|
12
|
+
preferred_username?: string
|
|
13
|
+
given_name?: string
|
|
14
|
+
family_name?: string
|
|
15
|
+
name?: string
|
|
16
|
+
role?: string | string[]
|
|
17
|
+
roles?: string | string[]
|
|
18
|
+
groups?: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type AuthListener = (snapshot: AuthSnapshot) => void
|
|
22
|
+
|
|
23
|
+
const keycloak = new Keycloak({
|
|
24
|
+
url: requiredEnv('VITE_KEYCLOAK_URL'),
|
|
25
|
+
realm: requiredEnv('VITE_KEYCLOAK_REALM'),
|
|
26
|
+
clientId: requiredEnv('VITE_KEYCLOAK_CLIENT_ID'),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const listeners = new Set<AuthListener>()
|
|
30
|
+
let initialized = false
|
|
31
|
+
let initPromise: Promise<boolean> | null = null
|
|
32
|
+
let refreshPromise: Promise<string | null> | null = null
|
|
33
|
+
let callbacksRegistered = false
|
|
34
|
+
|
|
35
|
+
function requiredEnv(name: string): string {
|
|
36
|
+
const value = readAppEnv(name)
|
|
37
|
+
if (!value) throw new Error(`Missing required env var: ${name}`)
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeUrl(value: string | null | undefined, fallbackPath: string): string {
|
|
42
|
+
const raw = String(value ?? '').trim()
|
|
43
|
+
if (!raw) return new URL(fallbackPath, window.location.origin).toString()
|
|
44
|
+
|
|
45
|
+
const next = new URL(raw, window.location.origin)
|
|
46
|
+
if (next.host === window.location.host && next.protocol !== window.location.protocol) {
|
|
47
|
+
next.protocol = window.location.protocol
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return next.toString()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveAppBaseUrl(): string {
|
|
54
|
+
const rawBase = String(import.meta.env.BASE_URL ?? '/').trim()
|
|
55
|
+
const normalizedBase = rawBase
|
|
56
|
+
? (rawBase.endsWith('/') ? rawBase : `${rawBase}/`)
|
|
57
|
+
: '/'
|
|
58
|
+
|
|
59
|
+
return new URL(normalizedBase, window.location.origin).toString()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveAppPublicUrl(path: string): string {
|
|
63
|
+
const normalizedPath = String(path ?? '').trim().replace(/^\/+/, '')
|
|
64
|
+
return new URL(normalizedPath, resolveAppBaseUrl()).toString()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseBoolean(value: string | boolean | null | undefined, defaultValue: boolean): boolean {
|
|
68
|
+
if (typeof value === 'boolean') return value
|
|
69
|
+
|
|
70
|
+
const normalized = String(value ?? '').trim().toLowerCase()
|
|
71
|
+
if (!normalized) return defaultValue
|
|
72
|
+
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true
|
|
73
|
+
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false
|
|
74
|
+
return defaultValue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveOnLoad(): 'login-required' | 'check-sso' {
|
|
78
|
+
const normalized = (readAppEnv('VITE_KEYCLOAK_ON_LOAD') || 'login-required').toLowerCase()
|
|
79
|
+
return normalized === 'check-sso' ? 'check-sso' : 'login-required'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shouldUseSilentCheckSso(onLoad: 'login-required' | 'check-sso'): boolean {
|
|
83
|
+
if (onLoad !== 'check-sso') return false
|
|
84
|
+
return parseBoolean(readAppEnv('VITE_KEYCLOAK_SILENT_CHECK_SSO_ENABLED'), false)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveSilentCheckSsoRedirectUri(): string {
|
|
88
|
+
return normalizeUrl(readAppEnv('VITE_KEYCLOAK_SILENT_CHECK_SSO_REDIRECT_URI'), resolveAppPublicUrl('silent-check-sso.html'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolvePostLogoutRedirectUri(): string {
|
|
92
|
+
const explicit = readAppEnv('VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URL')
|
|
93
|
+
if (explicit) return normalizeUrl(explicit, resolveAppBaseUrl())
|
|
94
|
+
return normalizeUrl(readAppEnv('VITE_KEYCLOAK_REDIRECT_URL'), resolveAppBaseUrl())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveAdminConsoleLocalLogoutEndpoints(): string[] {
|
|
98
|
+
const configuredUrls = [
|
|
99
|
+
readAppEnv('VITE_WATCHDOG_URL'),
|
|
100
|
+
readAppEnv('VITE_BACKGROUND_JOB_URL'),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
return Array.from(
|
|
104
|
+
new Set(
|
|
105
|
+
configuredUrls
|
|
106
|
+
.map((value) => String(value ?? '').trim())
|
|
107
|
+
.filter((value) => value.length > 0)
|
|
108
|
+
.map((value) => new URL(value, window.location.origin))
|
|
109
|
+
.map((url) => `${url.origin}/account/local-logout`),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function logoutKnownAdminConsoles(): Promise<void> {
|
|
115
|
+
const endpoints = resolveAdminConsoleLocalLogoutEndpoints()
|
|
116
|
+
if (endpoints.length === 0) return
|
|
117
|
+
|
|
118
|
+
await Promise.allSettled(
|
|
119
|
+
endpoints.map(async (endpoint) => {
|
|
120
|
+
await fetch(endpoint, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
credentials: 'include',
|
|
123
|
+
mode: 'cors',
|
|
124
|
+
cache: 'no-store',
|
|
125
|
+
keepalive: true,
|
|
126
|
+
headers: {
|
|
127
|
+
Accept: '*/*',
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveLoginRedirectUri(targetPath?: string | null): string {
|
|
135
|
+
const target = String(targetPath ?? '').trim()
|
|
136
|
+
if (/^https?:\/\//i.test(target)) return target
|
|
137
|
+
if (target.startsWith('/')) return new URL(target, window.location.origin).toString()
|
|
138
|
+
|
|
139
|
+
const currentUrl = new URL(window.location.href)
|
|
140
|
+
currentUrl.hash = ''
|
|
141
|
+
return currentUrl.toString()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function unique(values: Iterable<string | null | undefined>): string[] {
|
|
145
|
+
return Array.from(
|
|
146
|
+
new Set(
|
|
147
|
+
Array.from(values)
|
|
148
|
+
.map((value) => String(value ?? '').trim())
|
|
149
|
+
.filter((value) => value.length > 0),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readStringArrayClaim(value: unknown): string[] {
|
|
155
|
+
if (Array.isArray(value)) return unique(value.map((entry) => String(entry ?? '')))
|
|
156
|
+
if (typeof value === 'string') return unique(value.split(/[,\s]+/g))
|
|
157
|
+
return []
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function accessClaims(): KeycloakClaims | null {
|
|
161
|
+
return (keycloak.tokenParsed ?? null) as KeycloakClaims | null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function identityClaims(): KeycloakClaims | null {
|
|
165
|
+
const access = accessClaims()
|
|
166
|
+
const identity = (keycloak.idTokenParsed ?? null) as KeycloakClaims | null
|
|
167
|
+
if (!access && !identity) return null
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
...(access ?? {}),
|
|
171
|
+
...(identity ?? {}),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readRealmRoles(claims: KeycloakClaims | null): string[] {
|
|
176
|
+
return unique(claims?.realm_access?.roles ?? [])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readTopLevelRoles(claims: KeycloakClaims | null): string[] {
|
|
180
|
+
return unique([
|
|
181
|
+
...readStringArrayClaim(claims?.role),
|
|
182
|
+
...readStringArrayClaim(claims?.roles),
|
|
183
|
+
])
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readResourceRoles(claims: KeycloakClaims | null): Record<string, string[]> {
|
|
187
|
+
const resourceAccess = claims?.resource_access ?? {}
|
|
188
|
+
const entries = Object.entries(resourceAccess).map(([clientId, access]) => [
|
|
189
|
+
clientId,
|
|
190
|
+
unique(access?.roles ?? []),
|
|
191
|
+
] as const)
|
|
192
|
+
|
|
193
|
+
return Object.fromEntries(entries.filter(([, roles]) => roles.length > 0))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readGroupRoles(claims: KeycloakClaims | null): string[] {
|
|
197
|
+
return unique(
|
|
198
|
+
readStringArrayClaim(claims?.groups)
|
|
199
|
+
.map((group) => group.split('/').filter(Boolean).at(-1) ?? '')
|
|
200
|
+
.filter((group) => group === 'ngb-admin' || group === 'ngb-user'),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveDisplayName(claims: KeycloakClaims | null): string | null {
|
|
205
|
+
const explicitName = String(claims?.name ?? '').trim()
|
|
206
|
+
if (explicitName) return explicitName
|
|
207
|
+
|
|
208
|
+
const fullName = [claims?.given_name, claims?.family_name]
|
|
209
|
+
.map((part) => String(part ?? '').trim())
|
|
210
|
+
.filter((part) => part.length > 0)
|
|
211
|
+
.join(' ')
|
|
212
|
+
|
|
213
|
+
if (fullName) return fullName
|
|
214
|
+
|
|
215
|
+
const preferredUsername = String(claims?.preferred_username ?? '').trim()
|
|
216
|
+
if (preferredUsername) return preferredUsername
|
|
217
|
+
|
|
218
|
+
const email = String(claims?.email ?? '').trim()
|
|
219
|
+
return email || null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function snapshot(): AuthSnapshot {
|
|
223
|
+
const profile = identityClaims()
|
|
224
|
+
const access = accessClaims()
|
|
225
|
+
const topLevelRoles = readTopLevelRoles(access)
|
|
226
|
+
const realmRoles = readRealmRoles(access)
|
|
227
|
+
const resourceRoles = readResourceRoles(access)
|
|
228
|
+
const groupRoles = readGroupRoles(profile)
|
|
229
|
+
const roles = unique([
|
|
230
|
+
...topLevelRoles,
|
|
231
|
+
...realmRoles,
|
|
232
|
+
...Object.values(resourceRoles).flat(),
|
|
233
|
+
...groupRoles,
|
|
234
|
+
])
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
initialized,
|
|
238
|
+
authenticated: Boolean(keycloak.authenticated),
|
|
239
|
+
token: keycloak.token ?? null,
|
|
240
|
+
subject: keycloak.subject ?? (typeof profile?.sub === 'string' ? profile.sub : null),
|
|
241
|
+
displayName: resolveDisplayName(profile),
|
|
242
|
+
preferredUsername: String(profile?.preferred_username ?? '').trim() || null,
|
|
243
|
+
email: String(profile?.email ?? '').trim() || null,
|
|
244
|
+
realmRoles,
|
|
245
|
+
resourceRoles,
|
|
246
|
+
roles,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function notifyListeners(): void {
|
|
251
|
+
const nextSnapshot = snapshot()
|
|
252
|
+
for (const listener of listeners) listener(nextSnapshot)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function registerCallbacks(): void {
|
|
256
|
+
if (callbacksRegistered) return
|
|
257
|
+
callbacksRegistered = true
|
|
258
|
+
|
|
259
|
+
keycloak.onReady = () => {
|
|
260
|
+
initialized = true
|
|
261
|
+
notifyListeners()
|
|
262
|
+
}
|
|
263
|
+
keycloak.onAuthSuccess = notifyListeners
|
|
264
|
+
keycloak.onAuthLogout = notifyListeners
|
|
265
|
+
keycloak.onAuthRefreshSuccess = notifyListeners
|
|
266
|
+
keycloak.onAuthRefreshError = notifyListeners
|
|
267
|
+
keycloak.onAuthError = notifyListeners
|
|
268
|
+
keycloak.onTokenExpired = () => {
|
|
269
|
+
void refreshAccessToken(0).catch(() => {
|
|
270
|
+
keycloak.clearToken()
|
|
271
|
+
notifyListeners()
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function subscribeAuth(listener: AuthListener): () => void {
|
|
277
|
+
listeners.add(listener)
|
|
278
|
+
listener(snapshot())
|
|
279
|
+
return () => {
|
|
280
|
+
listeners.delete(listener)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function getAuthSnapshot(): AuthSnapshot {
|
|
285
|
+
return snapshot()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function initializeAuth(): Promise<AuthSnapshot> {
|
|
289
|
+
registerCallbacks()
|
|
290
|
+
|
|
291
|
+
if (!initPromise) {
|
|
292
|
+
const onLoad = resolveOnLoad()
|
|
293
|
+
const initOptions: KeycloakInitOptions = {
|
|
294
|
+
onLoad,
|
|
295
|
+
pkceMethod: 'S256',
|
|
296
|
+
responseMode: 'fragment',
|
|
297
|
+
checkLoginIframe: false,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (shouldUseSilentCheckSso(onLoad)) {
|
|
301
|
+
initOptions.silentCheckSsoRedirectUri = resolveSilentCheckSsoRedirectUri()
|
|
302
|
+
initOptions.silentCheckSsoFallback = false
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
initPromise = keycloak
|
|
306
|
+
.init(initOptions)
|
|
307
|
+
.then((authenticated) => {
|
|
308
|
+
initialized = true
|
|
309
|
+
notifyListeners()
|
|
310
|
+
return authenticated
|
|
311
|
+
})
|
|
312
|
+
.catch((error) => {
|
|
313
|
+
initPromise = null
|
|
314
|
+
throw error
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await initPromise
|
|
319
|
+
return snapshot()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function refreshAccessToken(minValidity: number): Promise<string | null> {
|
|
323
|
+
if (!initialized) await initializeAuth()
|
|
324
|
+
if (!keycloak.authenticated || !keycloak.token) return null
|
|
325
|
+
|
|
326
|
+
if (!refreshPromise) {
|
|
327
|
+
refreshPromise = keycloak
|
|
328
|
+
.updateToken(minValidity)
|
|
329
|
+
.then(() => {
|
|
330
|
+
notifyListeners()
|
|
331
|
+
return keycloak.token ?? null
|
|
332
|
+
})
|
|
333
|
+
.catch((error) => {
|
|
334
|
+
keycloak.clearToken()
|
|
335
|
+
notifyListeners()
|
|
336
|
+
throw error
|
|
337
|
+
})
|
|
338
|
+
.finally(() => {
|
|
339
|
+
refreshPromise = null
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return await refreshPromise
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function getAccessToken(minValidity: number = 30): Promise<string | null> {
|
|
347
|
+
return await refreshAccessToken(minValidity)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function forceRefreshAccessToken(): Promise<string | null> {
|
|
351
|
+
return await refreshAccessToken(-1)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function loginWithKeycloak(targetPath?: string | null): Promise<void> {
|
|
355
|
+
if (!initialized) await initializeAuth()
|
|
356
|
+
|
|
357
|
+
const options: KeycloakLoginOptions = {
|
|
358
|
+
redirectUri: resolveLoginRedirectUri(targetPath),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await keycloak.login(options)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function logoutFromKeycloak(): Promise<void> {
|
|
365
|
+
if (!initialized) await initializeAuth()
|
|
366
|
+
|
|
367
|
+
await logoutKnownAdminConsoles()
|
|
368
|
+
|
|
369
|
+
const options: KeycloakLogoutOptions = {
|
|
370
|
+
redirectUri: resolvePostLogoutRedirectUri(),
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
await keycloak.logout(options)
|
|
374
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NavigationGuardWithThis } from 'vue-router'
|
|
2
|
+
|
|
3
|
+
export type AuthGuardStore = {
|
|
4
|
+
redirecting: boolean
|
|
5
|
+
initialized: boolean
|
|
6
|
+
initializing: boolean
|
|
7
|
+
authenticated: boolean
|
|
8
|
+
error: string | null
|
|
9
|
+
initialize(): Promise<void>
|
|
10
|
+
login(targetPath?: string | null): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createAuthGuard(resolveAuthStore: () => AuthGuardStore): NavigationGuardWithThis<undefined> {
|
|
14
|
+
return async (to) => {
|
|
15
|
+
if (to.meta?.public === true) return true
|
|
16
|
+
|
|
17
|
+
const auth = resolveAuthStore()
|
|
18
|
+
if (auth.redirecting) return false
|
|
19
|
+
|
|
20
|
+
if (!auth.initialized && !auth.initializing) {
|
|
21
|
+
try {
|
|
22
|
+
await auth.initialize()
|
|
23
|
+
} catch {
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (auth.authenticated) return true
|
|
29
|
+
if (auth.initializing || auth.error) return false
|
|
30
|
+
|
|
31
|
+
await auth.login(to.fullPath)
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AuthSnapshot = {
|
|
2
|
+
initialized: boolean
|
|
3
|
+
authenticated: boolean
|
|
4
|
+
token: string | null
|
|
5
|
+
subject: string | null
|
|
6
|
+
displayName: string | null
|
|
7
|
+
preferredUsername: string | null
|
|
8
|
+
email: string | null
|
|
9
|
+
realmRoles: string[]
|
|
10
|
+
resourceRoles: Record<string, string[]>
|
|
11
|
+
roles: string[]
|
|
12
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { computed, ref } from 'vue'
|
|
2
|
+
import { defineStore } from 'pinia'
|
|
3
|
+
import {
|
|
4
|
+
getAuthSnapshot,
|
|
5
|
+
initializeAuth,
|
|
6
|
+
loginWithKeycloak,
|
|
7
|
+
logoutFromKeycloak,
|
|
8
|
+
subscribeAuth,
|
|
9
|
+
} from './keycloak'
|
|
10
|
+
import type { AuthSnapshot } from './types'
|
|
11
|
+
import { readAppEnv } from '../env/runtimeConfig'
|
|
12
|
+
|
|
13
|
+
const adminRole = readAppEnv('VITE_KEYCLOAK_ROLE_ADMIN')
|
|
14
|
+
|
|
15
|
+
function normalizeRoleKey(role: string): string {
|
|
16
|
+
return String(role ?? '')
|
|
17
|
+
.trim()
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/^\/+/, '')
|
|
20
|
+
.replace(/[\s._:]+/g, '-')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toKeycloakInitErrorMessage(error: unknown): string {
|
|
24
|
+
if (error instanceof Error && error.message.trim().length > 0) return error.message.trim()
|
|
25
|
+
return 'Unable to initialize Keycloak. Check the UI env vars and the client redirect URI settings.'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toFriendlyRoleLabel(role: string): string {
|
|
29
|
+
const value = String(role ?? '').trim()
|
|
30
|
+
const normalizedRole = normalizeRoleKey(value)
|
|
31
|
+
if (!value) return 'User'
|
|
32
|
+
|
|
33
|
+
switch (normalizedRole) {
|
|
34
|
+
case 'ngb-admin':
|
|
35
|
+
case 'admin':
|
|
36
|
+
case 'administrator':
|
|
37
|
+
case 'realm-admin':
|
|
38
|
+
return 'Administrator'
|
|
39
|
+
case 'ngb-user':
|
|
40
|
+
case 'user':
|
|
41
|
+
return 'User'
|
|
42
|
+
default: {
|
|
43
|
+
const normalized = value
|
|
44
|
+
.replace(/^ngb[-_:]?/i, '')
|
|
45
|
+
.replace(/[_-]+/g, ' ')
|
|
46
|
+
.trim()
|
|
47
|
+
|
|
48
|
+
const source = normalized || value
|
|
49
|
+
return source
|
|
50
|
+
.split(/\s+/g)
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
53
|
+
.join(' ')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rolePriority(role: string): number {
|
|
59
|
+
switch (normalizeRoleKey(role)) {
|
|
60
|
+
case 'ngb-admin':
|
|
61
|
+
case 'admin':
|
|
62
|
+
case 'administrator':
|
|
63
|
+
case 'realm-admin':
|
|
64
|
+
return 0
|
|
65
|
+
case 'ngb-user':
|
|
66
|
+
case 'user':
|
|
67
|
+
return 10
|
|
68
|
+
default:
|
|
69
|
+
return 100
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const useAuthStore = defineStore('auth', () => {
|
|
74
|
+
const initialized = ref(false)
|
|
75
|
+
const initializing = ref(false)
|
|
76
|
+
const redirecting = ref(false)
|
|
77
|
+
const authenticated = ref(false)
|
|
78
|
+
const token = ref<string | null>(null)
|
|
79
|
+
const subject = ref<string | null>(null)
|
|
80
|
+
const displayName = ref<string | null>(null)
|
|
81
|
+
const preferredUsername = ref<string | null>(null)
|
|
82
|
+
const email = ref<string | null>(null)
|
|
83
|
+
const realmRoles = ref<string[]>([])
|
|
84
|
+
const resourceRoles = ref<Record<string, string[]>>({})
|
|
85
|
+
const roles = ref<string[]>([])
|
|
86
|
+
const error = ref<string | null>(null)
|
|
87
|
+
|
|
88
|
+
let initializePromise: Promise<void> | null = null
|
|
89
|
+
|
|
90
|
+
function applySnapshot(snapshot: AuthSnapshot): void {
|
|
91
|
+
initialized.value = snapshot.initialized
|
|
92
|
+
authenticated.value = snapshot.authenticated
|
|
93
|
+
token.value = snapshot.token
|
|
94
|
+
subject.value = snapshot.subject
|
|
95
|
+
displayName.value = snapshot.displayName
|
|
96
|
+
preferredUsername.value = snapshot.preferredUsername
|
|
97
|
+
email.value = snapshot.email
|
|
98
|
+
realmRoles.value = snapshot.realmRoles
|
|
99
|
+
resourceRoles.value = snapshot.resourceRoles
|
|
100
|
+
roles.value = snapshot.roles
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
subscribeAuth(applySnapshot)
|
|
104
|
+
applySnapshot(getAuthSnapshot())
|
|
105
|
+
|
|
106
|
+
const userName = computed(() => displayName.value || preferredUsername.value || email.value || 'User')
|
|
107
|
+
const isAdmin = computed(() => {
|
|
108
|
+
const normalizedConfiguredAdminRole = normalizeRoleKey(adminRole)
|
|
109
|
+
return roles.value.some((role) => {
|
|
110
|
+
const normalizedRole = normalizeRoleKey(role)
|
|
111
|
+
return normalizedRole === 'ngb-admin'
|
|
112
|
+
|| normalizedRole === 'admin'
|
|
113
|
+
|| normalizedRole === 'administrator'
|
|
114
|
+
|| normalizedRole === 'realm-admin'
|
|
115
|
+
|| (!!normalizedConfiguredAdminRole && normalizedRole === normalizedConfiguredAdminRole)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
const primaryTechnicalRole = computed(() => {
|
|
119
|
+
const sorted = [...roles.value].sort((a, b) => rolePriority(a) - rolePriority(b) || a.localeCompare(b))
|
|
120
|
+
return sorted[0] ?? ''
|
|
121
|
+
})
|
|
122
|
+
const friendlyRoles = computed(() => {
|
|
123
|
+
const sorted = [...roles.value].sort((a, b) => rolePriority(a) - rolePriority(b) || a.localeCompare(b))
|
|
124
|
+
return Array.from(new Set(sorted.map((role) => toFriendlyRoleLabel(role))))
|
|
125
|
+
})
|
|
126
|
+
const primaryRoleLabel = computed(() => toFriendlyRoleLabel(primaryTechnicalRole.value))
|
|
127
|
+
const primaryRoleIcon = computed<'shield-check' | 'user'>(() =>
|
|
128
|
+
rolePriority(primaryTechnicalRole.value) === 0 ? 'shield-check' : 'user')
|
|
129
|
+
|
|
130
|
+
async function initialize(): Promise<void> {
|
|
131
|
+
if (initialized.value) return
|
|
132
|
+
if (initializePromise) return await initializePromise
|
|
133
|
+
|
|
134
|
+
initializing.value = true
|
|
135
|
+
error.value = null
|
|
136
|
+
|
|
137
|
+
initializePromise = initializeAuth()
|
|
138
|
+
.then((snapshot) => {
|
|
139
|
+
applySnapshot(snapshot)
|
|
140
|
+
})
|
|
141
|
+
.catch((cause) => {
|
|
142
|
+
error.value = toKeycloakInitErrorMessage(cause)
|
|
143
|
+
throw cause
|
|
144
|
+
})
|
|
145
|
+
.finally(() => {
|
|
146
|
+
initializing.value = false
|
|
147
|
+
initializePromise = null
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return await initializePromise
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function login(targetPath?: string | null): Promise<void> {
|
|
154
|
+
redirecting.value = true
|
|
155
|
+
error.value = null
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await loginWithKeycloak(targetPath)
|
|
159
|
+
} catch (cause) {
|
|
160
|
+
redirecting.value = false
|
|
161
|
+
error.value = toKeycloakInitErrorMessage(cause)
|
|
162
|
+
throw cause
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function logout(): Promise<void> {
|
|
167
|
+
redirecting.value = true
|
|
168
|
+
error.value = null
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await logoutFromKeycloak()
|
|
172
|
+
} catch (cause) {
|
|
173
|
+
redirecting.value = false
|
|
174
|
+
error.value = toKeycloakInitErrorMessage(cause)
|
|
175
|
+
throw cause
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hasRole(role: string): boolean {
|
|
180
|
+
const value = String(role ?? '').trim()
|
|
181
|
+
return value.length > 0 && roles.value.includes(value)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
initialized,
|
|
186
|
+
initializing,
|
|
187
|
+
redirecting,
|
|
188
|
+
authenticated,
|
|
189
|
+
token,
|
|
190
|
+
subject,
|
|
191
|
+
displayName,
|
|
192
|
+
preferredUsername,
|
|
193
|
+
email,
|
|
194
|
+
realmRoles,
|
|
195
|
+
resourceRoles,
|
|
196
|
+
roles,
|
|
197
|
+
userName,
|
|
198
|
+
isAdmin,
|
|
199
|
+
primaryTechnicalRole,
|
|
200
|
+
friendlyRoles,
|
|
201
|
+
primaryRoleLabel,
|
|
202
|
+
primaryRoleIcon,
|
|
203
|
+
error,
|
|
204
|
+
initialize,
|
|
205
|
+
login,
|
|
206
|
+
logout,
|
|
207
|
+
hasRole,
|
|
208
|
+
}
|
|
209
|
+
})
|