auth
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
|
||||
import { ForgotPasswordUseCase } from './ForgotPasswordUseCase';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
type ForgotPasswordOutput = {
|
||||
message: string;
|
||||
magicLink?: string | null;
|
||||
};
|
||||
|
||||
describe('ForgotPasswordUseCase', () => {
|
||||
let authRepo: {
|
||||
findByEmail: Mock;
|
||||
save: Mock;
|
||||
};
|
||||
let magicLinkRepo: {
|
||||
checkRateLimit: Mock;
|
||||
createPasswordResetRequest: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<ForgotPasswordOutput> & { present: Mock };
|
||||
let useCase: ForgotPasswordUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
authRepo = {
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
magicLinkRepo = {
|
||||
checkRateLimit: vi.fn(),
|
||||
createPasswordResetRequest: vi.fn(),
|
||||
};
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new ForgotPasswordUseCase(
|
||||
authRepo as unknown as IAuthRepository,
|
||||
magicLinkRepo as unknown as IMagicLinkRepository,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create magic link for existing user', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
|
||||
expect(magicLinkRepo.checkRateLimit).toHaveBeenCalledWith(input.email);
|
||||
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return success for non-existent email (security)', async () => {
|
||||
const input = { email: 'nonexistent@example.com' };
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
|
||||
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
message: 'If an account exists with this email, a password reset link will be sent',
|
||||
magicLink: null,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle rate limiting', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(
|
||||
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limited' } })
|
||||
);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const input = { email: 'invalid-email' };
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
|
||||
it('should generate secure tokens', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
let capturedToken: string | undefined;
|
||||
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
|
||||
capturedToken = data.token;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(capturedToken).toMatch(/^[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
|
||||
});
|
||||
|
||||
it('should set correct expiration time (15 minutes)', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const beforeCreate = Date.now();
|
||||
let capturedExpiresAt: Date | undefined;
|
||||
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
|
||||
capturedExpiresAt = data.expiresAt;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
const afterCreate = Date.now();
|
||||
expect(capturedExpiresAt).toBeDefined();
|
||||
const timeDiff = capturedExpiresAt!.getTime() - afterCreate;
|
||||
|
||||
// Should be approximately 15 minutes (900000ms)
|
||||
expect(timeDiff).toBeGreaterThan(890000);
|
||||
expect(timeDiff).toBeLessThan(910000);
|
||||
});
|
||||
|
||||
it('should return magic link in development mode', async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
magicLink: expect.stringContaining('token='),
|
||||
})
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = originalEnv ?? 'test';
|
||||
});
|
||||
|
||||
it('should not return magic link in production mode', async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
magicLink: null,
|
||||
})
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = originalEnv ?? 'test';
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
|
||||
authRepo.findByEmail.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toContain('Database error');
|
||||
});
|
||||
});
|
||||
132
core/identity/application/use-cases/ForgotPasswordUseCase.ts
Normal file
132
core/identity/application/use-cases/ForgotPasswordUseCase.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
|
||||
import { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
|
||||
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 { randomBytes } from 'crypto';
|
||||
|
||||
export type ForgotPasswordInput = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type ForgotPasswordResult = {
|
||||
message: string;
|
||||
magicLink?: string | null; // For development/demo purposes
|
||||
};
|
||||
|
||||
export type ForgotPasswordErrorCode = 'USER_NOT_FOUND' | 'REPOSITORY_ERROR' | 'RATE_LIMIT_EXCEEDED';
|
||||
|
||||
export type ForgotPasswordApplicationError = ApplicationErrorCode<ForgotPasswordErrorCode, { message: string }>;
|
||||
|
||||
/**
|
||||
* Application Use Case: ForgotPasswordUseCase
|
||||
*
|
||||
* Handles password reset requests by generating magic links.
|
||||
* In production, this would send an email with the magic link.
|
||||
* In development, it returns the link for testing purposes.
|
||||
*/
|
||||
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void, ForgotPasswordErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly magicLinkRepo: IMagicLinkRepository,
|
||||
private readonly notificationPort: IMagicLinkNotificationPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<ForgotPasswordResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: ForgotPasswordInput): Promise<Result<void, ForgotPasswordApplicationError>> {
|
||||
try {
|
||||
// Validate email format
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
|
||||
// Check if user exists
|
||||
const user = await this.authRepo.findByEmail(emailVO);
|
||||
|
||||
// Check rate limiting (implement in repository) - even if user doesn't exist
|
||||
const rateLimitResult = await this.magicLinkRepo.checkRateLimit(input.email);
|
||||
if (rateLimitResult.isErr()) {
|
||||
return Result.err({
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
details: { message: 'Too many reset attempts. Please try again later.' },
|
||||
});
|
||||
}
|
||||
|
||||
// If user exists, generate magic link
|
||||
if (user) {
|
||||
// Generate secure token
|
||||
const token = this.generateSecureToken();
|
||||
|
||||
// Set expiration (15 minutes)
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
|
||||
// Store magic link
|
||||
await this.magicLinkRepo.createPasswordResetRequest({
|
||||
email: input.email,
|
||||
token,
|
||||
expiresAt,
|
||||
userId: user.getId().value,
|
||||
});
|
||||
|
||||
// Generate magic link URL
|
||||
const magicLink = this.generateMagicLink(token);
|
||||
|
||||
this.logger.info('[ForgotPasswordUseCase] Magic link generated', {
|
||||
email: input.email,
|
||||
userId: user.getId().value,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Send notification via port
|
||||
await this.notificationPort.sendMagicLink({
|
||||
email: input.email,
|
||||
magicLink,
|
||||
userId: user.getId().value,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
this.output.present({
|
||||
message: 'Password reset link generated successfully',
|
||||
magicLink: process.env.NODE_ENV === 'development' ? magicLink : null,
|
||||
});
|
||||
} else {
|
||||
// User not found - still return success for security (prevents email enumeration)
|
||||
this.logger.info('[ForgotPasswordUseCase] User not found, but returning success for security', {
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
this.output.present({
|
||||
message: 'If an account exists with this email, a password reset link will be sent',
|
||||
magicLink: null,
|
||||
});
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: 'Failed to execute ForgotPasswordUseCase';
|
||||
|
||||
this.logger.error('ForgotPasswordUseCase.execute failed', error instanceof Error ? error : undefined, {
|
||||
input,
|
||||
});
|
||||
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private generateSecureToken(): string {
|
||||
// Generate 32-byte random token and convert to hex
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
private generateMagicLink(token: string): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
return `${baseUrl}/auth/reset-password?token=${token}`;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,6 @@ describe('GetCurrentSessionUseCase', () => {
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
passwordHash: 'hash',
|
||||
salt: 'salt',
|
||||
primaryDriverId: 'driver-123',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -44,7 +44,6 @@ describe('GetUserUseCase', () => {
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
passwordHash: 'hash',
|
||||
salt: 'salt',
|
||||
primaryDriverId: 'driver-1',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -57,20 +57,18 @@ describe('LoginWithEmailUseCase', () => {
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
// Import PasswordHash to create a proper hash
|
||||
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const passwordHash = await PasswordHash.create('password123');
|
||||
|
||||
const storedUser: StoredUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
passwordHash: '',
|
||||
salt: 'salt',
|
||||
passwordHash: passwordHash.value,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
storedUser.passwordHash = await (useCase as unknown as { hashPassword: (p: string, s: string) => Promise<string> }).hashPassword(
|
||||
input.password,
|
||||
storedUser.salt,
|
||||
);
|
||||
|
||||
const session = {
|
||||
user: {
|
||||
id: storedUser.id,
|
||||
@@ -141,12 +139,15 @@ describe('LoginWithEmailUseCase', () => {
|
||||
password: 'wrong',
|
||||
};
|
||||
|
||||
// Create a hash for a different password
|
||||
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const passwordHash = await PasswordHash.create('correct-password');
|
||||
|
||||
const storedUser: StoredUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
passwordHash: 'different-hash',
|
||||
salt: 'salt',
|
||||
passwordHash: passwordHash.value,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
|
||||
@@ -62,8 +62,12 @@ export class LoginWithEmailUseCase {
|
||||
} as LoginWithEmailApplicationError);
|
||||
}
|
||||
|
||||
const passwordHash = await this.hashPassword(input.password, user.salt);
|
||||
if (passwordHash !== user.passwordHash) {
|
||||
// Verify password using PasswordHash value object
|
||||
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const storedPasswordHash = PasswordHash.fromHash(user.passwordHash);
|
||||
const isValid = await storedPasswordHash.verify(input.password);
|
||||
|
||||
if (!isValid) {
|
||||
return Result.err({
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
details: { message: 'Invalid email or password' },
|
||||
@@ -117,23 +121,4 @@ export class LoginWithEmailUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||
// Simple hash for demo - in production, use bcrypt or argon2
|
||||
const data = password + salt;
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const encoder = new TextEncoder();
|
||||
const dataBuffer = encoder.encode(data);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// Fallback for environments without crypto.subtle
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
}
|
||||
239
core/identity/application/use-cases/ResetPasswordUseCase.test.ts
Normal file
239
core/identity/application/use-cases/ResetPasswordUseCase.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
|
||||
import { ResetPasswordUseCase } from './ResetPasswordUseCase';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
|
||||
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
type ResetPasswordOutput = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
describe('ResetPasswordUseCase', () => {
|
||||
let authRepo: {
|
||||
findByEmail: Mock;
|
||||
save: Mock;
|
||||
};
|
||||
let magicLinkRepo: {
|
||||
findByToken: Mock;
|
||||
markAsUsed: Mock;
|
||||
};
|
||||
let passwordService: {
|
||||
hash: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<ResetPasswordOutput> & { present: Mock };
|
||||
let useCase: ResetPasswordUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
authRepo = {
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
magicLinkRepo = {
|
||||
findByToken: vi.fn(),
|
||||
markAsUsed: vi.fn(),
|
||||
};
|
||||
passwordService = {
|
||||
hash: vi.fn(),
|
||||
};
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new ResetPasswordUseCase(
|
||||
authRepo as unknown as IAuthRepository,
|
||||
magicLinkRepo as unknown as IMagicLinkRepository,
|
||||
passwordService as unknown as IPasswordHashingService,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset password with valid token', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const resetRequest = {
|
||||
email: 'test@example.com',
|
||||
token: input.token,
|
||||
expiresAt: new Date(Date.now() + 60000), // 1 minute from now
|
||||
userId: user.getId().value,
|
||||
};
|
||||
|
||||
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
passwordService.hash.mockResolvedValue('hashed-new-password');
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(magicLinkRepo.findByToken).toHaveBeenCalledWith(input.token);
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create('test@example.com'));
|
||||
expect(passwordService.hash).toHaveBeenCalledWith(input.newPassword);
|
||||
expect(authRepo.save).toHaveBeenCalled();
|
||||
expect(magicLinkRepo.markAsUsed).toHaveBeenCalledWith(input.token);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
message: 'Password reset successfully. You can now log in with your new password.',
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
const input = {
|
||||
token: 'invalid-token',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
magicLinkRepo.findByToken.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
const input = {
|
||||
token: 'expired-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
const resetRequest = {
|
||||
email: 'test@example.com',
|
||||
token: input.token,
|
||||
expiresAt: new Date(Date.now() - 60000), // 1 minute ago
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('EXPIRED_TOKEN');
|
||||
});
|
||||
|
||||
it('should reject weak password', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'weak',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should reject password without uppercase', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'newpass123!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should reject password without number', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should reject password shorter than 8 characters', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'New1!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should handle user no longer exists', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
const resetRequest = {
|
||||
email: 'deleted@example.com',
|
||||
token: input.token,
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should handle token format validation', async () => {
|
||||
const input = {
|
||||
token: 'short',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
magicLinkRepo.findByToken.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toContain('Database error');
|
||||
});
|
||||
});
|
||||
143
core/identity/application/use-cases/ResetPasswordUseCase.ts
Normal file
143
core/identity/application/use-cases/ResetPasswordUseCase.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
|
||||
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
|
||||
export type ResetPasswordInput = {
|
||||
token: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export type ResetPasswordResult = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ResetPasswordErrorCode = 'INVALID_TOKEN' | 'EXPIRED_TOKEN' | 'WEAK_PASSWORD' | 'REPOSITORY_ERROR';
|
||||
|
||||
export type ResetPasswordApplicationError = ApplicationErrorCode<ResetPasswordErrorCode, { message: string }>;
|
||||
|
||||
/**
|
||||
* Application Use Case: ResetPasswordUseCase
|
||||
*
|
||||
* Handles password reset using a magic link token.
|
||||
* Validates token, checks expiration, and updates password.
|
||||
*/
|
||||
export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, void, ResetPasswordErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly magicLinkRepo: IMagicLinkRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<ResetPasswordResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: ResetPasswordInput): Promise<Result<void, ResetPasswordApplicationError>> {
|
||||
try {
|
||||
// Validate token format
|
||||
if (!input.token || typeof input.token !== 'string' || input.token.length < 32) {
|
||||
return Result.err({
|
||||
code: 'INVALID_TOKEN',
|
||||
details: { message: 'Invalid reset token' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (!this.isPasswordStrong(input.newPassword)) {
|
||||
return Result.err({
|
||||
code: 'WEAK_PASSWORD',
|
||||
details: { message: 'Password must be at least 8 characters and contain uppercase, lowercase, and number' },
|
||||
});
|
||||
}
|
||||
|
||||
// Find token
|
||||
const resetRequest = await this.magicLinkRepo.findByToken(input.token);
|
||||
if (!resetRequest) {
|
||||
return Result.err({
|
||||
code: 'INVALID_TOKEN',
|
||||
details: { message: 'Invalid or expired reset token' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (resetRequest.expiresAt < new Date()) {
|
||||
return Result.err({
|
||||
code: 'EXPIRED_TOKEN',
|
||||
details: { message: 'Reset token has expired. Please request a new one.' },
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const emailVO = EmailAddress.create(resetRequest.email);
|
||||
const user = await this.authRepo.findByEmail(emailVO);
|
||||
if (!user) {
|
||||
return Result.err({
|
||||
code: 'INVALID_TOKEN',
|
||||
details: { message: 'User no longer exists' },
|
||||
});
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await this.passwordService.hash(input.newPassword);
|
||||
|
||||
// Create a new user instance with updated password
|
||||
const UserModule = await import('../../domain/entities/User');
|
||||
const passwordHash = PasswordHash.fromHash(hashedPassword);
|
||||
const email = user.getEmail();
|
||||
const iracingCustomerId = user.getIracingCustomerId();
|
||||
const primaryDriverId = user.getPrimaryDriverId();
|
||||
const avatarUrl = user.getAvatarUrl();
|
||||
|
||||
const updatedUserInstance = UserModule.User.rehydrate({
|
||||
id: user.getId().value,
|
||||
displayName: user.getDisplayName(),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
passwordHash: passwordHash,
|
||||
...(iracingCustomerId !== undefined ? { iracingCustomerId } : {}),
|
||||
...(primaryDriverId !== undefined ? { primaryDriverId } : {}),
|
||||
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
||||
});
|
||||
|
||||
await this.authRepo.save(updatedUserInstance);
|
||||
|
||||
// Mark token as used
|
||||
await this.magicLinkRepo.markAsUsed(input.token);
|
||||
|
||||
this.logger.info('[ResetPasswordUseCase] Password reset successful', {
|
||||
userId: user.getId().value,
|
||||
email: resetRequest.email,
|
||||
});
|
||||
|
||||
this.output.present({
|
||||
message: 'Password reset successfully. You can now log in with your new password.',
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: 'Failed to execute ResetPasswordUseCase';
|
||||
|
||||
this.logger.error('ResetPasswordUseCase.execute failed', error instanceof Error ? error : undefined, {
|
||||
input,
|
||||
});
|
||||
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isPasswordStrong(password: string): boolean {
|
||||
if (password.length < 8) return false;
|
||||
if (!/[a-z]/.test(password)) return false;
|
||||
if (!/[A-Z]/.test(password)) return false;
|
||||
if (!/\d/.test(password)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export type SignupResult = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export type SignupErrorCode = 'USER_ALREADY_EXISTS' | 'REPOSITORY_ERROR';
|
||||
export type SignupErrorCode = 'USER_ALREADY_EXISTS' | 'WEAK_PASSWORD' | 'INVALID_DISPLAY_NAME' | 'REPOSITORY_ERROR';
|
||||
|
||||
export type SignupApplicationError = ApplicationErrorCode<SignupErrorCode, { message: string }>;
|
||||
|
||||
@@ -36,8 +36,18 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
|
||||
|
||||
async execute(input: SignupInput): Promise<Result<void, SignupApplicationError>> {
|
||||
try {
|
||||
// Validate email format
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
|
||||
// Validate password strength
|
||||
if (!this.isPasswordStrong(input.password)) {
|
||||
return Result.err({
|
||||
code: 'WEAK_PASSWORD',
|
||||
details: { message: 'Password must be at least 8 characters and contain uppercase, lowercase, and number' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await this.authRepo.findByEmail(emailVO);
|
||||
if (existingUser) {
|
||||
return Result.err({
|
||||
@@ -46,10 +56,12 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.passwordService.hash(input.password);
|
||||
const passwordHashModule = await import('../../domain/value-objects/PasswordHash');
|
||||
const passwordHash = passwordHashModule.PasswordHash.fromHash(hashedPassword);
|
||||
|
||||
// Create user (displayName validation happens in User entity constructor)
|
||||
const userId = UserId.create();
|
||||
const user = User.create({
|
||||
id: userId,
|
||||
@@ -63,6 +75,18 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
|
||||
this.output.present({ user });
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
// Handle specific validation errors from User entity
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Name must be at least') ||
|
||||
error.message.includes('Name can only contain') ||
|
||||
error.message.includes('Please use your real name')) {
|
||||
return Result.err({
|
||||
code: 'INVALID_DISPLAY_NAME',
|
||||
details: { message: error.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
@@ -78,4 +102,12 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isPasswordStrong(password: string): boolean {
|
||||
if (password.length < 8) return false;
|
||||
if (!/[a-z]/.test(password)) return false;
|
||||
if (!/[A-Z]/.test(password)) return false;
|
||||
if (!/\d/.test(password)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,6 @@ describe('SignupWithEmailUseCase', () => {
|
||||
email: command.email,
|
||||
displayName: command.displayName,
|
||||
passwordHash: 'hash',
|
||||
salt: 'salt',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ export class SignupWithEmailUseCase {
|
||||
}
|
||||
|
||||
try {
|
||||
// Hash password (simple hash for demo - in production use bcrypt)
|
||||
const salt = this.generateSalt();
|
||||
const passwordHash = await this.hashPassword(input.password, salt);
|
||||
// Hash password using PasswordHash value object
|
||||
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const passwordHash = await PasswordHash.create(input.password);
|
||||
|
||||
// Create user
|
||||
const userId = this.generateUserId();
|
||||
@@ -95,8 +95,7 @@ export class SignupWithEmailUseCase {
|
||||
id: userId,
|
||||
email: input.email.toLowerCase().trim(),
|
||||
displayName: input.displayName.trim(),
|
||||
passwordHash,
|
||||
salt,
|
||||
passwordHash: passwordHash.value,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
@@ -142,38 +141,6 @@ export class SignupWithEmailUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
private generateSalt(): string {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(array);
|
||||
} else {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||
// Simple hash for demo - in production, use bcrypt or argon2
|
||||
const data = password + salt;
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const encoder = new TextEncoder();
|
||||
const dataBuffer = encoder.encode(data);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// Fallback for environments without crypto.subtle
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
private generateUserId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
|
||||
@@ -24,12 +24,10 @@ export class User {
|
||||
private avatarUrl: string | undefined;
|
||||
|
||||
private constructor(props: UserProps) {
|
||||
if (!props.displayName || !props.displayName.trim()) {
|
||||
throw new Error('User displayName cannot be empty');
|
||||
}
|
||||
this.validateDisplayName(props.displayName);
|
||||
|
||||
this.id = props.id;
|
||||
this.displayName = props.displayName.trim();
|
||||
this.displayName = this.formatDisplayName(props.displayName);
|
||||
this.email = props.email;
|
||||
this.passwordHash = props.passwordHash;
|
||||
this.iracingCustomerId = props.iracingCustomerId;
|
||||
@@ -37,6 +35,56 @@ export class User {
|
||||
this.avatarUrl = props.avatarUrl;
|
||||
}
|
||||
|
||||
private validateDisplayName(displayName: string): void {
|
||||
const trimmed = displayName?.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new Error('Display name cannot be empty');
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
throw new Error('Name must be at least 2 characters long');
|
||||
}
|
||||
|
||||
if (trimmed.length > 50) {
|
||||
throw new Error('Name must be no more than 50 characters');
|
||||
}
|
||||
|
||||
// Only allow letters, spaces, hyphens, and apostrophes
|
||||
if (!/^[A-Za-z\s\-']+$/.test(trimmed)) {
|
||||
throw new Error('Name can only contain letters, spaces, hyphens, and apostrophes');
|
||||
}
|
||||
|
||||
// Block common nickname patterns
|
||||
const blockedPatterns = [
|
||||
/^user/i,
|
||||
/^test/i,
|
||||
/^demo/i,
|
||||
/^[a-z0-9_]+$/i, // No alphanumeric-only (likely username/nickname)
|
||||
/^guest/i,
|
||||
/^player/i
|
||||
];
|
||||
|
||||
if (blockedPatterns.some(pattern => pattern.test(trimmed))) {
|
||||
throw new Error('Please use your real name (first and last name), not a nickname or username');
|
||||
}
|
||||
|
||||
// Check for excessive spaces or repeated characters
|
||||
if (/\s{2,}/.test(trimmed)) {
|
||||
throw new Error('Name cannot contain multiple consecutive spaces');
|
||||
}
|
||||
|
||||
if (/(.)\1{2,}/.test(trimmed)) {
|
||||
throw new Error('Name cannot contain excessive repeated characters');
|
||||
}
|
||||
}
|
||||
|
||||
private formatDisplayName(displayName: string): string {
|
||||
const trimmed = displayName.trim();
|
||||
// Capitalize first letter of each word
|
||||
return trimmed.replace(/\b\w/g, char => char.toUpperCase());
|
||||
}
|
||||
|
||||
public static create(props: UserProps): User {
|
||||
if (props.email) {
|
||||
const result: EmailValidationResult = validateEmail(props.email);
|
||||
@@ -128,4 +176,21 @@ export class User {
|
||||
public getAvatarUrl(): string | undefined {
|
||||
return this.avatarUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update display name - NOT ALLOWED after initial creation
|
||||
* This method will always throw an error to enforce immutability
|
||||
*/
|
||||
public updateDisplayName(): void {
|
||||
throw new Error('Display name cannot be changed after account creation. Please contact support if you need to update your name.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user was created with a valid real name
|
||||
* Used to verify immutability for existing users
|
||||
*/
|
||||
public hasImmutableName(): boolean {
|
||||
// All users created through proper channels have immutable names
|
||||
return true;
|
||||
}
|
||||
}
|
||||
20
core/identity/domain/ports/IMagicLinkNotificationPort.ts
Normal file
20
core/identity/domain/ports/IMagicLinkNotificationPort.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Port for sending magic link notifications
|
||||
* In production, this would send emails
|
||||
* In development, it can log to console or return the link
|
||||
*/
|
||||
export interface MagicLinkNotificationInput {
|
||||
email: string;
|
||||
magicLink: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface IMagicLinkNotificationPort {
|
||||
/**
|
||||
* Send a magic link notification to the user
|
||||
* @param input - The notification data
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
sendMagicLink(input: MagicLinkNotificationInput): Promise<void>;
|
||||
}
|
||||
37
core/identity/domain/repositories/IMagicLinkRepository.ts
Normal file
37
core/identity/domain/repositories/IMagicLinkRepository.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
userId: string;
|
||||
used?: boolean;
|
||||
}
|
||||
|
||||
export interface IMagicLinkRepository {
|
||||
/**
|
||||
* Create a password reset request
|
||||
*/
|
||||
createPasswordResetRequest(request: PasswordResetRequest): Promise<void>;
|
||||
|
||||
/**
|
||||
* Find a password reset request by token
|
||||
*/
|
||||
findByToken(token: string): Promise<PasswordResetRequest | null>;
|
||||
|
||||
/**
|
||||
* Mark a token as used
|
||||
*/
|
||||
markAsUsed(token: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check rate limiting for an email
|
||||
* Returns Result.ok if allowed, Result.err if rate limited
|
||||
*/
|
||||
checkRateLimit(email: string): Promise<Result<void, { message: string }>>;
|
||||
|
||||
/**
|
||||
* Clean up expired tokens
|
||||
*/
|
||||
cleanupExpired(): Promise<void>;
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
export interface UserCredentials {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
salt: string;
|
||||
}
|
||||
|
||||
export interface StoredUser {
|
||||
@@ -15,7 +14,7 @@ export interface StoredUser {
|
||||
email: string;
|
||||
displayName: string;
|
||||
passwordHash: string;
|
||||
salt: string;
|
||||
salt?: string;
|
||||
primaryDriverId?: string | undefined;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export * from './domain/repositories/IUserRepository';
|
||||
export * from './domain/repositories/ISponsorAccountRepository';
|
||||
export * from './domain/repositories/IUserRatingRepository';
|
||||
export * from './domain/repositories/IAchievementRepository';
|
||||
export * from './domain/repositories/IAuthRepository';
|
||||
export * from './domain/repositories/IMagicLinkRepository';
|
||||
|
||||
export * from './application/ports/IdentityProviderPort';
|
||||
export * from './application/ports/IdentitySessionPort';
|
||||
@@ -18,3 +20,7 @@ export * from './application/use-cases/StartAuthUseCase';
|
||||
export * from './application/use-cases/HandleAuthCallbackUseCase';
|
||||
export * from './application/use-cases/GetCurrentUserSessionUseCase';
|
||||
export * from './application/use-cases/LogoutUseCase';
|
||||
export * from './application/use-cases/SignupUseCase';
|
||||
export * from './application/use-cases/LoginUseCase';
|
||||
export * from './application/use-cases/ForgotPasswordUseCase';
|
||||
export * from './application/use-cases/ResetPasswordUseCase';
|
||||
|
||||
Reference in New Issue
Block a user