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:
@@ -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>
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user