clean routes

This commit is contained in:
2026-01-03 02:42:47 +01:00
parent 07985fb8f1
commit 2f21dc4595
107 changed files with 7596 additions and 3401 deletions

View File

@@ -6,6 +6,9 @@ import { IPasswordHashingService } from '@core/identity/domain/services/Password
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
import { Email } from '@core/admin/domain/value-objects/Email';
export type DemoLoginInput = {
role: 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin';
@@ -24,7 +27,7 @@ export type DemoLoginApplicationError = ApplicationErrorCode<DemoLoginErrorCode,
*
* Provides demo login functionality for development environments.
* Creates demo users with predefined credentials.
*
*
* ⚠️ DEVELOPMENT ONLY - Should be disabled in production
*/
export class DemoLoginUseCase implements UseCase<DemoLoginInput, void, DemoLoginErrorCode> {
@@ -33,6 +36,7 @@ export class DemoLoginUseCase implements UseCase<DemoLoginInput, void, DemoLogin
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<DemoLoginResult>,
private readonly adminUserRepo?: IAdminUserRepository,
) {}
async execute(input: DemoLoginInput): Promise<Result<void, DemoLoginApplicationError>> {
@@ -47,13 +51,13 @@ export class DemoLoginUseCase implements UseCase<DemoLoginInput, void, DemoLogin
try {
// Generate demo user email and display name based on role
const roleConfig = {
'driver': { email: 'demo.driver@example.com', name: 'John Demo', primaryDriverId: true },
'sponsor': { email: 'demo.sponsor@example.com', name: 'Jane Sponsor', primaryDriverId: false },
'league-owner': { email: 'demo.owner@example.com', name: 'Alex Owner', primaryDriverId: true },
'league-steward': { email: 'demo.steward@example.com', name: 'Sam Steward', primaryDriverId: true },
'league-admin': { email: 'demo.admin@example.com', name: 'Taylor Admin', primaryDriverId: true },
'system-owner': { email: 'demo.systemowner@example.com', name: 'System Owner', primaryDriverId: true },
'super-admin': { email: 'demo.superadmin@example.com', name: 'Super Admin', primaryDriverId: true },
'driver': { email: 'demo.driver@example.com', name: 'John Demo', primaryDriverId: true, adminRole: null },
'sponsor': { email: 'demo.sponsor@example.com', name: 'Jane Sponsor', primaryDriverId: false, adminRole: null },
'league-owner': { email: 'demo.owner@example.com', name: 'Alex Owner', primaryDriverId: true, adminRole: 'owner' },
'league-steward': { email: 'demo.steward@example.com', name: 'Sam Steward', primaryDriverId: true, adminRole: 'admin' },
'league-admin': { email: 'demo.admin@example.com', name: 'Taylor Admin', primaryDriverId: true, adminRole: 'admin' },
'system-owner': { email: 'demo.systemowner@example.com', name: 'System Owner', primaryDriverId: true, adminRole: 'owner' },
'super-admin': { email: 'demo.superadmin@example.com', name: 'Super Admin', primaryDriverId: true, adminRole: 'admin' },
};
const config = roleConfig[input.role];
@@ -102,6 +106,45 @@ export class DemoLoginUseCase implements UseCase<DemoLoginInput, void, DemoLogin
});
}
// Also create admin user if this role requires admin access
if (config.adminRole && this.adminUserRepo) {
const existingAdmin = await this.adminUserRepo.findByEmail(Email.create(config.email));
if (!existingAdmin) {
this.logger.info('[DemoLoginUseCase] Creating admin user for demo', { role: config.adminRole });
const adminProps: {
id: string;
email: string;
roles: string[];
status: string;
displayName: string;
createdAt?: Date;
updatedAt?: Date;
lastLoginAt?: Date;
primaryDriverId?: string;
} = {
id: user.getId().value,
email: config.email,
displayName: config.name,
roles: [config.adminRole],
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
};
const primaryDriverId = user.getPrimaryDriverId();
if (primaryDriverId) {
adminProps.primaryDriverId = primaryDriverId;
}
const adminUser = AdminUser.create(adminProps);
await this.adminUserRepo.create(adminUser);
}
}
this.output.present({ user });
return Result.ok(undefined);
@@ -121,4 +164,4 @@ export class DemoLoginUseCase implements UseCase<DemoLoginInput, void, DemoLogin
});
}
}
}
}

View File

@@ -53,6 +53,7 @@ export class GetDashboardStatsUseCase {
try {
// Get actor (current user)
const actor = await this.adminUserRepo.findById(UserId.fromString(input.actorId));
if (!actor) {
return Result.err({
code: 'AUTHORIZATION_ERROR',
@@ -177,4 +178,4 @@ export class GetDashboardStatsUseCase {
});
}
}
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { IdentityPersistenceModule } from '../../persistence/identity/IdentityPersistenceModule';
import { InMemoryAdminPersistenceModule } from '../../persistence/inmemory/InMemoryAdminPersistenceModule';
import { AuthService } from './AuthService';
import { AuthController } from './AuthController';
import { AuthProviders } from './AuthProviders';
@@ -8,7 +9,7 @@ import { AuthorizationGuard } from './AuthorizationGuard';
import { AuthorizationService } from './AuthorizationService';
@Module({
imports: [IdentityPersistenceModule],
imports: [IdentityPersistenceModule, InMemoryAdminPersistenceModule],
controllers: [AuthController],
providers: [AuthService, ...AuthProviders, AuthenticationGuard, AuthorizationService, AuthorizationGuard],
exports: [AuthService, AuthenticationGuard, AuthorizationService, AuthorizationGuard],

View File

@@ -19,6 +19,7 @@ import type { ForgotPasswordResult } from '@core/identity/application/use-cases/
import type { ResetPasswordResult } from '@core/identity/application/use-cases/ResetPasswordUseCase';
import type { DemoLoginResult } from '../../development/use-cases/DemoLoginUseCase';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
import {
AUTH_REPOSITORY_TOKEN,
@@ -26,6 +27,7 @@ import {
USER_REPOSITORY_TOKEN,
MAGIC_LINK_REPOSITORY_TOKEN,
} from '../../persistence/identity/IdentityPersistenceTokens';
import { ADMIN_USER_REPOSITORY_TOKEN } from '../../persistence/admin/AdminPersistenceTokens';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
@@ -143,7 +145,8 @@ export const AuthProviders: Provider[] = [
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<DemoLoginResult>,
) => new DemoLoginUseCase(authRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, DEMO_LOGIN_OUTPUT_PORT_TOKEN],
adminUserRepo: IAdminUserRepository,
) => new DemoLoginUseCase(authRepo, passwordHashing, logger, output, adminUserRepo),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, DEMO_LOGIN_OUTPUT_PORT_TOKEN, ADMIN_USER_REPOSITORY_TOKEN],
},
];

View File

@@ -225,6 +225,7 @@ describe('AuthService - New Methods', () => {
userId: 'demo-user-123',
email: 'demo.driver@example.com',
displayName: 'Alex Johnson',
role: 'driver',
},
});
});

View File

@@ -89,13 +89,13 @@ export class AuthService {
const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) return null;
// TODO!!
return {
token: coreSession.token,
user: {
userId: coreSession.user.id,
email: coreSession.user.email ?? '',
displayName: coreSession.user.displayName,
role: coreSession.user.role as any,
},
};
}
@@ -307,6 +307,7 @@ export class AuthService {
id: sessionId,
displayName: user.getDisplayName(),
email: user.getEmail() ?? '',
role: params.role,
},
sessionOptions
);
@@ -315,6 +316,7 @@ export class AuthService {
userId: user.getId().value,
email: user.getEmail() ?? '',
displayName: user.getDisplayName(),
role: params.role,
};
if (primaryDriverId !== undefined) {
@@ -326,4 +328,4 @@ export class AuthService {
user: userDTO,
};
}
}
}

View File

@@ -3,7 +3,7 @@ import type { IdentitySessionPort } from '@core/identity/application/ports/Ident
import { IDENTITY_SESSION_PORT_TOKEN } from './AuthProviders';
type AuthenticatedRequest = {
user?: { userId: string };
user?: { userId: string; role?: string | undefined };
};
@Injectable()
@@ -22,7 +22,10 @@ export class AuthenticationGuard implements CanActivate {
const session = await this.sessionPort.getCurrentSession();
if (session?.user?.id) {
request.user = { userId: session.user.id };
request.user = {
userId: session.user.id,
role: session.user.role
};
}
return true;

View File

@@ -6,7 +6,7 @@ import { REQUIRE_AUTHENTICATED_USER_METADATA_KEY } from './RequireAuthenticatedU
import { REQUIRE_ROLES_METADATA_KEY, RequireRolesMetadata } from './RequireRoles';
type AuthenticatedRequest = {
user?: { userId: string };
user?: { userId: string; role?: string | undefined };
};
@Injectable()
@@ -16,7 +16,7 @@ export class AuthorizationGuard implements CanActivate {
private readonly authorizationService: AuthorizationService,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const controllerClass = context.getClass();
@@ -55,8 +55,24 @@ export class AuthorizationGuard implements CanActivate {
void requiresAuth;
if (rolesMetadata && rolesMetadata.anyOf.length > 0) {
const userRoles = this.authorizationService.getRolesForUser(userId);
const hasAnyRole = rolesMetadata.anyOf.some((r) => userRoles.includes(r));
let userRoles = this.authorizationService.getRolesForUser(userId);
// If no roles from service, check request for demo login roles
if (userRoles.length === 0 && request.user?.role) {
userRoles = [request.user.role];
}
// Map demo login roles to API expected roles
const mappedRoles = userRoles.map(role => {
if (role === 'league-admin') return 'admin';
if (role === 'league-owner') return 'owner';
if (role === 'league-steward') return 'steward';
if (role === 'system-owner') return 'owner';
if (role === 'super-admin') return 'admin';
return role;
});
const hasAnyRole = rolesMetadata.anyOf.some((r) => mappedRoles.includes(r));
if (!hasAnyRole) {
throw new ForbiddenException('Forbidden');
}

View File

@@ -3,10 +3,11 @@ import { getHttpRequestContext } from '@adapters/http/RequestContext';
export type Actor = {
userId: string;
driverId: string;
role?: string | undefined;
};
type AuthenticatedRequest = {
user?: { userId: string };
user?: { userId: string; role?: string };
};
export function getActorFromRequestContext(): Actor {
@@ -21,5 +22,6 @@ export function getActorFromRequestContext(): Actor {
// Current canonical mapping:
// - The authenticated session identity is `userId`.
// - In the current system, that `userId` is also treated as the performer `driverId`.
return { userId, driverId: userId };
// - Include role from session if available
return { userId, driverId: userId, role: req.user?.role };
}

View File

@@ -10,6 +10,11 @@ export async function requireLeagueAdminOrOwner(
): Promise<void> {
const actor = getActorFromRequestContext();
// Check for demo session roles - bypass database check
if (actor.role && ['league-admin', 'league-owner', 'super-admin', 'system-owner'].includes(actor.role)) {
return; // Allow access for demo admin roles
}
const permissionResult = await getLeagueAdminPermissionsUseCase.execute({
leagueId,
performerDriverId: actor.driverId,

View File

@@ -1,9 +1,10 @@
import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query, Inject } from '@nestjs/common';
import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query, Inject, UseGuards } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser';
import { RequireRoles } from '../auth/RequireRoles';
import { RequireCapability } from '../policy/RequireCapability';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import { SponsorService } from './SponsorService';
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
@@ -32,6 +33,7 @@ import type { RejectSponsorshipRequestResult } from '@core/racing/application/us
@ApiTags('sponsors')
@Controller('sponsors')
@UseGuards(AuthorizationGuard)
export class SponsorController {
constructor(@Inject(SponsorService) private readonly sponsorService: SponsorService) {}
@@ -78,7 +80,7 @@ export class SponsorController {
@Get('dashboard/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireRoles('admin', 'sponsor')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' })
@ApiResponse({
@@ -97,7 +99,7 @@ export class SponsorController {
@Get(':sponsorId/sponsorships')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireRoles('admin', 'sponsor')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({
summary: 'Get all sponsorships for a given sponsor',
@@ -193,7 +195,7 @@ export class SponsorController {
@Get('billing/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireRoles('admin', 'sponsor')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor billing information' })
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
@@ -209,7 +211,7 @@ export class SponsorController {
@Get('leagues/available')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireRoles('admin', 'sponsor')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
@ApiResponse({
@@ -224,7 +226,7 @@ export class SponsorController {
@Get('leagues/:leagueId/detail')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireRoles('admin', 'sponsor')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
@ApiResponse({ status: 200, description: 'League detail data', type: Object })

View File

@@ -283,6 +283,17 @@ export class SponsorService {
throw new Error('Sponsor billing not found');
}
const billingData = result.unwrap();
this.sponsorBillingPresenter.present({
paymentMethods: billingData.paymentMethods,
invoices: billingData.invoices,
stats: {
...billingData.stats,
nextPaymentDate: billingData.stats.nextPaymentDate ?? '',
nextPaymentAmount: billingData.stats.nextPaymentAmount ?? 0,
},
});
return this.sponsorBillingPresenter.viewModel;
}

View File

@@ -1,24 +1,26 @@
'use client';
import { ReactNode } from 'react';
import { RouteGuard } from '@/lib/gateways/RouteGuard';
import { headers } from 'next/headers';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface AdminLayoutProps {
children: ReactNode;
children: React.ReactNode;
}
/**
* Admin Layout
*
*
* Provides role-based protection for admin routes.
* Uses RouteGuard to ensure only users with 'owner' or 'admin' roles can access.
* Uses RouteGuard to enforce access control server-side.
*/
export default function AdminLayout({ children }: AdminLayoutProps) {
export default async function AdminLayout({ children }: AdminLayoutProps) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '/';
const guard = createRouteGuard();
await guard.enforce({ pathname });
return (
<RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</RouteGuard>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
);
}

View File

@@ -1,13 +1,10 @@
import { AdminLayout } from '@/components/admin/AdminLayout';
import { AdminUsersPage } from '@/components/admin/AdminUsersPage';
import { RouteGuard } from '@/lib/gateways/RouteGuard';
export default function AdminUsers() {
return (
<RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
<AdminLayout>
<AdminUsersPage />
</AdminLayout>
</RouteGuard>
<AdminLayout>
<AdminUsersPage />
</AdminLayout>
);
}

View File

@@ -1,35 +0,0 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
const STATE_COOKIE = 'gp_demo_auth_state';
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get('code') ?? undefined;
const state = url.searchParams.get('state') ?? undefined;
const rawReturnTo = url.searchParams.get('returnTo');
const returnTo = rawReturnTo ?? undefined;
if (!code || !state) {
return NextResponse.redirect('/auth/iracing');
}
const cookieStore = await cookies();
const storedState = cookieStore.get(STATE_COOKIE)?.value;
if (!storedState || storedState !== state) {
return NextResponse.redirect('/auth/iracing');
}
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
const authService = serviceFactory.createAuthService();
const loginInput = returnTo ? { code, state, returnTo } : { code, state };
await authService.loginWithIracingCallback(loginInput);
cookieStore.delete(STATE_COOKIE);
const redirectTarget = returnTo || '/dashboard';
const absoluteRedirect = new URL(redirectTarget, url.origin).toString();
return NextResponse.redirect(absoluteRedirect);
}

View File

@@ -1,280 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import {
Gamepad2,
Flag,
ArrowRight,
Shield,
Link as LinkIcon,
User,
Trophy,
BarChart3,
CheckCircle2,
} from 'lucide-react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import { useAuth } from '@/lib/auth/AuthContext';
interface ConnectionStep {
id: number;
icon: typeof Gamepad2;
title: string;
description: string;
}
const CONNECTION_STEPS: ConnectionStep[] = [
{
id: 1,
icon: Gamepad2,
title: 'Connect iRacing',
description: 'Authorize GridPilot to access your profile',
},
{
id: 2,
icon: User,
title: 'Import Profile',
description: 'We fetch your racing stats and history',
},
{
id: 3,
icon: Trophy,
title: 'Sync Achievements',
description: 'Your licenses, iRating, and results',
},
{
id: 4,
icon: BarChart3,
title: 'Ready to Race',
description: 'Access full GridPilot features',
},
];
const BENEFITS = [
'Automatic profile creation with your iRacing data',
'Real-time stats sync including iRating and Safety Rating',
'Import your racing history and achievements',
'No manual data entry required',
'Verified driver identity in leagues',
];
export default function IracingAuthPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
const startUrl = `/auth/iracing/start?returnTo=${encodeURIComponent(returnTo)}`;
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [isHovering, setIsHovering] = useState(false);
// Check if user is already authenticated
useEffect(() => {
if (session) {
router.replace('/dashboard');
}
}, [session, router]);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted || isHovering) return;
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % CONNECTION_STEPS.length);
}, 2500);
return () => clearInterval(interval);
}, [isMounted, isHovering]);
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative w-full max-w-2xl">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center gap-4 mb-6">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1 }}
className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30"
>
<Flag className="w-7 h-7 text-primary-blue" />
</motion.div>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex items-center"
>
<LinkIcon className="w-6 h-6 text-gray-500" />
</motion.div>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500/20 to-red-600/10 border border-orange-500/30"
>
<Gamepad2 className="w-7 h-7 text-orange-400" />
</motion.div>
</div>
<Heading level={1} className="mb-3">Connect Your iRacing Account</Heading>
<p className="text-gray-400 text-lg max-w-md mx-auto">
Link your iRacing profile for automatic stats sync and verified driver identity.
</p>
</div>
<Card className="relative overflow-hidden">
{/* Background accent */}
<div className="absolute top-0 right-0 w-48 h-48 bg-gradient-to-bl from-primary-blue/5 to-transparent rounded-bl-full" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-gradient-to-tr from-orange-500/5 to-transparent rounded-tr-full" />
<div className="relative">
{/* Connection Flow Animation */}
<div
className="bg-iron-gray/50 rounded-xl border border-charcoal-outline p-6 mb-6"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<p className="text-xs text-gray-500 text-center mb-4">Connection Flow</p>
{/* Steps */}
<div className="flex justify-between items-start gap-2">
{CONNECTION_STEPS.map((step, index) => {
const isActive = index === activeStep;
const isCompleted = index < activeStep;
const StepIcon = step.icon;
return (
<motion.button
key={step.id}
onClick={() => setActiveStep(index)}
className="flex flex-col items-center text-center flex-1 cursor-pointer"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
className={`w-12 h-12 rounded-xl border flex items-center justify-center mb-2 transition-all duration-300 ${
isActive
? 'bg-primary-blue/20 border-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.3)]'
: isCompleted
? 'bg-performance-green/20 border-performance-green/50'
: 'bg-deep-graphite border-charcoal-outline'
}`}
animate={isActive && !shouldReduceMotion ? {
scale: [1, 1.08, 1],
transition: { duration: 1, repeat: Infinity }
} : {}}
>
{isCompleted ? (
<CheckCircle2 className="w-5 h-5 text-performance-green" />
) : (
<StepIcon className={`w-5 h-5 ${isActive ? 'text-primary-blue' : 'text-gray-500'}`} />
)}
</motion.div>
<h4 className={`text-xs font-medium transition-colors ${
isActive ? 'text-white' : 'text-gray-500'
}`}>
{step.title}
</h4>
</motion.button>
);
})}
</div>
{/* Active Step Description */}
<AnimatePresence mode="wait">
<motion.div
key={activeStep}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className="mt-4 text-center"
>
<p className="text-sm text-gray-400">
{CONNECTION_STEPS[activeStep]?.description}
</p>
</motion.div>
</AnimatePresence>
</div>
{/* Benefits List */}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-300 mb-3">What you'll get:</h3>
<ul className="space-y-2">
{BENEFITS.map((benefit, index) => (
<li
key={index}
className="flex items-start gap-2 text-sm text-gray-400"
>
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0 mt-0.5" />
{benefit}
</li>
))}
</ul>
</div>
{/* Connect Button */}
<Link href={startUrl} className="block">
<Button
variant="primary"
className="w-full flex items-center justify-center gap-3 py-4"
>
<Gamepad2 className="w-5 h-5" />
<span>Connect iRacing Account</span>
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
{/* Trust Indicators */}
<div className="mt-6 pt-6 border-t border-charcoal-outline">
<div className="flex items-center justify-center gap-6 text-xs text-gray-500">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Secure OAuth connection</span>
</div>
<div className="flex items-center gap-2">
<LinkIcon className="w-4 h-4" />
<span>Read-only access</span>
</div>
</div>
</div>
{/* Alternative */}
<p className="mt-6 text-center text-sm text-gray-500">
Don't have iRacing?{' '}
<Link href="/auth/signup" className="text-primary-blue hover:underline">
Create account with email
</Link>
</p>
</div>
</Card>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
GridPilot only requests read access to your iRacing profile.
<br />
We never access your payment info or modify your account.
</p>
</div>
</main>
);
}

View File

@@ -1,22 +0,0 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const url = new URL(request.url);
const returnTo = url.searchParams.get('returnTo') ?? undefined;
const redirectUrl = `https://example.com/iracing/auth?returnTo=${encodeURIComponent(returnTo || '')}`;
// For now, generate a simple state - in production this should be cryptographically secure
const state = Math.random().toString(36).substring(2, 15);
const cookieStore = await cookies();
cookieStore.set('gp_demo_auth_state', state, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
});
const absoluteRedirect = new URL(redirectUrl, url.origin).toString();
return NextResponse.redirect(absoluteRedirect);
}

View File

@@ -0,0 +1,30 @@
import { headers } from 'next/headers';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface AuthLayoutProps {
children: React.ReactNode;
}
/**
* Auth Layout
*
* Provides authentication route protection for all auth routes.
* Uses RouteGuard to enforce access control server-side.
*
* Behavior:
* - Unauthenticated users can access auth pages (login, signup, etc.)
* - Authenticated users are redirected away from auth pages
*/
export default async function AuthLayout({ children }: AuthLayoutProps) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '/';
const guard = createRouteGuard();
await guard.enforce({ pathname });
return (
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4">
{children}
</div>
);
}

View File

@@ -1,11 +0,0 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const cookieStore = await cookies();
cookieStore.delete('gp_demo_session');
const url = new URL(request.url);
const redirectUrl = new URL('/', url.origin);
return NextResponse.redirect(redirectUrl);
}

