website refactor

This commit is contained in:
2026-01-14 02:02:24 +01:00
parent 8d7c709e0c
commit 4522d41aef
291 changed files with 12763 additions and 9309 deletions

View File

@@ -4,16 +4,13 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useRouter } from 'next/navigation';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { useCurrentSession } from "@/lib/hooks/auth/useCurrentSession";
import { useLogin } from "@/lib/hooks/auth/useLogin";
import { useLogout } from "@/lib/hooks/auth/useLogout";
export type AuthContextValue = {
@@ -39,8 +36,7 @@ export function AuthProvider({ initialSession = null, children }: AuthProviderPr
initialData: initialSession,
});
// Use mutation hooks for login/logout
const loginMutation = useLogin();
// Use mutation hooks for logout
const logoutMutation = useLogout();
const login = useCallback(

View File

@@ -9,8 +9,9 @@
* - Protected routes with session + wrong role: show permission error
*/
import { routes, routeMatchers, RouteGroup } from '@/lib/routing/RouteConfig';
import { routes, routeMatchers } from '@/lib/routing/RouteConfig';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
const logger = new ConsoleLogger();
@@ -137,12 +138,10 @@ export class AuthFlowRouter {
getLoginRedirectUrl(): string {
const action = this.getAction();
if (action.type === AuthActionType.REDIRECT_TO_LOGIN) {
const params = new URLSearchParams({ returnTo: action.returnTo });
return `${routes.auth.login}?${params.toString()}`;
return `${routes.auth.login}${SearchParamBuilder.auth(action.returnTo)}`;
}
if (action.type === AuthActionType.SHOW_PERMISSION_ERROR) {
const params = new URLSearchParams({ returnTo: action.requestedPath });
return `${routes.auth.login}?${params.toString()}`;
return `${routes.auth.login}${SearchParamBuilder.auth(action.requestedPath)}`;
}
throw new Error("Not in login redirect state");
}
@@ -234,4 +233,4 @@ export function handleAuthFlow(
logger.info('[handleAuthFlow] Returning SHOW_PERMISSION_ERROR, redirecting to home', { homeUrl, userRole: session?.role });
return { shouldRedirect: true, redirectUrl: homeUrl };
}
}
}

View File

@@ -3,6 +3,7 @@ import { RouteAccessPolicy } from './RouteAccessPolicy';
import { ReturnToSanitizer } from './ReturnToSanitizer';
import { RoutePathBuilder } from './RoutePathBuilder';
import { PathnameInterpreter } from './PathnameInterpreter';
import { SearchParamBuilder } from '../routing/search-params/SearchParamBuilder';
/**
* AuthRedirectBuilder - Builds redirect URLs for authentication flows
@@ -42,11 +43,8 @@ export class AuthRedirectBuilder {
// Sanitize returnTo (use current path as input, fallback to root)
const sanitizedReturnTo = this.sanitizer.sanitizeReturnTo(currentPathname, '/');
// Append returnTo as query parameter
const returnToParam = encodeURIComponent(sanitizedReturnTo);
return `${loginPath}?returnTo=${returnToParam}`;
return `${loginPath}${SearchParamBuilder.auth(sanitizedReturnTo)}`;
}
/**

View File

@@ -0,0 +1,76 @@
/**
* Login Flow Controller
*
* Deterministic state machine for authentication flow.
* Compliant with docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md
*/
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
export enum LoginState {
UNAUTHENTICATED = "UNAUTHENTICATED",
AUTHENTICATED_WITH_PERMISSIONS = "AUTHENTICATED_WITH_PERMISSIONS",
AUTHENTICATED_WITHOUT_PERMISSIONS = "AUTHENTICATED_WITHOUT_PERMISSIONS",
POST_AUTH_REDIRECT = "POST_AUTH_REDIRECT"
}
export type LoginAction =
| { type: 'SHOW_LOGIN_FORM' }
| { type: 'REDIRECT'; path: string }
| { type: 'SHOW_PERMISSION_ERROR' };
/**
* LoginFlowController - Immutable, deterministic state machine
*
* Rules:
* - Constructed from explicit inputs only
* - No side effects
* - Pure functions for state transitions
* - Side effects (routing) executed outside
*/
export class LoginFlowController {
// Immutable state
private readonly session: SessionViewModel | null;
private readonly returnTo: string;
// State machine
private state: LoginState;
constructor(session: SessionViewModel | null, returnTo: string) {
this.session = session;
this.returnTo = returnTo;
this.state = this.determineInitialState();
}
private determineInitialState(): LoginState {
if (!this.session) return LoginState.UNAUTHENTICATED;
if (this.returnTo === '/dashboard') return LoginState.AUTHENTICATED_WITH_PERMISSIONS;
return LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS;
}
// Pure function - no side effects
getState(): LoginState {
return this.state;
}
// Pure function - returns action, does not execute
getNextAction(): LoginAction {
switch (this.state) {
case LoginState.UNAUTHENTICATED:
return { type: 'SHOW_LOGIN_FORM' };
case LoginState.AUTHENTICATED_WITH_PERMISSIONS:
return { type: 'REDIRECT', path: '/dashboard' };
case LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS:
return { type: 'SHOW_PERMISSION_ERROR' };
case LoginState.POST_AUTH_REDIRECT:
return { type: 'REDIRECT', path: this.returnTo };
}
}
// Transition called after authentication
transitionToPostAuth(): void {
if (this.session) {
this.state = LoginState.POST_AUTH_REDIRECT;
}
}
}

View File

