import { Component, OnInit, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import * as QRCode from 'qrcode'; import { ApiService } from '../services/api'; import { AuthService } from '../services/auth'; @Component({ selector: 'app-settings', standalone: true, imports: [CommonModule, FormsModule, TranslateModule], templateUrl: './settings.html', }) export class Settings implements OnInit { // Recovery email recoveryEmail = ''; recoveryEmailSaved = signal(false); // 2FA totpEnabled = signal(false); totpSetupStep = signal<'idle' | 'scan' | 'disable'>('idle'); totpQrDataUrl = signal(null); totpCode = ''; totpError = signal(''); totpSuccess = signal(''); backupCodes = signal([]); backupCopied = signal(false); // Active Sessions sessions = signal([]); sessionsLoading = signal(false); revokeLoading = signal(null); revokeAllLoading = signal(false); // Data Export exportLoading = signal(false); exportedBeforeDelete = signal(false); // Notification Preferences notifPrefs = signal({ notif_deadlines: true, notif_budget_alerts: true, notif_monthly_summary: false }); notifSaving = signal(false); notifSaved = signal(false); // Danger Zone showDeleteModal = signal(false); showDeletePassword = signal(false); deletePassword = ''; deleteConfirmText = ''; deleteError = signal(''); get DELETE_PHRASE(): string { return this.translate.instant('profile.delete_account'); } constructor(private api: ApiService, private auth: AuthService, private translate: TranslateService) {} ngOnInit(): void { this.api.getProfile().subscribe({ next: (data) => { if (data.totp_enabled !== undefined) this.totpEnabled.set(data.totp_enabled); if (data.recovery_email !== undefined) this.recoveryEmail = data.recovery_email; this.notifPrefs.set({ notif_deadlines: data.notif_deadlines ?? true, notif_budget_alerts: data.notif_budget_alerts ?? true, notif_monthly_summary: data.notif_monthly_summary ?? false, }); }, }); this.loadSessions(); } // ── Recovery email ──────────────────────────────────────────────────────── saveRecoveryEmail(): void { this.api.updateProfile({ recovery_email: this.recoveryEmail }).subscribe({ next: () => { this.recoveryEmailSaved.set(true); setTimeout(() => this.recoveryEmailSaved.set(false), 3000); }, }); } // ── 2FA ────────────────────────────────────────────────────────────────── startEnable2FA(): void { this.totpError.set(''); this.totpCode = ''; this.api.get2FASetup().subscribe({ next: async (res) => { const dataUrl = await QRCode.toDataURL(res.uri, { width: 200, margin: 2 }); this.totpQrDataUrl.set(dataUrl); this.totpSetupStep.set('scan'); }, }); } confirmEnable2FA(): void { this.totpError.set(''); this.api.enable2FA(this.totpCode).subscribe({ next: (res) => { this.totpEnabled.set(true); this.totpSetupStep.set('idle'); this.totpQrDataUrl.set(null); this.totpCode = ''; this.backupCodes.set(res.backup_codes ?? []); }, error: () => this.totpError.set('profile.totp_invalid_code'), }); } startDisable2FA(): void { this.totpError.set(''); this.totpCode = ''; this.totpSetupStep.set('disable'); } confirmDisable2FA(): void { this.totpError.set(''); this.api.disable2FA(this.totpCode).subscribe({ next: () => { this.totpEnabled.set(false); this.totpSetupStep.set('idle'); this.totpCode = ''; this.totpSuccess.set('profile.totp_disabled_success'); setTimeout(() => this.totpSuccess.set(''), 3000); }, error: () => this.totpError.set('profile.totp_invalid_code'), }); } cancelTotp(): void { this.totpSetupStep.set('idle'); this.totpCode = ''; this.totpError.set(''); this.totpQrDataUrl.set(null); } copyBackupCodes(): void { navigator.clipboard.writeText(this.backupCodes().join('\n')).then(() => { this.backupCopied.set(true); setTimeout(() => this.backupCopied.set(false), 2000); }); } async downloadBackupCodesPdf(): Promise { const { jsPDF } = await import('jspdf'); const doc = new jsPDF({ unit: 'mm', format: 'a4' }); const codes = this.backupCodes(); doc.setFont('helvetica', 'bold'); doc.setFontSize(18); doc.text('Armarium — Backup Codes', 20, 24); doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(100); doc.text('Store these codes in a safe place. Each code can only be used once.', 20, 34); doc.text('Use one if you lose access to your authenticator app.', 20, 40); doc.setDrawColor(200); doc.line(20, 46, 190, 46); doc.setFontSize(14); doc.setFont('courier', 'normal'); doc.setTextColor(30); codes.forEach((code, i) => { doc.text(code, 20, 58 + i * 12); }); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(150); doc.text(`Generated ${new Date().toLocaleDateString()} · armarium.app`, 20, 200); doc.save('armarium-backup-codes.pdf'); } closeBackupCodes(): void { this.backupCodes.set([]); this.totpSuccess.set('profile.totp_enabled_success'); setTimeout(() => this.totpSuccess.set(''), 3000); } // ── Active Sessions ─────────────────────────────────────────────────────── loadSessions(): void { this.sessionsLoading.set(true); this.api.getSessions().subscribe({ next: (s) => { this.sessions.set(s); this.sessionsLoading.set(false); }, error: () => this.sessionsLoading.set(false), }); } revokeSession(key: string): void { this.revokeLoading.set(key); this.api.revokeSession(key).subscribe({ next: () => { this.sessions.set(this.sessions().filter(s => s.session_key !== key)); this.revokeLoading.set(null); }, error: () => this.revokeLoading.set(null), }); } revokeAllOtherSessions(): void { this.revokeAllLoading.set(true); this.api.revokeAllOtherSessions().subscribe({ next: () => { this.loadSessions(); this.revokeAllLoading.set(false); }, error: () => this.revokeAllLoading.set(false), }); } // ── Data Export ─────────────────────────────────────────────────────────── downloadExport(): void { this.exportLoading.set(true); this.api.downloadExport().subscribe({ next: (blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'armarium-export.zip'; a.click(); URL.revokeObjectURL(url); this.exportLoading.set(false); this.exportedBeforeDelete.set(true); }, error: () => this.exportLoading.set(false), }); } // ── Notification Preferences ────────────────────────────────────────────── toggleNotif(key: 'notif_deadlines' | 'notif_budget_alerts' | 'notif_monthly_summary'): void { this.notifPrefs.set({ ...this.notifPrefs(), [key]: !this.notifPrefs()[key] }); } saveNotifPrefs(): void { this.notifSaving.set(true); this.api.updateNotificationPrefs(this.notifPrefs()).subscribe({ next: () => { this.notifSaving.set(false); this.notifSaved.set(true); setTimeout(() => this.notifSaved.set(false), 3000); }, error: () => this.notifSaving.set(false), }); } // ── Danger Zone ─────────────────────────────────────────────────────────── get deleteFormValid(): boolean { return !!this.deletePassword && this.deleteConfirmText === this.DELETE_PHRASE; } openDeleteModal(): void { this.deletePassword = ''; this.deleteConfirmText = ''; this.deleteError.set(''); this.showDeletePassword.set(false); this.showDeleteModal.set(true); } closeDeleteModal(): void { this.showDeleteModal.set(false); this.deletePassword = ''; this.deleteConfirmText = ''; this.deleteError.set(''); this.showDeletePassword.set(false); } confirmDelete(): void { if (!this.deleteFormValid) return; this.deleteError.set(''); this.api.deleteProfile(this.deletePassword).subscribe({ next: () => { this.closeDeleteModal(); localStorage.clear(); sessionStorage.clear(); window.location.href = 'https://www.armarium.ch'; }, error: () => this.deleteError.set('settings.delete_wrong_password'), }); } }