Refactored UI and compose file
This commit is contained in:
@@ -1,43 +1,382 @@
|
||||
import { updateHasherSettings } from './services/toolsService';
|
||||
|
||||
export interface SecuritySettings {
|
||||
Authority: string;
|
||||
Audience: string;
|
||||
ClientId: string;
|
||||
ClientSecret: string;
|
||||
Authority: string;
|
||||
Audience: string;
|
||||
ClientId: string;
|
||||
ClientSecret: string;
|
||||
Scope?: string;
|
||||
}
|
||||
|
||||
export interface ApiSettings {
|
||||
BaseUrl: string;
|
||||
BaseUrl: string;
|
||||
}
|
||||
|
||||
export interface HasherSettings {
|
||||
Salt: string;
|
||||
MinHashLength: number;
|
||||
}
|
||||
|
||||
export type ConfigSource =
|
||||
| 'runtime-injected'
|
||||
| 'runtime-fetched'
|
||||
| 'build-env'
|
||||
| 'fallback-defaults';
|
||||
|
||||
export interface AppConfig {
|
||||
SecuritySettings: SecuritySettings;
|
||||
ApiSettings: ApiSettings;
|
||||
SecuritySettings: SecuritySettings;
|
||||
ApiSettings: ApiSettings;
|
||||
HasherSettings: HasherSettings;
|
||||
ConfigSource: ConfigSource;
|
||||
LoadedAt?: string;
|
||||
}
|
||||
|
||||
// Fallback presets (used only when no runtime or build-time variable is provided)
|
||||
export const defaultSecuritySettings: SecuritySettings = {
|
||||
Authority: (import.meta.env.VITE_OIDC_AUTHORITY as string) || 'https://sts.security.khongisa.co.za',
|
||||
Audience: (import.meta.env.VITE_OIDC_AUDIENCE as string) || 'pfm-api-dev',
|
||||
ClientId: (import.meta.env.VITE_OIDC_CLIENT_ID as string) || 'pfm-console-dev',
|
||||
ClientSecret: (import.meta.env.VITE_OIDC_CLIENT_SECRET as string) || 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||
Authority: 'https://sts.security.khongisa.co.za',
|
||||
Audience: 'pfm-api-dev',
|
||||
ClientId: 'pfm-console-dev',
|
||||
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||
Scope: 'openid profile roles email address pfm-api-dev',
|
||||
};
|
||||
|
||||
export const defaultApiSettings: ApiSettings = {
|
||||
BaseUrl: (import.meta.env.VITE_API_BASE_URL as string) || 'https://api.ifri.khongisa.co.za/',
|
||||
BaseUrl: 'https://api.ifri.khongisa.co.za/',
|
||||
};
|
||||
|
||||
const runtimeConfig: AppConfig = {
|
||||
SecuritySettings: defaultSecuritySettings,
|
||||
ApiSettings: defaultApiSettings,
|
||||
export const defaultHasherSettings: HasherSettings = {
|
||||
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
MinHashLength: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts first non-empty, trimmed string from candidate values.
|
||||
*/
|
||||
function pickFirstNonEmpty(...candidates: any[]): string | undefined {
|
||||
for (const c of candidates) {
|
||||
if (c !== undefined && c !== null) {
|
||||
const s = String(c).trim();
|
||||
if (s !== '' && s !== 'undefined' && s !== 'null') {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an API base URL ensuring proper protocol and single trailing slash.
|
||||
*/
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads any runtime config object injected into window.
|
||||
* Supports: window.__RUNTIME_CONFIG__, window.APP_CONFIG, window._env_
|
||||
*/
|
||||
function getWindowRuntimeConfig(): Record<string, any> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const win = window as any;
|
||||
return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and maps configuration from a given source object.
|
||||
* Checks flat keys, nested keys, and ASP.NET double-underscore conventions.
|
||||
*/
|
||||
function extractSourceConfig(src: Record<string, any>): {
|
||||
authority?: string;
|
||||
audience?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scope?: string;
|
||||
baseUrl?: string;
|
||||
hasherSalt?: string;
|
||||
hasherMinLength?: number;
|
||||
} {
|
||||
if (!src || typeof src !== 'object') return {};
|
||||
|
||||
const authority = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_AUTHORITY,
|
||||
src.OIDC_AUTHORITY,
|
||||
src.SecuritySettings?.Authority,
|
||||
src.SecuritySettings__Authority,
|
||||
src.SecuritySettings_Authority,
|
||||
src.AUTHORITY,
|
||||
src.STS_AUTHORITY
|
||||
);
|
||||
|
||||
const audience = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_AUDIENCE,
|
||||
src.OIDC_AUDIENCE,
|
||||
src.SecuritySettings?.Audience,
|
||||
src.SecuritySettings__Audience,
|
||||
src.SecuritySettings_Audience,
|
||||
src.AUDIENCE
|
||||
);
|
||||
|
||||
const clientId = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_CLIENT_ID,
|
||||
src.OIDC_CLIENT_ID,
|
||||
src.SecuritySettings?.ClientId,
|
||||
src.SecuritySettings__ClientId,
|
||||
src.SecuritySettings_ClientId,
|
||||
src.CLIENT_ID
|
||||
);
|
||||
|
||||
const clientSecret = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_CLIENT_SECRET,
|
||||
src.OIDC_CLIENT_SECRET,
|
||||
src.SecuritySettings?.ClientSecret,
|
||||
src.SecuritySettings__ClientSecret,
|
||||
src.SecuritySettings_ClientSecret,
|
||||
src.CLIENT_SECRET
|
||||
);
|
||||
|
||||
const scope = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_SCOPE,
|
||||
src.OIDC_SCOPE,
|
||||
src.SecuritySettings?.Scope,
|
||||
src.SecuritySettings__Scope,
|
||||
src.SecuritySettings_Scope,
|
||||
src.SCOPE
|
||||
);
|
||||
|
||||
const baseUrl = pickFirstNonEmpty(
|
||||
src.VITE_API_BASE_URL,
|
||||
src.API_BASE_URL,
|
||||
src.ApiSettings?.BaseUrl,
|
||||
src.ApiSettings__BaseUrl,
|
||||
src.ApiSettings_BaseUrl,
|
||||
src.PFM_API_BASE_URL
|
||||
);
|
||||
|
||||
const hasherSalt = pickFirstNonEmpty(
|
||||
src.VITE_HASHER_SALT,
|
||||
src.HASHER_SALT,
|
||||
src.HasherSettings?.Salt,
|
||||
src.HasherSettings__Salt,
|
||||
src.HasherSettings_Salt
|
||||
);
|
||||
|
||||
const rawMinLength = pickFirstNonEmpty(
|
||||
src.VITE_HASHER_MIN_LENGTH,
|
||||
src.HASHER_MIN_LENGTH,
|
||||
src.HasherSettings?.MinHashLength,
|
||||
src.HasherSettings__MinLength,
|
||||
src.HasherSettings_MinLength
|
||||
);
|
||||
|
||||
const hasherMinLength = rawMinLength ? Number(rawMinLength) : undefined;
|
||||
|
||||
return {
|
||||
authority,
|
||||
audience,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : undefined,
|
||||
hasherSalt,
|
||||
hasherMinLength: hasherMinLength && !isNaN(hasherMinLength) && hasherMinLength > 0 ? hasherMinLength : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the prioritized configuration by evaluating:
|
||||
* Tier 1: Injected Runtime Config (window.__RUNTIME_CONFIG__ from docker-entrypoint.sh)
|
||||
* Tier 2: Fetched Runtime Config (/config.json from server)
|
||||
* Tier 3: Vite Build-Time Variables (import.meta.env.VITE_*)
|
||||
* Tier 4: Fallback Defaults
|
||||
*/
|
||||
function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
const windowConfig = extractSourceConfig(getWindowRuntimeConfig());
|
||||
const fetchedConfig = fetchedJson ? extractSourceConfig(fetchedJson) : {};
|
||||
const buildEnvConfig = extractSourceConfig(
|
||||
(typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record<string, any>
|
||||
);
|
||||
|
||||
// Check if any runtime deployment override was provided
|
||||
const hasInjected = Object.values(windowConfig).some((v) => v !== undefined);
|
||||
const hasFetched = Object.values(fetchedConfig).some((v) => v !== undefined);
|
||||
const hasBuildEnv = Object.values(buildEnvConfig).some((v) => v !== undefined);
|
||||
|
||||
let source: ConfigSource = 'fallback-defaults';
|
||||
if (hasInjected) {
|
||||
source = 'runtime-injected';
|
||||
} else if (hasFetched) {
|
||||
source = 'runtime-fetched';
|
||||
} else if (hasBuildEnv) {
|
||||
source = 'build-env';
|
||||
}
|
||||
|
||||
const authority = pickFirstNonEmpty(
|
||||
windowConfig.authority,
|
||||
fetchedConfig.authority,
|
||||
buildEnvConfig.authority,
|
||||
defaultSecuritySettings.Authority
|
||||
)!;
|
||||
|
||||
const audience = pickFirstNonEmpty(
|
||||
windowConfig.audience,
|
||||
fetchedConfig.audience,
|
||||
buildEnvConfig.audience,
|
||||
defaultSecuritySettings.Audience
|
||||
)!;
|
||||
|
||||
const clientId = pickFirstNonEmpty(
|
||||
windowConfig.clientId,
|
||||
fetchedConfig.clientId,
|
||||
buildEnvConfig.clientId,
|
||||
defaultSecuritySettings.ClientId
|
||||
)!;
|
||||
|
||||
const clientSecret = pickFirstNonEmpty(
|
||||
windowConfig.clientSecret,
|
||||
fetchedConfig.clientSecret,
|
||||
buildEnvConfig.clientSecret,
|
||||
defaultSecuritySettings.ClientSecret
|
||||
)!;
|
||||
|
||||
const scope = pickFirstNonEmpty(
|
||||
windowConfig.scope,
|
||||
fetchedConfig.scope,
|
||||
buildEnvConfig.scope,
|
||||
defaultSecuritySettings.Scope
|
||||
);
|
||||
|
||||
const baseUrl = pickFirstNonEmpty(
|
||||
windowConfig.baseUrl,
|
||||
fetchedConfig.baseUrl,
|
||||
buildEnvConfig.baseUrl,
|
||||
defaultApiSettings.BaseUrl
|
||||
)!;
|
||||
|
||||
const salt = pickFirstNonEmpty(
|
||||
windowConfig.hasherSalt,
|
||||
fetchedConfig.hasherSalt,
|
||||
buildEnvConfig.hasherSalt,
|
||||
defaultHasherSettings.Salt
|
||||
)!;
|
||||
|
||||
const minHashLength =
|
||||
windowConfig.hasherMinLength ||
|
||||
fetchedConfig.hasherMinLength ||
|
||||
buildEnvConfig.hasherMinLength ||
|
||||
defaultHasherSettings.MinHashLength;
|
||||
|
||||
return {
|
||||
SecuritySettings: {
|
||||
Authority: authority,
|
||||
Audience: audience,
|
||||
ClientId: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
Scope: scope,
|
||||
},
|
||||
ApiSettings: {
|
||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||
},
|
||||
HasherSettings: {
|
||||
Salt: salt,
|
||||
MinHashLength: minHashLength,
|
||||
},
|
||||
ConfigSource: source,
|
||||
LoadedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Initial synchronous computation from window.__RUNTIME_CONFIG__ and build-time env
|
||||
let runtimeConfig: AppConfig = resolveConfig();
|
||||
|
||||
// Immediately apply deterministic hasher settings
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||
|
||||
export function onConfigChanged(listener: (config: AppConfig) => void): () => void {
|
||||
configListeners.push(listener);
|
||||
return () => {
|
||||
const idx = configListeners.indexOf(listener);
|
||||
if (idx !== -1) configListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
let loadPromise: Promise<AppConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Asynchronously loads and verifies runtime configuration.
|
||||
* Fetches /config.json with cache-busting to ensure latest deployment variables
|
||||
* are prioritized over static build artifacts.
|
||||
*/
|
||||
export async function loadRuntimeConfig(): Promise<AppConfig> {
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = (async () => {
|
||||
let fetchedJson: Record<string, any> | undefined;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
// Fetch /config.json with cache-busting to bypass any intermediary caching
|
||||
const res = await fetch(`/config.json?_t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && typeof data === 'object') {
|
||||
fetchedJson = data;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Fetching /config.json is non-blocking; window.__RUNTIME_CONFIG__ or env fallback will be used
|
||||
console.debug('[Config] Note: Remote /config.json fetch bypassed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
runtimeConfig = resolveConfig(fetchedJson);
|
||||
|
||||
// Synchronize deterministic hasher settings
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
console.info(
|
||||
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
||||
);
|
||||
|
||||
// Notify registered listeners
|
||||
configListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(runtimeConfig);
|
||||
} catch (e) {
|
||||
console.error('[Config] Listener error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
return runtimeConfig;
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export function getAppConfig(): AppConfig {
|
||||
return runtimeConfig;
|
||||
}
|
||||
|
||||
export function getSecuritySettings(): SecuritySettings {
|
||||
return runtimeConfig.SecuritySettings;
|
||||
return runtimeConfig.SecuritySettings;
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return runtimeConfig.ApiSettings.BaseUrl;
|
||||
return runtimeConfig.ApiSettings.BaseUrl;
|
||||
}
|
||||
|
||||
export function getHasherSettings(): HasherSettings {
|
||||
return runtimeConfig.HasherSettings;
|
||||
}
|
||||
|
||||
export function getConfigSource(): ConfigSource {
|
||||
return runtimeConfig.ConfigSource;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user