chore(web): merge session foundation updates

This commit is contained in:
José René White Enciso 2026-03-08 16:06:30 -06:00
commit d3ea144cc4
8 changed files with 264 additions and 33 deletions

3
docker/40-runtime-config.sh Normal file → Executable file
View File

@ -3,6 +3,7 @@ set -eu
cat > /usr/share/nginx/html/runtime-config.js <<EOT cat > /usr/share/nginx/html/runtime-config.js <<EOT
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}" API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}",
THALOS_AUTH_BASE_URL: "${THALOS_AUTH_BASE_URL:-${API_BASE_URL:-http://localhost:8080}}"
}; };
EOT EOT

View File

@ -3,4 +3,7 @@
- This repository hosts a React edge application for a single BFF. - This repository hosts a React edge application for a single BFF.
- Frontend data access flows through `src/api/*` adapter modules. - Frontend data access flows through `src/api/*` adapter modules.
- The UI does not access DAL or internal services directly. - The UI does not access DAL or internal services directly.
- API base URL is configured with `VITE_API_BASE_URL`. - Route shell and protected sections are session-aware via Thalos session endpoints.
- Runtime base URLs:
- `API_BASE_URL` for business BFF calls.
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me.

View File

@ -9,9 +9,17 @@ npm install
## Run ## Run
```bash ```bash
VITE_API_BASE_URL=http://localhost:8080 npm run dev VITE_API_BASE_URL=http://localhost:8080 \
VITE_THALOS_AUTH_BASE_URL=http://localhost:20080 \
npm run dev
``` ```
## Auth Model
- Login is executed against Thalos session endpoints.
- Business calls are gated behind session checks.
- Session cookies are sent with `credentials: include`.
## Build ## Build
```bash ```bash

View File

@ -1,3 +1,4 @@
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
API_BASE_URL: 'http://localhost:8080' API_BASE_URL: "http://localhost:8080",
THALOS_AUTH_BASE_URL: "http://localhost:20080"
}; };

View File

