website refactor
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
76
apps/website/lib/auth/LoginFlowController.ts
Normal file
76
apps/website/lib/auth/LoginFlowController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user