@@ -6,14 +6,6 @@ import { SessionGateway } from '../gateways/SessionGateway';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
// Hoist the mock redirect function
const mockRedirect = vi.hoisted(() => vi.fn());
// Mock next/navigation
vi.mock('next/navigation', () => ({
redirect: mockRedirect,
}));
// Mock dependencies
vi.mock('./PathnameInterpreter');
vi.mock('./RouteAccessPolicy');
@@ -69,14 +61,14 @@ describe('RouteGuard', () => {
mockPolicy.isAuthPage.mockReturnValue(false);
// Act
await routeGuard.enforce({ pathname });
const result = await routeGuard.enforce({ pathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith(pathname);
expect(mockPolicy.isPublic).toHaveBeenCalledWith('/public/page');
expect(mockPolicy.isAuthPage).toHaveBeenCalledWith('/public/page');
expect(mockGateway.getSession).not.toHaveBeenCalled();
expect(mockRedirect).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'allow' });
});
});
@@ -90,11 +82,11 @@ describe('RouteGuard', () => {
mockGateway.getSession.mockResolvedValue(null);
// Act
await routeGuard.enforce({ pathname });
const result = await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockRedirect).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'allow' });
});
});
@@ -114,7 +106,7 @@ describe('RouteGuard', () => {
mockBuilder.awayFromAuthPage.mockReturnValue('/dashboard');
// Act
await routeGuard.enforce({ pathname });
const result = await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
@@ -122,7 +114,7 @@ describe('RouteGuard', () => {
session: mockSession,
currentPathname: '/login',
});
expect(mockRedirect).toHaveBeenCalledWith('/dashboard');
expect(result).toEqual({ type: 'redirect', to: '/dashboard' });
});
});
@@ -137,12 +129,12 @@ describe('RouteGuard', () => {
mockBuilder.toLogin.mockReturnValue('/login?redirect=/protected/dashboard');
// Act
await routeGuard.enforce({ pathname });
const result = await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockBuilder.toLogin).toHaveBeenCalledWith({ currentPathname: '/protected/dashboard' });
expect(mockRedirect).toHaveBeenCalledWith('/login?redirect=/protected/dashboard');
expect(result).toEqual({ type: 'redirect', to: '/login?redirect=/protected/dashboard' });
});
});
@@ -163,13 +155,13 @@ describe('RouteGuard', () => {
mockBuilder.toLogin.mockReturnValue('/login?redirect=/admin/panel');
// Act
await routeGuard.enforce({ pathname });
const result = await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/admin/panel');
expect(mockBuilder.toLogin).toHaveBeenCalledWith({ currentPathname: '/admin/panel' });
expect(mockRedirect).toHaveBeenCalledWith('/login?redirect=/admin/panel');
expect(result).toEqual({ type: 'redirect', to: '/login?redirect=/admin/panel' });
});
});
@@ -189,12 +181,12 @@ describe('RouteGuard', () => {
mockPolicy.requiredRoles.mockReturnValue(['admin']);
// Act
await routeGuard.enforce({ pathname });
const result = await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/admin/panel');
expect(mockRedirect).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'allow' });
});
it('should allow access when no specific roles required', async () => {
@@ -212,12 +204,12 @@ describe('RouteGuard', () => {
mockPolicy.requiredRoles.mockReturnValue(null);
// Act
await routeGuard.enforce({ pathname });
const result = await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/dashboard');
expect(mockRedirect).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'allow' });
});
});
});

View File

@@ -1,13 +1,15 @@
import { redirect } from 'next/navigation';
import { PathnameInterpreter } from './PathnameInterpreter';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { SessionGateway } from '../gateways/SessionGateway';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
const logger = new ConsoleLogger();
export type RouteGuardResult =
| { type: 'allow' }
| { type: 'redirect'; to: string };
export class RouteGuard {
constructor(
private readonly interpreter: PathnameInterpreter,
@@ -16,7 +18,7 @@ export class RouteGuard {
private readonly builder: AuthRedirectBuilder
) {}
async enforce({ pathname }: { pathname: string }): Promise<void> {
async enforce({ pathname }: { pathname: string }): Promise<RouteGuardResult> {
logger.info('[RouteGuard] enforce called', { pathname });
// Step 1: Interpret the pathname
@@ -26,7 +28,7 @@ export class RouteGuard {
// Step 2: Check if public non-auth page
if (this.policy.isPublic(logicalPathname) && !this.policy.isAuthPage(logicalPathname)) {
logger.info('[RouteGuard] Public non-auth page, allowing access');
return; // Allow access
return { type: 'allow' };
}
// Step 3: Handle auth pages
@@ -37,11 +39,11 @@ export class RouteGuard {
// User is logged in, redirect away from auth page
const redirectPath = this.builder.awayFromAuthPage({ session, currentPathname: pathname });
logger.info('[RouteGuard] Redirecting away from auth page', { redirectPath });
redirect(redirectPath);
return { type: 'redirect', to: redirectPath };
}
// No session, allow access to auth page
logger.info('[RouteGuard] No session, allowing access to auth page');
return;
return { type: 'allow' };
}
// Step 4: Handle protected pages
@@ -52,7 +54,7 @@ export class RouteGuard {
if (!session) {
const loginPath = this.builder.toLogin({ currentPathname: pathname });
logger.info('[RouteGuard] No session, redirecting to login', { loginPath });
redirect(loginPath);
return { type: 'redirect', to: loginPath };
}
// Check required roles
@@ -61,11 +63,11 @@ export class RouteGuard {
if (reqRoles && session.user?.role && !reqRoles.includes(session.user.role)) {
const loginPath = this.builder.toLogin({ currentPathname: pathname });
logger.info('[RouteGuard] Role mismatch, redirecting to login', { loginPath, reqRoles, userRole: session.user.role });
redirect(loginPath);
return { type: 'redirect', to: loginPath };
}
// All checks passed, allow access
logger.info('[RouteGuard] All checks passed, allowing access');
return;
return { type: 'allow' };
}
}