feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests

- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow
- Add PII field encryption middleware with AES-256-GCM and deterministic search hashes
- Add agents, inquiries, and leads domain modules with entities, events, value objects
- Add web dashboard pages for inquiries and leads with detail dialogs
- Add 30+ component tests (valuation, charts, listings, search, providers, UI)
- Add Prisma migrations for encryption hash columns and MFA TOTP support
- Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes)
- Update dependencies and lock file
- Clean up obsolete exploration/QA docs, add audit documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 23:43:20 +07:00
parent 9e2bf9a4b5
commit 1fbe2f4e73
131 changed files with 11436 additions and 2595 deletions

View File

@@ -17,6 +17,10 @@ export interface UserProps {
kycStatus: KYCStatus;
kycData: unknown;
isActive: boolean;
totpSecret: string | null;
totpEnabled: boolean;
totpBackupCodes: string[];
totpEnabledAt: Date | null;
}
export class UserEntity extends AggregateRoot<string> {
@@ -29,6 +33,10 @@ export class UserEntity extends AggregateRoot<string> {
private _kycStatus: KYCStatus;
private _kycData: unknown;
private _isActive: boolean;
private _totpSecret: string | null;
private _totpEnabled: boolean;
private _totpBackupCodes: string[];
private _totpEnabledAt: Date | null;
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
@@ -41,6 +49,10 @@ export class UserEntity extends AggregateRoot<string> {
this._kycStatus = props.kycStatus;
this._kycData = props.kycData;
this._isActive = props.isActive;
this._totpSecret = props.totpSecret;
this._totpEnabled = props.totpEnabled;
this._totpBackupCodes = props.totpBackupCodes;
this._totpEnabledAt = props.totpEnabledAt;
}
get email(): Email | null { return this._email; }
@@ -52,6 +64,10 @@ export class UserEntity extends AggregateRoot<string> {
get kycStatus(): KYCStatus { return this._kycStatus; }
get kycData(): unknown { return this._kycData; }
get isActive(): boolean { return this._isActive; }
get totpSecret(): string | null { return this._totpSecret; }
get totpEnabled(): boolean { return this._totpEnabled; }
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
get totpEnabledAt(): Date | null { return this._totpEnabledAt; }
static createNew(
id: string,
@@ -71,6 +87,10 @@ export class UserEntity extends AggregateRoot<string> {
kycStatus: 'NONE',
kycData: null,
isActive: true,
totpSecret: null,
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
@@ -97,4 +117,25 @@ export class UserEntity extends AggregateRoot<string> {
this._isActive = true;
this.updatedAt = new Date();
}
enableTotp(secret: string, backupCodes: string[]): void {
this._totpSecret = secret;
this._totpEnabled = true;
this._totpBackupCodes = backupCodes;
this._totpEnabledAt = new Date();
this.updatedAt = new Date();
}
disableTotp(): void {
this._totpSecret = null;
this._totpEnabled = false;
this._totpBackupCodes = [];
this._totpEnabledAt = null;
this.updatedAt = new Date();
}
consumeBackupCode(index: number): void {
this._totpBackupCodes = this._totpBackupCodes.filter((_, i) => i !== index);
this.updatedAt = new Date();
}
}

View File

@@ -4,3 +4,8 @@ export {
type IRefreshTokenRepository,
type RefreshTokenRecord,
} from './refresh-token.repository';
export {
MFA_CHALLENGE_REPOSITORY,
type IMfaChallengeRepository,
type MfaChallengeRecord,
} from './mfa-challenge.repository';

View File

@@ -0,0 +1,21 @@
export const MFA_CHALLENGE_REPOSITORY = Symbol('MFA_CHALLENGE_REPOSITORY');
export interface MfaChallengeRecord {
id: string;
userId: string;
type: string;
attemptCount: number;
maxAttempts: number;
isVerified: boolean;
expiresAt: Date;
createdAt: Date;
}
export interface IMfaChallengeRepository {
create(record: Omit<MfaChallengeRecord, 'createdAt'>): Promise<MfaChallengeRecord>;
findById(id: string): Promise<MfaChallengeRecord | null>;
incrementAttempts(id: string): Promise<void>;
markVerified(id: string): Promise<void>;
deleteExpired(): Promise<number>;
deleteByUserId(userId: string): Promise<number>;
}

View File

@@ -8,4 +8,8 @@ export interface IUserRepository {
findByEmail(email: string): Promise<UserEntity | null>;
save(user: UserEntity): Promise<void>;
update(user: UserEntity): Promise<void>;
updateMfaSecret(userId: string, secret: string | null): Promise<void>;
updateMfaEnabled(userId: string, enabled: boolean, secret: string, backupCodes: string[]): Promise<void>;
updateMfaDisabled(userId: string): Promise<void>;
updateBackupCodes(userId: string, backupCodes: string[]): Promise<void>;
}