View File

@@ -1,24 +1,26 @@
'use client';
import { AuthGuard } from '@/lib/gateways/AuthGuard';
import { ReactNode } from 'react';
import { headers } from 'next/headers';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface DashboardLayoutProps {
children: ReactNode;
children: React.ReactNode;
}
/**
* Dashboard Layout
*
*
* Provides authentication protection for all dashboard routes.
* Wraps children with AuthGuard to ensure only authenticated users can access.
* Uses RouteGuard to enforce access control server-side.
*/
export default function DashboardLayout({ children }: DashboardLayoutProps) {
export default async function DashboardLayout({ children }: DashboardLayoutProps) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '/';
const guard = createRouteGuard();
await guard.enforce({ pathname });
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
);
}

View File

@@ -1,12 +1,10 @@
import AlphaFooter from '@/components/alpha/AlphaFooter';
import { AlphaNav } from '@/components/alpha/AlphaNav';
import DevToolbar from '@/components/dev/DevToolbar';
import { ApiErrorBoundary } from '@/components/errors/ApiErrorBoundary';
import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary';
import { NotificationIntegration } from '@/components/errors/NotificationIntegration';
import NotificationProvider from '@/components/notifications/NotificationProvider';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { getAppMode } from '@/lib/mode';
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
import { ServiceProvider } from '@/lib/services/ServiceProvider';
import { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler';
import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
@@ -54,8 +52,6 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
const mode = getAppMode();
// Initialize debug tools in development
if (process.env.NODE_ENV === 'development') {
try {
@@ -73,83 +69,52 @@ export default async function RootLayout({
console.warn('Failed to initialize debug tools:', error);
}
}
if (mode === 'alpha') {
//const session = await authService.getCurrentSession();
const session = null;
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
<head>
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased overflow-x-hidden min-h-screen bg-deep-graphite flex flex-col">
<ServiceProvider>
<AuthProvider initialSession={session}>
<NotificationProvider>
<NotificationIntegration />
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
<AlphaNav />
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
{children}
</main>
<AlphaFooter />
{/* Development Tools */}
{process.env.NODE_ENV === 'development' && (
<>
<DevToolbar />
</>
)}
</EnhancedErrorBoundary>
</NotificationProvider>
</AuthProvider>
</ServiceProvider>
</body>
</html>
);
}
// Initialize feature flag service
const featureService = FeatureFlagService.fromEnv();
const enabledFlags = featureService.getEnabledFlags();
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
<head>
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased overflow-x-hidden">
<NotificationProvider>
<NotificationIntegration />
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Link href="/" className="inline-flex items-center">
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
className="h-6 w-auto md:h-8"
priority
/>
</Link>
<p className="hidden sm:block text-sm text-gray-400 font-light">
Making league racing less chaotic
</p>
</div>
</div>
</div>
</header>
<div className="pt-16">
{children}
</div>
{/* Development Tools */}
{process.env.NODE_ENV === 'development' && (
<>
<DevToolbar />
</>
)}
</EnhancedErrorBoundary>
</NotificationProvider>
<ServiceProvider>
<AuthProvider>
<FeatureFlagProvider flags={enabledFlags}>
<NotificationProvider>
<NotificationIntegration />
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Link href="/" className="inline-flex items-center">
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
className="h-6 w-auto md:h-8"
priority
/>
</Link>
<p className="hidden sm:block text-sm text-gray-400 font-light">
Making league racing less chaotic
</p>
</div>
</div>
</div>
</header>
<div className="pt-16">{children}</div>
{/* Development Tools */}
{process.env.NODE_ENV === 'development' && <DevToolbar />}
</EnhancedErrorBoundary>
</NotificationProvider>
</FeatureFlagProvider>
</AuthProvider>
</ServiceProvider>
</body>
</html>
);

View File

@@ -1,24 +1,26 @@
'use client';
import { AuthGuard } from '@/lib/gateways/AuthGuard';
import { ReactNode } from 'react';
import { headers } from 'next/headers';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface OnboardingLayoutProps {
children: ReactNode;
children: React.ReactNode;
}
/**
* Onboarding Layout
*
*
* Provides authentication protection for the onboarding flow.
* Wraps children with AuthGuard to ensure only authenticated users can access.
* Uses RouteGuard to enforce access control server-side.
*/
export default function OnboardingLayout({ children }: OnboardingLayoutProps) {
export default async function OnboardingLayout({ children }: OnboardingLayoutProps) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '/';
const guard = createRouteGuard();
await guard.enforce({ pathname });
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation';
import Image from 'next/image';
import { getAppMode } from '@/lib/mode';
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
import Hero from '@/components/landing/Hero';
import AlternatingSection from '@/components/landing/AlternatingSection';
import FeatureGrid from '@/components/landing/FeatureGrid';
@@ -30,8 +30,8 @@ export default async function HomePage() {
redirect('/dashboard');
}
const mode = getAppMode();
const isAlpha = mode === 'alpha';
const featureService = FeatureFlagService.fromEnv();
const isAlpha = featureService.isEnabled('alpha_features');
const discovery = await landingService.getHomeDiscovery();
const upcomingRaces = discovery.upcomingRaces;
const topLeagues = discovery.topLeagues;

View File

@@ -1,24 +1,26 @@
'use client';
import { AuthGuard } from '@/lib/gateways/AuthGuard';
import { ReactNode } from 'react';
import { headers } from 'next/headers';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface ProfileLayoutProps {
children: ReactNode;
children: React.ReactNode;
}
/**
* Profile Layout
*
*
* Provides authentication protection for all profile-related routes.
* Wraps children with AuthGuard to ensure only authenticated users can access.
* Uses RouteGuard to enforce access control server-side.
*/
export default function ProfileLayout({ children }: ProfileLayoutProps) {
export default async function ProfileLayout({ children }: ProfileLayoutProps) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '/';
const guard = createRouteGuard();
await guard.enforce({ pathname });
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
);
}

View File

@@ -1,24 +1,26 @@
'use client';
import { AuthGuard } from '@/lib/gateways/AuthGuard';
import { ReactNode } from 'react';
import { headers } from 'next/headers';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface SponsorLayoutProps {
children: ReactNode;
children: React.ReactNode;
}
/**
* Sponsor Layout
*
*
* Provides authentication protection for all sponsor-related routes.
* Wraps children with AuthGuard to ensure only authenticated users can access.
* Uses RouteGuard to enforce access control server-side.
*/
export default function SponsorLayout({ children }: SponsorLayoutProps) {
export default async function SponsorLayout({ children }: SponsorLayoutProps) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '/';
const guard = createRouteGuard();
await guard.enforce({ pathname });
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
);
}

View File

@@ -1,5 +1,8 @@
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default function SponsorPage() {
// Server-side redirect to sponsor dashboard
redirect('/sponsor/dashboard');
}

View File

@@ -174,9 +174,10 @@ export default function SponsorSettingsPage() {
const handleDeleteAccount = () => {
if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data will be permanently removed.')) {
document.cookie = 'gridpilot_demo_mode=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
document.cookie = 'gridpilot_sponsor_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
router.push('/');
// Call logout API to clear session
fetch('/api/auth/logout', { method: 'POST' }).finally(() => {
router.push('/');
});
}
};

View File

@@ -143,10 +143,21 @@ export default function SponsorSignupPage() {
const handleDemoLogin = async () => {
setSubmitting(true);
try {
document.cookie = 'gridpilot_demo_mode=sponsor; path=/; max-age=86400';
document.cookie = 'gridpilot_sponsor_id=demo-sponsor-1; path=/; max-age=86400';
await new Promise(resolve => setTimeout(resolve, 500));
// Use the demo login API instead of setting cookies
const response = await fetch('/api/auth/demo-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'sponsor' }),
});
if (!response.ok) {
throw new Error('Demo login failed');
}
router.push('/sponsor/dashboard');
} catch (error) {
console.error('Demo login failed:', error);
alert('Demo login failed. Please check the API server status.');
} finally {
setSubmitting(false);
}
@@ -195,11 +206,18 @@ export default function SponsorSignupPage() {
setSubmitting(true);
try {
// Demo: set cookies for sponsor mode
document.cookie = 'gridpilot_demo_mode=sponsor; path=/; max-age=86400';
document.cookie = `gridpilot_sponsor_name=${encodeURIComponent(formData.companyName)}; path=/; max-age=86400`;
await new Promise(resolve => setTimeout(resolve, 800));
// For demo purposes, use the demo login API with sponsor role
// In production, this would create a real sponsor account
const response = await fetch('/api/auth/demo-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'sponsor' }),
});
if (!response.ok) {
throw new Error('Signup failed');
}
router.push('/sponsor/dashboard');
} catch (err) {
console.error('Sponsor signup failed:', err);

View File

@@ -1,50 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
export default function AlphaBanner() {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const dismissed = sessionStorage.getItem('alpha-banner-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
sessionStorage.setItem('alpha-banner-dismissed', 'true');
setIsDismissed(true);
};
if (!isMounted) return null;
if (isDismissed) return null;
return (
<div className="sticky top-0 z-50 bg-warning-amber/10 border-b border-warning-amber/20 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-warning-amber flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-white">
Alpha Version Data resets on page reload. No persistent storage.
</p>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-white transition-colors p-1"
aria-label="Dismiss banner"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
'use client';
import Link from 'next/link';
export default function AlphaFooter() {
return (
<footer className="mt-auto border-t border-charcoal-outline bg-deep-graphite">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="px-2 py-1 bg-warning-amber/10 text-warning-amber rounded border border-warning-amber/20 font-medium">
Alpha
</span>
</div>
<div className="flex items-center gap-6 text-sm">
<a
href="https://discord.gg/gridpilot"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Give Feedback
</a>
<a
href="/docs/roadmap"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Roadmap
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -1,79 +0,0 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import UserPill from '@/components/profile/UserPill';
import NotificationCenter from '@/components/notifications/NotificationCenter';
import { useAuth } from '@/lib/auth/AuthContext';
type AlphaNavProps = Record<string, never>;
const nonHomeLinks = [
{ href: '/leagues', label: 'Leagues' },
{ href: '/races', label: 'Races' },
{ href: '/teams', label: 'Teams' },
{ href: '/drivers', label: 'Drivers' },
{ href: '/leaderboards', label: 'Leaderboards' },
] as const;
export function AlphaNav({}: AlphaNavProps) {
const pathname = usePathname();
const { session } = useAuth();
const isAuthenticated = !!session;
const navLinks = isAuthenticated
? ([{ href: '/dashboard', label: 'Dashboard' } as const, ...nonHomeLinks] as const)
: ([{ href: '/', label: 'Home' } as const, ...nonHomeLinks] as const);
const loginHref = '/auth/iracing/start?returnTo=/dashboard';
return (
<nav className="sticky top-0 z-40 bg-deep-graphite/95 backdrop-blur-md border-b border-white/5">
<div className="max-w-7xl mx-auto px-6">
<div className="flex items-center justify-between h-14">
<div className="flex items-baseline space-x-3">
<Link
href="/"
className="text-xl font-semibold text-white hover:text-primary-blue transition-colors"
>
GridPilot
</Link>
<span className="text-xs text-gray-500 font-light">ALPHA</span>
</div>
<div className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => {
const isActive = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`
relative px-4 py-2 text-sm font-medium transition-all duration-200
${
isActive
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
{link.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue rounded-full" />
)}
</Link>
);
})}
</div>
<div className="hidden md:flex items-center space-x-3">
<NotificationCenter />
<UserPill />
</div>
<div className="md:hidden w-8" />
</div>
</div>
</nav>
);
}

View File

@@ -1,136 +0,0 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import Card from '../ui/Card';
type CompanionRace = {
track: string;
car: string;
scheduledAt: string | Date;
sessionType: string;
};
interface CompanionInstructionsProps {
race: CompanionRace;
leagueName?: string;
}
export default function CompanionInstructions({ race, leagueName }: CompanionInstructionsProps) {
const [copied, setCopied] = useState(false);
const formatDateTime = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
const raceDetails = `GridPilot Race: ${leagueName || 'League'}
Track: ${race.track}
Car: ${race.car}
Date/Time: ${formatDateTime(scheduledAt)}
Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slice(1)}`;
const handleCopyDetails = async () => {
try {
await navigator.clipboard.writeText(raceDetails);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<Card className="border border-primary-blue/20 bg-iron-gray">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1">Alpha Manual Workflow</h3>
<p className="text-sm text-gray-400">
Companion automation coming in production. For alpha, races are created manually.
</p>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary-blue/20 text-primary-blue text-xs font-semibold flex-shrink-0">
1
</span>
<p className="text-sm text-gray-300 pt-0.5">
Schedule race in GridPilot (completed)
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
2
</span>
<p className="text-sm text-gray-300 pt-0.5">
Copy race details using button below
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
3
</span>
<p className="text-sm text-gray-300 pt-0.5">
Create hosted session manually in iRacing website
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
4
</span>
<p className="text-sm text-gray-300 pt-0.5">
Return to GridPilot after race completes
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
5
</span>
<p className="text-sm text-gray-300 pt-0.5">
Import results via CSV upload
</p>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<div className="bg-deep-graphite rounded-lg p-3 mb-3">
<pre className="text-xs text-gray-300 whitespace-pre-wrap font-mono">
{raceDetails}
</pre>
</div>
<Button
variant="primary"
onClick={handleCopyDetails}
className="w-full"
>
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{copied ? 'Copied!' : 'Copy Race Details'}
</div>
</Button>
</div>
</Card>
);
}

View File

@@ -1,27 +0,0 @@
'use client';
interface CompanionStatusProps {
className?: string;
}
export default function CompanionStatus({ className = '' }: CompanionStatusProps) {
// Alpha: always disconnected
const isConnected = false;
const statusMessage = "Companion app available in production";
return (
<div className={`flex items-center gap-3 ${className}`}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-performance-green' : 'bg-gray-500'}`} />
<span className="text-sm text-gray-400">
Companion App: <span className={isConnected ? 'text-performance-green' : 'text-gray-400'}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</span>
</div>
<span className="text-xs text-gray-500">
{statusMessage}
</span>
</div>
);
}

View File

@@ -1,23 +0,0 @@
'use client';
interface FeatureLimitationTooltipProps {
message: string;
children: React.ReactNode;
}
export default function FeatureLimitationTooltip({ message, children }: FeatureLimitationTooltipProps) {
return (
<div className="group relative inline-block">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-sm text-gray-300 whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none z-50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary-blue flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{message}</span>
</div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-iron-gray" />
</div>
</div>
);
}

View File

@@ -1 +0,0 @@
export { default } from '../leagues/ScheduleRaceForm';

View File

@@ -71,9 +71,22 @@ export default function DevToolbar() {
// Determine login mode based on user email patterns
const email = session.user.email?.toLowerCase() || '';
const displayName = session.user.displayName?.toLowerCase() || '';
const role = (session.user as any).role;
let mode: LoginMode = 'none';
if (email.includes('sponsor') || displayName.includes('sponsor')) {
// First check session.role if available
if (role) {
if (role === 'sponsor') mode = 'sponsor';
else if (role === 'league-owner') mode = 'league-owner';
else if (role === 'league-steward') mode = 'league-steward';
else if (role === 'league-admin') mode = 'league-admin';
else if (role === 'system-owner') mode = 'system-owner';
else if (role === 'super-admin') mode = 'super-admin';
else if (role === 'driver') mode = 'driver';
}
// Fallback to email patterns
else if (email.includes('sponsor') || displayName.includes('sponsor')) {
mode = 'sponsor';
} else if (email.includes('league-owner') || displayName.includes('owner')) {
mode = 'league-owner';

View File

@@ -13,22 +13,7 @@ import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel';
import { useServices } from '@/lib/services/ServiceProvider';
// Hook to detect sponsor mode
function useSponsorMode(): boolean {
const [isSponsor, setIsSponsor] = useState(false);
useEffect(() => {
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith('gridpilot_demo_mode='));
if (cookie) {
const value = cookie.split('=')[1];
setIsSponsor(value === 'sponsor');
}
}, []);
return isSponsor;
}
// Hook to detect demo user mode
// Hook to detect demo user mode based on session
function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
const { session } = useAuth();
const [demoMode, setDemoMode] = useState({ isDemo: false, demoRole: null as string | null });
@@ -42,21 +27,26 @@ function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
const email = session.user.email?.toLowerCase() || '';
const displayName = session.user.displayName?.toLowerCase() || '';
const primaryDriverId = (session.user as any).primaryDriverId || '';
const role = (session.user as any).role;
// Check if this is a demo user
if (email.includes('demo') ||
displayName.includes('demo') ||
primaryDriverId.startsWith('demo-')) {
let role = 'driver';
if (email.includes('sponsor')) role = 'sponsor';
else if (email.includes('league-owner') || displayName.includes('owner')) role = 'league-owner';
else if (email.includes('league-steward') || displayName.includes('steward')) role = 'league-steward';
else if (email.includes('league-admin') || displayName.includes('admin')) role = 'league-admin';
else if (email.includes('system-owner') || displayName.includes('system owner')) role = 'system-owner';
else if (email.includes('super-admin') || displayName.includes('super admin')) role = 'super-admin';
// Use role from session if available, otherwise derive from email
let roleToUse = role;
if (!roleToUse) {
if (email.includes('sponsor')) roleToUse = 'sponsor';
else if (email.includes('league-owner') || displayName.includes('owner')) roleToUse = 'league-owner';
else if (email.includes('league-steward') || displayName.includes('steward')) roleToUse = 'league-steward';
else if (email.includes('league-admin') || displayName.includes('admin')) roleToUse = 'league-admin';
else if (email.includes('system-owner') || displayName.includes('system owner')) roleToUse = 'system-owner';
else if (email.includes('super-admin') || displayName.includes('super admin')) roleToUse = 'super-admin';
else roleToUse = 'driver';
}
setDemoMode({ isDemo: true, demoRole: role });
setDemoMode({ isDemo: true, demoRole: roleToUse });
} else {
setDemoMode({ isDemo: false, demoRole: null });
}
@@ -149,7 +139,6 @@ export default function UserPill() {
const { driverService, mediaService } = useServices();
const [driver, setDriver] = useState<DriverViewModel | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const isSponsorMode = useSponsorMode();
const { isDemo, demoRole } = useDemoUserMode();
const shouldReduceMotion = useReducedMotion();
@@ -236,8 +225,6 @@ export default function UserPill() {
try {
// Call the logout API
await fetch('/api/auth/logout', { method: 'POST' });
// Clear any demo mode cookies
document.cookie = 'gridpilot_demo_mode=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
// Redirect to home
window.location.href = '/';
} catch (error) {
@@ -509,4 +496,4 @@ export default function UserPill() {
</AnimatePresence>
</div>
);
}
}

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { useServices } from '@/lib/services/ServiceProvider';
import { useAuth } from '@/lib/auth/AuthContext';
import {
Eye,
TrendingUp,
@@ -445,18 +446,28 @@ export default function SponsorInsightsCard({
// ============================================================================
export function useSponsorMode(): boolean {
const { session } = useAuth();
const [isSponsor, setIsSponsor] = React.useState(false);
React.useEffect(() => {
if (typeof document !== 'undefined') {
const cookies = document.cookie.split(';');
const demoModeCookie = cookies.find(c => c.trim().startsWith('gridpilot_demo_mode='));
if (demoModeCookie) {
const value = demoModeCookie.split('=')[1]?.trim();
setIsSponsor(value === 'sponsor');
}
if (!session?.user) {
setIsSponsor(false);
return;
}
}, []);
// Check session.user.role for sponsor
const role = (session.user as any).role;
if (role === 'sponsor') {
setIsSponsor(true);
return;
}
// Fallback: check email patterns
const email = session.user.email?.toLowerCase() || '';
const displayName = session.user.displayName?.toLowerCase() || '';
setIsSponsor(email.includes('sponsor') || displayName.includes('sponsor'));
}, [session]);
return isSponsor;
}

View File

@@ -2,8 +2,6 @@ import { BaseApiClient } from '../base/BaseApiClient';
import { AuthSessionDTO } from '../../types/generated/AuthSessionDTO';
import { LoginParamsDTO } from '../../types/generated/LoginParamsDTO';
import { SignupParamsDTO } from '../../types/generated/SignupParamsDTO';
import { LoginWithIracingCallbackParamsDTO } from '../../types/generated/LoginWithIracingCallbackParamsDTO';
import { IracingAuthRedirectResultDTO } from '../../types/generated/IracingAuthRedirectResultDTO';
import { ForgotPasswordDTO } from '../../types/generated/ForgotPasswordDTO';
import { ResetPasswordDTO } from '../../types/generated/ResetPasswordDTO';
import { DemoLoginDTO } from '../../types/generated/DemoLoginDTO';
@@ -36,32 +34,6 @@ export class AuthApiClient extends BaseApiClient {
return this.post<void>('/auth/logout', {});
}
/** Start iRacing auth redirect */
startIracingAuthRedirect(returnTo?: string): Promise<IracingAuthRedirectResultDTO> {
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
return this.get<IracingAuthRedirectResultDTO>(`/auth/iracing/start${query}`);
}
/**
* Convenience: build iRacing auth start URL.
* Used by AuthService for view-layer navigation.
*/
getIracingAuthUrl(returnTo?: string): string {
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
return `${this.baseUrl}/auth/iracing/start${query}`;
}
/** Login with iRacing callback */
loginWithIracingCallback(params: LoginWithIracingCallbackParamsDTO): Promise<AuthSessionDTO> {
const query = new URLSearchParams();
query.append('code', params.code);
query.append('state', params.state);
if (params.returnTo) {
query.append('returnTo', params.returnTo);
}
return this.get<AuthSessionDTO>(`/auth/iracing/callback?${query.toString()}`);
}
/** Forgot password - send reset link */
forgotPassword(params: ForgotPasswordDTO): Promise<{ message: string; magicLink?: string }> {
return this.post<{ message: string; magicLink?: string }>('/auth/forgot-password', params);

View File

@@ -63,8 +63,8 @@ export function AuthProvider({ initialSession = null, children }: AuthProviderPr
}
const target = search.toString()
? `/auth/iracing?${search.toString()}`
: '/auth/iracing';
? `/auth/login?${search.toString()}`
: '/auth/login';
router.push(target);
},
@@ -103,4 +103,4 @@ export function useAuth(): AuthContextValue {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
}

View File

@@ -0,0 +1,515 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { ReturnToSanitizer } from './ReturnToSanitizer';
import { RoutePathBuilder } from './RoutePathBuilder';
import { PathnameInterpreter } from './PathnameInterpreter';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
describe('AuthRedirectBuilder', () => {
// Mock dependencies
let mockPolicy: Mocked<RouteAccessPolicy>;
let mockSanitizer: Mocked<ReturnToSanitizer>;
let mockPathBuilder: Mocked<RoutePathBuilder>;
let mockInterpreter: Mocked<PathnameInterpreter>;
// System under test
let builder: AuthRedirectBuilder;
beforeEach(() => {
// Create mock implementations
mockPolicy = {
roleHome: vi.fn(),
roleHomeRouteId: vi.fn(),
} as any;
mockSanitizer = {
sanitizeReturnTo: vi.fn(),
} as any;
mockPathBuilder = {
build: vi.fn(),
} as any;
mockInterpreter = {
interpret: vi.fn(),
} as any;
builder = new AuthRedirectBuilder(
mockPolicy,
mockSanitizer,
mockPathBuilder,
mockInterpreter
);
});
describe('toLogin', () => {
describe('without locale', () => {
it('should build login path without locale and append returnTo', () => {
// Arrange
const currentPathname = '/dashboard';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/dashboard',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/dashboard');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/dashboard');
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'auth.login',
{},
{ locale: null }
);
expect(mockSanitizer.sanitizeReturnTo).toHaveBeenCalledWith(
'/dashboard',
'/'
);
expect(result).toBe('/auth/login?returnTo=%2Fdashboard');
});
it('should handle root path as returnTo', () => {
// Arrange
const currentPathname = '/';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2F');
});
});
describe('with locale', () => {
it('should build login path with locale and append returnTo', () => {
// Arrange
const currentPathname = '/de/dashboard';
mockInterpreter.interpret.mockReturnValue({
locale: 'de',
logicalPathname: '/dashboard',
});
mockPathBuilder.build.mockReturnValue('/de/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/dashboard');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/de/dashboard');
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'auth.login',
{},
{ locale: 'de' }
);
expect(mockSanitizer.sanitizeReturnTo).toHaveBeenCalledWith(
'/de/dashboard',
'/'
);
expect(result).toBe('/de/auth/login?returnTo=%2Fdashboard');
});
it('should handle different locales', () => {
// Arrange
const currentPathname = '/fr/races/123';
mockInterpreter.interpret.mockReturnValue({
locale: 'fr',
logicalPathname: '/races/123',
});
mockPathBuilder.build.mockReturnValue('/fr/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/races/123');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/fr/auth/login?returnTo=%2Fraces%2F123');
});
});
describe('with invalid returnTo', () => {
it('should use fallback when sanitizer returns fallback', () => {
// Arrange
const currentPathname = '/api/something';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/api/something',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(mockSanitizer.sanitizeReturnTo).toHaveBeenCalledWith(
'/api/something',
'/'
);
expect(result).toBe('/auth/login?returnTo=%2F');
});
it('should handle malicious URLs', () => {
// Arrange
const currentPathname = 'https://evil.com/phishing';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: 'https://evil.com/phishing',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2F');
});
});
describe('edge cases', () => {
it('should handle empty currentPathname', () => {
// Arrange
const currentPathname = '';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2F');
});
it('should handle paths with query strings', () => {
// Arrange
const currentPathname = '/dashboard?tab=settings';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/dashboard?tab=settings',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/dashboard?tab=settings');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2Fdashboard%3Ftab%3Dsettings');
});
});
});
describe('awayFromAuthPage', () => {
describe('with driver role', () => {
it('should redirect to driver dashboard without locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-123',
email: 'driver@example.com',
displayName: 'Driver',
role: 'driver',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/auth/login');
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('driver');
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'dashboard',
{},
{ locale: null }
);
expect(result).toBe('/dashboard');
});
it('should redirect to driver dashboard with locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-123',
email: 'driver@example.com',
displayName: 'Driver',
role: 'driver',
},
};
const currentPathname = '/de/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: 'de',
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/de/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'dashboard',
{},
{ locale: 'de' }
);
expect(result).toBe('/de/dashboard');
});
});
describe('with sponsor role', () => {
it('should redirect to sponsor dashboard without locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-456',
email: 'sponsor@example.com',
displayName: 'Sponsor',
role: 'sponsor',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('sponsor.dashboard');
mockPathBuilder.build.mockReturnValue('/sponsor/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('sponsor');
expect(result).toBe('/sponsor/dashboard');
});
it('should redirect to sponsor dashboard with locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-456',
email: 'sponsor@example.com',
displayName: 'Sponsor',
role: 'sponsor',
},
};
const currentPathname = '/fr/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: 'fr',
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('sponsor.dashboard');
mockPathBuilder.build.mockReturnValue('/fr/sponsor/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'sponsor.dashboard',
{},
{ locale: 'fr' }
);
expect(result).toBe('/fr/sponsor/dashboard');
});
});
describe('with admin role', () => {
it('should redirect to admin dashboard without locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-789',
email: 'admin@example.com',
displayName: 'Admin',
role: 'admin',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('admin');
mockPathBuilder.build.mockReturnValue('/admin');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('admin');
expect(result).toBe('/admin');
});
it('should redirect to admin dashboard with locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-789',
email: 'admin@example.com',
displayName: 'Admin',
role: 'admin',
},
};
const currentPathname = '/es/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: 'es',
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('admin');
mockPathBuilder.build.mockReturnValue('/es/admin');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'admin',
{},
{ locale: 'es' }
);
expect(result).toBe('/es/admin');
});
});
describe('with owner role', () => {
it('should redirect to admin dashboard (owner maps to admin)', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-owner',
email: 'owner@example.com',
displayName: 'Owner',
role: 'owner',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('admin');
mockPathBuilder.build.mockReturnValue('/admin');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('owner');
expect(result).toBe('/admin');
});
});
describe('edge cases', () => {
it('should handle missing role (defaults to /dashboard)', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-no-role',
email: 'norole@example.com',
displayName: 'NoRole',
// role is undefined
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('');
expect(result).toBe('/dashboard');
});
it('should handle empty role string', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-empty-role',
email: 'emptyrole@example.com',
displayName: 'EmptyRole',
role: '',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('');
expect(result).toBe('/dashboard');
});
it('should handle paths with locale and complex paths', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-123',
email: 'driver@example.com',
displayName: 'Driver',
role: 'driver',
},
};
const currentPathname = '/de/leagues/123/roster/admin';
mockInterpreter.interpret.mockReturnValue({
locale: 'de',
logicalPathname: '/leagues/123/roster/admin',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/de/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/de/leagues/123/roster/admin');
expect(result).toBe('/de/dashboard');
});
});
});
});

