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
143 lines
8.6 KiB
HTML
143 lines
8.6 KiB
HTML
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-8">
|
|
<div class="w-full max-w-md">
|
|
|
|
<!-- Logo -->
|
|
<div class="text-center mb-6">
|
|
<img src="assets/Logo_vertikal.svg" alt="Armarium" class="h-16 mx-auto mb-3 dark:invert" />
|
|
<p class="text-gray-500 dark:text-gray-400">{{ 'auth.tagline_register' | translate }}</p>
|
|
</div>
|
|
|
|
<!-- Card -->
|
|
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-6">
|
|
|
|
<!-- Toolbar -->
|
|
<div class="flex items-center justify-between mb-5">
|
|
<app-lang-switcher />
|
|
<button type="button" (click)="themeService.toggle()"
|
|
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
@if (themeService.isDark()) {
|
|
<!-- Flowbite: solid/weather/sun -->
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path fill-rule="evenodd" d="M13 3a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V3ZM6.343 4.929A1 1 0 0 0 4.93 6.343l1.414 1.414a1 1 0 0 0 1.414-1.414L6.343 4.929Zm12.728 1.414a1 1 0 0 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 1.414 1.414l1.414-1.414ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm-9 4a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H3Zm16 0a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2h-2ZM7.757 17.657a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414Zm9.9-1.414a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM13 19a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Z" clip-rule="evenodd"/>
|
|
</svg>
|
|
} @else {
|
|
<!-- Flowbite: solid/weather/moon -->
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
|
|
</svg>
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Heading -->
|
|
<h1 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ 'auth.create_account' | translate }}
|
|
</h1>
|
|
<p class="mb-5 text-gray-500 dark:text-gray-400">
|
|
{{ 'auth.has_account' | translate }}
|
|
<a routerLink="/login" class="font-medium text-violet-700 hover:underline dark:text-violet-500">
|
|
{{ 'auth.sign_in' | translate }}
|
|
</a>
|
|
</p>
|
|
|
|
<!-- Fields -->
|
|
<div class="space-y-4">
|
|
|
|
<!-- Email -->
|
|
<div>
|
|
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ 'auth.email' | translate }}
|
|
</label>
|
|
<input type="email" [(ngModel)]="email" autocomplete="email"
|
|
class="block w-full 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:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
|
|
</div>
|
|
|
|
<!-- Password -->
|
|
<div>
|
|
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ 'auth.password' | translate }}
|
|
</label>
|
|
<div class="relative">
|
|
<input [type]="showPassword() ? 'text' : 'password'" [(ngModel)]="password" autocomplete="new-password"
|
|
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-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
|
|
<button type="button" (click)="showPassword.set(!showPassword())"
|
|
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 (showPassword()) {
|
|
<!-- 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>
|
|
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">{{ 'auth.password_hint' | translate }}</p>
|
|
</div>
|
|
|
|
<!-- Confirm Password -->
|
|
<div>
|
|
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ 'auth.confirm_password' | translate }}
|
|
</label>
|
|
<div class="relative">
|
|
<input [type]="showConfirmPassword() ? 'text' : 'password'" [(ngModel)]="confirmPassword"
|
|
autocomplete="new-password" (keyup.enter)="submit()"
|
|
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-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
|
|
<button type="button" (click)="showConfirmPassword.set(!showConfirmPassword())"
|
|
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 (showConfirmPassword()) {
|
|
<!-- 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>
|
|
|
|
<!-- Error -->
|
|
@if (error()) {
|
|
<div class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
|
|
<!-- Flowbite: outline/alerts/circle-exclamation -->
|
|
<svg class="w-4 h-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>
|
|
{{ error() | translate }}
|
|
</div>
|
|
}
|
|
|
|
<app-turnstile class="mt-4 block" (resolved)="turnstileToken = $event" />
|
|
|
|
<!-- Submit -->
|
|
<button (click)="submit()" [disabled]="loading() || !turnstileToken"
|
|
class="mt-4 w-full rounded-lg bg-violet-700 px-5 py-2.5 text-center 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 (loading()) {
|
|
<span class="inline-flex items-center gap-2">
|
|
<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"/>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
</svg>
|
|
{{ 'auth.creating_account' | translate }}
|
|
</span>
|
|
} @else {
|
|
{{ 'auth.sign_up' | translate }}
|
|
}
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|