From 167e82a52be4b8a3d41051a74e39c1a87dc3155e Mon Sep 17 00:00:00 2001
From: Marc Mintel
Date: Wed, 31 Dec 2025 19:55:43 +0100
Subject: [PATCH] auth
---
.../InMemoryMagicLinkRepository.test.ts | 140 +++++
.../inmemory/InMemoryMagicLinkRepository.ts | 118 ++++
.../entities/PasswordResetRequestOrmEntity.ts | 31 +
.../typeorm/mappers/UserOrmMapper.ts | 6 +-
.../TypeOrmMagicLinkRepository.ts | 130 ++++
.../ConsoleMagicLinkNotificationAdapter.ts | 31 +
.../typeorm/entities/DriverStatsOrmEntity.ts | 46 ++
.../typeorm/mappers/DriverStatsOrmMapper.ts | 65 ++
.../TypeOrmDriverStatsRepository.ts | 45 ++
.../development/use-cases/DemoLoginUseCase.ts | 122 ++++
apps/api/src/domain/auth/AuthController.ts | 22 +-
apps/api/src/domain/auth/AuthProviders.ts | 81 +++
.../src/domain/auth/AuthService.new.test.ts | 248 ++++++++
apps/api/src/domain/auth/AuthService.test.ts | 54 ++
apps/api/src/domain/auth/AuthService.ts | 125 +++-
apps/api/src/domain/auth/ProductionGuard.ts | 18 +
apps/api/src/domain/auth/dtos/AuthDto.ts | 29 +
.../auth/presenters/AuthSessionPresenter.ts | 4 +
.../auth/presenters/DemoLoginPresenter.ts | 23 +
.../presenters/ForgotPasswordPresenter.ts | 23 +
.../auth/presenters/ResetPasswordPresenter.ts | 23 +
.../domain/dashboard/DashboardProviders.ts | 16 +
.../domain/dashboard/DashboardService.test.ts | 3 +
.../src/domain/dashboard/DashboardService.ts | 200 ++++++-
.../identity/IdentityPersistenceTokens.ts | 3 +-
.../InMemoryIdentityPersistenceModule.ts | 11 +-
.../PostgresIdentityPersistenceModule.ts | 13 +-
.../PostgresRacingPersistenceModule.ts | 19 +-
.../website/app/auth/forgot-password/page.tsx | 235 ++++++++
apps/website/app/auth/login/page.tsx | 40 +-
apps/website/app/auth/reset-password/page.tsx | 356 +++++++++++
apps/website/app/auth/signup/page.tsx | 128 ++--
apps/website/app/layout.tsx | 24 +-
apps/website/components/dev/DevToolbar.tsx | 213 +++++--
apps/website/components/profile/UserPill.tsx | 209 ++++++-
apps/website/lib/api/auth/AuthApiClient.ts | 18 +
apps/website/lib/mode.ts | 19 +-
apps/website/lib/services/auth/AuthService.ts | 37 ++
.../types/generated/AuthenticatedUserDTO.ts | 2 +
.../lib/types/generated/DemoLoginDTO.ts | 3 +
.../lib/types/generated/ForgotPasswordDTO.ts | 3 +
.../lib/types/generated/ResetPasswordDTO.ts | 4 +
.../lib/view-models/SessionViewModel.ts | 8 +-
apps/website/middleware.ts | 48 +-
apps/website/next.config.mjs | 7 +-
.../use-cases/ForgotPasswordUseCase.test.ts | 236 ++++++++
.../use-cases/ForgotPasswordUseCase.ts | 132 +++++
.../GetCurrentSessionUseCase.test.ts | 1 -
.../use-cases/GetUserUseCase.test.ts | 1 -
.../use-cases/LoginWithEmailUseCase.test.ts | 19 +-
.../use-cases/LoginWithEmailUseCase.ts | 27 +-
.../use-cases/ResetPasswordUseCase.test.ts | 239 ++++++++
.../use-cases/ResetPasswordUseCase.ts | 143 +++++
.../application/use-cases/SignupUseCase.ts | 34 +-
.../use-cases/SignupWithEmailUseCase.test.ts | 1 -
.../use-cases/SignupWithEmailUseCase.ts | 41 +-
core/identity/domain/entities/User.ts | 73 ++-
.../ports/IMagicLinkNotificationPort.ts | 20 +
.../repositories/IMagicLinkRepository.ts | 37 ++
.../domain/repositories/IUserRepository.ts | 3 +-
core/identity/index.ts | 6 +
docker-compose.dev.yml | 6 +-
docs/MESSAGING.md | 247 ++++++++
docs/OBSERVABILITY.md | 199 +++++++
plans/auth-finalization-plan.md | 560 ++++++++++++++++++
plans/auth-finalization-summary.md | 324 ++++++++++
66 files changed, 5124 insertions(+), 228 deletions(-)
create mode 100644 adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.test.ts
create mode 100644 adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.ts
create mode 100644 adapters/identity/persistence/typeorm/entities/PasswordResetRequestOrmEntity.ts
create mode 100644 adapters/identity/persistence/typeorm/repositories/TypeOrmMagicLinkRepository.ts
create mode 100644 adapters/notifications/ports/ConsoleMagicLinkNotificationAdapter.ts
create mode 100644 adapters/racing/persistence/typeorm/entities/DriverStatsOrmEntity.ts
create mode 100644 adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts
create mode 100644 adapters/racing/persistence/typeorm/repositories/TypeOrmDriverStatsRepository.ts
create mode 100644 apps/api/src/development/use-cases/DemoLoginUseCase.ts
create mode 100644 apps/api/src/domain/auth/AuthService.new.test.ts
create mode 100644 apps/api/src/domain/auth/ProductionGuard.ts
create mode 100644 apps/api/src/domain/auth/presenters/DemoLoginPresenter.ts
create mode 100644 apps/api/src/domain/auth/presenters/ForgotPasswordPresenter.ts
create mode 100644 apps/api/src/domain/auth/presenters/ResetPasswordPresenter.ts
create mode 100644 apps/website/app/auth/forgot-password/page.tsx
create mode 100644 apps/website/app/auth/reset-password/page.tsx
create mode 100644 apps/website/lib/types/generated/DemoLoginDTO.ts
create mode 100644 apps/website/lib/types/generated/ForgotPasswordDTO.ts
create mode 100644 apps/website/lib/types/generated/ResetPasswordDTO.ts
create mode 100644 core/identity/application/use-cases/ForgotPasswordUseCase.test.ts
create mode 100644 core/identity/application/use-cases/ForgotPasswordUseCase.ts
create mode 100644 core/identity/application/use-cases/ResetPasswordUseCase.test.ts
create mode 100644 core/identity/application/use-cases/ResetPasswordUseCase.ts
create mode 100644 core/identity/domain/ports/IMagicLinkNotificationPort.ts
create mode 100644 core/identity/domain/repositories/IMagicLinkRepository.ts
create mode 100644 docs/MESSAGING.md
create mode 100644 docs/OBSERVABILITY.md
create mode 100644 plans/auth-finalization-plan.md
create mode 100644 plans/auth-finalization-summary.md
diff --git a/adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.test.ts b/adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.test.ts
new file mode 100644
index 000000000..85bfd27cb
--- /dev/null
+++ b/adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { InMemoryMagicLinkRepository } from './InMemoryMagicLinkRepository';
+
+const mockLogger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+describe('InMemoryMagicLinkRepository', () => {
+ let repository: InMemoryMagicLinkRepository;
+
+ beforeEach(() => {
+ repository = new InMemoryMagicLinkRepository(mockLogger as any);
+ });
+
+ describe('createPasswordResetRequest', () => {
+ it('should create a password reset request', async () => {
+ const request = {
+ email: 'test@example.com',
+ token: 'abc123',
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000),
+ userId: 'user-123',
+ };
+
+ await repository.createPasswordResetRequest(request);
+
+ const found = await repository.findByToken('abc123');
+ expect(found).toEqual(request);
+ });
+
+ it('should enforce rate limiting', async () => {
+ const request = {
+ email: 'test@example.com',
+ token: 'token1',
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000),
+ userId: 'user-123',
+ };
+
+ // Create 3 requests for same email
+ await repository.createPasswordResetRequest({ ...request, token: 'token1' });
+ await repository.createPasswordResetRequest({ ...request, token: 'token2' });
+ await repository.createPasswordResetRequest({ ...request, token: 'token3' });
+
+ // 4th should fail
+ const result = await repository.checkRateLimit('test@example.com');
+ expect(result.isErr()).toBe(true);
+ });
+
+ it('should allow requests after time window expires', async () => {
+ const now = Date.now();
+ const request = {
+ email: 'test@example.com',
+ token: 'token1',
+ expiresAt: new Date(now + 15 * 60 * 1000),
+ userId: 'user-123',
+ };
+
+ // Mock Date.now to return time after rate limit window
+ const originalNow = Date.now;
+ Date.now = () => now + (16 * 60 * 1000); // 16 minutes later
+
+ try {
+ await repository.createPasswordResetRequest(request);
+ const found = await repository.findByToken('token1');
+ expect(found).toBeDefined();
+ } finally {
+ Date.now = originalNow;
+ }
+ });
+ });
+
+ describe('findByToken', () => {
+ it('should find existing token', async () => {
+ const request = {
+ email: 'test@example.com',
+ token: 'abc123',
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000),
+ userId: 'user-123',
+ };
+
+ await repository.createPasswordResetRequest(request);
+ const found = await repository.findByToken('abc123');
+
+ expect(found).toEqual(request);
+ });
+
+ it('should return null for non-existent token', async () => {
+ const found = await repository.findByToken('nonexistent');
+ expect(found).toBeNull();
+ });
+ });
+
+ describe('markAsUsed', () => {
+ it('should mark token as used', async () => {
+ const request = {
+ email: 'test@example.com',
+ token: 'abc123',
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000),
+ userId: 'user-123',
+ };
+
+ await repository.createPasswordResetRequest(request);
+ await repository.markAsUsed('abc123');
+
+ const found = await repository.findByToken('abc123');
+ expect(found).toBeNull();
+ });
+
+ it('should handle non-existent token gracefully', async () => {
+ await expect(repository.markAsUsed('nonexistent')).resolves.not.toThrow();
+ });
+ });
+
+ describe('checkRateLimit', () => {
+ it('should allow requests under limit', async () => {
+ const result = await repository.checkRateLimit('test@example.com');
+ expect(result.isOk()).toBe(true);
+ });
+
+ it('should reject requests over limit', async () => {
+ const email = 'test@example.com';
+ const request = {
+ email,
+ token: 'token',
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000),
+ userId: 'user-123',
+ };
+
+ // Create 3 requests
+ await repository.createPasswordResetRequest({ ...request, token: 'token1' });
+ await repository.createPasswordResetRequest({ ...request, token: 'token2' });
+ await repository.createPasswordResetRequest({ ...request, token: 'token3' });
+
+ const result = await repository.checkRateLimit(email);
+ expect(result.isErr()).toBe(true);
+ });
+ });
+});
\ No newline at end of file
diff --git a/adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.ts b/adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.ts
new file mode 100644
index 000000000..e48a44654
--- /dev/null
+++ b/adapters/identity/persistence/inmemory/InMemoryMagicLinkRepository.ts
@@ -0,0 +1,118 @@
+import { IMagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/IMagicLinkRepository';
+import { Result } from '@core/shared/application/Result';
+import { Logger } from '@core/shared/application';
+
+export class InMemoryMagicLinkRepository implements IMagicLinkRepository {
+ private resetRequests: Map = new Map();
+ private rateLimitStore: Map = new Map();
+
+ // Rate limit: max 3 requests per 15 minutes
+ private readonly RATE_LIMIT_MAX = 3;
+ private readonly RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
+
+ constructor(private readonly logger: Logger) {}
+
+ async createPasswordResetRequest(request: PasswordResetRequest): Promise {
+ this.logger.debug('[InMemoryMagicLinkRepository] Creating password reset request', {
+ email: request.email,
+ token: request.token.substring(0, 10) + '...',
+ expiresAt: request.expiresAt,
+ });
+
+ this.resetRequests.set(request.token, request);
+
+ // Update rate limit tracking
+ const now = new Date();
+ const existing = this.rateLimitStore.get(request.email);
+
+ if (existing) {
+ // Reset count if window has passed
+ if (now.getTime() - existing.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
+ this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
+ } else {
+ this.rateLimitStore.set(request.email, {
+ count: existing.count + 1,
+ lastRequest: now
+ });
+ }
+ } else {
+ this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
+ }
+ }
+
+ async findByToken(token: string): Promise {
+ const request = this.resetRequests.get(token);
+
+ if (!request) {
+ return null;
+ }
+
+ // Check if expired
+ if (request.expiresAt < new Date()) {
+ this.resetRequests.delete(token);
+ return null;
+ }
+
+ // Check if already used
+ if (request.used) {
+ return null;
+ }
+
+ return request;
+ }
+
+ async markAsUsed(token: string): Promise {
+ const request = this.resetRequests.get(token);
+ if (request) {
+ request.used = true;
+ this.logger.debug('[InMemoryMagicLinkRepository] Marked token as used', {
+ token: token.substring(0, 10) + '...',
+ });
+ }
+ }
+
+ async checkRateLimit(email: string): Promise> {
+ const now = new Date();
+ const tracking = this.rateLimitStore.get(email);
+
+ if (!tracking) {
+ return Result.ok(undefined);
+ }
+
+ // Check if window has passed
+ if (now.getTime() - tracking.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
+ return Result.ok(undefined);
+ }
+
+ // Check if exceeded limit
+ if (tracking.count >= this.RATE_LIMIT_MAX) {
+ const timeRemaining = Math.ceil(
+ (this.RATE_LIMIT_WINDOW - (now.getTime() - tracking.lastRequest.getTime())) / 60000
+ );
+ return Result.err({
+ message: `Too many reset attempts. Please try again in ${timeRemaining} minutes.`,
+ });
+ }
+
+ return Result.ok(undefined);
+ }
+
+ async cleanupExpired(): Promise {
+ const now = new Date();
+ const toDelete: string[] = [];
+
+ for (const [token, request] of this.resetRequests.entries()) {
+ if (request.expiresAt < now || request.used) {
+ toDelete.push(token);
+ }
+ }
+
+ toDelete.forEach(token => this.resetRequests.delete(token));
+
+ if (toDelete.length > 0) {
+ this.logger.debug('[InMemoryMagicLinkRepository] Cleaned up expired tokens', {
+ count: toDelete.length,
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/adapters/identity/persistence/typeorm/entities/PasswordResetRequestOrmEntity.ts b/adapters/identity/persistence/typeorm/entities/PasswordResetRequestOrmEntity.ts
new file mode 100644
index 000000000..f9a0d9840
--- /dev/null
+++ b/adapters/identity/persistence/typeorm/entities/PasswordResetRequestOrmEntity.ts
@@ -0,0 +1,31 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+
+@Entity('password_reset_requests')
+export class PasswordResetRequestOrmEntity {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string;
+
+ @Column()
+ email!: string;
+
+ @Column({ unique: true })
+ token!: string;
+
+ @Column()
+ expiresAt!: Date;
+
+ @Column()
+ userId!: string;
+
+ @Column({ default: false })
+ used!: boolean;
+
+ @Column({ default: 0 })
+ attemptCount!: number;
+
+ @CreateDateColumn()
+ createdAt!: Date;
+
+ @UpdateDateColumn()
+ updatedAt!: Date;
+}
\ No newline at end of file
diff --git a/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts
index f7260edf1..592ccf21a 100644
--- a/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts
+++ b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts
@@ -15,7 +15,7 @@ export class UserOrmMapper {
assertNonEmptyString(entityName, 'email', entity.email);
assertNonEmptyString(entityName, 'displayName', entity.displayName);
assertNonEmptyString(entityName, 'passwordHash', entity.passwordHash);
- assertNonEmptyString(entityName, 'salt', entity.salt);
+ assertOptionalStringOrNull(entityName, 'salt', entity.salt);
assertOptionalStringOrNull(entityName, 'primaryDriverId', entity.primaryDriverId);
assertDate(entityName, 'createdAt', entity.createdAt);
} catch (error) {
@@ -48,7 +48,7 @@ export class UserOrmMapper {
entity.email = stored.email;
entity.displayName = stored.displayName;
entity.passwordHash = stored.passwordHash;
- entity.salt = stored.salt;
+ entity.salt = stored.salt ?? '';
entity.primaryDriverId = stored.primaryDriverId ?? null;
entity.createdAt = stored.createdAt;
return entity;
@@ -60,7 +60,7 @@ export class UserOrmMapper {
email: entity.email,
displayName: entity.displayName,
passwordHash: entity.passwordHash,
- salt: entity.salt,
+ ...(entity.salt ? { salt: entity.salt } : {}),
primaryDriverId: entity.primaryDriverId ?? undefined,
createdAt: entity.createdAt,
};
diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmMagicLinkRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmMagicLinkRepository.ts
new file mode 100644
index 000000000..cf6e9f3c1
--- /dev/null
+++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmMagicLinkRepository.ts
@@ -0,0 +1,130 @@
+import type { DataSource } from 'typeorm';
+import { IMagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/IMagicLinkRepository';
+import { Result } from '@core/shared/application/Result';
+import { Logger } from '@core/shared/application';
+import { PasswordResetRequestOrmEntity } from '../entities/PasswordResetRequestOrmEntity';
+
+export class TypeOrmMagicLinkRepository implements IMagicLinkRepository {
+ // Rate limit: max 3 requests per 15 minutes
+ private readonly RATE_LIMIT_MAX = 3;
+ private readonly RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
+
+ constructor(
+ private readonly dataSource: DataSource,
+ private readonly logger: Logger,
+ ) {}
+
+ async createPasswordResetRequest(request: PasswordResetRequest): Promise {
+ this.logger.debug('[TypeOrmMagicLinkRepository] Creating password reset request', {
+ email: request.email,
+ token: request.token.substring(0, 10) + '...',
+ expiresAt: request.expiresAt,
+ });
+
+ const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
+
+ const entity = new PasswordResetRequestOrmEntity();
+ entity.email = request.email;
+ entity.token = request.token;
+ entity.expiresAt = request.expiresAt;
+ entity.userId = request.userId;
+ entity.used = false;
+ entity.attemptCount = 0;
+
+ await repo.save(entity);
+ }
+
+ async findByToken(token: string): Promise {
+ const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
+
+ const entity = await repo.findOne({ where: { token } });
+
+ if (!entity) {
+ return null;
+ }
+
+ // Check if expired
+ if (entity.expiresAt < new Date()) {
+ await repo.delete(entity.id);
+ return null;
+ }
+
+ // Check if already used
+ if (entity.used) {
+ return null;
+ }
+
+ return {
+ email: entity.email,
+ token: entity.token,
+ expiresAt: entity.expiresAt,
+ userId: entity.userId,
+ used: entity.used,
+ };
+ }
+
+ async markAsUsed(token: string): Promise {
+ const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
+
+ await repo.update(
+ { token },
+ {
+ used: true,
+ attemptCount: () => 'attemptCount + 1'
+ }
+ );
+
+ this.logger.debug('[TypeOrmMagicLinkRepository] Marked token as used', {
+ token: token.substring(0, 10) + '...',
+ });
+ }
+
+ async checkRateLimit(email: string): Promise> {
+ const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
+ const now = new Date();
+ const windowStart = new Date(now.getTime() - this.RATE_LIMIT_WINDOW);
+
+ // Count requests in the current window
+ const recentRequests = await repo.count({
+ where: {
+ email,
+ createdAt: windowStart,
+ used: false,
+ },
+ });
+
+ if (recentRequests >= this.RATE_LIMIT_MAX) {
+ // Find the oldest request to calculate remaining time
+ const oldestRequest = await repo.findOne({
+ where: { email },
+ order: { createdAt: 'ASC' },
+ });
+
+ if (oldestRequest) {
+ const timeRemaining = Math.ceil(
+ (this.RATE_LIMIT_WINDOW - (now.getTime() - oldestRequest.createdAt.getTime())) / 60000
+ );
+ return Result.err({
+ message: `Too many reset attempts. Please try again in ${timeRemaining} minutes.`,
+ });
+ }
+ }
+
+ return Result.ok(undefined);
+ }
+
+ async cleanupExpired(): Promise {
+ const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
+ const now = new Date();
+
+ const result = await repo.delete({
+ expiresAt: now,
+ });
+
+ if (result.affected && result.affected > 0) {
+ this.logger.debug('[TypeOrmMagicLinkRepository] Cleaned up expired tokens', {
+ count: result.affected,
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/adapters/notifications/ports/ConsoleMagicLinkNotificationAdapter.ts b/adapters/notifications/ports/ConsoleMagicLinkNotificationAdapter.ts
new file mode 100644
index 000000000..d0c2da592
--- /dev/null
+++ b/adapters/notifications/ports/ConsoleMagicLinkNotificationAdapter.ts
@@ -0,0 +1,31 @@
+import { IMagicLinkNotificationPort, MagicLinkNotificationInput } from '@core/identity/domain/ports/IMagicLinkNotificationPort';
+import { Logger } from '@core/shared/application';
+
+/**
+ * Console adapter for magic link notifications
+ * Logs to console for development/testing purposes
+ */
+export class ConsoleMagicLinkNotificationAdapter implements IMagicLinkNotificationPort {
+ constructor(private readonly logger: Logger) {}
+
+ async sendMagicLink(input: MagicLinkNotificationInput): Promise {
+ this.logger.info('[ConsoleMagicLinkNotificationAdapter] Magic link generated', {
+ email: input.email,
+ userId: input.userId,
+ magicLink: input.magicLink,
+ expiresAt: input.expiresAt,
+ });
+
+ // In development, log to console
+ if (process.env.NODE_ENV === 'development') {
+ console.log('\nπ PASSWORD RESET MAGIC LINK');
+ console.log('='.repeat(50));
+ console.log(`π§ Email: ${input.email}`);
+ console.log(`π€ User ID: ${input.userId}`);
+ console.log(`π Link: ${input.magicLink}`);
+ console.log(`β° Expires: ${input.expiresAt.toLocaleString()}`);
+ console.log('='.repeat(50));
+ console.log('β οΈ This would be sent via email in production\n');
+ }
+ }
+}
\ No newline at end of file
diff --git a/adapters/racing/persistence/typeorm/entities/DriverStatsOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/DriverStatsOrmEntity.ts
new file mode 100644
index 000000000..2ea163a01
--- /dev/null
+++ b/adapters/racing/persistence/typeorm/entities/DriverStatsOrmEntity.ts
@@ -0,0 +1,46 @@
+import { Column, Entity, PrimaryColumn } from 'typeorm';
+
+@Entity({ name: 'driver_stats' })
+export class DriverStatsOrmEntity {
+ @PrimaryColumn({ type: 'uuid' })
+ driverId!: string;
+
+ @Column({ type: 'integer' })
+ rating!: number;
+
+ @Column({ type: 'integer' })
+ safetyRating!: number;
+
+ @Column({ type: 'numeric', precision: 3, scale: 1 })
+ sportsmanshipRating!: number;
+
+ @Column({ type: 'integer' })
+ totalRaces!: number;
+
+ @Column({ type: 'integer' })
+ wins!: number;
+
+ @Column({ type: 'integer' })
+ podiums!: number;
+
+ @Column({ type: 'integer' })
+ dnfs!: number;
+
+ @Column({ type: 'numeric', precision: 5, scale: 2 })
+ avgFinish!: number;
+
+ @Column({ type: 'integer' })
+ bestFinish!: number;
+
+ @Column({ type: 'integer' })
+ worstFinish!: number;
+
+ @Column({ type: 'integer' })
+ consistency!: number;
+
+ @Column({ type: 'text' })
+ experienceLevel!: string;
+
+ @Column({ type: 'integer', nullable: true })
+ overallRank!: number | null;
+}
\ No newline at end of file
diff --git a/adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts
new file mode 100644
index 000000000..f35546c8b
--- /dev/null
+++ b/adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts
@@ -0,0 +1,65 @@
+import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
+
+import { DriverStatsOrmEntity } from '../entities/DriverStatsOrmEntity';
+import {
+ assertNonEmptyString,
+ assertInteger,
+ assertNumber
+} from '../schema/TypeOrmSchemaGuards';
+
+export class DriverStatsOrmMapper {
+ toOrmEntity(driverId: string, domain: DriverStats): DriverStatsOrmEntity {
+ const entity = new DriverStatsOrmEntity();
+ entity.driverId = driverId;
+ entity.rating = domain.rating;
+ entity.safetyRating = domain.safetyRating;
+ entity.sportsmanshipRating = domain.sportsmanshipRating;
+ entity.totalRaces = domain.totalRaces;
+ entity.wins = domain.wins;
+ entity.podiums = domain.podiums;
+ entity.dnfs = domain.dnfs;
+ entity.avgFinish = domain.avgFinish;
+ entity.bestFinish = domain.bestFinish;
+ entity.worstFinish = domain.worstFinish;
+ entity.consistency = domain.consistency;
+ entity.experienceLevel = domain.experienceLevel;
+ entity.overallRank = domain.overallRank ?? null;
+ return entity;
+ }
+
+ toDomain(entity: DriverStatsOrmEntity): DriverStats {
+ const entityName = 'DriverStats';
+
+ assertNonEmptyString(entityName, 'driverId', entity.driverId);
+ assertInteger(entityName, 'rating', entity.rating);
+ assertInteger(entityName, 'safetyRating', entity.safetyRating);
+ assertInteger(entityName, 'sportsmanshipRating', entity.sportsmanshipRating);
+ assertInteger(entityName, 'totalRaces', entity.totalRaces);
+ assertInteger(entityName, 'wins', entity.wins);
+ assertInteger(entityName, 'podiums', entity.podiums);
+ assertInteger(entityName, 'dnfs', entity.dnfs);
+ assertNumber(entityName, 'avgFinish', entity.avgFinish);
+ assertInteger(entityName, 'bestFinish', entity.bestFinish);
+ assertInteger(entityName, 'worstFinish', entity.worstFinish);
+ assertInteger(entityName, 'consistency', entity.consistency);
+ assertNonEmptyString(entityName, 'experienceLevel', entity.experienceLevel);
+
+ const result: DriverStats = {
+ rating: entity.rating,
+ safetyRating: entity.safetyRating,
+ sportsmanshipRating: entity.sportsmanshipRating,
+ totalRaces: entity.totalRaces,
+ wins: entity.wins,
+ podiums: entity.podiums,
+ dnfs: entity.dnfs,
+ avgFinish: entity.avgFinish,
+ bestFinish: entity.bestFinish,
+ worstFinish: entity.worstFinish,
+ consistency: entity.consistency,
+ experienceLevel: entity.experienceLevel,
+ overallRank: entity.overallRank ?? null,
+ };
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverStatsRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverStatsRepository.ts
new file mode 100644
index 000000000..ba080ebe5
--- /dev/null
+++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverStatsRepository.ts
@@ -0,0 +1,45 @@
+import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
+import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
+import type { Repository } from 'typeorm';
+
+import { DriverStatsOrmEntity } from '../entities/DriverStatsOrmEntity';
+import { DriverStatsOrmMapper } from '../mappers/DriverStatsOrmMapper';
+
+export class TypeOrmDriverStatsRepository implements IDriverStatsRepository {
+ constructor(
+ private readonly repo: Repository,
+ private readonly mapper: DriverStatsOrmMapper,
+ ) {}
+
+ async getDriverStats(driverId: string): Promise {
+ const entity = await this.repo.findOne({ where: { driverId } });
+ return entity ? this.mapper.toDomain(entity) : null;
+ }
+
+ getDriverStatsSync(_driverId: string): DriverStats | null {
+ // TypeORM repositories don't support synchronous operations
+ // This method is provided for interface compatibility but should not be used
+ // with TypeORM implementations. Return null to indicate it's not supported.
+ return null;
+ }
+
+ async saveDriverStats(driverId: string, stats: DriverStats): Promise {
+ const entity = this.mapper.toOrmEntity(driverId, stats);
+ await this.repo.save(entity);
+ }
+
+ async getAllStats(): Promise
+ {/* Name Immutability Notice */}
+
+
+
+
+ Note: Your display name cannot be changed after signup. Please ensure it's correct when creating your account.
+
+
+
+
{/* Footer */}
By signing in, you agree to our{' '}
diff --git a/apps/website/app/auth/reset-password/page.tsx b/apps/website/app/auth/reset-password/page.tsx
new file mode 100644
index 000000000..ca99f8586
--- /dev/null
+++ b/apps/website/app/auth/reset-password/page.tsx
@@ -0,0 +1,356 @@
+'use client';
+
+import { useState, FormEvent, type ChangeEvent, useEffect } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import Link from 'next/link';
+import { motion } from 'framer-motion';
+import {
+ Lock,
+ Eye,
+ EyeOff,
+ AlertCircle,
+ Flag,
+ Shield,
+ CheckCircle2,
+ ArrowLeft,
+} from 'lucide-react';
+
+import Card from '@/components/ui/Card';
+import Button from '@/components/ui/Button';
+import Input from '@/components/ui/Input';
+import Heading from '@/components/ui/Heading';
+
+interface FormErrors {
+ newPassword?: string;
+ confirmPassword?: string;
+ submit?: string;
+}
+
+interface PasswordStrength {
+ score: number;
+ label: string;
+ color: string;
+}
+
+function checkPasswordStrength(password: string): PasswordStrength {
+ let score = 0;
+ if (password.length >= 8) score++;
+ if (password.length >= 12) score++;
+ if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
+ if (/\d/.test(password)) score++;
+ if (/[^a-zA-Z\d]/.test(password)) score++;
+
+ if (score <= 1) return { score, label: 'Weak', color: 'bg-red-500' };
+ if (score <= 2) return { score, label: 'Fair', color: 'bg-warning-amber' };
+ if (score <= 3) return { score, label: 'Good', color: 'bg-primary-blue' };
+ return { score, label: 'Strong', color: 'bg-performance-green' };
+}
+
+export default function ResetPasswordPage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [loading, setLoading] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [errors, setErrors] = useState({});
+ const [success, setSuccess] = useState(null);
+ const [formData, setFormData] = useState({
+ newPassword: '',
+ confirmPassword: '',
+ });
+ const [token, setToken] = useState('');
+
+ // Extract token from URL on mount
+ useEffect(() => {
+ const tokenParam = searchParams.get('token');
+ if (tokenParam) {
+ setToken(tokenParam);
+ }
+ }, [searchParams]);
+
+ const passwordStrength = checkPasswordStrength(formData.newPassword);
+
+ const passwordRequirements = [
+ { met: formData.newPassword.length >= 8, label: 'At least 8 characters' },
+ { met: /[a-z]/.test(formData.newPassword) && /[A-Z]/.test(formData.newPassword), label: 'Upper and lowercase letters' },
+ { met: /\d/.test(formData.newPassword), label: 'At least one number' },
+ { met: /[^a-zA-Z\d]/.test(formData.newPassword), label: 'At least one special character' },
+ ];
+
+ const validateForm = (): boolean => {
+ const newErrors: FormErrors = {};
+
+ if (!formData.newPassword) {
+ newErrors.newPassword = 'New password is required';
+ } else if (formData.newPassword.length < 8) {
+ newErrors.newPassword = 'Password must be at least 8 characters';
+ } else if (!/[a-z]/.test(formData.newPassword) || !/[A-Z]/.test(formData.newPassword) || !/\d/.test(formData.newPassword)) {
+ newErrors.newPassword = 'Password must contain uppercase, lowercase, and number';
+ }
+
+ if (!formData.confirmPassword) {
+ newErrors.confirmPassword = 'Please confirm your password';
+ } else if (formData.newPassword !== formData.confirmPassword) {
+ newErrors.confirmPassword = 'Passwords do not match';
+ }
+
+ if (!token) {
+ newErrors.submit = 'Invalid reset token. Please request a new reset link.';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (loading) return;
+
+ if (!validateForm()) return;
+
+ setLoading(true);
+ setErrors({});
+ setSuccess(null);
+
+ try {
+ const { ServiceFactory } = await import('@/lib/services/ServiceFactory');
+ const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
+ const authService = serviceFactory.createAuthService();
+ const result = await authService.resetPassword({
+ token,
+ newPassword: formData.newPassword,
+ });
+
+ setSuccess(result.message);
+ } catch (error) {
+ setErrors({
+ submit: error instanceof Error ? error.message : 'Failed to reset password. Please try again.',
+ });
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Background Pattern */}
+
+
+
+
+ {/* Header */}
+
+
+
+
+
Set New Password
+
+ Create a strong password for your account
+
+
+
+
+ {/* Background accent */}
+
+
+ {!success ? (
+
+ ) : (
+
+
+
+
+
{success}
+
+ Your password has been successfully reset
+
+
+
+
+
+
+ )}
+
+
+ {/* Trust Indicators */}
+
+
+
+ Encrypted & secure
+
+
+
+ Instant update
+
+
+
+ {/* Footer */}
+
+ Need help?{' '}
+
+ Contact support
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx
index b0c543c8c..b82decee8 100644
--- a/apps/website/app/auth/signup/page.tsx
+++ b/apps/website/app/auth/signup/page.tsx
@@ -32,7 +32,8 @@ import Heading from '@/components/ui/Heading';
import { useAuth } from '@/lib/auth/AuthContext';
interface FormErrors {
- displayName?: string;
+ firstName?: string;
+ lastName?: string;
email?: string;
password?: string;
confirmPassword?: string;
@@ -101,7 +102,8 @@ export default function SignupPage() {
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [errors, setErrors] = useState({});
const [formData, setFormData] = useState({
- displayName: '',
+ firstName: '',
+ lastName: '',
email: '',
password: '',
confirmPassword: '',
@@ -138,10 +140,32 @@ export default function SignupPage() {
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
- if (!formData.displayName.trim()) {
- newErrors.displayName = 'Display name is required';
- } else if (formData.displayName.trim().length < 3) {
- newErrors.displayName = 'Display name must be at least 3 characters';
+ // First name validation
+ const firstName = formData.firstName.trim();
+ if (!firstName) {
+ newErrors.firstName = 'First name is required';
+ } else if (firstName.length < 2) {
+ newErrors.firstName = 'First name must be at least 2 characters';
+ } else if (firstName.length > 25) {
+ newErrors.firstName = 'First name must be no more than 25 characters';
+ } else if (!/^[A-Za-z\-']+$/.test(firstName)) {
+ newErrors.firstName = 'First name can only contain letters, hyphens, and apostrophes';
+ } else if (/^(user|test|demo|guest|player)/i.test(firstName)) {
+ newErrors.firstName = 'Please use your real first name, not a nickname';
+ }
+
+ // Last name validation
+ const lastName = formData.lastName.trim();
+ if (!lastName) {
+ newErrors.lastName = 'Last name is required';
+ } else if (lastName.length < 2) {
+ newErrors.lastName = 'Last name must be at least 2 characters';
+ } else if (lastName.length > 25) {
+ newErrors.lastName = 'Last name must be no more than 25 characters';
+ } else if (!/^[A-Za-z\-']+$/.test(lastName)) {
+ newErrors.lastName = 'Last name can only contain letters, hyphens, and apostrophes';
+ } else if (/^(user|test|demo|guest|player)/i.test(lastName)) {
+ newErrors.lastName = 'Please use your real last name, not a nickname';
}
if (!formData.email.trim()) {
@@ -150,10 +174,13 @@ export default function SignupPage() {
newErrors.email = 'Invalid email format';
}
+ // Password strength validation
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
+ } else if (!/[a-z]/.test(formData.password) || !/[A-Z]/.test(formData.password) || !/\d/.test(formData.password)) {
+ newErrors.password = 'Password must contain uppercase, lowercase, and number';
}
if (!formData.confirmPassword) {
@@ -176,21 +203,18 @@ export default function SignupPage() {
setErrors({});
try {
- const response = await fetch('/api/auth/signup', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- email: formData.email,
- password: formData.password,
- displayName: formData.displayName,
- }),
+ const { ServiceFactory } = await import('@/lib/services/ServiceFactory');
+ const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
+ const authService = serviceFactory.createAuthService();
+
+ // Combine first and last name into display name
+ const displayName = `${formData.firstName} ${formData.lastName}`.trim();
+
+ await authService.signup({
+ email: formData.email,
+ password: formData.password,
+ displayName,
});
-
- const data = await response.json();
-
- if (!response.ok) {
- throw new Error(data.error || 'Signup failed');
- }
// Refresh session in context so header updates immediately
await refreshSession();
@@ -206,8 +230,12 @@ export default function SignupPage() {
const handleDemoLogin = async () => {
setLoading(true);
try {
- // Demo: Set cookie to indicate driver mode (works without OAuth)
- document.cookie = 'gridpilot_demo_mode=driver; path=/; max-age=86400';
+ const { ServiceFactory } = await import('@/lib/services/ServiceFactory');
+ const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
+ const authService = serviceFactory.createAuthService();
+
+ await authService.demoLogin({ role: 'driver' });
+
await new Promise(resolve => setTimeout(resolve, 500));
router.push(returnTo === '/onboarding' ? '/dashboard' : returnTo);
} catch {
@@ -337,27 +365,57 @@ export default function SignupPage() {