import { ChangeDetectionStrategy, Component, effect, inject, input, signal, ViewEncapsulation } from '@angular/core'; import { BaseAuthService } from '../../service/auth.service'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { BaseAsync } from '../abstract/base_async'; import { toDataURL } from 'qrcode'; @Component({ selector: 'sail-twofactor-setup', templateUrl: './twofactor_setup.html', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [ ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, RouterLink, ], }) export class TwoFactorSetupComponent extends BaseAsync { private readonly auth = inject(BaseAuthService); private readonly fb = inject(FormBuilder); /** keel has no 2FA-status endpoint — the app tells us whether 2FA is already * on so returning users land on the manage/disable view, not a fresh setup * (which would rotate their seed and revoke all refresh tokens). */ readonly twoFactorEnabled = input(false); readonly step = signal<'init' | 'qr' | 'verify' | 'done'>('init'); readonly qrDataUrl = signal(''); readonly secret = signal(''); readonly backupCodes = signal([]); readonly showCodes = signal(false); // setup2FA requires the user's current password (or current TOTP code // if 2FA already enabled — pass twoFactorCode instead). Disable additionally // requires both. readonly initForm = this.fb.group({ password: ['', Validators.required], }); readonly verifyForm = this.fb.group({ code: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(6)]], }); readonly disableForm = this.fb.group({ password: ['', Validators.required], code: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(6)]], }); constructor() { super(); // Only flips between the two idle states; never yanks a user out of a flow. effect(() => { const enabled = this.twoFactorEnabled(); this.step.update((s) => (s === 'init' || s === 'done') ? (enabled ? 'done' : 'init') : s); }); } startSetup() { if (this.initForm.invalid) return; const password = this.initForm.value.password!; this.run( this.auth.setup2FA({ password }), (res) => { // keel returns an otpauth:// provisioning URI, not an image — render it. toDataURL(res.qrUri, { width: 200, margin: 1 }) .then((url) => this.qrDataUrl.set(url)) .catch(() => this.qrDataUrl.set('')); this.secret.set(res.secret); this.backupCodes.set(res.backupCodes); this.step.set('qr'); this.initForm.reset(); }, '2FA setup failed.', ); } proceedToVerify() { this.step.set('verify'); } // Backup codes are shown once at setup. Offer a download (they're hashed // server-side, so they can never be re-displayed later — only regenerated). downloadBackupCodes() { const blob = new Blob([this.backupCodes().join('\n') + '\n'], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'backup-codes.txt'; a.click(); URL.revokeObjectURL(url); } confirmSetup() { if (this.verifyForm.invalid) return; const { code } = this.verifyForm.value; this.run( this.auth.verify2FA({ code: code! }), (res) => { if (res.valid) { this.successMessage.set('Two-factor authentication enabled.'); this.step.set('done'); } else { this.errorMessage.set('Invalid code. Please try again.'); } }, 'Verification failed.', ); } disable2FA() { if (this.disableForm.invalid) return; const { password, code } = this.disableForm.value; this.run( this.auth.disable2FA(password!, code!), () => { this.successMessage.set('Two-factor authentication disabled.'); this.step.set('init'); this.disableForm.reset(); }, 'Failed to disable 2FA.', ); } }