Files
armarium-suite/frontend/src/app/settings/settings.ts
T
Daniel Krähenbühl 1a7ef09805 feat: Armarium v1.1.0 — dashboard, auth, 2FA, SMTP, settings, deploy
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
2026-05-25 22:46:30 +02:00

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'),
});
}
}