auth
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user