@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { getApiBaseUrl } from './client'; import { getApiBaseUrl, getThalosAuthBaseUrl } from './client';
describe('getApiBaseUrl', () => { describe('client runtime base URLs', () => {
afterEach(() => { afterEach(() => {
delete window.__APP_CONFIG__; delete window.__APP_CONFIG__;
vi.unstubAllEnvs(); vi.unstubAllEnvs();
@ -20,6 +20,21 @@ describe('getApiBaseUrl', () => {
expect(getApiBaseUrl()).toBe('http://env.example'); expect(getApiBaseUrl()).toBe('http://env.example');
}); });
it('uses THALOS auth base URL from runtime config when present', () => {
window.__APP_CONFIG__ = {
API_BASE_URL: 'http://api.example',
THALOS_AUTH_BASE_URL: 'http://thalos.example'
};
expect(getThalosAuthBaseUrl()).toBe('http://thalos.example');
});
it('falls back to API base URL when THALOS auth base URL is missing', () => {
window.__APP_CONFIG__ = { API_BASE_URL: 'http://api.example' };
expect(getThalosAuthBaseUrl()).toBe('http://api.example');
});
it('falls back to localhost default when both runtime and env are missing', () => { it('falls back to localhost default when both runtime and env are missing', () => {
expect(getApiBaseUrl()).toBe('http://localhost:8080'); expect(getApiBaseUrl()).toBe('http://localhost:8080');
}); });

View File

@ -2,10 +2,33 @@ declare global {
interface Window { interface Window {
__APP_CONFIG__?: { __APP_CONFIG__?: {
API_BASE_URL?: string; API_BASE_URL?: string;
THALOS_AUTH_BASE_URL?: string;
}; };
} }
} }
export type ApiErrorBody = {
code?: string;
message?: string;
correlationId?: string;
};
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
readonly correlationId?: string;
constructor(status: number, message: string, code?: string, correlationId?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
const CorrelationHeaderName = 'x-correlation-id';
export function getApiBaseUrl(): string { export function getApiBaseUrl(): string {
const runtimeValue = window.__APP_CONFIG__?.API_BASE_URL; const runtimeValue = window.__APP_CONFIG__?.API_BASE_URL;
if (runtimeValue && runtimeValue.length > 0) { if (runtimeValue && runtimeValue.length > 0) {
@ -15,11 +38,87 @@ export function getApiBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'; return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
} }
export async function getJson<T>(path: string): Promise<T> { export function getThalosAuthBaseUrl(): string {
const response = await fetch(`${getApiBaseUrl()}${path}`); const runtimeValue = window.__APP_CONFIG__?.THALOS_AUTH_BASE_URL;
if (!response.ok) { if (runtimeValue && runtimeValue.length > 0) {
throw new Error(`GET ${path} failed with status ${response.status}`); return runtimeValue;
}
return import.meta.env.VITE_THALOS_AUTH_BASE_URL ?? getApiBaseUrl();
}
export async function getJson<T>(path: string, baseUrl = getApiBaseUrl()): Promise<T> {
return requestJson<T>(baseUrl, path, { method: 'GET' });
}
export async function postJson<TResponse>(
path: string,
body: unknown,
baseUrl = getApiBaseUrl()
): Promise<TResponse> {
return requestJson<TResponse>(baseUrl, path, {
method: 'POST',
body: JSON.stringify(body)
});
}
export async function postNoContent(
path: string,
body: unknown,
baseUrl = getApiBaseUrl()
): Promise<void> {
await request(baseUrl, path, {
method: 'POST',
body: JSON.stringify(body)
});
}
async function requestJson<T>(baseUrl: string, path: string, init: RequestInit): Promise<T> {
const response = await request(baseUrl, path, init);
if (response.status === 204) {
return {} as T;
} }
return (await response.json()) as T; return (await response.json()) as T;
} }
async function request(baseUrl: string, path: string, init: RequestInit): Promise<Response> {
const correlationId = createCorrelationId();
const headers = new Headers(init.headers ?? {});
headers.set('Accept', 'application/json');
if (init.body) {
headers.set('Content-Type', 'application/json');
}
headers.set(CorrelationHeaderName, correlationId);
const response = await fetch(`${baseUrl}${path}`, {
...init,
credentials: 'include',
headers
});
if (!response.ok) {
const apiError = await parseApiError(response);
const message = apiError.message ?? `${init.method ?? 'GET'} ${path} failed with status ${response.status}`;
throw new ApiError(response.status, message, apiError.code, apiError.correlationId ?? correlationId);
}
return response;
}
async function parseApiError(response: Response): Promise<ApiErrorBody> {
try {
return (await response.json()) as ApiErrorBody;
} catch {
return {};
}
}
function createCorrelationId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

39
src/api/sessionApi.ts Normal file
View File

@ -0,0 +1,39 @@
import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client';
export type SessionProfile = {
isAuthenticated: boolean;
subjectId: string;
tenantId: string;
provider: string;
};
export type SessionLoginRequest = {
subjectId: string;
tenantId: string;
correlationId: string;
provider: string;
externalToken: string;
};
export type SessionLoginResponse = {
subjectId: string;
tenantId: string;
provider: string;
expiresInSeconds: number;
};
export async function loginSession(request: SessionLoginRequest): Promise<SessionLoginResponse> {
return postJson<SessionLoginResponse>('/api/identity/session/login', request, getThalosAuthBaseUrl());
}
export async function refreshSession(): Promise<SessionLoginResponse> {
return postJson<SessionLoginResponse>('/api/identity/session/refresh', {}, getThalosAuthBaseUrl());
}
export async function logoutSession(): Promise<void> {
return postNoContent('/api/identity/session/logout', {}, getThalosAuthBaseUrl());
}
export async function getSessionMe(): Promise<SessionProfile> {
return getJson<SessionProfile>('/api/identity/session/me', getThalosAuthBaseUrl());
}

View File

@ -1,56 +1,121 @@
:root { :root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif; font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: #111827;
background: radial-gradient(circle at top, #f7f8fb 0%, #eef2ff 45%, #f8fafc 100%);
}
* {
box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
background: linear-gradient(140deg, #f5f8ff, #eef3f0); min-height: 100vh;
color: #1d2230;
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
} }
.app { .app {
width: min(760px, 100%); width: min(960px, 94vw);
margin: 0 auto;
padding: 1.5rem 0 3rem;
}
.card {
background: #ffffff; background: #ffffff;
border: 1px solid #dce4f2; border: 1px solid #dbe3f4;
border-radius: 14px; border-radius: 14px;
padding: 20px; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
box-shadow: 0 8px 30px rgba(37, 52, 92, 0.08); padding: 1rem;
margin-top: 1rem;
} }
.row { .row {
display: flex; display: flex;
gap: 10px; gap: 0.75rem;
align-items: center; align-items: center;
margin-bottom: 12px; flex-wrap: wrap;
}
.col {
display: flex;
flex-direction: column;
gap: 0.5rem;
} }
input, input,
button { select,
textarea {
border: 1px solid #c8d2ea;
border-radius: 10px;
padding: 0.6rem 0.8rem;
font: inherit; font: inherit;
border-radius: 8px; }
border: 1px solid #c7d4ee;
padding: 8px 10px; textarea {
min-height: 120px;
resize: vertical;
} }
button { button {
background: #2158d6; border: none;
color: #fff; border-radius: 10px;
border-color: #2158d6; padding: 0.6rem 0.9rem;
font-weight: 600;
cursor: pointer; cursor: pointer;
background: #1d4ed8;
color: #ffffff;
}
button.secondary {
background: #475569;
}
button.warn {
background: #b91c1c;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.alert {
border-radius: 10px;
padding: 0.7rem 0.9rem;
border: 1px solid #fecaca;
background: #fef2f2;
color: #7f1d1d;
}
.muted {
color: #475569;
}
.badge {
display: inline-block;
border-radius: 999px;
background: #e2e8f0;
color: #0f172a;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
} }
pre { pre {
overflow: auto; margin: 0;
background: #0f172a; background: #0f172a;
color: #dbeafe; color: #e2e8f0;
border-radius: 8px; border-radius: 10px;
padding: 12px; padding: 0.8rem;
overflow: auto;
max-height: 360px;
} }