Files
gridpilot.gg/core/identity/application/use-cases/LoginWithEmailUseCase.ts
2025-12-23 15:38:50 +01:00

139 lines
4.5 KiB
TypeScript

/**
* Login with Email Use Case
*
* Authenticates a user with email and password.
*/
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type LoginWithEmailInput = {
email: string;
password: string;
};
export type LoginWithEmailResult = {
sessionToken: string;
userId: string;
displayName: string;
email?: string;
primaryDriverId?: string;
issuedAt: number;
expiresAt: number;
};
export type LoginWithEmailErrorCode =
| 'INVALID_INPUT'
| 'INVALID_CREDENTIALS'
| 'REPOSITORY_ERROR';
export type LoginWithEmailApplicationError = ApplicationErrorCode<
LoginWithEmailErrorCode,
{ message: string }
>;
export class LoginWithEmailUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LoginWithEmailResult>,
) {}
async execute(input: LoginWithEmailInput): Promise<Result<void, LoginWithEmailApplicationError>> {
try {
if (!input.email || !input.password) {
return Result.err({
code: 'INVALID_INPUT',
details: { message: 'Email and password are required' },
} as LoginWithEmailApplicationError);
}
const normalizedEmail = input.email.toLowerCase().trim();
const user = await this.userRepository.findByEmail(normalizedEmail);
if (!user) {
return Result.err({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid email or password' },
} as LoginWithEmailApplicationError);
}
const passwordHash = await this.hashPassword(input.password, user.salt);
if (passwordHash !== user.passwordHash) {
return Result.err({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid email or password' },
} as LoginWithEmailApplicationError);
}
type CreateSessionInput = Parameters<IdentitySessionPort['createSession']>[0];
const createSessionInput = {
id: user.id,
displayName: user.displayName,
...(user.email !== undefined ? { email: user.email } : {}),
...(user.primaryDriverId !== undefined
? { primaryDriverId: user.primaryDriverId }
: {}),
} satisfies CreateSessionInput;
const session = await this.sessionPort.createSession(createSessionInput);
const result: LoginWithEmailResult = {
sessionToken: session.token,
userId: session.user.id,
displayName: session.user.displayName,
...(session.user.email !== undefined ? { email: session.user.email } : {}),
...(session.user.primaryDriverId !== undefined
? { primaryDriverId: session.user.primaryDriverId }
: {}),
issuedAt: session.issuedAt,
expiresAt: session.expiresAt,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute LoginWithEmailUseCase';
this.logger.error(
'LoginWithEmailUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as LoginWithEmailApplicationError);
}
}
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');
}
}