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
This commit is contained in:
Daniel Krähenbühl
2026-05-25 22:45:18 +02:00
parent 807ebc41a5
commit 1a7ef09805
150 changed files with 22862 additions and 3 deletions
+452
View File
@@ -0,0 +1,452 @@
<div class="max-w-2xl mx-auto space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'nav.settings' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ 'settings.subtitle' | translate }}</p>
</div>
<!-- Recovery Email Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-1">{{ 'settings.recovery_email' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">{{ 'settings.recovery_email_hint' | translate }}</p>
<div class="flex flex-col gap-2 sm:flex-row">
<input type="email" [(ngModel)]="recoveryEmail" placeholder="backup@example.com"
class="block flex-1 rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button (click)="saveRecoveryEmail()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors whitespace-nowrap">
{{ 'common.save' | translate }}
</button>
</div>
@if (recoveryEmailSaved()) {
<div class="mt-3 flex items-center gap-2 rounded-lg bg-emerald-50 px-3 py-2 text-xs text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'settings.recovery_email_saved' | translate }}
</div>
}
</div>
<!-- 2FA Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'profile.totp_title' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'profile.totp_subtitle' | translate }}</p>
</div>
<span class="text-xs font-medium px-2.5 py-1 rounded-full"
[class]="totpEnabled()
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'">
{{ (totpEnabled() ? 'profile.totp_on' : 'profile.totp_off') | translate }}
</span>
</div>
@if (totpSuccess()) {
<div class="mb-4 flex items-center gap-2 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ totpSuccess() | translate }}
</div>
}
@if (totpSetupStep() === 'idle') {
@if (!totpEnabled()) {
<button (click)="startEnable2FA()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
{{ 'profile.totp_enable' | translate }}
</button>
} @else {
<button (click)="startDisable2FA()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 transition-colors">
{{ 'profile.totp_disable' | translate }}
</button>
}
}
@if (totpSetupStep() === 'scan') {
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ 'profile.totp_scan_hint' | translate }}</p>
@if (totpQrDataUrl()) {
<div class="flex justify-center">
<img [src]="totpQrDataUrl()!" alt="QR Code"
class="w-48 h-48 rounded-lg border border-gray-200 dark:border-gray-600 bg-white p-1" />
</div>
}
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.totp_code_label' | translate }}</label>
<input type="text" [(ngModel)]="totpCode" maxlength="6" inputmode="numeric" placeholder="000000"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-center text-sm tracking-[0.4em] text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
@if (totpError()) {
<div class="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ totpError() | translate }}
</div>
}
<div class="flex gap-2">
<button (click)="confirmEnable2FA()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
{{ 'profile.totp_confirm' | translate }}
</button>
<button (click)="cancelTotp()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
}
@if (totpSetupStep() === 'disable') {
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ 'profile.totp_disable_hint' | translate }}</p>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.totp_code_label' | translate }}</label>
<input type="text" [(ngModel)]="totpCode" maxlength="6" inputmode="numeric" placeholder="000000"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-center text-sm tracking-[0.4em] text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
@if (totpError()) {
<div class="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ totpError() | translate }}
</div>
}
<div class="flex gap-2">
<button (click)="confirmDisable2FA()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 transition-colors">
{{ 'profile.totp_disable' | translate }}
</button>
<button (click)="cancelTotp()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
}
</div>
<!-- Active Sessions Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-1">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'settings.sessions_title' | translate }}</h2>
@if (sessions().length > 1) {
<button (click)="revokeAllOtherSessions()" [disabled]="revokeAllLoading()"
class="text-xs text-red-600 dark:text-red-400 hover:underline disabled:opacity-50">
{{ 'settings.sessions_revoke_all' | translate }}
</button>
}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">{{ 'settings.sessions_hint' | translate }}</p>
@if (sessionsLoading()) {
<p class="text-sm text-gray-400 dark:text-gray-500">{{ 'settings.sessions_loading' | translate }}</p>
} @else {
<div class="space-y-2">
@for (session of sessions(); track session.session_key) {
<div class="flex items-center justify-between rounded-lg bg-gray-50 dark:bg-gray-900 p-3 gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ session.device_name || ('settings.sessions_unknown_device' | translate) }}
</span>
@if (session.is_current) {
<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300">
{{ 'settings.sessions_current' | translate }}
</span>
}
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ session.ip_address }}
@if (session.ip_address) { · }
{{ session.last_active_at | date:'dd.MM.yyyy HH:mm' }}
</p>
</div>
@if (!session.is_current) {
<button (click)="revokeSession(session.session_key)"
[disabled]="revokeLoading() === session.session_key"
class="shrink-0 text-xs text-red-600 dark:text-red-400 hover:underline disabled:opacity-50">
{{ 'settings.sessions_revoke' | translate }}
</button>
}
</div>
}
</div>
}
</div>
<!-- Data Export Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-1">{{ 'settings.export_title' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">{{ 'settings.export_hint' | translate }}</p>
<button (click)="downloadExport()" [disabled]="exportLoading()"
class="inline-flex items-center gap-2 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
@if (exportLoading()) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ 'settings.export_loading' | translate }}
} @else {
<!-- Flowbite: outline/general/download -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/>
</svg>
{{ 'settings.export_btn' | translate }}
}
</button>
</div>
<!-- Notification Preferences Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-1">{{ 'settings.notif_title' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-5">{{ 'settings.notif_hint' | translate }}</p>
<div class="space-y-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'settings.notif_deadlines' | translate }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'settings.notif_deadlines_hint' | translate }}</p>
</div>
<button type="button" (click)="toggleNotif('notif_deadlines')"
[class]="notifPrefs().notif_deadlines ? 'bg-violet-600' : 'bg-gray-200 dark:bg-gray-600'"
class="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors">
<span [class]="notifPrefs().notif_deadlines ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'settings.notif_budget_alerts' | translate }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'settings.notif_budget_alerts_hint' | translate }}</p>
</div>
<button type="button" (click)="toggleNotif('notif_budget_alerts')"
[class]="notifPrefs().notif_budget_alerts ? 'bg-violet-600' : 'bg-gray-200 dark:bg-gray-600'"
class="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors">
<span [class]="notifPrefs().notif_budget_alerts ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'settings.notif_monthly_summary' | translate }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'settings.notif_monthly_summary_hint' | translate }}</p>
</div>
<button type="button" (click)="toggleNotif('notif_monthly_summary')"
[class]="notifPrefs().notif_monthly_summary ? 'bg-violet-600' : 'bg-gray-200 dark:bg-gray-600'"
class="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors">
<span [class]="notifPrefs().notif_monthly_summary ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
</div>
<div class="flex items-center gap-3 mt-5">
<button (click)="saveNotifPrefs()" [disabled]="notifSaving()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
{{ 'common.save' | translate }}
</button>
@if (notifSaved()) {
<span class="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'settings.notif_saved' | translate }}
</span>
}
</div>
</div>
<!-- Danger Zone -->
<div class="rounded-lg bg-white border border-red-200 dark:bg-gray-800 dark:border-red-900 p-6">
<h2 class="text-base font-semibold text-red-600 mb-2">{{ 'profile.danger_zone' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ 'profile.danger_text' | translate }}</p>
<button (click)="openDeleteModal()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 transition-colors">
{{ 'profile.delete_account' | translate }}
</button>
</div>
</div>
<!-- BACKUP CODES MODAL -->
@if (backupCodes().length > 0) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50">
<!-- Flowbite: outline/security/lock-key (key icon) -->
<svg class="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 2.5v-5M12 7h.01"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'profile.backup_codes_title' | translate }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'profile.backup_codes_hint' | translate }}</p>
</div>
</div>
<div class="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-gray-50 dark:bg-gray-900 p-4">
@for (code of backupCodes(); track code) {
<span class="font-mono text-sm tracking-wider text-gray-800 dark:text-gray-200">{{ code }}</span>
}
</div>
<div class="flex flex-wrap gap-2">
<button (click)="copyBackupCodes()"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
@if (backupCopied()) {
<!-- Flowbite: outline/alerts/check-circle -->
<svg class="w-4 h-4 text-emerald-500" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'profile.backup_copied' | translate }}
} @else {
<!-- Flowbite: outline/general/copy -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-6 5h6m-6 4h6M10 3v4h4V3h-4Z"/>
</svg>
{{ 'profile.backup_copy' | translate }}
}
</button>
<button (click)="downloadBackupCodesPdf()"
class="inline-flex items-center gap-1.5 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
<!-- Flowbite: outline/general/download -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/>
</svg>
{{ 'profile.backup_download_pdf' | translate }}
</button>
<button (click)="closeBackupCodes()"
class="ml-auto rounded-lg px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:underline">
{{ 'profile.backup_saved' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- DELETE CONFIRMATION MODAL -->
@if (showDeleteModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeDeleteModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5 text-center">
@if (!exportedBeforeDelete()) {
<!-- Step 1: Export required -->
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
<!-- Flowbite: outline/general/download -->
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'settings.delete_step1_title' | translate }}</h3>
<p class="mb-6 text-sm text-gray-500 dark:text-gray-400">{{ 'settings.delete_step1_hint' | translate }}</p>
<div class="flex justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="downloadExport()" [disabled]="exportLoading()"
class="inline-flex items-center gap-2 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
@if (exportLoading()) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
}
{{ 'settings.delete_step1_btn' | translate }}
</button>
</div>
} @else {
<!-- Step 2: Credentials + confirmation phrase -->
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<!-- Flowbite: outline/alerts/circle-exclamation -->
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<div class="mb-4 flex items-center justify-center gap-1.5">
<svg class="h-4 w-4 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
<span class="text-xs text-emerald-600 dark:text-emerald-400">{{ 'settings.delete_step2_exported' | translate }}</span>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'profile.delete_account_confirm' | translate }}</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'profile.delete_account_text' | translate }}</p>
<div class="mb-5 space-y-4 text-left">
<div>
<label class="mb-2 block text-xs font-medium text-gray-900 dark:text-white">{{ 'settings.delete_password_label' | translate }}</label>
<div class="relative">
<input [type]="showDeletePassword() ? 'text' : 'password'" [(ngModel)]="deletePassword"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-red-500 dark:focus:ring-red-500" />
<button type="button" (click)="showDeletePassword.set(!showDeletePassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showDeletePassword()) {
<!-- Flowbite: outline/general/eye-slash -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<!-- Flowbite: outline/general/eye -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
</div>
<div>
<label class="mb-2 block text-xs font-medium text-gray-900 dark:text-white">
{{ 'settings.delete_phrase_label' | translate }}
<span class="ml-1 font-mono text-red-600 dark:text-red-400">{{ DELETE_PHRASE }}</span>
</label>
<input type="text" [(ngModel)]="deleteConfirmText" autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-red-500 dark:focus:ring-red-500" />
</div>
@if (deleteError()) {
<div class="flex items-center gap-2 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ deleteError() | translate }}
</div>
}
</div>
<div class="flex justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="confirmDelete()" [disabled]="!deleteFormValid"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{{ 'profile.delete_account' | translate }}
</button>
</div>
}
</div>
</div>
</div>
}
+287
View File
@@ -0,0 +1,287 @@
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'),
});
}
}