This commit is contained in:
2025-12-31 19:55:43 +01:00
parent 8260bf7baf
commit 167e82a52b
66 changed files with 5124 additions and 228 deletions

View File

@@ -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;
}
}

View 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>;
}

View 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>;
}

View File

@@ -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;
}