1a7ef09805
Dashboard: - ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart - KPI cards: income, fixed costs, savings rate with configurable goal - Greeting with time-of-day and locale-aware date/time display Authentication & security: - Email-based login (no username), case-insensitive lookup - JWT access/refresh tokens with rotation and blacklist - TOTP 2FA with QR code, backup codes (copy + PDF export) - 2FA recovery via email code - Cloudflare Turnstile CAPTCHA on login and register Email flows: - Email verification on registration (24h token) - Password reset flow (15min token, anti-enumeration) - Brevo SMTP integration with HTML + plaintext email templates - Notification emails: 2FA recovery, password changed, email changed Settings page: - 2FA management (enable/disable, QR, backup codes) - Active sessions list with per-device revoke - Data export: ZIP with 6 PDFs via fpdf2 - Notification preferences (3 toggles) - Danger zone: account deletion with mandatory export + confirmation phrase UI & layout: - Sidebar with collapsible/flyout mode, Angular signal-based dropdowns - Dark mode (class-based), language switcher (DE/FR/IT/EN) - Mobile-responsive layout with touch-friendly targets - Roboto font via @fontsource (GDPR-compliant, no Google CDN) - Pure Tailwind CSS v3 Infrastructure: - Forgejo Actions CI/CD pipeline (auto-deploy on push to main) - Gunicorn + Nginx + PostgreSQL production setup - Rate limiting, HSTS, secure cookies, CSRF protection
288 lines
9.2 KiB
TypeScript
288 lines
9.2 KiB
TypeScript
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<string | null>(null);
|
|
totpCode = '';
|
|
totpError = signal('');
|
|
totpSuccess = signal('');
|
|
backupCodes = signal<string[]>([]);
|
|
backupCopied = signal(false);
|
|
|
|
// Active Sessions
|
|
sessions = signal<any[]>([]);
|
|
sessionsLoading = signal(false);
|
|
revokeLoading = signal<string | null>(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<void> {
|
|
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'),
|
|
});
|
|
}
|
|
}
|