View File

@@ -0,0 +1,82 @@
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { ReturnToSanitizer } from './ReturnToSanitizer';
import { RoutePathBuilder } from './RoutePathBuilder';
import { PathnameInterpreter } from './PathnameInterpreter';
/**
* AuthRedirectBuilder - Builds redirect URLs for authentication flows
*
* Responsibilities:
* - Build login redirect with sanitized returnTo parameter
* - Build redirect away from auth pages based on user role
* - Preserve locale from current path
*
* Pure-ish (no server dependencies)
*/
export class AuthRedirectBuilder {
constructor(
private policy: RouteAccessPolicy,
private sanitizer: ReturnToSanitizer,
private pathBuilder: RoutePathBuilder,
private interpreter: PathnameInterpreter
) {}
/**
* Build redirect URL to login page with returnTo parameter
*
* @param currentPathname - The current URL pathname (can include locale)
* @returns Redirect URL to login page with sanitized returnTo
*
* Example:
* - '/dashboard' → '/auth/login?returnTo=%2Fdashboard'
* - '/de/dashboard' → '/de/auth/login?returnTo=%2Fdashboard'
* - '/api/evil' → '/auth/login?returnTo=%2F' (sanitized)
*/
toLogin({ currentPathname }: { currentPathname: string }): string {
// Interpret current path to extract locale
const { locale } = this.interpreter.interpret(currentPathname);
// Build login path with locale
const loginPath = this.pathBuilder.build('auth.login', {}, { locale });
// 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}`;
}
/**
* Build redirect URL away from auth page based on user role
*
* @param session - Current authentication session
* @param currentPathname - The current URL pathname (can include locale)
* @returns Redirect URL to role-appropriate home page
*
* Example:
* - driver role, '/auth/login' → '/dashboard'
* - sponsor role, '/de/auth/login' → '/de/sponsor/dashboard'
* - admin role, '/auth/login' → '/admin'
* - no role, '/auth/login' → '/dashboard' (default)
*/
awayFromAuthPage({
session,
currentPathname,
}: {
session: AuthSessionDTO;
currentPathname: string;
}): string {
// Extract locale from current path
const { locale } = this.interpreter.interpret(currentPathname);
// Get role-appropriate route ID
const role = session.user?.role;
const routeId = this.policy.roleHomeRouteId(role ?? '');
// Build path with locale
return this.pathBuilder.build(routeId, {}, { locale });
}
}

View File

@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest';
import { PathnameInterpreter } from './PathnameInterpreter';
describe('PathnameInterpreter', () => {
describe('interpret() - no locale prefix cases', () => {
it('should handle root path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/');
expect(result).toEqual({
locale: null,
logicalPathname: '/',
});
});
it('should handle simple path without locale', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/dashboard',
});
});
it('should handle dynamic route without locale', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/leagues/123');
expect(result).toEqual({
locale: null,
logicalPathname: '/leagues/123',
});
});
it('should handle nested path without locale', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/auth/login');
expect(result).toEqual({
locale: null,
logicalPathname: '/auth/login',
});
});
});
describe('interpret() - with locale prefix', () => {
it('should strip valid 2-letter locale prefix', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/de/dashboard');
expect(result).toEqual({
locale: 'de',
logicalPathname: '/dashboard',
});
});
it('should handle locale prefix with dynamic route', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/en/leagues/456');
expect(result).toEqual({
locale: 'en',
logicalPathname: '/leagues/456',
});
});
it('should handle locale prefix with root path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/fr/');
expect(result).toEqual({
locale: 'fr',
logicalPathname: '/',
});
});
it('should handle locale prefix with nested path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/es/auth/settings');
expect(result).toEqual({
locale: 'es',
logicalPathname: '/auth/settings',
});
});
});
describe('interpret() - edge cases', () => {
it('should not strip invalid locale (numeric)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/999/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/999/dashboard',
});
});
it('should not strip invalid locale (3 letters)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/eng/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/eng/dashboard',
});
});
it('should not strip invalid locale (uppercase)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/DE/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/DE/dashboard',
});
});
it('should not strip invalid locale (with special chars)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/d-/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/d-/dashboard',
});
});
it('should handle empty path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('');
expect(result).toEqual({
locale: null,
logicalPathname: '',
});
});
it('should handle path with only locale (no trailing slash)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/de');
expect(result).toEqual({
locale: 'de',
logicalPathname: '/',
});
});
it('should handle path with only locale (with trailing slash)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/de/');
expect(result).toEqual({
locale: 'de',
logicalPathname: '/',
});
});
});
});

View File

@@ -0,0 +1,97 @@
/**
* PathnameInterpreter
*
* Server-only utility for interpreting URL pathnames and extracting locale information.
* Strips locale prefix if present and returns the logical pathname.
*
* Examples:
* - '/de/dashboard' → { locale: 'de', logicalPathname: '/dashboard' }
* - '/dashboard' → { locale: null, logicalPathname: '/dashboard' }
* - '/' → { locale: null, logicalPathname: '/' }
* - '/999/dashboard' → { locale: null, logicalPathname: '/999/dashboard' }
*/
export interface PathnameInterpretation {
locale: string | null;
logicalPathname: string;
}
export class PathnameInterpreter {
/**
* Interprets a pathname and extracts locale information
*
* @param pathname - The URL pathname to interpret
* @returns Object with locale (if valid 2-letter code) and logical pathname
*/
interpret(pathname: string): PathnameInterpretation {
// Handle empty path
if (pathname === '') {
return {
locale: null,
logicalPathname: '',
};
}
// Handle root path
if (pathname === '/') {
return {
locale: null,
logicalPathname: '/',
};
}
// Normalize pathname (remove trailing slash for consistent processing)
const normalizedPathname = pathname.endsWith('/') && pathname.length > 1
? pathname.slice(0, -1)
: pathname;
// Split into segments
const segments = normalizedPathname.split('/').filter(Boolean);
// No segments to process
if (segments.length === 0) {
return {
locale: null,
logicalPathname: '/',
};
}
// Check if first segment is a valid 2-letter locale code
const firstSegment = segments[0];
if (this.isValidLocale(firstSegment)) {
// Valid locale detected - strip it
const remainingSegments = segments.slice(1);
const logicalPathname = remainingSegments.length > 0
? '/' + remainingSegments.join('/')
: '/';
return {
locale: firstSegment,
logicalPathname,
};
}
// No valid locale prefix found
return {
locale: null,
logicalPathname: pathname,
};
}
/**
* Validates if a string is a valid 2-letter locale code
* Must be exactly 2 lowercase letters (a-z)
*
* @param segment - The segment to validate
* @returns True if valid locale code
*/
private isValidLocale(segment: string): boolean {
// Must be exactly 2 characters
if (segment.length !== 2) {
return false;
}
// Must be lowercase letters only (a-z)
return /^[a-z]{2}$/.test(segment);
}
}

View File

@@ -0,0 +1,168 @@
import { ReturnToSanitizer } from './ReturnToSanitizer';
describe('ReturnToSanitizer', () => {
let sanitizer: ReturnToSanitizer;
beforeEach(() => {
sanitizer = new ReturnToSanitizer();
});
describe('sanitizeReturnTo', () => {
const FALLBACK = '/dashboard';
it('should return fallback when input is null', () => {
const result = sanitizer.sanitizeReturnTo(null, FALLBACK);
expect(result).toBe(FALLBACK);
});
it('should return fallback when input is empty string', () => {
const result = sanitizer.sanitizeReturnTo('', FALLBACK);
expect(result).toBe(FALLBACK);
});
it('should return fallback when input is undefined', () => {
const result = sanitizer.sanitizeReturnTo(undefined as any, FALLBACK);
expect(result).toBe(FALLBACK);
});
it('should accept valid relative paths starting with /', () => {
const validPaths = [
'/dashboard',
'/profile/settings',
'/leagues/123',
'/sponsor/dashboard',
'/admin/users',
'/',
];
validPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(path);
});
});
it('should strip protocol and host from absolute URLs', () => {
const testCases = [
{ input: 'https://example.com/dashboard', expected: '/dashboard' },
{ input: 'http://example.com/profile', expected: '/profile' },
{ input: 'https://evil.com/steal', expected: '/steal' },
];
testCases.forEach(({ input, expected }) => {
const result = sanitizer.sanitizeReturnTo(input, FALLBACK);
expect(result).toBe(expected);
});
});
it('should reject paths starting with /api/', () => {
const apiPaths = [
'/api/users',
'/api/auth/login',
'/api/internal/endpoint',
];
apiPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should reject paths starting with /_next/', () => {
const nextPaths = [
'/_next/static',
'/_next/data',
'/_next/image',
];
nextPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should reject paths with file extensions', () => {
const filePaths = [
'/document.pdf',
'/image.jpg',
'/script.js',
'/style.css',
'/data.json',
'/path/to/file.txt',
];
filePaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should reject relative paths without leading /', () => {
const relativePaths = [
'dashboard',
'profile/settings',
'../evil',
'./local',
];
relativePaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should handle complex valid paths', () => {
const complexPaths = [
'/leagues/abc-123/schedule',
'/races/456/results',
'/profile/liveries/upload',
'/sponsor/leagues/def-456',
];
complexPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(path);
});
});
it('should handle absolute URLs with query parameters', () => {
const result = sanitizer.sanitizeReturnTo(
'https://example.com/dashboard?tab=settings',
FALLBACK
);
expect(result).toBe('/dashboard?tab=settings');
});
it('should handle relative paths with query parameters', () => {
const result = sanitizer.sanitizeReturnTo(
'/profile?section=security',
FALLBACK
);
expect(result).toBe('/profile?section=security');
});
it('should reject paths with multiple dots (potential file extensions)', () => {
const paths = [
'/path/file.tar.gz',
'/api/v1/data.xml',
'/download/file.backup',
];
paths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should accept paths with dots that are not extensions', () => {
const validPaths = [
'/leagues/v1.0/dashboard', // version in path
'/user/john.doe', // username with dot
];
validPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(path);
});
});
});
});

View File

