chore(web): merge protected session mvp updates
This commit is contained in:
commit
c74f56a4b0
@ -4,6 +4,22 @@
|
|||||||
- 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.
|
||||||
- Route shell and protected sections are session-aware via Thalos session endpoints.
|
- Route shell and protected sections are session-aware via Thalos session endpoints.
|
||||||
- Runtime base URLs:
|
|
||||||
- `API_BASE_URL` for business BFF calls.
|
## Runtime Base URLs
|
||||||
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me.
|
|
||||||
|
- `API_BASE_URL` for business BFF calls.
|
||||||
|
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me.
|
||||||
|
|
||||||
|
## Protected Workflow Endpoints
|
||||||
|
|
||||||
|
- `POST /api/identity/session/login`
|
||||||
|
- `POST /api/identity/session/refresh`
|
||||||
|
- `POST /api/identity/session/logout`
|
||||||
|
- `GET /api/identity/session/me`
|
||||||
|
|
||||||
|
## UI Workflow Coverage
|
||||||
|
|
||||||
|
- Session login
|
||||||
|
- Session me/profile inspection
|
||||||
|
- Session refresh
|
||||||
|
- Session logout
|
||||||
|
|||||||
@ -14,9 +14,9 @@ npm run test:ci
|
|||||||
|
|
||||||
## Coverage Scope
|
## Coverage Scope
|
||||||
|
|
||||||
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior
|
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
|
||||||
- `src/api/dashboardApi.test.ts`: endpoint path/query contract generation
|
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
|
||||||
- `src/App.test.tsx`: render baseline and mocked load flow
|
- `src/App.test.tsx`: protected-route render and workflow trigger behavior.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +1,68 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('./api/sessionApi', () => ({
|
||||||
|
getSessionMe: vi.fn(),
|
||||||
|
loginSession: vi.fn(),
|
||||||
|
refreshSession: vi.fn(),
|
||||||
|
logoutSession: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./api/dashboardApi', () => ({
|
vi.mock('./api/dashboardApi', () => ({
|
||||||
loadDashboard: vi.fn()
|
loadDashboard: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadDashboard } from './api/dashboardApi';
|
import { loadDashboard } from './api/dashboardApi';
|
||||||
|
import { getSessionMe, loginSession } from './api/sessionApi';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('Thalos App', () => {
|
||||||
it('renders baseline page', () => {
|
beforeEach(() => {
|
||||||
render(<App />);
|
vi.mocked(loadDashboard).mockReset();
|
||||||
|
vi.mocked(getSessionMe).mockReset();
|
||||||
expect(screen.getByRole('heading', { name: 'Thalos Web' })).toBeInTheDocument();
|
vi.mocked(loginSession).mockReset();
|
||||||
expect(screen.getByRole('button', { name: 'Load' })).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads dashboard data when user clicks load', async () => {
|
it('renders login gate and signs in through Thalos session endpoint', async () => {
|
||||||
vi.mocked(loadDashboard).mockResolvedValue({ summary: 'ok' });
|
vi.mocked(getSessionMe)
|
||||||
|
.mockResolvedValueOnce({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 })
|
||||||
|
.mockResolvedValueOnce({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', provider: 0 });
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ctx stage28' } });
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Load' }));
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Subject Id'), { target: { value: 'alice' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(loadDashboard).toHaveBeenCalledWith('ctx stage28');
|
expect(loginSession).toHaveBeenCalledTimes(1);
|
||||||
expect(screen.getByText(/summary/)).toBeInTheDocument();
|
expect(screen.getByText(/subject: demo-user/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads protected session snapshot through dashboard adapter', async () => {
|
||||||
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
subjectId: 'demo-user',
|
||||||
|
tenantId: 'demo-tenant',
|
||||||
|
provider: 0
|
||||||
|
});
|
||||||
|
vi.mocked(loadDashboard).mockResolvedValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
subjectId: 'demo-user',
|
||||||
|
tenantId: 'demo-tenant',
|
||||||
|
provider: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Reload Snapshot' })).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Reload Snapshot' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(loadDashboard).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByText(/demo-user/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
186
src/App.tsx
186
src/App.tsx
@ -1,41 +1,191 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { loadDashboard } from './api/dashboardApi';
|
import { loadDashboard } from './api/dashboardApi';
|
||||||
|
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
||||||
|
import type { IdentityProvider } from './api/sessionApi';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [contextId, setContextId] = useState('demo-context');
|
return (
|
||||||
const [payload, setPayload] = useState<unknown>(null);
|
<SessionProvider>
|
||||||
const [error, setError] = useState<string | null>(null);
|
<ThalosShell />
|
||||||
const [loading, setLoading] = useState(false);
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const onLoad = async (): Promise<void> => {
|
type RouteKey = 'overview' | 'session';
|
||||||
setLoading(true);
|
|
||||||
|
function ThalosShell() {
|
||||||
|
const session = useSessionContext();
|
||||||
|
const [route, setRoute] = useState<RouteKey>('overview');
|
||||||
|
const [snapshot, setSnapshot] = useState<unknown>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const onReloadSnapshot = async () => {
|
||||||
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await loadDashboard(contextId);
|
const payload = await loadDashboard();
|
||||||
setPayload(response);
|
setSnapshot(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown request error';
|
setError(err instanceof Error ? err.message : 'Failed to reload session snapshot.');
|
||||||
setError(message);
|
|
||||||
setPayload(null);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (session.status === 'loading') {
|
||||||
|
return (
|
||||||
|
<main className="app">
|
||||||
|
<h1>Thalos Web</h1>
|
||||||
|
<p className="muted">Restoring session...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== 'authenticated' || !session.profile) {
|
||||||
|
return (
|
||||||
|
<main className="app">
|
||||||
|
<h1>Thalos Web</h1>
|
||||||
|
<p className="muted">Login against Thalos session endpoints to access protected routes.</p>
|
||||||
|
{session.error && <div className="alert">{session.error}</div>}
|
||||||
|
<LoginCard />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<h1>Thalos Web</h1>
|
<h1>Thalos Web</h1>
|
||||||
<p>React baseline wired to its corresponding BFF via an API adapter module.</p>
|
<p className="muted">Session management MVP for login, me, refresh, and logout.</p>
|
||||||
|
|
||||||
|
<section className="card row">
|
||||||
|
<span className="badge">subject: {session.profile.subjectId}</span>
|
||||||
|
<span className="badge">tenant: {session.profile.tenantId}</span>
|
||||||
|
<span className="badge">provider: {providerLabel(session.profile.provider)}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="secondary" onClick={() => void session.refresh()}>
|
||||||
|
Refresh Session
|
||||||
|
</button>
|
||||||
|
<button type="button" className="warn" onClick={() => void session.logout()}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card tabs" aria-label="route-shell">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={route === 'overview' ? 'active' : undefined}
|
||||||
|
onClick={() => setRoute('overview')}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={route === 'session' ? 'active' : undefined}
|
||||||
|
onClick={() => setRoute('session')}
|
||||||
|
>
|
||||||
|
Session Actions
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{session.error && <div className="alert">{session.error}</div>}
|
||||||
|
{error && <div className="alert">{error}</div>}
|
||||||
|
|
||||||
|
{route === 'overview' && (
|
||||||
|
<section className="card col">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<input value={contextId} onChange={(event) => setContextId(event.target.value)} />
|
<strong>Session Snapshot</strong>
|
||||||
<button type="button" onClick={onLoad} disabled={loading}>
|
<span className="spacer" />
|
||||||
{loading ? 'Loading...' : 'Load'}
|
<button type="button" onClick={() => void onReloadSnapshot()} disabled={busy}>
|
||||||
|
{busy ? 'Loading...' : 'Reload Snapshot'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p>{error}</p>}
|
<pre>{JSON.stringify(snapshot ?? session.profile, null, 2)}</pre>
|
||||||
<pre>{JSON.stringify(payload, null, 2)}</pre>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{route === 'session' && <LoginCard />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoginCard() {
|
||||||
|
const session = useSessionContext();
|
||||||
|
const [subjectId, setSubjectId] = useState('demo-user');
|
||||||
|
const [tenantId, setTenantId] = useState('demo-tenant');
|
||||||
|
const [provider, setProvider] = useState<IdentityProvider>(0);
|
||||||
|
const [externalToken, setExternalToken] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await session.login({
|
||||||
|
subjectId,
|
||||||
|
tenantId,
|
||||||
|
provider,
|
||||||
|
externalToken
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card col">
|
||||||
|
<div className="grid">
|
||||||
|
<label className="col">
|
||||||
|
Subject Id
|
||||||
|
<input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="col">
|
||||||
|
Tenant Id
|
||||||
|
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="col">
|
||||||
|
Provider
|
||||||
|
<select
|
||||||
|
value={String(provider)}
|
||||||
|
onChange={(event) => setProvider(Number(event.target.value) as IdentityProvider)}
|
||||||
|
>
|
||||||
|
<option value="0">Internal JWT</option>
|
||||||
|
<option value="1">Azure AD (simulated)</option>
|
||||||
|
<option value="2">Google (simulated)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="col">
|
||||||
|
External Token (optional for simulated external providers)
|
||||||
|
<input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="row">
|
||||||
|
<button type="button" onClick={() => void onSubmit()} disabled={submitting}>
|
||||||
|
{submitting ? 'Signing In...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert">{error}</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(provider: IdentityProvider): string {
|
||||||
|
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
||||||
|
return 'Internal JWT';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 1 || provider === '1' || provider === 'AzureAd') {
|
||||||
|
return 'Azure AD';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 2 || provider === '2' || provider === 'Google') {
|
||||||
|
return 'Google';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(provider);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -1,18 +1,55 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
vi.mock('./client', () => ({
|
||||||
getJson: vi.fn()
|
getJson: vi.fn(),
|
||||||
|
postJson: vi.fn(),
|
||||||
|
postNoContent: vi.fn(),
|
||||||
|
getThalosAuthBaseUrl: vi.fn(() => 'http://thalos-auth')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getJson } from './client';
|
import { getJson, postJson, postNoContent } from './client';
|
||||||
import { loadDashboard } from './dashboardApi';
|
import { loadDashboard, loginDashboardSession, logoutDashboardSession, refreshDashboardSession } from './dashboardApi';
|
||||||
|
|
||||||
describe('loadDashboard', () => {
|
describe('thalos dashboard api', () => {
|
||||||
it('builds encoded endpoint path and delegates to getJson', async () => {
|
it('loads session snapshot from canonical me endpoint', async () => {
|
||||||
vi.mocked(getJson).mockResolvedValue({ ok: true });
|
vi.mocked(getJson).mockResolvedValue({ isAuthenticated: true });
|
||||||
|
|
||||||
await loadDashboard('demo context/1');
|
await loadDashboard();
|
||||||
|
|
||||||
expect(getJson).toHaveBeenCalledWith('/api/thalos/session/refresh?sessionId=demo%20context%2F1');
|
expect(getJson).toHaveBeenCalledWith('/api/identity/session/me', 'http://thalos-auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes session through canonical refresh endpoint', async () => {
|
||||||
|
vi.mocked(postJson).mockResolvedValue({});
|
||||||
|
|
||||||
|
await refreshDashboardSession();
|
||||||
|
|
||||||
|
expect(postJson).toHaveBeenCalledWith('/api/identity/session/refresh', {}, 'http://thalos-auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs in with request payload and logs out with no-content endpoint', async () => {
|
||||||
|
vi.mocked(postJson).mockResolvedValue({});
|
||||||
|
|
||||||
|
await loginDashboardSession({
|
||||||
|
subjectId: 'demo-user',
|
||||||
|
tenantId: 'demo-tenant',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
provider: 0,
|
||||||
|
externalToken: ''
|
||||||
|
});
|
||||||
|
await logoutDashboardSession();
|
||||||
|
|
||||||
|
expect(postJson).toHaveBeenCalledWith(
|
||||||
|
'/api/identity/session/login',
|
||||||
|
{
|
||||||
|
subjectId: 'demo-user',
|
||||||
|
tenantId: 'demo-tenant',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
provider: 0,
|
||||||
|
externalToken: ''
|
||||||
|
},
|
||||||
|
'http://thalos-auth'
|
||||||
|
);
|
||||||
|
expect(postNoContent).toHaveBeenCalledWith('/api/identity/session/logout', {}, 'http://thalos-auth');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,20 @@
|
|||||||
import { getJson } from './client';
|
import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client';
|
||||||
|
import type { SessionLoginRequest, SessionLoginResponse, SessionProfile } from './sessionApi';
|
||||||
|
|
||||||
export async function loadDashboard(contextId: string): Promise<unknown> {
|
export type ThalosSessionSnapshot = SessionProfile;
|
||||||
return getJson(`/api/thalos/session/refresh?sessionId=${encodeURIComponent(contextId)}`);
|
|
||||||
|
export async function loadDashboard(): Promise<ThalosSessionSnapshot> {
|
||||||
|
return getJson<ThalosSessionSnapshot>('/api/identity/session/me', getThalosAuthBaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshDashboardSession(): Promise<SessionLoginResponse> {
|
||||||
|
return postJson<SessionLoginResponse>('/api/identity/session/refresh', {}, getThalosAuthBaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginDashboardSession(request: SessionLoginRequest): Promise<SessionLoginResponse> {
|
||||||
|
return postJson<SessionLoginResponse>('/api/identity/session/login', request, getThalosAuthBaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutDashboardSession(): Promise<void> {
|
||||||
|
return postNoContent('/api/identity/session/logout', {}, getThalosAuthBaseUrl());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,26 @@
|
|||||||
import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client';
|
import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client';
|
||||||
|
|
||||||
|
export type IdentityProvider = 0 | 1 | 2 | string | number;
|
||||||
|
|
||||||
export type SessionProfile = {
|
export type SessionProfile = {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
provider: string;
|
provider: IdentityProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionLoginRequest = {
|
export type SessionLoginRequest = {
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
provider: string;
|
provider: IdentityProvider;
|
||||||
externalToken: string;
|
externalToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionLoginResponse = {
|
export type SessionLoginResponse = {
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
provider: string;
|
provider: IdentityProvider;
|
||||||
expiresInSeconds: number;
|
expiresInSeconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
120
src/auth/sessionContext.tsx
Normal file
120
src/auth/sessionContext.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ApiError } from '../api/client';
|
||||||
|
import {
|
||||||
|
getSessionMe,
|
||||||
|
loginSession,
|
||||||
|
logoutSession,
|
||||||
|
refreshSession,
|
||||||
|
type SessionLoginRequest,
|
||||||
|
type SessionProfile
|
||||||
|
} from '../api/sessionApi';
|
||||||
|
|
||||||
|
export type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||||
|
|
||||||
|
type SessionContextValue = {
|
||||||
|
status: SessionStatus;
|
||||||
|
profile: SessionProfile | null;
|
||||||
|
error: string | null;
|
||||||
|
login: (request: Omit<SessionLoginRequest, 'correlationId'> & { correlationId?: string }) => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
revalidate: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SessionProvider({ children }: PropsWithChildren) {
|
||||||
|
const [status, setStatus] = useState<SessionStatus>('loading');
|
||||||
|
const [profile, setProfile] = useState<SessionProfile | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const revalidate = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const me = await getSessionMe();
|
||||||
|
if (me.isAuthenticated) {
|
||||||
|
setProfile(me);
|
||||||
|
setStatus('authenticated');
|
||||||
|
} else {
|
||||||
|
setProfile(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
setProfile(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : 'Session validation failed.';
|
||||||
|
setProfile(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void revalidate();
|
||||||
|
}, [revalidate]);
|
||||||
|
|
||||||
|
const login = useCallback<SessionContextValue['login']>(
|
||||||
|
async (request) => {
|
||||||
|
setError(null);
|
||||||
|
await loginSession({
|
||||||
|
...request,
|
||||||
|
correlationId: request.correlationId && request.correlationId.length > 0 ? request.correlationId : createCorrelationId()
|
||||||
|
});
|
||||||
|
await revalidate();
|
||||||
|
},
|
||||||
|
[revalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
await refreshSession();
|
||||||
|
await revalidate();
|
||||||
|
}, [revalidate]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await logoutSession();
|
||||||
|
} finally {
|
||||||
|
setProfile(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<SessionContextValue>(
|
||||||
|
() => ({
|
||||||
|
status,
|
||||||
|
profile,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
refresh,
|
||||||
|
logout,
|
||||||
|
revalidate
|
||||||
|
}),
|
||||||
|
[status, profile, error, login, refresh, logout, revalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionContext(): SessionContextValue {
|
||||||
|
const value = useContext(SessionContext);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('useSessionContext must be used within SessionProvider.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCorrelationId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
@ -77,6 +77,11 @@ button.warn {
|
|||||||
background: #b91c1c;
|
background: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@ -110,6 +115,26 @@ button:disabled {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user