diff --git a/docker/40-runtime-config.sh b/docker/40-runtime-config.sh old mode 100644 new mode 100755 index ac38eef..8c2cc72 --- a/docker/40-runtime-config.sh +++ b/docker/40-runtime-config.sh @@ -3,6 +3,7 @@ set -eu cat > /usr/share/nginx/html/runtime-config.js < { +describe('client runtime base URLs', () => { afterEach(() => { delete window.__APP_CONFIG__; vi.unstubAllEnvs(); @@ -20,6 +20,21 @@ describe('getApiBaseUrl', () => { 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', () => { expect(getApiBaseUrl()).toBe('http://localhost:8080'); }); diff --git a/src/api/client.ts b/src/api/client.ts index 2e1ad4e..4ead4eb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,10 +2,33 @@ declare global { interface Window { __APP_CONFIG__?: { 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 { const runtimeValue = window.__APP_CONFIG__?.API_BASE_URL; if (runtimeValue && runtimeValue.length > 0) { @@ -15,11 +38,87 @@ export function getApiBaseUrl(): string { return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'; } -export async function getJson(path: string): Promise { - const response = await fetch(`${getApiBaseUrl()}${path}`); - if (!response.ok) { - throw new Error(`GET ${path} failed with status ${response.status}`); +export function getThalosAuthBaseUrl(): string { + const runtimeValue = window.__APP_CONFIG__?.THALOS_AUTH_BASE_URL; + if (runtimeValue && runtimeValue.length > 0) { + return runtimeValue; + } + + return import.meta.env.VITE_THALOS_AUTH_BASE_URL ?? getApiBaseUrl(); +} + +export async function getJson(path: string, baseUrl = getApiBaseUrl()): Promise { + return requestJson(baseUrl, path, { method: 'GET' }); +} + +export async function postJson( + path: string, + body: unknown, + baseUrl = getApiBaseUrl() +): Promise { + return requestJson(baseUrl, path, { + method: 'POST', + body: JSON.stringify(body) + }); +} + +export async function postNoContent( + path: string, + body: unknown, + baseUrl = getApiBaseUrl() +): Promise { + await request(baseUrl, path, { + method: 'POST', + body: JSON.stringify(body) + }); +} + +async function requestJson(baseUrl: string, path: string, init: RequestInit): Promise { + const response = await request(baseUrl, path, init); + if (response.status === 204) { + return {} as T; } return (await response.json()) as T; } + +async function request(baseUrl: string, path: string, init: RequestInit): Promise { + 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 { + 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)}`; +} diff --git a/src/api/sessionApi.ts b/src/api/sessionApi.ts new file mode 100644 index 0000000..5f90f70 --- /dev/null +++ b/src/api/sessionApi.ts @@ -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 { + return postJson('/api/identity/session/login', request, getThalosAuthBaseUrl()); +} + +export async function refreshSession(): Promise { + return postJson('/api/identity/session/refresh', {}, getThalosAuthBaseUrl()); +} + +export async function logoutSession(): Promise { + return postNoContent('/api/identity/session/logout', {}, getThalosAuthBaseUrl()); +} + +export async function getSessionMe(): Promise { + return getJson('/api/identity/session/me', getThalosAuthBaseUrl()); +} diff --git a/src/styles.css b/src/styles.css index a4cda32..eb74e06 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,56 +1,121 @@ :root { - color-scheme: light; 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 { margin: 0; - background: linear-gradient(140deg, #f5f8ff, #eef3f0); - color: #1d2230; + min-height: 100vh; } #root { min-height: 100vh; - display: grid; - place-items: center; - padding: 24px; } .app { - width: min(760px, 100%); + width: min(960px, 94vw); + margin: 0 auto; + padding: 1.5rem 0 3rem; +} + +.card { background: #ffffff; - border: 1px solid #dce4f2; + border: 1px solid #dbe3f4; border-radius: 14px; - padding: 20px; - box-shadow: 0 8px 30px rgba(37, 52, 92, 0.08); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + padding: 1rem; + margin-top: 1rem; } .row { display: flex; - gap: 10px; + gap: 0.75rem; align-items: center; - margin-bottom: 12px; + flex-wrap: wrap; +} + +.col { + display: flex; + flex-direction: column; + gap: 0.5rem; } input, -button { +select, +textarea { + border: 1px solid #c8d2ea; + border-radius: 10px; + padding: 0.6rem 0.8rem; font: inherit; - border-radius: 8px; - border: 1px solid #c7d4ee; - padding: 8px 10px; +} + +textarea { + min-height: 120px; + resize: vertical; } button { - background: #2158d6; - color: #fff; - border-color: #2158d6; + border: none; + border-radius: 10px; + padding: 0.6rem 0.9rem; + font-weight: 600; 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 { - overflow: auto; + margin: 0; background: #0f172a; - color: #dbeafe; - border-radius: 8px; - padding: 12px; + color: #e2e8f0; + border-radius: 10px; + padding: 0.8rem; + overflow: auto; + max-height: 360px; }