@@ -0,0 +1,69 @@
/**
* ReturnToSanitizer - Sanitizes returnTo URLs to prevent open redirects
*
* Security Rules:
* - Must start with '/'
* - Strip protocol and host from absolute URLs
* - Block /api/* routes
* - Block /_next/* routes (Next.js internals)
* - Block paths with file extensions
* - Return fallback for invalid inputs
*/
export class ReturnToSanitizer {
/**
* Sanitizes a returnTo URL to ensure it's safe for redirection
*
* @param input - The raw returnTo value (can be null, undefined, or string)
* @param fallbackPathname - Fallback path if input is invalid
* @returns Sanitized path safe for redirection
*/
sanitizeReturnTo(
input: string | null | undefined,
fallbackPathname: string
): string {
// Handle null/undefined/empty
if (!input || input.trim() === '') {
return fallbackPathname;
}
let path = input.trim();
// Strip protocol and host from absolute URLs
// Matches: https://example.com/path, http://localhost:3000/path
if (path.match(/^https?:\/\//)) {
try {
const url = new URL(path);
path = url.pathname + url.search;
} catch {
// Invalid URL format
return fallbackPathname;
}
}
// Must start with /
if (!path.startsWith('/')) {
return fallbackPathname;
}
// Block API routes
if (path.startsWith('/api/')) {
return fallbackPathname;
}
// Block Next.js internal routes
if (path.startsWith('/_next/')) {
return fallbackPathname;
}
// Block paths with file extensions
// Check for common file extensions at the end or before query string
// Excludes version numbers (v1.0) and usernames (john.doe) but catches .pdf, .jpg, .tar.gz, .backup, etc.
const fileExtensionPattern = /\.(pdf|jpg|jpeg|png|gif|webp|ico|css|js|json|xml|txt|csv|tar|gz|zip|mp4|webm|mov|avi|mp3|wav|svg|bmp|tiff|woff|woff2|ttf|eot|backup|bak|sql|db|exe|dmg|iso|rar|7z)($|\?)/i;
if (fileExtensionPattern.test(path)) {
return fallbackPathname;
}
// Valid path
return path;
}
}

View File

@@ -0,0 +1,256 @@
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { RouteCatalog } from './RouteCatalog';
describe('RouteAccessPolicy', () => {
let policy: RouteAccessPolicy;
let catalog: RouteCatalog;
beforeEach(() => {
catalog = new RouteCatalog();
policy = new RouteAccessPolicy(catalog);
});
describe('isPublic', () => {
it('should return true for public routes', () => {
const publicRoutes = [
'/',
'/leagues',
'/drivers',
'/teams',
'/leaderboards',
'/races',
'/sponsor/signup',
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
'/404',
'/500',
];
publicRoutes.forEach(route => {
expect(policy.isPublic(route)).toBe(true);
});
});
it('should return false for protected routes', () => {
const protectedRoutes = [
'/dashboard',
'/onboarding',
'/profile',
'/profile/settings',
'/sponsor/dashboard',
'/sponsor/billing',
'/admin/users',
'/leagues/create',
];
protectedRoutes.forEach(route => {
expect(policy.isPublic(route)).toBe(false);
});
});
it('should handle wildcard patterns', () => {
// These should match patterns from RouteCatalog
expect(policy.isPublic('/leagues/123')).toBe(true);
expect(policy.isPublic('/drivers/456')).toBe(true);
expect(policy.isPublic('/teams/789')).toBe(true);
expect(policy.isPublic('/races/123')).toBe(true);
expect(policy.isPublic('/races/all')).toBe(true);
});
});
describe('isAuthPage', () => {
it('should return true for auth pages', () => {
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
];
authRoutes.forEach(route => {
expect(policy.isAuthPage(route)).toBe(true);
});
});
it('should return false for non-auth pages', () => {
const nonAuthRoutes = [
'/',
'/dashboard',
'/leagues',
'/sponsor/dashboard',
'/admin/users',
];
nonAuthRoutes.forEach(route => {
expect(policy.isAuthPage(route)).toBe(false);
});
});
});
describe('requiredRoles', () => {
it('should return null for public routes', () => {
const publicRoutes = [
'/',
'/leagues',
'/drivers',
'/auth/login',
];
publicRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toBeNull();
});
});
it('should return null for auth-only routes (no specific role)', () => {
const authRoutes = [
'/dashboard',
'/onboarding',
'/profile',
'/profile/settings',
'/profile/leagues',
];
authRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toBeNull();
});
});
it('should return sponsor role for sponsor routes', () => {
const sponsorRoutes = [
'/sponsor',
'/sponsor/dashboard',
'/sponsor/billing',
'/sponsor/campaigns',
'/sponsor/leagues',
'/sponsor/settings',
];
sponsorRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toEqual(['sponsor']);
});
});
it('should return admin roles for admin routes', () => {
const adminRoutes = [
'/admin',
'/admin/users',
'/leagues/123/schedule/admin',
'/leagues/123/roster/admin',
'/leagues/123/stewarding',
'/leagues/123/wallet',
];
adminRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toEqual(['system-owner', 'super-admin', 'league-admin']);
});
});
it('should return steward roles for race stewarding routes', () => {
const stewardRoutes = [
'/races/456/stewarding',
];
stewardRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toEqual(['system-owner', 'super-admin', 'league-steward']);
});
});
it('should handle league-specific admin routes', () => {
const result = policy.requiredRoles('/leagues/abc-123/settings');
expect(result).toEqual(['system-owner', 'super-admin', 'league-admin']);
});
it('should handle race-specific stewarding routes', () => {
const result = policy.requiredRoles('/races/xyz-789/stewarding');
expect(result).toEqual(['system-owner', 'super-admin', 'league-steward']);
});
});
describe('roleHome', () => {
it('should return correct home path for driver role', () => {
const result = policy.roleHome('driver');
expect(result).toBe('/dashboard');
});
it('should return correct home path for sponsor role', () => {
const result = policy.roleHome('sponsor');
expect(result).toBe('/sponsor/dashboard');
});
it('should return correct home path for league-admin role', () => {
const result = policy.roleHome('league-admin');
expect(result).toBe('/admin');
});
it('should return correct home path for league-steward role', () => {
const result = policy.roleHome('league-steward');
expect(result).toBe('/admin');
});
it('should return correct home path for league-owner role', () => {
const result = policy.roleHome('league-owner');
expect(result).toBe('/admin');
});
it('should return correct home path for system-owner role', () => {
const result = policy.roleHome('system-owner');
expect(result).toBe('/admin');
});
it('should return correct home path for super-admin role', () => {
const result = policy.roleHome('super-admin');
expect(result).toBe('/admin');
});
it('should handle unknown roles gracefully', () => {
const result = policy.roleHome('unknown');
// Should return a sensible default (dashboard)
expect(result).toBe('/dashboard');
});
});
describe('roleHomeRouteId', () => {
it('should return correct route ID for driver role', () => {
const result = policy.roleHomeRouteId('driver');
expect(result).toBe('dashboard');
});
it('should return correct route ID for sponsor role', () => {
const result = policy.roleHomeRouteId('sponsor');
expect(result).toBe('sponsor.dashboard');
});
it('should return correct route ID for admin roles', () => {
const adminRoles = ['league-admin', 'league-steward', 'league-owner', 'system-owner', 'super-admin'];
adminRoles.forEach(role => {
const result = policy.roleHomeRouteId(role);
expect(result).toBe('admin');
});
});
});
describe('integration scenarios', () => {
it('should correctly classify common user journey paths', () => {
// Public user browsing
expect(policy.isPublic('/leagues')).toBe(true);
expect(policy.requiredRoles('/leagues')).toBeNull();
// Authenticated user
expect(policy.isPublic('/dashboard')).toBe(false);
expect(policy.requiredRoles('/dashboard')).toBeNull();
// Sponsor user
expect(policy.isPublic('/sponsor/dashboard')).toBe(false);
expect(policy.requiredRoles('/sponsor/dashboard')).toEqual(['sponsor']);
expect(policy.roleHome('sponsor')).toBe('/sponsor/dashboard');
// Admin user
expect(policy.isPublic('/admin/users')).toBe(false);
expect(policy.requiredRoles('/admin/users')).toEqual(['system-owner', 'super-admin', 'league-admin']);
expect(policy.roleHome('league-admin')).toBe('/admin');
});
});
});

View File

@@ -0,0 +1,72 @@
import { RouteCatalog } from './RouteCatalog';
/**
* RouteAccessPolicy - Determines access requirements for routes
*
* Responsibilities:
* - Check if a route is public
* - Check if a route is an auth page
* - Determine required roles for a route
* - Get home path for a specific role
*
* Design: Uses ONLY RouteCatalog patterns/matchers, no hardcoded arrays/strings
*/
export class RouteAccessPolicy {
constructor(private catalog: RouteCatalog) {}
/**
* Check if a logical pathname is publicly accessible
* @param logicalPathname - The path to check
* @returns true if the route is public (no auth required)
*/
isPublic(logicalPathname: string): boolean {
// Get the route ID for this path
const routeId = this.catalog.getRouteIdByPath(logicalPathname);
if (!routeId) {
// No route found, not public
return false;
}
// Check if this route ID is in the public routes list
const publicRouteIds = this.catalog.listPublicRoutes();
return publicRouteIds.includes(routeId);
}
/**
* Check if a logical pathname is an auth page
* @param logicalPathname - The path to check
* @returns true if the route is an auth page
*/
isAuthPage(logicalPathname: string): boolean {
return this.catalog.isAuthPage(logicalPathname);
}
/**
* Get required roles for a logical pathname
* @param logicalPathname - The path to check
* @returns Array of required roles, or null if no specific role required
*/
requiredRoles(logicalPathname: string): string[] | null {
// Use catalog's role-based access method
return this.catalog.getRequiredRoles(logicalPathname);
}
/**
* Get the home path for a specific role
* @param role - The role name
* @returns The logical path for that role's home page
*/
roleHome(role: string): string {
return this.catalog.getRoleHome(role);
}
/**
* Get the route ID for a specific role's home page
* @param role - The role name
* @returns The route ID for that role's home page
*/
roleHomeRouteId(role: string): string {
return this.catalog.getRoleHomeRouteId(role);
}
}

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { RouteCatalog } from './RouteCatalog';
describe('RouteCatalog', () => {
describe('constructor', () => {
it('should create an instance without errors', () => {
const catalog = new RouteCatalog();
expect(catalog).toBeInstanceOf(RouteCatalog);
});
});
describe('listPublicRoutes()', () => {
it('should return array of public route IDs', () => {
const catalog = new RouteCatalog();
const publicRoutes = catalog.listPublicRoutes();
expect(Array.isArray(publicRoutes)).toBe(true);
expect(publicRoutes.length).toBeGreaterThan(0);
expect(publicRoutes).toContain('auth.login');
expect(publicRoutes).toContain('public.home');
expect(publicRoutes).toContain('error.notFound');
});
});
describe('listProtectedRoutes()', () => {
it('should return array of protected route IDs', () => {
const catalog = new RouteCatalog();
const protectedRoutes = catalog.listProtectedRoutes();
expect(Array.isArray(protectedRoutes)).toBe(true);
expect(protectedRoutes.length).toBeGreaterThan(0);
expect(protectedRoutes).toContain('protected.dashboard');
expect(protectedRoutes).toContain('protected.profile');
expect(protectedRoutes).toContain('sponsor.dashboard');
});
});
describe('getPattern()', () => {
it('should return pattern for simple route ID', () => {
const catalog = new RouteCatalog();
const pattern = catalog.getPattern('auth.login');
expect(pattern).toBe('/auth/login');
});
it('should return pattern for protected route', () => {
const catalog = new RouteCatalog();
const pattern = catalog.getPattern('protected.dashboard');
expect(pattern).toBe('/dashboard');
});
it('should return pattern for public route', () => {
const catalog = new RouteCatalog();
const pattern = catalog.getPattern('public.leagues');
expect(pattern).toBe('/leagues');
});
it('should throw error for unknown route ID', () => {
const catalog = new RouteCatalog();
expect(() => catalog.getPattern('unknown.route')).toThrow('Unknown route ID: unknown.route');
});
});
describe('isAuthPage()', () => {
it('should return true for auth pages', () => {
const catalog = new RouteCatalog();
expect(catalog.isAuthPage('/auth/login')).toBe(true);
expect(catalog.isAuthPage('/auth/signup')).toBe(true);
expect(catalog.isAuthPage('/auth/forgot-password')).toBe(true);
});
it('should return false for non-auth pages', () => {
const catalog = new RouteCatalog();
expect(catalog.isAuthPage('/dashboard')).toBe(false);
expect(catalog.isAuthPage('/leagues')).toBe(false);
expect(catalog.isAuthPage('/')).toBe(false);
});
});
describe('getAllPatterns()', () => {
it('should return all route patterns', () => {
const catalog = new RouteCatalog();
const patterns = catalog.getAllPatterns();
expect(Array.isArray(patterns)).toBe(true);
expect(patterns.length).toBeGreaterThan(0);
expect(patterns.some(p => p.routeId === 'auth.login')).toBe(true);
expect(patterns.some(p => p.routeId === 'protected.dashboard')).toBe(true);
});
});
describe('getRouteIdByPath()', () => {
it('should return route ID for exact match', () => {
const catalog = new RouteCatalog();
const routeId = catalog.getRouteIdByPath('/auth/login');
expect(routeId).toBe('auth.login');
});
it('should return route ID for protected path', () => {
const catalog = new RouteCatalog();
const routeId = catalog.getRouteIdByPath('/dashboard');
expect(routeId).toBe('protected.dashboard');
});
it('should return null for unknown path', () => {
const catalog = new RouteCatalog();
const routeId = catalog.getRouteIdByPath('/unknown/path');
expect(routeId).toBeNull();
});
});
});

View File

@@ -0,0 +1,274 @@
import { routes, routeMatchers } from '../routing/RouteConfig';
/**
* RouteCatalog exposes route IDs and patterns for matching
*
* Route IDs follow the pattern: 'category.routeName'
* Examples:
* - 'auth.login' → '/auth/login'
* - 'protected.dashboard' → '/dashboard'
* - 'league.detail' → '/leagues/[id]' (pattern)
*/
export class RouteCatalog {
/**
* List all public route IDs
* Public routes are accessible without authentication
*/
listPublicRoutes(): string[] {
return [
'public.home',
'public.leagues',
'public.drivers',
'public.teams',
'public.leaderboards',
'public.races',
'public.sponsorSignup',
'auth.login',
'auth.signup',
'auth.forgotPassword',
'auth.resetPassword',
'auth.iRacingStart',
'auth.iRacingCallback',
'error.notFound',
'error.serverError',
// Parameterized public routes
'league.detail',
'league.rulebook',
'league.schedule',
'league.standings',
'driver.detail',
'team.detail',
'race.detail',
'race.results',
'race.all',
];
}
/**
* List all protected route IDs
* Protected routes require authentication
*/
listProtectedRoutes(): string[] {
return [
'protected.dashboard',
'protected.onboarding',
'protected.profile',
'protected.profileSettings',
'protected.profileLeagues',
'protected.profileLiveries',
'protected.profileLiveryUpload',
'protected.profileSponsorshipRequests',
'sponsor.root',
'sponsor.dashboard',
'sponsor.billing',
'sponsor.campaigns',
'sponsor.leagues',
'sponsor.settings',
'admin.root',
'admin.users',
'league.create',
'race.root',
'team.root',
'team.leaderboard',
];
}
/**
* List all admin route IDs
* Admin routes require admin-level permissions
*/
listAdminRoutes(): string[] {
return [
'admin.root',
'admin.users',
'league.rosterAdmin',
'league.scheduleAdmin',
'league.stewarding',
'league.settings',
'league.sponsorships',
'league.wallet',
'race.stewarding',
];
}
/**
* List all sponsor route IDs
* Sponsor routes require sponsor role
*/
listSponsorRoutes(): string[] {
return [
'sponsor.root',
'sponsor.dashboard',
'sponsor.billing',
'sponsor.campaigns',
'sponsor.leagues',
'sponsor.settings',
];
}
/**
* Get the path pattern for a route ID
* @param routeId - Route ID in format 'category.routeName'
* @returns Path pattern (e.g., '/auth/login' or '/leagues/[id]')
* @throws Error if route ID is unknown
*/
getPattern(routeId: string): string {
const parts = routeId.split('.');
let route: any = routes;
for (const part of parts) {
route = route[part];
if (!route) {
throw new Error(`Unknown route ID: ${routeId}`);
}
}
// Handle parameterized routes
if (typeof route === 'function') {
// Return pattern with placeholder
const paramPattern = route('placeholder');
return paramPattern.replace('/placeholder', '/[id]');
}
return route as string;
}
/**
* Check if a path is an auth page
* @param logicalPath - Path to check
* @returns True if path is an auth page
*/
isAuthPage(logicalPath: string): boolean {
return routeMatchers.isInGroup(logicalPath, 'auth');
}
/**
* Get all route patterns with their IDs
* @returns Array of route patterns with IDs
*/
getAllPatterns(): Array<{ routeId: string; pattern: string }> {
const patterns: Array<{ routeId: string; pattern: string }> = [];
// Helper to traverse routes and build patterns
const traverse = (obj: any, prefix: string) => {
for (const [key, value] of Object.entries(obj)) {
const routeId = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'function') {
// Parameterized route
const pattern = value('placeholder').replace('/placeholder', '/[id]');
patterns.push({ routeId, pattern });
} else if (typeof value === 'object' && value !== null) {
// Nested category
traverse(value, routeId);
} else if (typeof value === 'string') {
// Simple route
patterns.push({ routeId, pattern: value });
}
}
};
traverse(routes, '');
return patterns;
}
/**
* Get route ID by path
* @param path - Path to find
* @returns Route ID or null if not found
*
* Note: This method prioritizes exact matches over parameterized matches.
* For example, '/leagues/create' will match 'league.create' before 'league.detail'.
*/
getRouteIdByPath(path: string): string | null {
const allPatterns = this.getAllPatterns();
// First, try exact matches
for (const { routeId, pattern } of allPatterns) {
if (pattern === path) {
return routeId;
}
}
// Then, try parameterized matches
for (const { routeId, pattern } of allPatterns) {
if (pattern.includes('[')) {
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
if (regex.test(path)) {
return routeId;
}
}
}
return null;
}
/**
* Check if a path requires specific role-based access
* @param logicalPath - Path to check
* @returns Array of required roles or null
*/
getRequiredRoles(logicalPath: string): string[] | null {
// Check admin routes
if (routeMatchers.isInGroup(logicalPath, 'admin')) {
return ['system-owner', 'super-admin', 'league-admin'];
}
// Check sponsor routes
if (routeMatchers.isInGroup(logicalPath, 'sponsor')) {
return ['sponsor'];
}
// Check league admin routes (specific patterns)
if (logicalPath.match(/\/leagues\/[^/]+\/(roster\/admin|schedule\/admin|stewarding|settings|sponsorships|wallet)/)) {
return ['system-owner', 'super-admin', 'league-admin'];
}
// Check race stewarding routes
if (logicalPath.match(/\/races\/[^/]+\/stewarding/)) {
return ['system-owner', 'super-admin', 'league-steward'];
}
// Public or auth-only routes (no specific role)
return null;
}
/**
* Get the home path for a specific role
* @param role - The role name
* @returns The logical path for that role's home page
*/
getRoleHome(role: string): string {
const roleHomeMap: Record<string, string> = {
'driver': '/dashboard',
'sponsor': '/sponsor/dashboard',
'league-admin': '/admin',
'league-steward': '/admin',
'league-owner': '/admin',
'system-owner': '/admin',
'super-admin': '/admin',
};
return roleHomeMap[role] || '/dashboard';
}
/**
* Get the route ID for a specific role's home page
* @param role - The role name
* @returns The route ID for that role's home page
*/
getRoleHomeRouteId(role: string): string {
const roleHomeRouteMap: Record<string, string> = {
'driver': 'protected.dashboard',
'sponsor': 'sponsor.dashboard',
'league-admin': 'admin',
'league-steward': 'admin',
'league-owner': 'admin',
'system-owner': 'admin',
'super-admin': 'admin',
};
return roleHomeRouteMap[role] || 'protected.dashboard';
}
}

View File

@@ -0,0 +1,223 @@
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
import { RouteGuard } from './RouteGuard';
import { PathnameInterpreter } from './PathnameInterpreter';
import { RouteAccessPolicy } from './RouteAccessPolicy';
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');
vi.mock('../gateways/SessionGateway');
vi.mock('./AuthRedirectBuilder');
describe('RouteGuard', () => {
let routeGuard: RouteGuard;
let mockInterpreter: Mocked<PathnameInterpreter>;
let mockPolicy: Mocked<RouteAccessPolicy>;
let mockGateway: Mocked<SessionGateway>;
let mockBuilder: Mocked<AuthRedirectBuilder>;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create mock instances
mockInterpreter = {
interpret: vi.fn(),
} as any;
mockPolicy = {
isPublic: vi.fn(),
isAuthPage: vi.fn(),
requiredRoles: vi.fn(),
} as any;
mockGateway = {
getSession: vi.fn(),
} as any;
mockBuilder = {
awayFromAuthPage: vi.fn(),
toLogin: vi.fn(),
} as any;
// Create RouteGuard instance
routeGuard = new RouteGuard(
mockInterpreter,
mockPolicy,
mockGateway,
mockBuilder
);
});
describe('RED: public non-auth page → no redirect', () => {
it('should allow access without redirect for public non-auth pages', async () => {
// Arrange
const pathname = '/public/page';
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/public/page' });
mockPolicy.isPublic.mockReturnValue(true);
mockPolicy.isAuthPage.mockReturnValue(false);
// Act
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();
});
});
describe('auth page, no session → allow', () => {
it('should allow access to auth page when no session exists', async () => {
// Arrange
const pathname = '/login';
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/login' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(true);
mockGateway.getSession.mockResolvedValue(null);
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockRedirect).not.toHaveBeenCalled();
});
});
describe('auth page, session → away redirect', () => {
it('should redirect away from auth page when session exists', async () => {
// Arrange
const pathname = '/login';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/login' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(true);
mockGateway.getSession.mockResolvedValue(mockSession);
mockBuilder.awayFromAuthPage.mockReturnValue('/dashboard');
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockBuilder.awayFromAuthPage).toHaveBeenCalledWith({
session: mockSession,
currentPathname: '/login',
});
expect(mockRedirect).toHaveBeenCalledWith('/dashboard');
});
});
describe('protected, no session → login redirect', () => {
it('should redirect to login when accessing protected page without session', async () => {
// Arrange
const pathname = '/protected/dashboard';
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/protected/dashboard' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(null);
mockBuilder.toLogin.mockReturnValue('/login?redirect=/protected/dashboard');
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockBuilder.toLogin).toHaveBeenCalledWith({ currentPathname: '/protected/dashboard' });
expect(mockRedirect).toHaveBeenCalledWith('/login?redirect=/protected/dashboard');
});
});
describe('protected, wrong role → login', () => {
it('should redirect to login when user lacks required role', async () => {
// Arrange
const pathname = '/admin/panel';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/admin/panel' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(mockSession);
mockPolicy.requiredRoles.mockReturnValue(['admin']);
mockBuilder.toLogin.mockReturnValue('/login?redirect=/admin/panel');
// Act
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');
});
});
describe('protected, correct role → allow', () => {
it('should allow access when user has required role', async () => {
// Arrange
const pathname = '/admin/panel';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'admin', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/admin/panel' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(mockSession);
mockPolicy.requiredRoles.mockReturnValue(['admin']);
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/admin/panel');
expect(mockRedirect).not.toHaveBeenCalled();
});
it('should allow access when no specific roles required', async () => {
// Arrange
const pathname = '/dashboard';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/dashboard' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(mockSession);
mockPolicy.requiredRoles.mockReturnValue(null);
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/dashboard');
expect(mockRedirect).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,56 @@
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';
export class RouteGuard {
constructor(
private readonly interpreter: PathnameInterpreter,
private readonly policy: RouteAccessPolicy,
private readonly gateway: SessionGateway,
private readonly builder: AuthRedirectBuilder
) {}
async enforce({ pathname }: { pathname: string }): Promise<void> {
// Step 1: Interpret the pathname
const { logicalPathname } = this.interpreter.interpret(pathname);
// Step 2: Check if public non-auth page
if (this.policy.isPublic(logicalPathname) && !this.policy.isAuthPage(logicalPathname)) {
return; // Allow access
}
// Step 3: Handle auth pages
if (this.policy.isAuthPage(logicalPathname)) {
const session = await this.gateway.getSession();
if (session) {
// User is logged in, redirect away from auth page
const redirectPath = this.builder.awayFromAuthPage({ session, currentPathname: pathname });
redirect(redirectPath);
}
// No session, allow access to auth page
return;
}
// Step 4: Handle protected pages
const session = await this.gateway.getSession();
// No session, redirect to login
if (!session) {
const loginPath = this.builder.toLogin({ currentPathname: pathname });
redirect(loginPath);
}
// Check required roles
const reqRoles = this.policy.requiredRoles(logicalPathname);
if (reqRoles && session.user?.role && !reqRoles.includes(session.user.role)) {
const loginPath = this.builder.toLogin({ currentPathname: pathname });
redirect(loginPath);
}
// All checks passed, allow access
return;
}
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import { RoutePathBuilder } from './RoutePathBuilder';
describe('RoutePathBuilder', () => {
describe('constructor', () => {
it('should create an instance without errors', () => {
const builder = new RoutePathBuilder();
expect(builder).toBeInstanceOf(RoutePathBuilder);
});
});
describe('build()', () => {
it('should build simple route paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('auth.login');
expect(path).toBe('/auth/login');
});
it('should build protected route paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('protected.dashboard');
expect(path).toBe('/dashboard');
});
it('should build parameterized route paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('league.detail', { id: '123' });
expect(path).toBe('/leagues/123');
});
it('should build sponsor league detail paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('sponsor.leagueDetail', { id: '456' });
expect(path).toBe('/sponsor/leagues/456');
});
it('should build paths with locale prefix', () => {
const builder = new RoutePathBuilder();
const path = builder.build('auth.login', {}, { locale: 'de' });
expect(path).toBe('/de/auth/login');
});
it('should build parameterized paths with locale', () => {
const builder = new RoutePathBuilder();
const path = builder.build('league.detail', { id: '123' }, { locale: 'de' });
expect(path).toBe('/de/leagues/123');
});
it('should build paths with different locales', () => {
const builder = new RoutePathBuilder();
const pathEn = builder.build('public.home', {}, { locale: 'en' });
const pathDe = builder.build('public.home', {}, { locale: 'de' });
expect(pathEn).toBe('/en/');
expect(pathDe).toBe('/de/');
});
it('should build paths without locale when not provided', () => {
const builder = new RoutePathBuilder();
const path = builder.build('public.leagues');
expect(path).toBe('/leagues');
});
it('should throw error for unknown route ID', () => {
const builder = new RoutePathBuilder();
expect(() => builder.build('unknown.route')).toThrow('Unknown route: unknown.route');
});
it('should throw error when parameterized route missing params', () => {
const builder = new RoutePathBuilder();
expect(() => builder.build('league.detail')).toThrow('Route league.detail requires parameters');
});
it('should throw error when parameterized route missing required param', () => {
const builder = new RoutePathBuilder();
expect(() => builder.build('league.detail', {})).toThrow('Route league.detail requires parameters');
});
it('should handle all route categories', () => {
const builder = new RoutePathBuilder();
// Auth routes
expect(builder.build('auth.login')).toBe('/auth/login');
expect(builder.build('auth.signup')).toBe('/auth/signup');
// Public routes
expect(builder.build('public.home')).toBe('/');
expect(builder.build('public.leagues')).toBe('/leagues');
// Protected routes
expect(builder.build('protected.dashboard')).toBe('/dashboard');
expect(builder.build('protected.profile')).toBe('/profile');
// Sponsor routes
expect(builder.build('sponsor.dashboard')).toBe('/sponsor/dashboard');
// Admin routes
expect(builder.build('admin.users')).toBe('/admin/users');
// League routes
expect(builder.build('league.detail', { id: '789' })).toBe('/leagues/789');
// Race routes
expect(builder.build('race.detail', { id: '999' })).toBe('/races/999');
});
it('should handle locale with all route types', () => {
const builder = new RoutePathBuilder();
expect(builder.build('auth.login', {}, { locale: 'fr' })).toBe('/fr/auth/login');
expect(builder.build('public.leagues', {}, { locale: 'fr' })).toBe('/fr/leagues');
expect(builder.build('protected.dashboard', {}, { locale: 'fr' })).toBe('/fr/dashboard');
expect(builder.build('league.detail', { id: '123' }, { locale: 'fr' })).toBe('/fr/leagues/123');
});
});
});

View File

@@ -0,0 +1,45 @@
import { buildPath } from '../routing/RouteConfig';
/**
* RoutePathBuilder builds paths from route IDs with optional parameters and locale
*
* Usage:
* ```typescript
* const builder = new RoutePathBuilder();
*
* // Simple route
* builder.build('auth.login'); // → '/auth/login'
*
* // With parameters
* builder.build('league.detail', { id: '123' }); // → '/leagues/123'
*
* // With locale
* builder.build('auth.login', {}, { locale: 'de' }); // → '/de/auth/login'
*
* // With parameters and locale
* builder.build('league.detail', { id: '123' }, { locale: 'de' }); // → '/de/leagues/123'
* ```
*/
export class RoutePathBuilder {
/**
* Build a path from route ID with optional parameters and locale
* @param routeId - Route ID in format 'category.routeName'
* @param params - Optional parameters for parameterized routes
* @param options - Optional options including locale
* @returns Complete path with optional locale prefix
*/
build(
routeId: string,
params?: Record<string, string>,
options?: { locale?: string | null }
): string {
const path = buildPath(routeId, params);
// Add locale prefix if provided
if (options?.locale) {
return `/${options.locale}${path}`;
}
return path;
}
}

View File

@@ -0,0 +1,31 @@
import { RouteGuard } from './RouteGuard';
import { PathnameInterpreter } from './PathnameInterpreter';
import { RouteCatalog } from './RouteCatalog';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { ReturnToSanitizer } from './ReturnToSanitizer';
import { RoutePathBuilder } from './RoutePathBuilder';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import { SessionGateway } from '../gateways/SessionGateway';
/**
* Factory function to create a RouteGuard instance with all dependencies
*
* Usage:
* ```typescript
* const guard = createRouteGuard();
* await guard.enforce({ pathname: '/dashboard' });
* ```
*
* @returns RouteGuard instance configured with all required dependencies
*/
export function createRouteGuard(): RouteGuard {
const catalog = new RouteCatalog();
const interpreter = new PathnameInterpreter();
const policy = new RouteAccessPolicy(catalog);
const sanitizer = new ReturnToSanitizer();
const pathBuilder = new RoutePathBuilder();
const redirectBuilder = new AuthRedirectBuilder(policy, sanitizer, pathBuilder, interpreter);
const gateway = new SessionGateway();
return new RouteGuard(interpreter, policy, gateway, redirectBuilder);
}

View File

@@ -1,263 +0,0 @@
/**
* TDD Tests for AuthorizationBlocker
*
* These tests verify the authorization blocker logic following TDD principles.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthorizationBlocker } from './AuthorizationBlocker';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
// Mock SessionViewModel factory
function createMockSession(overrides: Partial<SessionViewModel> = {}): SessionViewModel {
const baseSession = {
isAuthenticated: true,
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
role: undefined,
};
// Handle the case where overrides might have a user object
// (for backward compatibility with existing test patterns)
if (overrides.user) {
const { user, ...rest } = overrides;
return {
...baseSession,
...rest,
userId: user.userId || baseSession.userId,
email: user.email || baseSession.email,
displayName: user.displayName || baseSession.displayName,
role: user.role,
};
}
return {
...baseSession,
...overrides,
};
}
describe('AuthorizationBlocker', () => {
describe('Session Management', () => {
it('should start with no session', () => {
const blocker = new AuthorizationBlocker([]);
expect(blocker.getReason()).toBe('unauthenticated');
expect(blocker.canExecute()).toBe(false);
});
it('should update session correctly', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.getReason()).toBe('enabled');
expect(blocker.canExecute()).toBe(true);
});
it('should handle null session', () => {
const blocker = new AuthorizationBlocker([]);
blocker.updateSession(null);
expect(blocker.getReason()).toBe('unauthenticated');
expect(blocker.canExecute()).toBe(false);
});
});
describe('Authentication State', () => {
it('should detect unauthenticated session', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession({ isAuthenticated: false });
blocker.updateSession(session);
expect(blocker.getReason()).toBe('unauthenticated');
expect(blocker.canExecute()).toBe(false);
});
it('should allow access for authenticated session', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession({ isAuthenticated: true });
blocker.updateSession(session);
expect(blocker.getReason()).toBe('enabled');
expect(blocker.canExecute()).toBe(true);
});
});
describe('Role Requirements', () => {
// Note: Current AuthorizationBlocker implementation always returns 'enabled' for authenticated users
// These tests document the intended behavior for when role system is fully implemented
it('should allow access when no roles required', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.getReason()).toBe('enabled');
expect(blocker.canExecute()).toBe(true);
});
it('should deny access when user lacks required role', () => {
const blocker = new AuthorizationBlocker(['admin']);
const session = createMockSession();
blocker.updateSession(session);
// Session has no role, so access is denied
expect(blocker.getReason()).toBe('unauthorized');
expect(blocker.canExecute()).toBe(false);
});
});
describe('Block and Release', () => {
it('should block access when requested', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.canExecute()).toBe(true);
blocker.block();
expect(blocker.canExecute()).toBe(false);
expect(blocker.getReason()).toBe('unauthenticated');
});
it('should release block (no-op in current implementation)', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession();
blocker.updateSession(session);
blocker.block();
// Release is a no-op in current implementation
blocker.release();
// Block state persists
expect(blocker.canExecute()).toBe(false);
});
});
describe('Block Messages', () => {
it('should provide message for unauthenticated user', () => {
const blocker = new AuthorizationBlocker([]);
const message = blocker.getBlockMessage();
expect(message).toBe('You must be logged in to access this area.');
});
it('should provide message for unauthorized user', () => {
const blocker = new AuthorizationBlocker(['admin']);
// Simulate unauthorized state by manually setting reason
// Note: This is a limitation of current implementation
// In a real implementation, this would be tested differently
// For now, we'll test the message generation logic
// by checking what it would return for different reasons
expect(true).toBe(true); // Placeholder
});
it('should provide message for insufficient role', () => {
const blocker = new AuthorizationBlocker(['admin', 'moderator']);
// Current implementation doesn't support this scenario
// but the message template exists
expect(blocker.getBlockMessage()).toContain('logged in');
});
it('should provide message for granted access', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.getBlockMessage()).toBe('Access granted');
});
});
describe('Edge Cases', () => {
it('should handle empty required roles array', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.canExecute()).toBe(true);
});
it('should handle undefined session properties', () => {
const blocker = new AuthorizationBlocker([]);
const session = {
isAuthenticated: true,
user: null as any,
} as SessionViewModel;
blocker.updateSession(session);
// Current implementation allows access
expect(blocker.canExecute()).toBe(true);
});
it('should handle multiple role updates', () => {
const blocker = new AuthorizationBlocker(['admin']);
// First session with admin role
const session1 = createMockSession({
user: {
userId: 'user-123',
email: 'admin@example.com',
displayName: 'Admin User',
role: 'admin',
},
});
blocker.updateSession(session1);
expect(blocker.canExecute()).toBe(true);
// Update with different session that lacks admin role
const session2 = createMockSession({
user: {
userId: 'user-456',
email: 'other@example.com',
displayName: 'Other User',
role: 'user',
},
});
blocker.updateSession(session2);
expect(blocker.canExecute()).toBe(false);
expect(blocker.getReason()).toBe('insufficient_role');
});
});
describe('Reason Codes', () => {
it('should return correct reason for unauthenticated', () => {
const blocker = new AuthorizationBlocker([]);
expect(blocker.getReason()).toBe('unauthenticated');
});
it('should return correct reason for enabled (authenticated)', () => {
const blocker = new AuthorizationBlocker([]);
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.getReason()).toBe('enabled');
});
it('should return correct reason for loading (handled by AuthContext)', () => {
// Loading state is handled by AuthContext, not AuthorizationBlocker
// This test documents that limitation
const blocker = new AuthorizationBlocker([]);
// AuthorizationBlocker doesn't have a loading state
// It relies on AuthContext to handle loading
expect(blocker.getReason()).toBe('unauthenticated');
});
});
});

View File

@@ -1,110 +0,0 @@
/**
* Blocker: AuthorizationBlocker
*
* Frontend blocker that prevents unauthorized access to admin features.
* This is a UX improvement, NOT a security mechanism.
* Security is enforced by backend Guards.
*/
import { Blocker } from './Blocker';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
export type AuthorizationBlockReason =
| 'loading' // User data not loaded yet
| 'unauthenticated' // User not logged in
| 'unauthorized' // User logged in but lacks required role
| 'insufficient_role' // User has role but not high enough
| 'enabled'; // Access granted
export class AuthorizationBlocker extends Blocker {
private currentSession: SessionViewModel | null = null;
private requiredRoles: string[] = [];
constructor(requiredRoles: string[]) {
super();
this.requiredRoles = requiredRoles;
}
/**
* Update the current session state
*/
updateSession(session: SessionViewModel | null): void {
this.currentSession = session;
}
/**
* Get the current block reason
*/
getReason(): AuthorizationBlockReason {
if (!this.currentSession) {
// Session is null - this means unauthenticated (not loading)
// Loading state is handled by AuthContext
return 'unauthenticated';
}
if (!this.currentSession.isAuthenticated) {
return 'unauthenticated';
}
// If no roles are required, allow access
if (this.requiredRoles.length === 0) {
return 'enabled';
}
// Check if user has a role
if (!this.currentSession.role) {
return 'unauthorized';
}
// Check if user's role matches any of the required roles
if (this.requiredRoles.includes(this.currentSession.role)) {
return 'enabled';
}
// User has a role but it's not in the required list
return 'insufficient_role';
}
/**
* Check if user can execute (access admin area)
*/
canExecute(): boolean {
const reason = this.getReason();
return reason === 'enabled';
}
/**
* Block access (for testing/demo purposes)
*/
block(): void {
// Simulate blocking by setting session to null
this.currentSession = null;
}
/**
* Release the block
*/
release(): void {
// No-op - blocking is state-based, not persistent
}
/**
* Get user-friendly message for block reason
*/
getBlockMessage(): string {
const reason = this.getReason();
switch (reason) {
case 'unauthenticated':
return 'You must be logged in to access this area.';
case 'unauthorized':
return 'You do not have permission to access this area.';
case 'insufficient_role':
return `Access requires one of: ${this.requiredRoles.join(', ')}`;
case 'enabled':
return 'Access granted';
default:
return 'Access denied';
}
}
}

View File

@@ -1,8 +0,0 @@
import { describe, it, expect } from 'vitest';
import { CapabilityBlocker } from './CapabilityBlocker';
describe('CapabilityBlocker', () => {
it('should be defined', () => {
expect(CapabilityBlocker).toBeDefined();
});
});

View File

@@ -1,66 +0,0 @@
import { Blocker } from './Blocker';
import type { PolicySnapshotDto } from '../api/policy/PolicyApiClient';
import { PolicyService } from '../services/policy/PolicyService';
export type CapabilityBlockReason = 'loading' | 'enabled' | 'coming_soon' | 'disabled' | 'hidden';
export class CapabilityBlocker extends Blocker {
private snapshot: PolicySnapshotDto | null = null;
constructor(
private readonly policyService: PolicyService,
private readonly capabilityKey: string,
) {
super();
}
updateSnapshot(snapshot: PolicySnapshotDto | null): void {
this.snapshot = snapshot;
}
canExecute(): boolean {
return this.getReason() === 'enabled';
}
getReason(): CapabilityBlockReason {
if (!this.snapshot) {
return 'loading';
}
return this.policyService.getCapabilityState(this.snapshot, this.capabilityKey);
}
block(): void {
this.snapshot = {
...(this.snapshot ?? {
policyVersion: 0,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date().toISOString(),
}),
capabilities: {
...(this.snapshot?.capabilities ?? {}),
[this.capabilityKey]: 'disabled',
},
};
}
release(): void {
this.snapshot = {
...(this.snapshot ?? {
policyVersion: 0,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date().toISOString(),
}),
capabilities: {
...(this.snapshot?.capabilities ?? {}),
[this.capabilityKey]: 'enabled',
},
};
}
}

View File

@@ -1,6 +1,8 @@
/**
* @file index.ts
* Blockers exports
*/
export { Blocker } from './Blocker';
export { CapabilityBlocker } from './CapabilityBlocker';
export { SubmitBlocker } from './SubmitBlocker';
export { ThrottleBlocker } from './ThrottleBlocker';
export { AuthorizationBlocker } from './AuthorizationBlocker';
export type { AuthorizationBlockReason } from './AuthorizationBlocker';
export { ThrottleBlocker } from './ThrottleBlocker';

View File

@@ -0,0 +1,49 @@
'use client';
import React, { createContext, useContext, useMemo, ReactNode } from 'react';
import { FeatureFlagContextType, MockFeatureFlagService, mockFeatureFlags } from './FeatureFlagService';
const FeatureFlagContext = createContext<FeatureFlagContextType>(mockFeatureFlags);
interface FeatureFlagProviderProps {
children: ReactNode;
flags?: string[];
}
/**
* Provider for feature flags on the client side
* Can be initialized with specific flags or defaults to mock implementation
*/
export function FeatureFlagProvider({ children, flags }: FeatureFlagProviderProps) {
const service = useMemo(() => {
if (flags) {
return new MockFeatureFlagService(flags);
}
return mockFeatureFlags;
}, [flags]);
return (
<FeatureFlagContext.Provider value={service}>
{children}
</FeatureFlagContext.Provider>
);
}
/**
* Hook to access feature flags in client components
*/
export function useFeatureFlags(): FeatureFlagContextType {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
}
/**
* Hook to check if a specific feature is enabled
*/
export function useFeatureFlag(flag: string): boolean {
const { isEnabled } = useFeatureFlags();
return isEnabled(flag);
}

View File

@@ -0,0 +1,70 @@
/**
* FeatureFlagService - Manages feature flags for both server and client
*
* Server: Reads from process.env.FEATURE_FLAGS (comma-separated)
* Client: Reads from session context or provides mock implementation
*/
// Server-side implementation
export class FeatureFlagService {
private flags: Set<string>;
constructor(flags?: string[]) {
if (flags) {
this.flags = new Set(flags);
} else {
// Parse from environment variable
const flagsEnv = process.env.FEATURE_FLAGS;
this.flags = flagsEnv
? new Set(flagsEnv.split(',').map(f => f.trim()))
: new Set();
}
}
/**
* Check if a feature flag is enabled
*/
isEnabled(flag: string): boolean {
return this.flags.has(flag);
}
/**
* Get all enabled flags
*/
getEnabledFlags(): string[] {
return Array.from(this.flags);
}
/**
* Factory method to create service with environment flags
*/
static fromEnv(): FeatureFlagService {
return new FeatureFlagService();
}
}
// Client-side context interface
export interface FeatureFlagContextType {
isEnabled: (flag: string) => boolean;
getEnabledFlags: () => string[];
}
// Mock implementation for client-side when no context is available
export class MockFeatureFlagService implements FeatureFlagContextType {
private flags: Set<string>;
constructor(flags: string[] = []) {
this.flags = new Set(flags);
}
isEnabled(flag: string): boolean {
return this.flags.has(flag);
}
getEnabledFlags(): string[] {
return Array.from(this.flags);
}
}
// Default mock instance for client-side usage
export const mockFeatureFlags = new MockFeatureFlagService(['alpha_features']);

View File

@@ -1,350 +0,0 @@
/**
* TDD Tests for AuthGateway
*
* These tests verify the authentication gateway logic following TDD principles:
* 1. Write failing tests first
* 2. Implement minimal code to pass
* 3. Refactor while keeping tests green
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthGateway, AuthGatewayConfig } from './AuthGateway';
import type { AuthContextValue } from '@/lib/auth/AuthContext';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
// Mock SessionViewModel factory
function createMockSession(overrides: Partial<SessionViewModel> = {}): SessionViewModel {
const baseSession = {
isAuthenticated: true,
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
role: undefined,
};
// Handle the case where overrides might have a user object
// (for backward compatibility with existing test patterns)
if (overrides.user) {
const { user, ...rest } = overrides;
return {
...baseSession,
...rest,
userId: user.userId || baseSession.userId,
email: user.email || baseSession.email,
displayName: user.displayName || baseSession.displayName,
role: user.role,
};
}
return {
...baseSession,
...overrides,
};
}
// Mock AuthContext factory
function createMockAuthContext(overrides: Partial<AuthContextValue> = {}): AuthContextValue {
return {
session: null,
loading: false,
login: async () => {},
logout: async () => {},
refreshSession: async () => {},
...overrides,
};
}
describe('AuthGateway', () => {
describe('Basic Authentication', () => {
it('should allow access when user is authenticated with no role requirements', () => {
const authContext = createMockAuthContext({
session: createMockSession(),
});
const gateway = new AuthGateway(authContext, {});
expect(gateway.canAccess()).toBe(true);
expect(gateway.isAuthenticated()).toBe(true);
expect(gateway.isLoading()).toBe(false);
});
it('should deny access when user is not authenticated', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {});
expect(gateway.canAccess()).toBe(false);
expect(gateway.isAuthenticated()).toBe(false);
expect(gateway.isLoading()).toBe(false);
});
it('should deny access when auth context is loading', () => {
const authContext = createMockAuthContext({
session: null,
loading: true,
});
const gateway = new AuthGateway(authContext, {});
expect(gateway.canAccess()).toBe(false);
expect(gateway.isLoading()).toBe(true);
});
});
describe('Role-Based Access Control', () => {
// Note: AuthorizationBlocker currently returns 'enabled' for all authenticated users
// in demo mode. These tests document the intended behavior for when role-based
// access control is fully implemented.
it('should allow access when user has required role', () => {
const authContext = createMockAuthContext({
session: createMockSession({
user: {
userId: 'user-123',
email: 'admin@example.com',
displayName: 'Admin User',
role: 'admin',
},
}),
});
const gateway = new AuthGateway(authContext, {
requiredRoles: ['admin'],
});
expect(gateway.canAccess()).toBe(true);
});
it('should deny access when user lacks required role', () => {
const authContext = createMockAuthContext({
session: createMockSession({
user: {
userId: 'user-123',
email: 'user@example.com',
displayName: 'Regular User',
role: 'user',
},
}),
});
const gateway = new AuthGateway(authContext, {
requiredRoles: ['admin'],
});
expect(gateway.canAccess()).toBe(false);
expect(gateway.getBlockMessage()).toContain('admin');
});
});
describe('Redirect Configuration', () => {
it('should use default redirect path when not specified', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {});
expect(gateway.getUnauthorizedRedirectPath()).toBe('/auth/login');
});
it('should use custom redirect path when specified', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {
unauthorizedRedirectPath: '/custom-login',
});
expect(gateway.getUnauthorizedRedirectPath()).toBe('/custom-login');
});
it('should respect redirectOnUnauthorized configuration', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {
redirectOnUnauthorized: false,
});
expect(gateway.redirectIfUnauthorized()).toBe(false);
});
it('should indicate redirect is needed when unauthorized and redirect enabled', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {
redirectOnUnauthorized: true,
});
expect(gateway.redirectIfUnauthorized()).toBe(true);
});
});
describe('Access State', () => {
it('should return complete access state', () => {
const authContext = createMockAuthContext({
session: createMockSession(),
});
const gateway = new AuthGateway(authContext, {});
const state = gateway.getAccessState();
expect(state).toEqual({
canAccess: true,
reason: 'Access granted',
isLoading: false,
isAuthenticated: true,
});
});
it('should return loading state correctly', () => {
const authContext = createMockAuthContext({
session: null,
loading: true,
});
const gateway = new AuthGateway(authContext, {});
const state = gateway.getAccessState();
expect(state.isLoading).toBe(true);
expect(state.canAccess).toBe(false);
});
});
describe('Session Refresh', () => {
it('should update access state after session refresh', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {});
expect(gateway.canAccess()).toBe(false);
// Simulate session refresh
authContext.session = createMockSession();
gateway.refresh();
expect(gateway.canAccess()).toBe(true);
expect(gateway.isAuthenticated()).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle undefined session gracefully', () => {
const authContext = createMockAuthContext({
session: undefined as any,
});
const gateway = new AuthGateway(authContext, {});
expect(gateway.canAccess()).toBe(false);
expect(gateway.isAuthenticated()).toBe(false);
});
it('should handle empty required roles array', () => {
const authContext = createMockAuthContext({
session: createMockSession(),
});
const gateway = new AuthGateway(authContext, {
requiredRoles: [],
});
expect(gateway.canAccess()).toBe(true);
});
it('should handle session with no user object', () => {
const authContext = createMockAuthContext({
session: {
isAuthenticated: true,
user: null as any,
},
});
const gateway = new AuthGateway(authContext, {});
expect(gateway.canAccess()).toBe(true); // Authenticated but no user
expect(gateway.isAuthenticated()).toBe(true);
});
it('should handle case sensitivity in role matching', () => {
const authContext = createMockAuthContext({
session: createMockSession({
user: {
userId: 'user-123',
email: 'admin@example.com',
displayName: 'Admin User',
role: 'ADMIN', // uppercase
},
}),
});
const gateway = new AuthGateway(authContext, {
requiredRoles: ['admin'], // lowercase
});
// Role matching is case-sensitive
expect(gateway.canAccess()).toBe(false);
expect(gateway.getBlockMessage()).toContain('admin');
});
});
describe('Error Handling', () => {
it('should throw error when enforceAccess is called without access', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {});
expect(() => gateway.enforceAccess()).toThrow('Access denied');
});
it('should not throw error when enforceAccess is called with access', () => {
const authContext = createMockAuthContext({
session: createMockSession(),
});
const gateway = new AuthGateway(authContext, {});
expect(() => gateway.enforceAccess()).not.toThrow();
});
});
describe('Block Messages', () => {
it('should provide appropriate block message for unauthenticated user', () => {
const authContext = createMockAuthContext({
session: null,
});
const gateway = new AuthGateway(authContext, {});
const message = gateway.getBlockMessage();
// Current behavior: AuthorizationBlocker returns "You must be logged in to access this area."
expect(message).toContain('logged in');
});
it('should provide appropriate block message for missing roles', () => {
const authContext = createMockAuthContext({
session: createMockSession({
user: {
userId: 'user-123',
email: 'user@example.com',
displayName: 'Regular User',
role: 'user',
},
}),
});
const gateway = new AuthGateway(authContext, {
requiredRoles: ['admin'],
});
const canAccess = gateway.canAccess();
const state = gateway.getAccessState();
expect(canAccess).toBe(false);
expect(state.reason).toContain('admin');
});
it('should provide appropriate block message when loading', () => {
const authContext = createMockAuthContext({
session: null,
loading: true,
});
const gateway = new AuthGateway(authContext, {});
const message = gateway.getBlockMessage();
// Current behavior: AuthorizationBlocker returns "You must be logged in to access this area."
expect(message).toContain('logged in');
});
});
});

View File

@@ -1,149 +0,0 @@
/**
* Gateway: AuthGateway
*
* Component-based gateway that manages authentication state and access control.
* Follows clean architecture by orchestrating between auth context and blockers.
*
* Gateways are the entry point for component-level access control.
* They coordinate between services, blockers, and the UI.
*/
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import type { AuthContextValue } from '@/lib/auth/AuthContext';
import { AuthorizationBlocker } from '@/lib/blockers/AuthorizationBlocker';
export interface AuthGatewayConfig {
/** Required roles for access (empty array = any authenticated user) */
requiredRoles?: string[];
/** Whether to redirect if unauthorized */
redirectOnUnauthorized?: boolean;
/** Redirect path if unauthorized */
unauthorizedRedirectPath?: string;
}
export class AuthGateway {
private blocker: AuthorizationBlocker;
private config: Required<AuthGatewayConfig>;
constructor(
private authContext: AuthContextValue,
config: AuthGatewayConfig = {}
) {
this.config = {
requiredRoles: config.requiredRoles || [],
redirectOnUnauthorized: config.redirectOnUnauthorized ?? true,
unauthorizedRedirectPath: config.unauthorizedRedirectPath || '/auth/login',
};
this.blocker = new AuthorizationBlocker(this.config.requiredRoles);
}
/**
* Check if current user has access
*/
canAccess(): boolean {
// Update blocker with current session
this.blocker.updateSession(this.authContext.session);
return this.blocker.canExecute();
}
/**
* Get the current access state
*/
getAccessState(): {
canAccess: boolean;
reason: string;
isLoading: boolean;
isAuthenticated: boolean;
} {
const reason = this.blocker.getReason();
return {
canAccess: this.canAccess(),
reason: this.blocker.getBlockMessage(),
// Only show loading if auth context is still loading
// If auth context is done but session is null, that's unauthenticated (not loading)
isLoading: this.authContext.loading,
isAuthenticated: this.authContext.session?.isAuthenticated ?? false,
};
}
/**
* Enforce access control - throws if access denied
* Used for programmatic access control
*/
enforceAccess(): void {
if (!this.canAccess()) {
const reason = this.blocker.getBlockMessage();
throw new Error(`Access denied: ${reason}`);
}
}
/**
* Redirect to unauthorized page if needed
* Returns true if redirect was performed
*/
redirectIfUnauthorized(): boolean {
if (this.canAccess()) {
return false;
}
if (this.config.redirectOnUnauthorized) {
// Note: We can't use router here since this is a pure class
// The component using this gateway should handle the redirect
return true;
}
return false;
}
/**
* Get redirect path for unauthorized access
*/
getUnauthorizedRedirectPath(): string {
return this.config.unauthorizedRedirectPath;
}
/**
* Refresh the gateway state (e.g., after login/logout)
*/
refresh(): void {
this.blocker.updateSession(this.authContext.session);
}
/**
* Check if user is loading
*/
isLoading(): boolean {
return this.authContext.loading;
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return this.authContext.session?.isAuthenticated ?? false;
}
/**
* Get current session
*/
getSession(): SessionViewModel | null {
return this.authContext.session;
}
/**
* Get block reason for debugging
*/
getBlockReason(): string {
return this.blocker.getReason();
}
/**
* Get user-friendly block message
*/
getBlockMessage(): string {
return this.blocker.getBlockMessage();
}
}

View File

@@ -1,644 +0,0 @@
/**
* TDD Tests for AuthGuard Component
*
* Tests authentication protection for React components
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthGuard, useAuthAccess } from './AuthGuard';
describe('AuthGuard', () => {
describe('Component Structure', () => {
it('should export AuthGuard component', () => {
expect(typeof AuthGuard).toBe('function');
});
it('should export useAuthAccess hook', () => {
expect(typeof useAuthAccess).toBe('function');
});
});
describe('Default Configuration', () => {
it('should use /auth/login as default redirect path', () => {
// The component should default to /auth/login when not authenticated
// This is verified by the default parameter in the component
const defaultProps = {
redirectPath: '/auth/login',
};
expect(defaultProps.redirectPath).toBe('/auth/login');
});
it('should accept custom redirect path', () => {
const customProps = {
redirectPath: '/custom-login',
};
expect(customProps.redirectPath).toBe('/custom-login');
});
});
describe('Authentication Requirements', () => {
it('should require authentication for any authenticated user', () => {
// AuthGuard uses empty requiredRoles array, meaning any authenticated user
const config = {
requiredRoles: [],
};
expect(config.requiredRoles).toEqual([]);
expect(config.requiredRoles.length).toBe(0);
});
it('should redirect on unauthorized access', () => {
const config = {
redirectOnUnauthorized: true,
unauthorizedRedirectPath: '/auth/login',
};
expect(config.redirectOnUnauthorized).toBe(true);
expect(config.unauthorizedRedirectPath).toBe('/auth/login');
});
});
describe('Component Props', () => {
it('should accept children prop', () => {
const props = {
children: 'mock-children',
};
expect(props.children).toBe('mock-children');
});
it('should accept optional loadingComponent', () => {
const props = {
children: 'mock-children',
loadingComponent: 'loading...',
};
expect(props.loadingComponent).toBe('loading...');
});
it('should accept optional unauthorizedComponent', () => {
const props = {
children: 'mock-children',
unauthorizedComponent: 'unauthorized',
};
expect(props.unauthorizedComponent).toBe('unauthorized');
});
});
describe('Integration with RouteGuard', () => {
it('should pass correct config to RouteGuard', () => {
const expectedConfig = {
requiredRoles: [],
redirectOnUnauthorized: true,
unauthorizedRedirectPath: '/auth/login',
};
expect(expectedConfig.requiredRoles).toEqual([]);
expect(expectedConfig.redirectOnUnauthorized).toBe(true);
expect(expectedConfig.unauthorizedRedirectPath).toBe('/auth/login');
});
it('should support custom redirect paths', () => {
const customPath = '/dashboard';
const config = {
requiredRoles: [],
redirectOnUnauthorized: true,
unauthorizedRedirectPath: customPath,
};
expect(config.unauthorizedRedirectPath).toBe('/dashboard');
});
});
describe('Hook Functionality', () => {
it('should export useRouteGuard as useAuthAccess', () => {
// This verifies the hook export is correct
expect(typeof useAuthAccess).toBe('function');
});
it('should provide authentication status', () => {
// The hook should return authentication status
// This is a structural test - actual implementation tested in RouteGuard
expect(useAuthAccess).toBeDefined();
});
});
describe('Security Requirements', () => {
it('should protect routes from unauthenticated access', () => {
const securityConfig = {
requiresAuth: true,
redirectIfUnauthenticated: true,
redirectPath: '/auth/login',
};
expect(securityConfig.requiresAuth).toBe(true);
expect(securityConfig.redirectIfUnauthenticated).toBe(true);
});
it('should not require specific roles', () => {
// AuthGuard is for any authenticated user, not role-specific
const config = {
requiredRoles: [],
};
expect(config.requiredRoles.length).toBe(0);
});
});
describe('Edge Cases', () => {
it('should handle empty children', () => {
const props = {
children: null,
};
expect(props.children).toBeNull();
});
it('should handle undefined optional props', () => {
const props = {
children: 'content',
loadingComponent: undefined,
unauthorizedComponent: undefined,
};
expect(props.loadingComponent).toBeUndefined();
expect(props.unauthorizedComponent).toBeUndefined();
});
it('should support multiple redirect paths', () => {
const paths = ['/auth/login', '/auth/signup', '/login'];
paths.forEach(path => {
expect(typeof path).toBe('string');
expect(path.startsWith('/')).toBe(true);
});
});
});
describe('Component Usage Patterns', () => {
it('should support nested children', () => {
const nestedStructure = {
parent: {
child: {
grandchild: 'content',
},
},
};
expect(nestedStructure.parent.child.grandchild).toBe('content');
});
it('should work with conditional rendering', () => {
const scenarios = [
{ authenticated: true, showContent: true },
{ authenticated: false, showContent: false },
];
scenarios.forEach(scenario => {
expect(typeof scenario.authenticated).toBe('boolean');
expect(typeof scenario.showContent).toBe('boolean');
});
});
});
describe('Performance Considerations', () => {
it('should not cause infinite re-renders', () => {
// Component should be stable
const renderCount = 1;
expect(renderCount).toBe(1);
});
it('should handle rapid authentication state changes', () => {
const states = [
{ loading: true, authenticated: false },
{ loading: false, authenticated: true },
{ loading: false, authenticated: false },
];
states.forEach(state => {
expect(typeof state.loading).toBe('boolean');
expect(typeof state.authenticated).toBe('boolean');
});
});
});
describe('Error Handling', () => {
it('should handle missing redirect path gracefully', () => {
const props = {
children: 'content',
// redirectPath uses default
};
expect(props.children).toBe('content');
// Default is applied in component definition
});
it('should handle invalid redirect paths', () => {
const invalidPaths = ['', null, undefined];
invalidPaths.forEach(path => {
// Component should handle these gracefully
if (path !== null && path !== undefined) {
expect(typeof path).toBe('string');
}
});
});
});
describe('Browser Compatibility', () => {
it('should work in client-side rendering', () => {
// Uses 'use client' directive
const isClientComponent = true;
expect(isClientComponent).toBe(true);
});
it('should handle window navigation', () => {
// Should support navigation to redirect paths
const redirectPath = '/auth/login';
expect(redirectPath.startsWith('/')).toBe(true);
});
});
describe('Accessibility', () => {
it('should support screen readers', () => {
// Component should be accessible
const accessible = true;
expect(accessible).toBe(true);
});
it('should handle keyboard navigation', () => {
// Should work with keyboard-only users
const keyboardFriendly = true;
expect(keyboardFriendly).toBe(true);
});
});
describe('Type Safety', () => {
it('should have correct TypeScript types', () => {
const props = {
children: 'mock-children',
redirectPath: '/auth/login',
loadingComponent: 'loading',
unauthorizedComponent: 'unauthorized',
};
expect(props.children).toBeDefined();
expect(props.redirectPath).toBeDefined();
});
it('should validate prop types', () => {
const validProps = {
children: 'content',
redirectPath: '/path',
};
expect(typeof validProps.children).toBe('string');
expect(typeof validProps.redirectPath).toBe('string');
});
});
});
describe('AuthGuard Integration Tests', () => {
describe('Complete Authentication Flow', () => {
it('should protect dashboard from unauthenticated users', () => {
const flow = {
unauthenticated: {
visits: '/dashboard',
action: 'redirect',
destination: '/auth/login',
},
};
expect(flow.unauthenticated.action).toBe('redirect');
expect(flow.unauthenticated.destination).toBe('/auth/login');
});
it('should allow authenticated users to access protected content', () => {
const flow = {
authenticated: {
visits: '/dashboard',
action: 'show',
content: 'dashboard-content',
},
};
expect(flow.authenticated.action).toBe('show');
expect(flow.authenticated.content).toBe('dashboard-content');
});
it('should redirect authenticated users from auth pages', () => {
const flow = {
authenticated: {
visits: '/auth/login',
action: 'redirect',
destination: '/dashboard',
},
};
expect(flow.authenticated.action).toBe('redirect');
expect(flow.authenticated.destination).toBe('/dashboard');
});
});
describe('Session Management', () => {
it('should handle session expiration', () => {
const session = {
active: true,
expired: false,
redirectOnExpiry: '/auth/login',
};
expect(session.redirectOnExpiry).toBe('/auth/login');
});
it('should handle remember me sessions', () => {
const session = {
type: 'remember-me',
duration: '30 days',
redirectPath: '/dashboard',
};
expect(session.duration).toBe('30 days');
expect(session.redirectPath).toBe('/dashboard');
});
});
describe('Role-Based Access (Future)', () => {
it('should support role-based restrictions', () => {
const config = {
requiredRoles: ['admin', 'moderator'],
};
expect(config.requiredRoles.length).toBeGreaterThan(0);
});
it('should handle multiple role requirements', () => {
const roles = ['user', 'admin', 'moderator'];
expect(roles.length).toBe(3);
});
});
});
describe('AuthGuard Security Tests', () => {
describe('Cross-Site Request Forgery Protection', () => {
it('should validate redirect paths', () => {
const safePaths = ['/dashboard', '/auth/login', '/profile'];
safePaths.forEach(path => {
expect(path.startsWith('/')).toBe(true);
expect(path.includes('://')).toBe(false);
});
});
it('should prevent open redirects', () => {
const maliciousPaths = [
'https://evil.com',
'//evil.com',
'/evil.com',
];
maliciousPaths.forEach(path => {
const isSafe = !path.includes('://') && !path.startsWith('//') && path.startsWith('/');
// Only /evil.com is considered safe (relative path)
// https://evil.com and //evil.com are unsafe
if (path === '/evil.com') {
expect(isSafe).toBe(true);
} else {
expect(isSafe).toBe(false);
}
});
});
});
describe('Authentication State Security', () => {
it('should verify authentication before allowing access', () => {
const securityCheck = {
requiresVerification: true,
checkBeforeRedirect: true,
};
expect(securityCheck.requiresVerification).toBe(true);
});
it('should handle token validation', () => {
const tokenValidation = {
required: true,
validateOnMount: true,
redirectIfInvalid: '/auth/login',
};
expect(tokenValidation.redirectIfInvalid).toBe('/auth/login');
});
});
describe('Data Protection', () => {
it('should not expose sensitive data in URL', () => {
const safeUrl = '/dashboard';
const unsafeUrl = '/dashboard?token=secret';
expect(safeUrl).not.toContain('token');
expect(unsafeUrl).toContain('token');
});
it('should use secure cookies', () => {
const cookieConfig = {
name: 'gp_session',
secure: true,
httpOnly: true,
sameSite: 'lax',
};
expect(cookieConfig.secure).toBe(true);
expect(cookieConfig.httpOnly).toBe(true);
});
});
});
describe('AuthGuard Performance Tests', () => {
describe('Rendering Performance', () => {
it('should render quickly', () => {
const renderTime = 50; // ms
expect(renderTime).toBeLessThan(100);
});
it('should minimize re-renders', () => {
const reRenderCount = 0;
expect(reRenderCount).toBe(0);
});
});
describe('Memory Management', () => {
it('should clean up event listeners', () => {
const cleanup = {
listeners: 0,
afterUnmount: 0,
};
expect(cleanup.listeners).toBe(cleanup.afterUnmount);
});
it('should handle large component trees', () => {
const treeSize = {
depth: 5,
branches: 10,
totalNodes: 15625, // 10^5
};
expect(treeSize.totalNodes).toBeGreaterThan(0);
});
});
});
describe('AuthGuard Edge Cases', () => {
describe('Network Issues', () => {
it('should handle offline mode', () => {
const networkState = {
online: false,
fallback: 'cached',
};
expect(networkState.online).toBe(false);
});
it('should handle slow connections', () => {
const connection = {
speed: 'slow',
timeout: 5000,
showLoading: true,
};
expect(connection.showLoading).toBe(true);
});
});
describe('Browser State', () => {
it('should handle tab switching', () => {
const tabState = {
active: true,
lastActive: Date.now(),
};
expect(tabState.active).toBe(true);
});
it('should handle page refresh', () => {
const refreshState = {
preserved: true,
sessionRestored: true,
};
expect(refreshState.preserved).toBe(true);
});
});
describe('User Actions', () => {
it('should handle logout during protected view', () => {
const logoutScenario = {
state: 'protected',
action: 'logout',
result: 'redirect',
destination: '/auth/login',
};
expect(logoutScenario.result).toBe('redirect');
});
it('should handle login during auth page view', () => {
const loginScenario = {
state: '/auth/login',
action: 'login',
result: 'redirect',
destination: '/dashboard',
};
expect(loginScenario.result).toBe('redirect');
});
});
});
describe('AuthGuard Compliance Tests', () => {
describe('GDPR Compliance', () => {
it('should handle consent requirements', () => {
const consent = {
required: true,
beforeAuth: true,
storage: 'cookies',
};
expect(consent.required).toBe(true);
});
it('should provide data access', () => {
const dataAccess = {
canExport: true,
canDelete: true,
transparent: true,
};
expect(dataAccess.canExport).toBe(true);
});
});
describe('Accessibility Standards', () => {
it('should meet WCAG 2.1 Level AA', () => {
const standards = {
colorContrast: true,
keyboardNav: true,
screenReader: true,
focusVisible: true,
};
expect(standards.screenReader).toBe(true);
});
it('should support reduced motion', () => {
const motion = {
respectPreference: true,
fallback: 'instant',
};
expect(motion.respectPreference).toBe(true);
});
});
describe('Security Standards', () => {
it('should prevent XSS attacks', () => {
const xssProtection = {
inputValidation: true,
outputEncoding: true,
csp: true,
};
expect(xssProtection.csp).toBe(true);
});
it('should prevent CSRF attacks', () => {
const csrfProtection = {
tokenValidation: true,
originCheck: true,
sameSite: true,
};
expect(csrfProtection.sameSite).toBe(true);
});
});
});
describe('AuthGuard Final Validation', () => {
it('should meet all user requirements', () => {
const requirements = {
loginForwarding: true,
authPageProtection: true,
rememberMe: true,
security: true,
performance: true,
accessibility: true,
};
Object.values(requirements).forEach(value => {
expect(value).toBe(true);
});
});
it('should be production-ready', () => {
const productionReady = {
tested: true,
documented: true,
secure: true,
performant: true,
accessible: true,
};
expect(productionReady.tested).toBe(true);
});
});

View File

@@ -1,8 +0,0 @@
import { describe, it, expect } from 'vitest';
import { AuthGuard } from './AuthGuard';
describe('AuthGuard', () => {
it('should be defined', () => {
expect(AuthGuard).toBeDefined();
});
});

View File

@@ -1,72 +0,0 @@
/**
* Component: AuthGuard
*
* Protects routes that require authentication but not specific roles.
* Uses the same Gateway pattern for consistency.
*/
'use client';
import { ReactNode } from 'react';
import { RouteGuard } from './RouteGuard';
interface AuthGuardProps {
children: ReactNode;
/**
* Path to redirect to if not authenticated
*/
redirectPath?: string;
/**
* Custom loading component (optional)
*/
loadingComponent?: ReactNode;
/**
* Custom unauthorized component (optional)
*/
unauthorizedComponent?: ReactNode;
}
/**
* AuthGuard Component
*
* Protects child components requiring authentication.
*
* Usage:
* ```tsx
* <AuthGuard>
* <ProtectedPage />
* </AuthGuard>
* ```
*/
export function AuthGuard({
children,
redirectPath = '/auth/login',
loadingComponent,
unauthorizedComponent,
}: AuthGuardProps) {
return (
<RouteGuard
config={{
requiredRoles: [], // Any authenticated user
redirectOnUnauthorized: true,
unauthorizedRedirectPath: redirectPath,
}}
loadingComponent={loadingComponent}
unauthorizedComponent={unauthorizedComponent}
>
{children}
</RouteGuard>
);
}
/**
* useAuth Hook
*
* Simplified hook for checking authentication status.
*
* Usage:
* ```tsx
* const { isAuthenticated, loading } = useAuth();
* ```
*/
export { useRouteGuard as useAuthAccess } from './RouteGuard';

View File

@@ -1,356 +0,0 @@
/**
* TDD Tests for RouteGuard Component
*
* These tests verify the RouteGuard component logic following TDD principles.
* Note: These are integration tests that verify the component behavior.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { RouteGuard } from './RouteGuard';
import { useAuth } from '@/lib/auth/AuthContext';
import { useRouter } from 'next/navigation';
import type { AuthContextValue } from '@/lib/auth/AuthContext';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
// Mock dependencies
vi.mock('@/lib/auth/AuthContext');
vi.mock('next/navigation');
// Mock SessionViewModel factory
function createMockSession(overrides: Partial<SessionViewModel> = {}): SessionViewModel {
const baseSession = {
isAuthenticated: true,
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
role: undefined,
};
// Handle the case where overrides might have a user object
// (for backward compatibility with existing test patterns)
if (overrides.user) {
const { user, ...rest } = overrides;
return {
...baseSession,
...rest,
userId: user.userId || baseSession.userId,
email: user.email || baseSession.email,
displayName: user.displayName || baseSession.displayName,
role: user.role,
};
}
return {
...baseSession,
...overrides,
};
}
// Mock AuthContext factory
function createMockAuthContext(overrides: Partial<AuthContextValue> = {}): AuthContextValue {
return {
session: null,
loading: false,
login: vi.fn(),
logout: vi.fn(),
refreshSession: vi.fn(),
...overrides,
};
}
describe('RouteGuard', () => {
const mockUseAuth = vi.mocked(useAuth);
const mockUseRouter = vi.mocked(useRouter);
let mockRouter: { push: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRouter = { push: vi.fn() };
mockUseRouter.mockReturnValue(mockRouter as any);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Authentication State', () => {
it('should render children when user is authenticated', async () => {
const mockAuthContext = createMockAuthContext({
session: createMockSession(),
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});
});
it('should show loading state when auth context is loading', () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: true,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
// Should show loading state, not children
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
it('should redirect when user is not authenticated', async () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
});
});
});
describe('Custom Configuration', () => {
it('should use custom redirect path when specified', async () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard config={{ unauthorizedRedirectPath: '/custom-login' }}>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/custom-login');
});
});
it('should not redirect when redirectOnUnauthorized is false', async () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard config={{ redirectOnUnauthorized: false }}>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
// Wait for any potential redirects
await new Promise(resolve => setTimeout(resolve, 100));
expect(mockRouter.push).not.toHaveBeenCalled();
});
it('should show unauthorized component when redirect is disabled', async () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
const unauthorizedComponent = <div data-testid="unauthorized">Access Denied</div>;
render(
<RouteGuard
config={{ redirectOnUnauthorized: false }}
unauthorizedComponent={unauthorizedComponent}
>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(screen.getByTestId('unauthorized')).toBeInTheDocument();
});
});
});
describe('Custom Loading Component', () => {
it('should show custom loading component when specified', () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: true,
});
mockUseAuth.mockReturnValue(mockAuthContext);
const loadingComponent = <div data-testid="custom-loading">Custom Loading...</div>;
render(
<RouteGuard loadingComponent={loadingComponent}>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
expect(screen.getByTestId('custom-loading')).toBeInTheDocument();
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
});
describe('Role-Based Access', () => {
it('should allow access when user has required role', async () => {
const mockAuthContext = createMockAuthContext({
session: createMockSession({
user: {
userId: 'user-123',
email: 'admin@example.com',
displayName: 'Admin User',
role: 'admin',
},
}),
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard config={{ requiredRoles: ['admin'] }}>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});
});
it('should redirect when user lacks required role', async () => {
const mockAuthContext = createMockAuthContext({
session: createMockSession({
user: {
userId: 'user-123',
email: 'user@example.com',
displayName: 'Regular User',
role: 'user',
},
}),
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard config={{ requiredRoles: ['admin'] }}>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
});
});
});
describe('Edge Cases', () => {
it('should handle undefined session gracefully', async () => {
const mockAuthContext = createMockAuthContext({
session: undefined as any,
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
});
});
it('should handle empty required roles array', async () => {
const mockAuthContext = createMockAuthContext({
session: createMockSession(),
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard config={{ requiredRoles: [] }}>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});
});
it('should handle rapid session state changes', async () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: true,
});
mockUseAuth.mockReturnValue(mockAuthContext);
const { rerender } = render(
<RouteGuard>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
// Simulate session becoming available
mockAuthContext.session = createMockSession();
mockAuthContext.loading = false;
rerender(
<RouteGuard>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
await waitFor(() => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});
});
});
describe('Redirect Timing', () => {
it('should wait before redirecting (500ms delay)', async () => {
const mockAuthContext = createMockAuthContext({
session: null,
loading: false,
});
mockUseAuth.mockReturnValue(mockAuthContext);
render(
<RouteGuard>
<div data-testid="protected-content">Protected Content</div>
</RouteGuard>
);
// Should not redirect immediately
expect(mockRouter.push).not.toHaveBeenCalled();
// Wait for the delay
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
}, { timeout: 1000 });
});
});
});

View File

@@ -1,153 +0,0 @@
/**
* Component: RouteGuard
*
* Higher-order component that protects routes using Gateways and Blockers.
* Follows clean architecture by separating concerns:
* - Gateway handles access logic
* - Blocker handles prevention logic
* - Component handles UI rendering
*/
'use client';
import { ReactNode, useEffect, useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth/AuthContext';
import { AuthGateway, AuthGatewayConfig } from './AuthGateway';
import { LoadingState } from '@/components/shared/LoadingState';
interface RouteGuardProps {
children: ReactNode;
config?: AuthGatewayConfig;
/**
* Custom loading component (optional)
*/
loadingComponent?: ReactNode;
/**
* Custom unauthorized component (optional)
*/
unauthorizedComponent?: ReactNode;
}
/**
* RouteGuard Component
*
* Protects child components based on authentication and authorization rules.
* Uses Gateway pattern for access control.
*
* Usage:
* ```tsx
* <RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
* <AdminDashboard />
* </RouteGuard>
* ```
*/
export function RouteGuard({
children,
config = {},
loadingComponent,
unauthorizedComponent,
}: RouteGuardProps) {
const router = useRouter();
const authContext = useAuth();
const [gateway] = useState(() => new AuthGateway(authContext, config));
const [isChecking, setIsChecking] = useState(true);
// Calculate access state
const accessState = useMemo(() => {
gateway.refresh();
return {
canAccess: gateway.canAccess(),
reason: gateway.getBlockMessage(),
redirectPath: gateway.getUnauthorizedRedirectPath(),
};
}, [authContext.session, authContext.loading, gateway]);
// Handle the loading state and redirects
useEffect(() => {
// If we're loading, stay in checking state
if (authContext.loading) {
setIsChecking(true);
return;
}
// Done loading, can exit checking state
setIsChecking(false);
// If we can't access and should redirect, do it
if (!accessState.canAccess && config.redirectOnUnauthorized !== false) {
const timer = setTimeout(() => {
router.push(accessState.redirectPath);
}, 500);
return () => clearTimeout(timer);
}
}, [authContext.loading, accessState.canAccess, accessState.redirectPath, config.redirectOnUnauthorized, router]);
// Show loading state
if (isChecking || authContext.loading) {
return loadingComponent || (
<div className="flex items-center justify-center min-h-screen">
<LoadingState message="Verifying authentication..." className="min-h-screen" />
</div>
);
}
// Show unauthorized state (only if not redirecting)
if (!accessState.canAccess && config.redirectOnUnauthorized === false) {
return unauthorizedComponent || (
<div className="flex items-center justify-center min-h-screen">
<div className="bg-iron-gray p-8 rounded-lg border border-charcoal-outline max-w-md text-center">
<h2 className="text-xl font-bold text-racing-red mb-4">Access Denied</h2>
<p className="text-gray-300 mb-6">{accessState.reason}</p>
<button
onClick={() => router.push('/auth/login')}
className="px-4 py-2 bg-primary-blue text-white rounded hover:bg-blue-600 transition-colors"
>
Go to Login
</button>
</div>
</div>
);
}
// Show redirecting state
if (!accessState.canAccess && config.redirectOnUnauthorized !== false) {
// Don't show a message, just redirect silently
// The redirect happens in the useEffect above
return null;
}
// Render protected content
return <>{children}</>;
}
/**
* useRouteGuard Hook
*
* Hook for programmatic access control within components.
*
* Usage:
* ```tsx
* const { canAccess, reason, isLoading } = useRouteGuard({ requiredRoles: ['admin'] });
* ```
*/
export function useRouteGuard(config: AuthGatewayConfig = {}) {
const authContext = useAuth();
const [gateway] = useState(() => new AuthGateway(authContext, config));
const [state, setState] = useState(gateway.getAccessState());
useEffect(() => {
gateway.refresh();
setState(gateway.getAccessState());
}, [authContext.session, authContext.loading, gateway]);
return {
canAccess: state.canAccess,
reason: state.reason,
isLoading: state.isLoading,
isAuthenticated: state.isAuthenticated,
enforceAccess: () => gateway.enforceAccess(),
redirectIfUnauthorized: () => gateway.redirectIfUnauthorized(),
};
}

View File

@@ -0,0 +1,150 @@
/**
* SessionGateway tests
*
* TDD: All tests mock cookies() from 'next/headers' and global.fetch
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionGateway } from './SessionGateway';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
// Mock next/headers
vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));
// Mock global.fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('SessionGateway', () => {
let gateway: SessionGateway;
let mockCookies: ReturnType<typeof vi.mocked>;
beforeEach(async () => {
vi.clearAllMocks();
const nextHeaders = await import('next/headers');
mockCookies = vi.mocked(nextHeaders.cookies);
gateway = new SessionGateway();
});
describe('getSession()', () => {
it('should return null when no cookies are present', async () => {
// Arrange
mockCookies.mockReturnValue({
toString: () => '',
} as any);
// Act
const result = await gateway.getSession();
// Assert
expect(result).toBeNull();
expect(mockFetch).not.toHaveBeenCalled();
});
it('should return session object when valid gp_session cookie exists', async () => {
// Arrange
const mockSession: AuthSessionDTO = {
token: 'valid-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
role: 'driver',
},
};
mockCookies.mockReturnValue({
toString: () => 'gp_session=valid-token; other=value',
} as any);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockSession,
} as Response);
// Act
const result = await gateway.getSession();
// Assert
expect(result).toEqual(mockSession);
expect(mockFetch).toHaveBeenCalledWith('/api/auth/session', {
headers: { cookie: 'gp_session=valid-token; other=value' },
cache: 'no-store',
});
});
it('should return null when session is invalid or expired', async () => {
// Arrange
mockCookies.mockReturnValue({
toString: () => 'gp_session=expired-token',
} as any);
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
} as Response);
// Act
const result = await gateway.getSession();
// Assert
expect(result).toBeNull();
expect(mockFetch).toHaveBeenCalled();
});
it('should return null on non-2xx response', async () => {
// Arrange
mockCookies.mockReturnValue({
toString: () => 'gp_session=some-token',
} as any);
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response);
// Act
const result = await gateway.getSession();
// Assert
expect(result).toBeNull();
expect(mockFetch).toHaveBeenCalled();
});
it('should return null on network error', async () => {
// Arrange
mockCookies.mockReturnValue({
toString: () => 'gp_session=some-token',
} as any);
mockFetch.mockRejectedValueOnce(new Error('Network error'));
// Act
const result = await gateway.getSession();
// Assert
expect(result).toBeNull();
expect(mockFetch).toHaveBeenCalled();
});
it('should return null when fetch throws any error', async () => {
// Arrange
mockCookies.mockReturnValue({
toString: () => 'gp_session=some-token',
} as any);
mockFetch.mockRejectedValueOnce(new Error('Connection timeout'));
// Act
const result = await gateway.getSession();
// Assert
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* SessionGateway - Server-side session management
*
* Fetches session data from the API using server cookies.
* Designed for 'use server' contexts.
*/
import { cookies } from 'next/headers';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
/**
* SessionGateway class for server-side session management
*
* Uses Next.js server cookies and fetches session from API
* Returns null on any error or non-2xx response (no throws)
*/
export class SessionGateway {
/**
* Get current authentication session
*
* @returns Promise<AuthSessionDTO | null> - Session object or null if not authenticated/error
*/
async getSession(): Promise<AuthSessionDTO | null> {
try {
// Get cookies from the current request
const cookieStore = await cookies();
const cookieString = cookieStore.toString();
// If no cookies, return null immediately
if (!cookieString) {
return null;
}
// Determine API base URL
// In Docker/test: use API_BASE_URL env var or direct API URL
// In production: use relative path which will be rewritten
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3101';
const apiUrl = `${baseUrl}/auth/session`;
// Fetch session from API with cookies forwarded
// Use credentials: 'include' to ensure cookies are sent
const response = await fetch(apiUrl, {
headers: {
cookie: cookieString,
},
cache: 'no-store',
credentials: 'include',
});
// Return null for non-2xx responses
if (!response.ok) {
return null;
}
// Parse and return session data
const session = await response.json();
return session as AuthSessionDTO;
} catch (error) {
// Return null on any error (network, parsing, etc.)
return null;
}
}
}

View File

@@ -1,8 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('gateways index', () => {
it('should export gateways', async () => {
const module = await import('./index');
expect(Object.keys(module).length).toBeGreaterThan(0);
});
});

View File

@@ -1,13 +0,0 @@
/**
* Gateways - Component-based access control
*
* Follows clean architecture by separating concerns:
* - Blockers: Prevent execution (frontend UX)
* - Gateways: Orchestrate access control
* - Guards: Enforce security (backend)
*/
export { AuthGateway } from './AuthGateway';
export type { AuthGatewayConfig } from './AuthGateway';
export { RouteGuard, useRouteGuard } from './RouteGuard';
export { AuthGuard, useAuthAccess } from './AuthGuard';

View File

@@ -427,9 +427,9 @@ export class ApiRequestLogger {
/**
* Create a logged fetch function
*/
createLoggedFetch(): typeof window.fetch {
createLoggedFetch(): typeof fetch {
const logger = this;
const originalFetch = window.fetch;
const originalFetch = typeof window !== 'undefined' ? window.fetch : fetch;
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const startTime = performance.now();
@@ -507,7 +507,7 @@ export function initializeApiLogger(options?: ApiRequestLoggerOptions): ApiReque
/**
* Fetch interceptor that automatically logs all requests
*/
export function createLoggedFetch(originalFetch: typeof window.fetch = window.fetch): typeof window.fetch {
export function createLoggedFetch(originalFetch: typeof fetch = typeof window !== 'undefined' ? window.fetch : fetch): typeof fetch {
const logger = getGlobalApiLogger();
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {

View File

@@ -70,6 +70,14 @@ export class GlobalErrorHandler {
return;
}
// Only initialize in browser environment
if (typeof window === 'undefined') {
if (this.options.verboseLogging) {
this.logger.info('Global error handler skipped (server-side)');
}
return;
}
// Handle uncaught JavaScript errors
window.addEventListener('error', this.handleWindowError);
@@ -454,12 +462,14 @@ export class GlobalErrorHandler {
* Destroy the error handler and remove all listeners
*/
destroy(): void {
window.removeEventListener('error', this.handleWindowError);
window.removeEventListener('unhandledrejection', this.handleUnhandledRejection);
// Restore original console.error
if ((console as any)._originalError) {
console.error = (console as any)._originalError;
if (typeof window !== 'undefined') {
window.removeEventListener('error', this.handleWindowError);
window.removeEventListener('unhandledrejection', this.handleUnhandledRejection);
// Restore original console.error
if ((console as any)._originalError) {
console.error = (console as any)._originalError;
}
}
this.isInitialized = false;

View File

@@ -74,6 +74,9 @@ export function getPublicRoutes(): readonly string[] {
'/leaderboards',
'/races',
// Sponsor signup (publicly accessible)
'/sponsor/signup',
// Auth routes
'/api/signup',
'/api/auth/signup',
@@ -87,9 +90,6 @@ export function getPublicRoutes(): readonly string[] {
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/iracing',
'/auth/iracing/start',
'/auth/iracing/callback',
] as const;
}

View File

@@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import { routes, routeMatchers, buildPath } from './RouteConfig';
describe('RouteConfig', () => {
describe('routes', () => {
it('should have all expected route categories', () => {
expect(routes.auth).toBeDefined();
expect(routes.public).toBeDefined();
expect(routes.protected).toBeDefined();
expect(routes.sponsor).toBeDefined();
expect(routes.admin).toBeDefined();
expect(routes.league).toBeDefined();
});
it('should have correct route paths', () => {
expect(routes.protected.dashboard).toBe('/dashboard');
expect(routes.auth.login).toBe('/auth/login');
expect(routes.admin.root).toBe('/admin');
expect(routes.public.leagues).toBe('/leagues');
});
it('should have parameterized route functions', () => {
expect(routes.league.detail('123')).toBe('/leagues/123');
expect(routes.sponsor.leagueDetail('456')).toBe('/sponsor/leagues/456');
expect(routes.race.detail('789')).toBe('/races/789');
});
});
describe('routeMatchers.matches()', () => {
it('should match exact paths', () => {
expect(routeMatchers.matches('/dashboard', '/dashboard')).toBe(true);
expect(routeMatchers.matches('/dashboard', '/admin')).toBe(false);
});
it('should match wildcard patterns', () => {
expect(routeMatchers.matches('/admin/users', '/admin/*')).toBe(true);
expect(routeMatchers.matches('/admin', '/admin/*')).toBe(true);
expect(routeMatchers.matches('/dashboard', '/admin/*')).toBe(false);
});
it('should match parameterized patterns', () => {
expect(routeMatchers.matches('/leagues/123', '/leagues/[id]')).toBe(true);
expect(routeMatchers.matches('/leagues/123/settings', '/leagues/[id]/settings')).toBe(true);
expect(routeMatchers.matches('/leagues/abc', '/leagues/[id]')).toBe(true);
});
});
describe('routeMatchers.isInGroup()', () => {
it('should identify admin routes', () => {
expect(routeMatchers.isInGroup('/admin', 'admin')).toBe(true);
expect(routeMatchers.isInGroup('/admin/users', 'admin')).toBe(true);
expect(routeMatchers.isInGroup('/dashboard', 'admin')).toBe(false);
});
it('should identify sponsor routes', () => {
expect(routeMatchers.isInGroup('/sponsor/dashboard', 'sponsor')).toBe(true);
expect(routeMatchers.isInGroup('/sponsor/billing', 'sponsor')).toBe(true);
expect(routeMatchers.isInGroup('/dashboard', 'sponsor')).toBe(false);
});
it('should identify public routes', () => {
expect(routeMatchers.isInGroup('/leagues', 'public')).toBe(true);
expect(routeMatchers.isInGroup('/', 'public')).toBe(true);
// Note: /dashboard starts with / which is in public, but this is expected behavior
// The actual route matching uses more specific logic
});
});
describe('routeMatchers.isPublic()', () => {
it('should return true for public routes', () => {
expect(routeMatchers.isPublic('/')).toBe(true);
expect(routeMatchers.isPublic('/leagues')).toBe(true);
expect(routeMatchers.isPublic('/auth/login')).toBe(true);
expect(routeMatchers.isPublic('/404')).toBe(true);
});
it('should return false for protected routes', () => {
expect(routeMatchers.isPublic('/dashboard')).toBe(false);
expect(routeMatchers.isPublic('/admin')).toBe(false);
expect(routeMatchers.isPublic('/sponsor/dashboard')).toBe(false);
});
});
describe('routeMatchers.requiresAuth()', () => {
it('should return true for protected routes', () => {
expect(routeMatchers.requiresAuth('/dashboard')).toBe(true);
expect(routeMatchers.requiresAuth('/admin')).toBe(true);
expect(routeMatchers.requiresAuth('/sponsor/dashboard')).toBe(true);
});
it('should return false for public routes', () => {
expect(routeMatchers.requiresAuth('/')).toBe(false);
expect(routeMatchers.requiresAuth('/leagues')).toBe(false);
expect(routeMatchers.requiresAuth('/auth/login')).toBe(false);
});
});
describe('routeMatchers.requiresRole()', () => {
it('should return admin roles for admin routes', () => {
const roles = routeMatchers.requiresRole('/admin');
expect(roles).toContain('admin');
expect(roles).toContain('owner');
});
it('should return sponsor roles for sponsor routes', () => {
const roles = routeMatchers.requiresRole('/sponsor/dashboard');
expect(roles).toEqual(['sponsor']);
});
it('should return null for routes without role requirements', () => {
expect(routeMatchers.requiresRole('/dashboard')).toBeNull();
expect(routeMatchers.requiresRole('/leagues')).toBeNull();
});
});
describe('buildPath()', () => {
it('should build simple paths', () => {
const path = buildPath('protected.dashboard');
expect(path).toBe('/dashboard');
});
it('should build parameterized paths', () => {
const path = buildPath('league.detail', { id: '123' });
expect(path).toBe('/leagues/123');
});
it('should build sponsor league paths', () => {
const path = buildPath('sponsor.leagueDetail', { id: '456' });
expect(path).toBe('/sponsor/leagues/456');
});
it('should throw on unknown routes', () => {
expect(() => buildPath('unknown.route')).toThrow('Unknown route: unknown.route');
});
it('should throw when parameterized route missing params', () => {
expect(() => buildPath('league.detail', {})).toThrow('Route league.detail requires parameters');
});
});
describe('Route configuration integrity', () => {
it('all public routes should be accessible without auth', () => {
const publicRoutes = routeMatchers.getPublicPatterns();
expect(publicRoutes.length).toBeGreaterThan(0);
publicRoutes.forEach(route => {
expect(routeMatchers.isPublic(route)).toBe(true);
expect(routeMatchers.requiresAuth(route)).toBe(false);
});
});
it('all admin routes should require admin role', () => {
const adminPaths = ['/admin', '/admin/users'];
adminPaths.forEach(path => {
expect(routeMatchers.isInGroup(path, 'admin')).toBe(true);
const roles = routeMatchers.requiresRole(path);
expect(roles).toContain('admin');
});
});
it('all sponsor routes should require sponsor role', () => {
const sponsorPaths = ['/sponsor/dashboard', '/sponsor/billing'];
sponsorPaths.forEach(path => {
expect(routeMatchers.isInGroup(path, 'sponsor')).toBe(true);
const roles = routeMatchers.requiresRole(path);
expect(roles).toEqual(['sponsor']);
});
});
});
});

View File

@@ -0,0 +1,328 @@
/**
* @file RouteConfig.ts
* Centralized routing configuration for clean, maintainable paths
*
* Design Principles:
* - Single source of truth for all routes
* - i18n-ready: paths can be localized
* - Type-safe: compile-time checking
* - Easy to refactor: change in one place
* - Environment-specific: can vary by mode
*/
export interface RouteDefinition {
path: string;
name: string;
description?: string;
}
export interface RouteGroup {
auth: {
login: string;
signup: string;
forgotPassword: string;
resetPassword: string;
};
public: {
home: string;
leagues: string;
drivers: string;
teams: string;
leaderboards: string;
races: string;
sponsorSignup: string;
};
protected: {
dashboard: string;
onboarding: string;
profile: string;
profileSettings: string;
profileLeagues: string;
profileLiveries: string;
profileLiveryUpload: string;
profileSponsorshipRequests: string;
};
sponsor: {
root: string;
dashboard: string;
billing: string;
campaigns: string;
leagues: string;
leagueDetail: (id: string) => string;
settings: string;
};
admin: {
root: string;
users: string;
};
league: {
detail: (id: string) => string;
rosterAdmin: (id: string) => string;
rulebook: (id: string) => string;
schedule: (id: string) => string;
scheduleAdmin: (id: string) => string;
settings: (id: string) => string;
sponsorships: (id: string) => string;
standings: (id: string) => string;
stewarding: (id: string) => string;
wallet: (id: string) => string;
create: string;
};
race: {
root: string;
all: string;
detail: (id: string) => string;
results: (id: string) => string;
stewarding: (id: string) => string;
};
team: {
root: string;
leaderboard: string;
detail: (id: string) => string;
};
driver: {
root: string;
detail: (id: string) => string;
};
error: {
notFound: string;
serverError: string;
};
}
/**
* Route configuration with i18n support
*
* Usage:
* ```typescript
* import { routes } from '@/lib/routing/RouteConfig';
*
* // Navigate to login
* router.push(routes.auth.login);
*
* // Navigate to league detail
* router.push(routes.league.detail('league-123'));
*
* // Check if current path is protected
* if (currentPath.startsWith(routes.protected.dashboard)) {
* // Handle protected route
* }
* ```
*/
export const routes: RouteGroup = {
auth: {
login: '/auth/login',
signup: '/auth/signup',
forgotPassword: '/auth/forgot-password',
resetPassword: '/auth/reset-password',
},
public: {
home: '/',
leagues: '/leagues',
drivers: '/drivers',
teams: '/teams',
leaderboards: '/leaderboards',
races: '/races',
sponsorSignup: '/sponsor/signup',
},
protected: {
dashboard: '/dashboard',
onboarding: '/onboarding',
profile: '/profile',
profileSettings: '/profile/settings',
profileLeagues: '/profile/leagues',
profileLiveries: '/profile/liveries',
profileLiveryUpload: '/profile/liveries/upload',
profileSponsorshipRequests: '/profile/sponsorship-requests',
},
sponsor: {
root: '/sponsor',
dashboard: '/sponsor/dashboard',
billing: '/sponsor/billing',
campaigns: '/sponsor/campaigns',
leagues: '/sponsor/leagues',
leagueDetail: (id: string) => `/sponsor/leagues/${id}`,
settings: '/sponsor/settings',
},
admin: {
root: '/admin',
users: '/admin/users',
},
league: {
detail: (id: string) => `/leagues/${id}`,
rosterAdmin: (id: string) => `/leagues/${id}/roster/admin`,
rulebook: (id: string) => `/leagues/${id}/rulebook`,
schedule: (id: string) => `/leagues/${id}/schedule`,
scheduleAdmin: (id: string) => `/leagues/${id}/schedule/admin`,
settings: (id: string) => `/leagues/${id}/settings`,
sponsorships: (id: string) => `/leagues/${id}/sponsorships`,
standings: (id: string) => `/leagues/${id}/standings`,
stewarding: (id: string) => `/leagues/${id}/stewarding`,
wallet: (id: string) => `/leagues/${id}/wallet`,
create: '/leagues/create',
},
race: {
root: '/races',
all: '/races/all',
detail: (id: string) => `/races/${id}`,
results: (id: string) => `/races/${id}/results`,
stewarding: (id: string) => `/races/${id}/stewarding`,
},
team: {
root: '/teams',
leaderboard: '/teams/leaderboard',
detail: (id: string) => `/teams/${id}`,
},
driver: {
root: '/drivers',
detail: (id: string) => `/drivers/${id}`,
},
error: {
notFound: '/404',
serverError: '/500',
},
};
/**
* Route matcher utilities for pattern matching
*/
export const routeMatchers = {
/**
* Check if path matches a pattern
*/
matches(path: string, pattern: string): boolean {
// Exact match
if (pattern === path) return true;
// Wildcard match (starts with)
if (pattern.endsWith('/*') && path.startsWith(pattern.slice(0, -2))) {
return true;
}
// Parameterized match (e.g., /leagues/[id])
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
return regex.test(path);
},
/**
* Check if path is in a route group
*/
isInGroup(path: string, group: keyof RouteGroup): boolean {
const groupRoutes = routes[group];
// Handle nested objects (like sponsor.leagueDetail)
const values = Object.values(groupRoutes);
return values.some(value => {
if (typeof value === 'function') {
// For parameterized routes, check pattern
const pattern = value('placeholder');
return path.startsWith(pattern.replace('/placeholder', ''));
}
return path.startsWith(value as string);
});
},
/**
* Get all public route patterns
*/
getPublicPatterns(): string[] {
return [
routes.public.home,
routes.public.leagues,
routes.public.drivers,
routes.public.teams,
routes.public.leaderboards,
routes.public.races,
routes.public.sponsorSignup,
routes.auth.login,
routes.auth.signup,
routes.auth.forgotPassword,
routes.auth.resetPassword,
routes.error.notFound,
routes.error.serverError,
];
},
/**
* Check if path is public
*/
isPublic(path: string): boolean {
const publicPatterns = this.getPublicPatterns();
// Check exact matches
if (publicPatterns.includes(path)) return true;
// Check parameterized patterns
return publicPatterns.some(pattern => {
if (pattern.includes('[')) {
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
return regex.test(path);
}
return false;
});
},
/**
* Check if path requires authentication
*/
requiresAuth(path: string): boolean {
return !this.isPublic(path);
},
/**
* Check if path requires specific role
*/
requiresRole(path: string): string[] | null {
if (this.isInGroup(path, 'admin')) {
return ['owner', 'admin'];
}
if (this.isInGroup(path, 'sponsor')) {
return ['sponsor'];
}
return null;
},
};
/**
* i18n-ready path builder
*
* Usage:
* ```typescript
* // With current locale
* const path = buildPath('leagueDetail', { id: '123' });
*
* // With specific locale
* const path = buildPath('leagueDetail', { id: '123' }, 'de');
* ```
*/
export function buildPath(
routeName: string,
params: Record<string, string> = {},
locale?: string
): string {
// This is a placeholder for future i18n implementation
// For now, it just builds the path using the route config
const parts = routeName.split('.');
let route: any = routes;
for (const part of parts) {
route = route[part];
if (!route) {
throw new Error(`Unknown route: ${routeName}`);
}
}
if (typeof route === 'function') {
const paramKeys = Object.keys(params);
if (paramKeys.length === 0) {
throw new Error(`Route ${routeName} requires parameters`);
}
return route(params[paramKeys[0]]);
}
return route as string;
}

View File

@@ -12,7 +12,6 @@ describe('AuthService', () => {
signup: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
getIracingAuthUrl: vi.fn(),
} as Mocked<AuthApiClient>;
service = new AuthService(mockApiClient);
@@ -118,29 +117,4 @@ describe('AuthService', () => {
await expect(service.logout()).rejects.toThrow('Logout failed');
});
});
describe('getIracingAuthUrl', () => {
it('should call apiClient.getIracingAuthUrl with returnTo', () => {
const returnTo = '/dashboard';
const expectedUrl = 'https://api.example.com/auth/iracing/start?returnTo=%2Fdashboard';
mockApiClient.getIracingAuthUrl.mockReturnValue(expectedUrl);
const result = service.getIracingAuthUrl(returnTo);
expect(mockApiClient.getIracingAuthUrl).toHaveBeenCalledWith(returnTo);
expect(result).toBe(expectedUrl);
});
it('should call apiClient.getIracingAuthUrl without returnTo', () => {
const expectedUrl = 'https://api.example.com/auth/iracing/start';
mockApiClient.getIracingAuthUrl.mockReturnValue(expectedUrl);
const result = service.getIracingAuthUrl();
expect(mockApiClient.getIracingAuthUrl).toHaveBeenCalledWith(undefined);
expect(result).toBe(expectedUrl);
});
});
});

View File

@@ -2,7 +2,6 @@ import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
import type { LoginParamsDTO } from '../../types/generated/LoginParamsDTO';
import type { SignupParamsDTO } from '../../types/generated/SignupParamsDTO';
import type { LoginWithIracingCallbackParamsDTO } from '../../types/generated/LoginWithIracingCallbackParamsDTO';
import type { ForgotPasswordDTO } from '../../types/generated/ForgotPasswordDTO';
import type { ResetPasswordDTO } from '../../types/generated/ResetPasswordDTO';
import type { DemoLoginDTO } from '../../types/generated/DemoLoginDTO';
@@ -53,25 +52,6 @@ export class AuthService {
}
}
/**
* Get iRacing authentication URL
*/
getIracingAuthUrl(returnTo?: string): string {
return this.apiClient.getIracingAuthUrl(returnTo);
}
/**
* Login with iRacing callback
*/
async loginWithIracingCallback(params: LoginWithIracingCallbackParamsDTO): Promise<SessionViewModel> {
try {
const dto = await this.apiClient.loginWithIracingCallback(params);
return new SessionViewModel(dto.user);
} catch (error) {
throw error;
}
}
/**
* Forgot password - send reset link
*/
@@ -105,4 +85,4 @@ export class AuthService {
throw error;
}
}
}
}

View File

@@ -1,84 +1,21 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getAppMode, isPublicRoute } from './lib/mode';
import { NextResponse } from 'next/server';
/**
* Next.js middleware for route protection
*
* Features:
* - Public routes are always accessible
* - Protected routes require authentication
* - Demo mode allows access to all routes
* - Returns 401 for unauthenticated access to protected routes
* Minimal middleware that only sets x-pathname header
* All auth/role/demo logic has been removed
*/
export function middleware(request: NextRequest) {
const mode = getAppMode();
const { pathname } = request.nextUrl;
// Always allow Next.js error routes (needed for build/prerender)
if (pathname === '/404' || pathname === '/500' || pathname === '/_error') {
return NextResponse.next();
}
// Always allow static assets and API routes (API handles its own auth)
// Also allow /media/ routes which are proxied to the API
if (
pathname.startsWith('/_next/') ||
pathname.startsWith('/api/') ||
pathname.startsWith('/media/') ||
pathname.match(/\.(svg|png|jpg|jpeg|gif|webp|ico|css|js)$/)
) {
return NextResponse.next();
}
// Check for authentication cookie
const cookies = request.cookies;
const hasAuthCookie = cookies.has('gp_session');
// Public routes are always accessible
if (isPublicRoute(pathname)) {
// Special handling for auth routes - redirect authenticated users away
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/iracing',
'/auth/iracing/start',
'/auth/iracing/callback',
];
if (authRoutes.includes(pathname) && hasAuthCookie) {
// User is authenticated and trying to access auth page, redirect to dashboard
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
// In demo/alpha mode, allow access if session cookie exists
if (mode === 'alpha' && hasAuthCookie) {
return NextResponse.next();
}
// In demo/alpha mode without auth, redirect to login
if (mode === 'alpha' && !hasAuthCookie) {
const loginUrl = new URL('/auth/login', request.url);
loginUrl.searchParams.set('returnTo', pathname);
return NextResponse.redirect(loginUrl);
}
// In pre-launch mode, only public routes are accessible
// Protected routes return 404 (non-disclosure)
return new NextResponse(null, {
status: 404,
statusText: 'Not Found',
});
const response = NextResponse.next();
response.headers.set('x-pathname', pathname);
return response;
}
/**
* Configure which routes the middleware should run on
* Excludes Next.js internal routes and static assets
*/
export const config = {
matcher: [
@@ -86,8 +23,9 @@ export const config = {
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - _next/data (Next.js data requests)
* - favicon.ico (favicon file)
* - public folder files
* - Files with extensions (static assets)
*/
'/((?!_next/static|_next/image|_next/data|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|mp4|webm|mov|avi)$).*)',
],