2 Commits

Author SHA1 Message Date
Daniel Krähenbühl fe4aeb3034 feat: financial year planning — annual budgets, income tracking, household sharing
- Financial year page (/financial-year): year selector, 3 KPI cards (income,
  fixed costs, actual expenses), income and budget-items tabs with inline CRUD
- Revenue accounts as income source: salary-months toggle (12/13) per account
- Household support: create household, invite members by email (existing and
  new users via PendingHouseholdInvite), accept invitations, set roles
- Combined household income view across all active members
- FinancialYear, YearlyIncome, YearlyBudgetItem, Household, HouseholdMembership
  models with migrations; household invite email template
- Management command to migrate existing accounts/budgets to financial years
- FinancialYearService in Angular with full API integration
- Dashboard updated: income/fixed-costs read from financial year data,
  year dropdown synced with available financial years
- Sidebar: financial year nav item added
- i18n: all keys in DE/EN/FR/IT
2026-05-25 22:46:30 +02:00
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
164 changed files with 25523 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: production
steps:
- name: Pull latest code
run: |
cd /home/armarium/armarium-suite
git fetch origin
git reset --hard origin/main
- name: Backend — install dependencies
run: |
cd /home/armarium/armarium-suite/backend
source venv/bin/activate
pip install -r requirements.txt --quiet
- name: Backend — run migrations
run: |
cd /home/armarium/armarium-suite/backend
source venv/bin/activate
python manage.py migrate --no-input
- name: Frontend — build
run: |
cd /home/armarium/armarium-suite/frontend
npm install --silent
npm run build -- --configuration production
- name: Restart service
run: sudo systemctl restart armarium
+7
View File
@@ -1,6 +1,11 @@
# ── Temporäre Upload-Ordner ─────────────────────────────────────────────────── # ── Temporäre Upload-Ordner ───────────────────────────────────────────────────
Logos_Armarium/ Logos_Armarium/
# ── Lokales Referenzmaterial (nicht für VCS) ─────────────────────────────────
flowbite-reference/
flowbite-admin-dashboard-v2.2.0.zip
flowbite-admin-dashboard-v2.2.0.zip:Zone.Identifier
# ── Persönliche Entwicklungsnotizen (nicht für VCS) ────────────────────────── # ── Persönliche Entwicklungsnotizen (nicht für VCS) ──────────────────────────
backend/commands.md backend/commands.md
backend/tasks.json backend/tasks.json
@@ -17,3 +22,5 @@ Thumbs.db
# ── Claude Code (lokale Konfiguration) ─────────────────────────────────────── # ── Claude Code (lokale Konfiguration) ───────────────────────────────────────
.claude/ .claude/
.mcp.json .mcp.json
flowbite-admin-dashboard-v2.2.0.zipZone.Identifier
start.sh
+191
View File
@@ -7,6 +7,197 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Financial Year: Income tab zeigt neu Revenue Accounts (Typ «Einnahmequelle») statt YearlyIncome-Einträge — Monatsgehalt × Monate = Jahreseinkommen; Toggle-Button pro Konto für 12 oder 13 Monatslöhne; Gesamtjahreseinkommen-Summe am Tab-Ende
- Account-Model: `salary_months` Feld (IntegerField, default 12, choices 12/13, Migration 0021); `patchAccount()` in ApiService
- Financial Year: Summary-Cards überarbeitet — (1) Jahreseinkommen aus Revenue Accounts, (2) Fixkosten/Monat × 12 = Jahresbetrag aus `/budgets`, (3) tatsächliche Ausgaben des gewählten Jahres aus `/expenses` (ersetzt «Verfügbar»)
- Financial Year: Haushalt-Finanzjahr erstellbar — Modal «Neues Jahr starten» zeigt Radio-Buttons «Persönlich» / Haushalt-Name wenn User aktive Haushaltsmitgliedschaft hat; Backend akzeptiert optionales `household_id` bei `POST /api/financial-years/`
- Financial Year: Haushalt-Modus Einnahmen-Tab zeigt Revenue Accounts aller aktiven Haushaltsmitglieder (neuer Endpoint `GET /api/households/<pk>/revenue-accounts/`); Partner-Accounts mit E-Mail-Hinweis
- `household_id` in FinancialYear-Serializer-Response
- Haushalt Einladungsflow für nicht-registrierte Benutzer: `PendingHouseholdInvite` Model (Migration 0022) speichert E-Mail ohne User-FK; nach Registrierung wird `HouseholdMembership` automatisch angelegt und `PendingHouseholdInvite` gelöscht
- Einladungs-E-Mail via `household_invite` Template (HTML + Plaintext) mit variablem CTA-Label; bestehende User erhalten «Einladung annehmen» → `/financial-year`, neue User «Konto erstellen & beitreten» → `/register`
- Frontend zeigt ausstehende Einladungen an nicht-registrierte Adressen mit Badge «Nicht registriert» in der Haushaltsliste
- `FRONTEND_URL` Setting (default `http://localhost:4200`; Prod: `https://www.armarium.ch` in `.env`)
- ProfileSerializer: `get_email()` gibt `user.email` zurück wenn Profile-Email leer — verhindert dass `myMembership()` für neue User keine Treffer findet
### Fixed
- Dashboard: `totalExpenses()` filterte nicht nach ausgewähltem Jahr — alle Ausgaben wurden summiert
- Dashboard: `totalIncome()` und `totalFixedCosts()` lasen aus FinancialYear statt aus Revenue Accounts / `/budgets` — inkonsistent mit Dateneingabe-Workflow des Users
- Financial Year: `updateIncome()` und `updateBudgetItem()` verwendeten `PUT` statt `PATCH` → 405 Method Not Allowed
- Financial Year: `reloadCurrentYear()` löste `NG0100 ExpressionChangedAfterItHasBeenCheckedError` aus — Signal-Updates in `setTimeout()` verschoben
- Financial Year: `PATCH /incomes/<id>/` und `/budget-items/<id>/` gaben 403 zurück wenn `is_active=False` auf FinancialYear — `is_active`-Check aus 5 Backend-Views entfernt
- Backend: `Profile.email_verified` und verwandte Felder existierten in DB aber nicht im Model → `IntegrityError` beim Login neuer User; Felder ins Model aufgenommen (Migration 0023, fake-applied); DB-Defaults via `ALTER COLUMN ... SET DEFAULT` gesetzt
### Added
- Feature: Jahresplanung (`/financial-year`) — neue Seite mit Jahres-Dropdown, 3 Summary-Cards (Einnahmen, Fixkosten, Verfügbar + Sparquote), Tabs Einnahmen/Fixkosten, Inline-Formular für CRUD; Button "Neues Jahr starten" — max. 1 Jahr im Voraus (Backend + Frontend enforced)
- Backend: `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` Modelle (Migration 0019); exclusivity-Constraint via `CheckConstraint(condition=...)` (Django 6.0.4), partielle Unique-Constraints für persönliche und Haushalt-Jahre
- Backend: `FinancialYearListCreateView`, `FinancialYearDetailView`, `FinancialYearCopyView`, `YearlyIncomeListCreateView/DetailView`, `YearlyBudgetItemListCreateView/DetailView`, `HouseholdListCreateView`, `HouseholdInviteView`, `HouseholdAcceptView`, `HouseholdLeaveView`, `HouseholdSetRoleView`
- Backend: `GET/POST /api/financial-years/`, `GET/PATCH/DELETE /api/financial-years/<year>/`, `POST /api/financial-years/<year>/copy-from/<source>/`, nested Endpunkte für Incomes und Budget Items; `GET/POST /api/households/`, Invite/Accept/Leave/SetRole
- Backend: Jahr-Erstellungs-Begrenzer — max. `current_calendar_year + 1`; plus "nur nächstes Jahr nach dem Maximum" Constraint
- Backend: `role` Feld auf `HouseholdMembership` (`member` | `admin`, Migration 0020); Gründer erhält automatisch `role='admin'`; Einladungen erlaubt für Gründer und aktive Admins; Rollenvergabe nur durch Gründer via `POST /api/households/<pk>/members/<id>/set-role/`
- Dashboard: `totalIncome()` und `totalFixedCosts()` lesen nun aus `FinancialYearService.list()` für das gewählte Jahr (statt alte Account/Budget-Daten); Jahres-Dropdown zeigt echte FinancialYear-Jahre; Donut-Chart zeigt `YearlyBudgetItem` des gewählten Jahres; Jahrwechsel re-rendert beide Charts
- Backend: Django Management Command `migrate_to_financial_year` — migriert bestehende Revenue-Accounts → `YearlyIncome` und Budgets → `YearlyBudgetItem` für Jahr 2026; idempotent, `--dry-run` Flag verfügbar
- Frontend: `FinancialYearService` (`services/financial-year.ts`) mit Typen `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` und allen API-Methoden
- Frontend: Household-Sektion auf `/financial-year` — Haushalt gründen (Inline-Form), Mitglieder-Liste mit Status- und Rollen-Badge, Einladen per E-Mail (Admins + Gründer), Rollen-Toggle (Key-Icon, nur Gründer), Pending-Einladungs-Banner mit "Annehmen", "Verlassen"-Button mit Bestätigungs-Modal
- Sidebar: "Jahresplanung" Nav-Item (Bar-Chart-Icon, Violet) zwischen Kalender und Konten
- i18n: `sidebar.financial_year`, `financial_year.*` Schlüssel (DE/EN/FR/IT)
- Dashboard: Einnahmen vs. Ausgaben — Flowbite-Redesign mit Icon-Header (Violet), 3 Serien (Einnahmen/Fixkosten/Variable Ausgaben), gerundete Balken, kein Grid/Y-Axis, custom Tooltip mit ausgeschriebenem Monatsnamen in Landessprache, Jahres-Dropdown im Footer
- Dashboard: Fixkostenaufschlüsselung — Pie Chart (war: Donut) mit %-Datenlabels direkt auf Segmenten; Toggle-Button (Violet) wechselt zur Listenansicht mit Name, CHF-Betrag und %; Violet-Farbpalette
- Dashboard: Sparquote — Violet-Marker auf Progress-Bar an der Zielposition; Settings-Toggle (Badge-Icon, Violet) öffnet Einstellungsansicht mit Zahlenfeld, Live-Marker-Preview und Speichern/Abbrechen; Ziel persisted im Profil (`savings_rate_goal`, Default 20%)
- Backend: `savings_rate_goal` Feld auf `Profile`-Modell (Migration 0018)
- i18n: `dashboard.view_report`, `dashboard.goal_hint` in DE/EN/FR/IT; `dashboard.goal` von "Ziel: 20%" zu "Sparziel" geändert
- Security: Cloudflare Turnstile CAPTCHA on login and register — `TurnstileComponent` (Angular, polls until script loaded, auto-reset on error); backend verifies token via `_verify_turnstile()` using urllib (no extra dependency); `DEBUG=True` and `localhost` bypass for local development; Submit button disabled until widget resolves
- Infrastructure: Brevo SMTP configured for transactional email (`smtp-relay.brevo.com:587`, TLS); domain `armarium.ch` verified with SPF/DKIM; account activation pending (requested via contact@brevo.com)
- i18n: `auth.errors.captcha_failed` key in DE/EN/FR/IT
- Docs: `design-system.md` — Brand design reference with colors, typography (desktop/mobile), icons, component patterns and Tailwind classes
### Changed
- `.env.example`: added `TURNSTILE_SECRET_KEY` and Brevo `EMAIL_*` variables
---
## [1.1.0] - 2026-05-17
### Added
- Auth: E-Mail-Verifikation bei Registrierung — Token (SHA-256-Hash in DB, 24h gültig) wird per Mail versendet; `/verify-email?token=` Frontend-Route löst automatisch `POST /api/auth/verify-email/` aus
- Auth: Passwort vergessen / Reset — `POST /api/auth/password-reset/` (anti-enumeration); `POST /api/auth/password-reset/confirm/` setzt Passwort und invalidiert alle aktiven Sessions; Token (SHA-256-Hash, 15min TTL) via Brevo-Mail mit Link
- Auth: `ForgotPassword`-Komponente (`/forgot-password`), `ResetPassword`-Komponente (`/reset-password`), `VerifyEmail`-Komponente (`/verify-email`)
- Auth: "Passwort vergessen?"-Link auf Login-Seite
- E-Mail-Templates: `registration_confirm`, `password_reset`, `password_changed`, `email_changed` (je HTML + Plaintext)
- Backend: `finance/email.py` — generischer `send_email()` Helper mit `EmailMultiAlternatives`
- Backend: `FRONTEND_URL` Env-Var für absolute Links in Mails; `EMAIL_BACKEND` via Env-Var überschreibbar
- i18n: `auth.forgot_password`, `auth.reset_password`, `auth.new_password`, `auth.email_verified` + Error-Keys (DE/EN/FR/IT)
- Feature: Jahresplanung (`/financial-year`) — Jahres-Dropdown, 3 Summary-Cards, Tabs Einnahmen/Fixkosten mit Inline-CRUD; "Neues Jahr starten" (max. 1 Jahr im Voraus)
- Backend: `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` Modelle (Migration 0019)
- Backend: vollständige REST-API für Jahresplanung und Haushalte inkl. Invite/Accept/Leave/SetRole
- Backend: Django Management Command `migrate_to_financial_year` (idempotent, `--dry-run`)
- Frontend: `FinancialYearService` mit allen Typen und API-Methoden
- Frontend: Household-Sektion auf `/financial-year` (Gründen, Einladen, Rollen, Annehmen, Verlassen)
- Sidebar: "Jahresplanung" Nav-Item
- Dashboard: Einnahmen/Fixkosten aus `FinancialYearService` statt Account/Budget-Daten
- Dashboard: Einnahmen vs. Ausgaben — Flowbite-Redesign, 3 Serien, Jahres-Dropdown
- Dashboard: Fixkostenaufschlüsselung — Pie Chart mit %-Labels, Toggle zur Listenansicht
- Dashboard: Sparquote — personalisierbarer Ziel-Marker, Settings-Toggle zum Anpassen
- Security: Cloudflare Turnstile CAPTCHA auf Login + Register
- Infrastructure: Brevo SMTP (`smtp-relay.brevo.com:587`), Domain `armarium.ch` verifiziert (SPF/DKIM)
- i18n: `sidebar.financial_year`, `financial_year.*`, `dashboard.*`, `auth.errors.captcha_failed` (DE/EN/FR/IT)
- Settings: Active Sessions card — lists all logged-in devices (device name, IP, last active); individual revoke and "sign out all others" buttons; current session marked with badge; `UserSession` model with `session_key`, `refresh_jti`, `device_name`, `ip_address`; `_create_session()` called on every successful login (including 2FA and recovery flows)
- Settings: Data Export — downloads a ZIP containing 6 structured PDFs (Profil, Konten, Budgets, Ausgaben, Transaktionen, Termine) generated server-side with fpdf2; violet header bar, alternating row fill, gray footer with page numbers
- Settings: Notification Preferences — toggles for "Anstehende Termine", "Budget-Warnungen", "Monatliche Zusammenfassung"; saved via `PATCH /api/notifications/prefs/`; loaded from profile on page open
- Settings: Account deletion now requires two steps — (1) mandatory data export, (2) confirmation form with password field (show/hide eye icon) and translated confirmation phrase (`profile.delete_account` in current language); delete button disabled until both conditions met
- Settings: After account deletion, user is redirected to `https://www.armarium.ch` and both storages are cleared
- Auth: Language switcher (`LangSwitcher` component) inside the login and register cards (top-left); uses `@HostListener` for outside-click close and `[class]` binding to avoid Tailwind dark-mode colon conflicts
- Auth: "Angemeldet bleiben" checkbox on login with two-line label; toggles between `localStorage` (persistent) and `sessionStorage` (session-only); persisted through full 2FA and recovery flows via `keepSignedIn` parameter
- Auth: `session_key` stored alongside JWT tokens in same storage; sent as `X-Session-Key` header by interceptor so backend can identify current session
- Backend: `PATCH /api/notifications/prefs/` endpoint (`NotificationPrefsView`)
- Backend: `GET /api/auth/sessions/`, `DELETE /api/auth/sessions/<key>/`, `DELETE /api/auth/sessions/revoke-all/` endpoints
- Backend: `GET /api/export/` returns ZIP with 6 PDFs via fpdf2
- Backend: Admin URL configurable via `ADMIN_URL` env var (default `manage/`); obscures the standard `/admin/` path from scanners
- Backend: SMTP email config from env vars (`EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS`, `DEFAULT_FROM_EMAIL`); console backend in DEBUG mode
- Backend: production security block (`if not DEBUG`) — `SECURE_SSL_REDIRECT`, HSTS (1 year, preload, subdomains), secure cookies, `SECURE_CONTENT_TYPE_NOSNIFF`, proxy SSL header
- Backend: `DATA_UPLOAD_MAX_MEMORY_SIZE` and `FILE_UPLOAD_MAX_MEMORY_SIZE` capped at 5 MB
- Backend: `CSRF_TRUSTED_ORIGINS` from env var
- i18n: `settings.sessions_*`, `settings.export_*`, `settings.notif_*`, `settings.delete_*` keys (DE/FR/IT/EN)
- i18n: `auth.keep_signed_in`, `auth.keep_signed_in_hint`, `auth.back_to_login` keys (DE/FR/IT/EN)
- Security: TOTP-based two-factor authentication (2FA) — setup via QR code, verified on login; production-ready with HMAC-signed temp token (5 min expiry), replay protection via `totp_last_used_code`, and 8 backup codes in `XXXXXXXX-XXXXXXXX` format (SHA-256 hashed in DB)
- Security: `TwoFactorSetupView`, `TwoFactorEnableView`, `TwoFactorDisableView`, `TwoFactorLoginView` backend endpoints; `BackupCode` model with index on `(user, used)`
- Settings page (`/settings`): 2FA card (enable/disable, QR scan, backup code copy + PDF download) and Danger Zone (account deletion); accessible from navbar avatar menu
- Login: two-step flow — step 1 credentials, step 2 TOTP/backup code entry when 2FA is active
- Backup codes: copy to clipboard and PDF download via jsPDF (client-side, no server round-trip)
- Dashboard: donut chart "Fixed Costs Breakdown" now shows individual budget entry names and amounts (was: grouped by category)
- Dashboard: toggle button (top-right of donut card) switches between chart view and a scrollable breakdown list with color dot, name, CHF amount and percentage per entry
- i18n: `auth.totp_title`, `auth.totp_hint`, `auth.totp_or_backup`, `auth.back_to_login`, `auth.errors.invalid_totp` keys (DE/FR/IT/EN)
- i18n: `profile.totp_*`, `profile.backup_*` keys for all 2FA labels, backup codes and messages (DE/FR/IT/EN)
- i18n: `settings.subtitle` key (DE/FR/IT/EN)
### Changed
- `.env.example`: `FRONTEND_URL` und `EMAIL_BACKEND` Variablen ergänzt
- Profile-Model: `email_verify_token` neu als SHA-256-Hash gespeichert; `email_verify_token_expires` (24h TTL) hinzugefügt (Migration 0020)
- **UI:** migrated frontend to Flowbite design system — custom Tailwind v4 theme (`budget-app-theme.css`) with violet primary color, consistent rounded-lg cards, Flowbite outline SVG icons throughout
- **Navbar:** avatar dropdown and notification panel converted from Flowbite JS (`data-dropdown-toggle`) to Angular state management (`avatarDropdownOpen` signal + backdrop `<div>` for outside-click close); eliminates Flowbite JS runtime dependency
- **Navbar:** sun/moon/bell icons replaced with Flowbite outline variants; logout entry in avatar dropdown now shows `arrow-right-to-bracket` icon
- **Sidebar:** navigation icons restored to fill style (`fill="currentColor" viewBox="0 0 20 20"`) for consistency with pre-migration appearance; Settings link added to mobile drawer after Profile link
- **Dashboard:** greeting H1 changed to `font-light` (weight 300)
- **Mobile — tables:** non-critical columns hidden on small screens (`hidden sm:table-cell` for category, `hidden md:table-cell` for account) in expense and transaction lists
- **Mobile — touch targets:** all icon-only buttons (edit, delete, modal close, calendar navigation arrows) updated to `p-2` minimum (was `p-1.5`)
- **Mobile — form grids:** `grid-cols-2` changed to `grid-cols-1 sm:grid-cols-2` in expense modals and profile form
- **Mobile — budget entries:** `min-w-0 flex-1` and `truncate` on name/account spans prevent overflow on narrow screens
- **Mobile — calendar:** day cells `min-h-[48px] sm:min-h-[64px]`, day detail drawer `w-full sm:w-80`
- **Mobile — dashboard:** KPI card padding `p-3 sm:p-5`, KPI values `text-xl sm:text-2xl`
- **Mobile — login:** OTP digit inputs `h-10 w-10 sm:h-12 sm:w-12`; backup code field placeholder removed (hint text above suffices)
- **Mobile — settings:** recovery email row `flex-col sm:flex-row` so button stacks below input on narrow screens
- **Search:** placeholder text styled `placeholder-gray-400` (was unstyled)
- Typography: Roboto font self-hosted via `@fontsource/roboto` (300/400/500/700 weights) — no Google Fonts CDN, DSGVO/nDSG compliant; replaces Inter which was defined in the theme but never actually loaded
- Typography: unified font-size system — page title H1 `text-2xl` (24px), card/section H2 headers `text-base` (16px), sidebar navigation `text-sm` (14px), savings rate display `text-3xl` (30px); applied across Dashboard, Budgets, Expenses, Calendar, Profile, Settings and Sidebar
- Profile: password change fields no longer show browser autofill suggestions (`autocomplete="new-password"`); show/hide eye icon added to both password fields
- Sidebar: version number updated to 1.1.0
- Login: registration is email-only — username field removed; backend auto-sets `username=email`; `RegisterSerializer` updated accordingly
- Login: font sizes increased throughout login/register flow (`text-xs``text-sm`, `text-sm``text-base`, card `max-w-sm``max-w-md`)
- Settings: 2FA card and Danger Zone remain; Recovery Email, Active Sessions, Data Export, Notification Preferences added above Danger Zone
- Settings: Danger Zone delete flow is now three-step (export → credentials + phrase → redirect)
- `LogoutView`: added `permission_classes = [AllowAny]` so logout works even when access token is expired; now also deletes `UserSession` by JTI and `X-Session-Key`
- `ChangePasswordView`: invalidates and blacklists all other active sessions on password change
- `authInterceptor`: sends `X-Session-Key` header on all internal API requests
- Auth: `completeLogin()` and `storeTokens()` accept optional `session_key`; `logout()` clears session key from both storages
- Auth: `refreshToken()` bug fixed — was storing `tokens.access` twice instead of `tokens.access` and `tokens.refresh`
- Login endpoint `POST /api/auth/token/` replaced with custom `LoginView` supporting 2FA challenge response
- Profile page: 2FA and Danger Zone sections removed and moved to new Settings page
- Authenticator app recommendation updated to: Proton Pass, Aegis (Android), Raivo OTP (iOS)
- Calendar: holidays and school holidays now shown in the current app language; reloaded automatically on language change via `translate.onLangChange` subscription
- Calendar: OpenHolidays API requests include `languageIsoCode` parameter; cache key includes language
- Calendar: today's date shown as violet ring/outline only (not filled) to prevent white-on-white hover text
- Notifications: "Mark as read" checkmark button per notification (replaces X icon)
- Notifications: "Mark all as read" button in panel header
- i18n: `nav.mark_read`, `nav.mark_all_read` keys (DE/FR/IT/EN)
- Calendar: live holiday and school holiday data via OpenHolidays API (openholidaysapi.org, AGPL-3.0) with automatic fallback to static data on API failure
- Calendar: in-memory cache per year/canton to avoid redundant API requests
- Budgets: show info modal when no accounts exist, with link button to accounts page
- Expenses: show info modal when no accounts exist, with link button to accounts page
- i18n: `common.no_accounts_title`, `common.no_accounts_text`, `common.go_to_accounts` keys (DE/FR/IT/EN)
- Login: authentication changed from username-based to email-based; password manager only needs email + password
- Mobile sidebar drawer: Notifications, Dark/Light toggle, Profile link and Logout moved from navbar into the sidebar so all user actions are accessible via the hamburger menu
### Security
- Password Reset invalidiert alle aktiven Sessions des Nutzers (analog zu `ChangePasswordView`)
- Email-Verify-Token: SHA-256-Hash statt Klartext in DB; Ablaufzeit 24h
- `VerifyEmailView` + `PasswordResetConfirmView` mit `AuthThrottle` (5/min) gesichert
### Fixed
- Security: `TwoFactorRecoverConfirmView` now verifies `temp_token` (password proof) before accepting a recovery code — previously anyone with a valid recovery code could bypass authentication entirely
- Security: `ProfileView.delete` now requires password verification (`check_password`) before deletion; returns 403 on failure
- Backend: URL conflict — `api/notifications/` was mapped to both `NotificationsView` and `NotificationPrefsView`; prefs endpoint moved to `api/notifications/prefs/`
- Backend: fpdf2 export — em/en dash characters (`—`, ``) caused `FPDFUnicodeEncodingException` with Helvetica (Latin-1 only); fixed with `safe()` helper using `encode('latin-1', errors='replace')` and replaced placeholder dashes with ASCII `-`
- Backend: migration 0017 (`finance_profile.notif_deadlines` etc.) was not applied on server restart, causing 500 on login; now documented that `python manage.py migrate` must be run after deployment
- Auth: `LangSwitcher``[class.dark:text-violet-400]` Angular binding error caused by Tailwind dark-mode colon in class name; fixed by using `itemClass()` method with `[class]` binding
- Auth: `LangSwitcher``current` signal initialized before `langService` was available; fixed by setting default `'de'` and calling `this.current.set(langService.current)` in constructor body
- Dashboard: bar chart right padding increased so December bars are no longer clipped at the container edge
- Calendar: legend block removed from footer
- Calendar: ZH Spring Holidays 2026 corrected to 20.04.02.05. in static fallback data (source: OpenHolidays API)
- Calendar: date input now renders in the app language format (lang attribute bound to current language)
- Calendar: deadline type dropdown now shows placeholder on open; defaults to "other" if none selected
- Calendar: title input placeholder text styled correctly in gray
- Login: show/hide password toggle with eye icon
- Register: show/hide password toggle on both password fields, independently toggleable
- Register: hint text below username field explaining it is used as in-app display name (DE/FR/IT/EN)
- i18n: `nav.dark_mode`, `nav.light_mode` keys for sidebar mobile theme toggle (DE/FR/IT/EN)
- i18n: `auth.username_hint` key for register page (DE/FR/IT/EN)
- Mobile navbar: right-side icons (notifications, theme toggle, avatar) caused the navbar to wrap to a second line on narrow screens, pushing page content below the fixed offset; icons are now hidden on mobile and integrated into the sidebar drawer
- Mobile notification panel: was full-width and flush against the top of the screen; now has `left-4`/`right-4` margins, `top-20` offset and `rounded-xl` corners
- Auth: new `EmailAuthBackend` performs case-insensitive email lookup so login works regardless of capitalisation
- Login/Register: placeholder texts removed from all username and password fields
---
## [1.0.1] - 2026-04-14
### Fixed
- Production deployment: replaced hardcoded `http://127.0.0.1:8000` with relative paths (`/api`, `/api/auth`) in `ApiService` and `AuthService` so the frontend works on any server
- Production deployment: avatar image URLs in `profile.ts` and `navbar.ts` now use the relative path returned by the backend instead of prepending `http://127.0.0.1:8000`
- Sidebar: Budgets and Accounts submenus not expanding on mobile — replaced Flowbite `data-collapse-toggle` with Angular signal-based state (`budgetsOpen`, `accountsOpen` in `SidebarService`)
### Added
- `nginx.conf`: reverse proxy config — serves Angular static build, proxies `/api/` to Gunicorn on port 8000, serves `/media/` as static files
--- ---
## [1.0.0] - 2026-04-13 ## [1.0.0] - 2026-04-13
+35
View File
@@ -0,0 +1,35 @@
# Armarium — Claude Code Instructions
## Icons
**Immer inline SVG verwenden — kein Flowbite, kein fb-icon, keine externe Icon-Komponente.**
SVG-Icons direkt aus [Flowbite Icons](https://flowbite.com/icons/) oder ähnlichen Quellen kopieren und inline einbetten:
```html
<!-- Beispiel: Search-Icon -->
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<!-- Beispiel: Trash-Icon in rot -->
<svg class="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
```
- Farbe via Tailwind-Klassen: `text-gray-400`, `text-red-500`, etc.
- Grösse via `w-X h-X` Klassen
- `fill="none"` + `stroke="currentColor"` für Outline-Icons
- `fill="currentColor"` für Solid-Icons
---
## Stack
- **Frontend:** Angular 21, Standalone Components, Signals, Tailwind CSS v3, ngx-translate
- **Backend:** Django 5, DRF, PostgreSQL
- **i18n:** DE / FR / IT / EN — immer alle 4 Sprachen gleichzeitig aktualisieren
- **CSS:** Reines Tailwind v3 — kein Flowbite, kein @tailwindcss/postcss
Submodule backend deleted from 980535b2a4
+24
View File
@@ -0,0 +1,24 @@
SECRET_KEY=your-secret-key-here
DEBUG=False
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DB_NAME=budget_db
DB_USER=budget_user
DB_PASSWORD=your-db-password
DB_HOST=localhost
DB_PORT=5432
# Cloudflare Turnstile (https://dash.cloudflare.com → Turnstile)
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
# Email / Brevo SMTP (https://app.brevo.com → SMTP & API → SMTP)
EMAIL_HOST=smtp-relay.brevo.com
EMAIL_PORT=587
EMAIL_HOST_USER=your-brevo-smtp-login
EMAIL_HOST_PASSWORD=your-brevo-smtp-password
EMAIL_USE_TLS=True
DEFAULT_FROM_EMAIL=noreply@armarium.ch
# Frontend URL (used in emails for links)
FRONTEND_URL=https://app.armarium.ch
+20
View File
@@ -0,0 +1,20 @@
# Python
__pycache__/
*.py[cod]
*$py.class
venv/
.venv/
env/
# Django
db.sqlite3
.env
static/
media/
# Logs
*.log
logs/
# Betriebssystem
.DS_Store
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for core project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application()
+170
View File
@@ -0,0 +1,170 @@
from pathlib import Path
from datetime import timedelta
import os
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
SECRET_KEY = os.environ['SECRET_KEY']
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
TURNSTILE_SECRET_KEY = os.environ.get('TURNSTILE_SECRET_KEY', '')
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
'corsheaders',
'finance',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
CORS_ALLOWED_ORIGINS = os.environ.get(
'CORS_ALLOWED_ORIGINS', 'http://localhost:4200'
).split(',')
ROOT_URLCONF = 'core.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'core.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'budget_db'),
'USER': os.environ.get('DB_USER', 'budget_user'),
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ── Email ─────────────────────────────────────────────────────────────────────
_default_email_backend = (
'django.core.mail.backends.console.EmailBackend' if DEBUG
else 'django.core.mail.backends.smtp.EmailBackend'
)
EMAIL_BACKEND = os.environ.get('EMAIL_BACKEND', _default_email_backend)
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'localhost')
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', '587'))
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch')
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200')
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {'class': 'logging.StreamHandler'},
},
'loggers': {
'django.mail': {'handlers': ['console'], 'level': 'ERROR'},
'armarium': {'handlers': ['console'], 'level': 'INFO'},
},
}
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '20/min',
'user': '200/min',
'auth': '5/min',
},
}
AUTHENTICATION_BACKENDS = [
'finance.backends.EmailAuthBackend',
]
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
# ── Uploads ───────────────────────────────────────────────────────────────────
DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5 MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5 MB
# ── CSRF ──────────────────────────────────────────────────────────────────────
CSRF_TRUSTED_ORIGINS = os.environ.get(
'CSRF_TRUSTED_ORIGINS', 'http://localhost:4200'
).split(',')
# ── Production security (nur aktiv wenn DEBUG=False) ─────────────────────────
if not DEBUG:
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_HSTS_SECONDS = 31_536_000 # 1 Jahr
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_CONTENT_TYPE_NOSNIFF = True
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200')
+73
View File
@@ -0,0 +1,73 @@
import os
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenRefreshView
from finance.views import (
AccountViewSet, TransactionViewSet, BudgetViewSet,
ExpenseViewSet, DeadlineViewSet, ProfileView, RegisterView, LogoutView, ChangePasswordView,
ICalUrlView, ICalFeedView, NotificationsView, SearchView,
LoginView, TwoFactorLoginView, TwoFactorSetupView, TwoFactorEnableView, TwoFactorDisableView,
TwoFactorRecoverRequestView, TwoFactorRecoverConfirmView,
SessionListView, SessionRevokeView, SessionRevokeAllView,
DataExportView, NotificationPrefsView,
VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView,
FinancialYearListCreateView, FinancialYearDetailView, FinancialYearCopyView,
YearlyIncomeListCreateView, YearlyIncomeDetailView,
YearlyBudgetItemListCreateView, YearlyBudgetItemDetailView,
HouseholdListCreateView, HouseholdInviteView, HouseholdAcceptView, HouseholdLeaveView,
HouseholdSetRoleView, HouseholdRevenueAccountsView,
)
router = DefaultRouter()
router.register(r'accounts', AccountViewSet, basename='account')
router.register(r'transactions', TransactionViewSet, basename='transaction')
router.register(r'budgets', BudgetViewSet, basename='budget')
router.register(r'expenses', ExpenseViewSet, basename='expense')
router.register(r'deadlines', DeadlineViewSet, basename='deadline')
_admin_url = os.environ.get('ADMIN_URL', 'manage/').strip('/')+ '/'
urlpatterns = [
path(_admin_url, admin.site.urls),
path('api/', include(router.urls)),
path('api/profile/', ProfileView.as_view()),
path('api/auth/register/', RegisterView.as_view()),
path('api/auth/token/', LoginView.as_view()),
path('api/auth/token/refresh/', TokenRefreshView.as_view()),
path('api/auth/logout/', LogoutView.as_view()),
path('api/auth/password/', ChangePasswordView.as_view()),
path('api/auth/verify-email/', VerifyEmailView.as_view()),
path('api/auth/password-reset/', PasswordResetRequestView.as_view()),
path('api/auth/password-reset/confirm/', PasswordResetConfirmView.as_view()),
path('api/auth/2fa/login/', TwoFactorLoginView.as_view()),
path('api/auth/2fa/setup/', TwoFactorSetupView.as_view()),
path('api/auth/2fa/enable/', TwoFactorEnableView.as_view()),
path('api/auth/2fa/disable/', TwoFactorDisableView.as_view()),
path('api/auth/2fa/recover/', TwoFactorRecoverRequestView.as_view()),
path('api/auth/2fa/recover/confirm/', TwoFactorRecoverConfirmView.as_view()),
path('api/auth/sessions/', SessionListView.as_view()),
path('api/auth/sessions/revoke-all/', SessionRevokeAllView.as_view()),
path('api/auth/sessions/<str:session_key>/', SessionRevokeView.as_view()),
path('api/export/', DataExportView.as_view()),
path('api/notifications/prefs/', NotificationPrefsView.as_view()),
path('api/search/', SearchView.as_view()),
path('api/notifications/', NotificationsView.as_view()),
path('api/calendar/ical-url/', ICalUrlView.as_view()),
path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.as_view()),
path('api/financial-years/', FinancialYearListCreateView.as_view()),
path('api/financial-years/<int:year>/', FinancialYearDetailView.as_view()),
path('api/financial-years/<int:year>/copy-from/<int:source_year>/', FinancialYearCopyView.as_view()),
path('api/financial-years/<int:year>/incomes/', YearlyIncomeListCreateView.as_view()),
path('api/financial-years/<int:year>/incomes/<int:pk>/', YearlyIncomeDetailView.as_view()),
path('api/financial-years/<int:year>/budget-items/', YearlyBudgetItemListCreateView.as_view()),
path('api/financial-years/<int:year>/budget-items/<int:pk>/', YearlyBudgetItemDetailView.as_view()),
path('api/households/', HouseholdListCreateView.as_view()),
path('api/households/<int:pk>/invite/', HouseholdInviteView.as_view()),
path('api/households/<int:pk>/accept/', HouseholdAcceptView.as_view()),
path('api/households/<int:pk>/leave/', HouseholdLeaveView.as_view()),
path('api/households/<int:pk>/members/<int:membership_id>/set-role/', HouseholdSetRoleView.as_view()),
path('api/households/<int:pk>/revenue-accounts/', HouseholdRevenueAccountsView.as_view()),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()
View File
+5
View File
@@ -0,0 +1,5 @@
from django.contrib import admin
from .models import Account, Transaction
admin.site.register(Account)
admin.site.register(Transaction)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class FinanceConfig(AppConfig):
name = 'finance'
+14
View File
@@ -0,0 +1,14 @@
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
class EmailAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
User = get_user_model()
try:
user = User.objects.get(email__iexact=username)
except (User.DoesNotExist, User.MultipleObjectsReturned):
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
+33
View File
@@ -0,0 +1,33 @@
import logging
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
def send_email(template_name: str, context: dict, subject: str, to: str | list[str]) -> bool:
"""
Render and send an HTML email with plaintext fallback.
Returns True on success, False on failure.
"""
if isinstance(to, str):
to = [to]
html = render_to_string(f'emails/{template_name}.html', context)
text = render_to_string(f'emails/{template_name}.txt', context)
msg = EmailMultiAlternatives(
subject=subject,
body=text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=to,
)
msg.attach_alternative(html, 'text/html')
try:
msg.send()
return True
except Exception:
logger.exception('Failed to send email "%s" to %s', template_name, to)
return False
@@ -0,0 +1,85 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from finance.models import Account, Budget, FinancialYear, YearlyIncome, YearlyBudgetItem
User = get_user_model()
TARGET_YEAR = 2026
class Command(BaseCommand):
help = 'Migrate existing revenue accounts and budgets into FinancialYear 2026. Idempotent — safe to run multiple times.'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview what would be created without writing to the database.',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN — no changes will be saved.\n'))
total_incomes = 0
total_budgets = 0
for user in User.objects.all():
revenue_accounts = Account.objects.filter(user=user, account_type='revenue', active=True)
budgets = Budget.objects.filter(account__user=user, active=True)
if not revenue_accounts.exists() and not budgets.exists():
continue
self.stdout.write(f'\nUser: {user.email}')
if dry_run:
fy = FinancialYear.objects.filter(user=user, year=TARGET_YEAR).first()
if fy:
self.stdout.write(f' FinancialYear {TARGET_YEAR} already exists (id={fy.pk})')
else:
self.stdout.write(f' Would create FinancialYear {TARGET_YEAR}')
else:
fy, fy_created = FinancialYear.objects.get_or_create(user=user, year=TARGET_YEAR)
if fy_created:
self.stdout.write(f' Created FinancialYear {TARGET_YEAR} (id={fy.pk})')
else:
self.stdout.write(f' FinancialYear {TARGET_YEAR} exists (id={fy.pk})')
for account in revenue_accounts:
label = f'YearlyIncome "{account.name}" CHF {account.balance}'
if dry_run:
exists = fy and YearlyIncome.objects.filter(financial_year=fy, name=account.name).exists()
self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}')
else:
_, created = YearlyIncome.objects.get_or_create(
financial_year=fy,
name=account.name,
defaults={'amount': account.balance, 'member': user, 'active': True},
)
self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}')
if created:
total_incomes += 1
for budget in budgets:
label = f'YearlyBudgetItem "{budget.name}" CHF {budget.amount}'
if dry_run:
exists = fy and YearlyBudgetItem.objects.filter(financial_year=fy, name=budget.name).exists()
self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}')
else:
_, created = YearlyBudgetItem.objects.get_or_create(
financial_year=fy,
name=budget.name,
defaults={'amount': budget.amount, 'active': budget.active},
)
self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}')
if created:
total_budgets += 1
if not dry_run:
self.stdout.write(self.style.SUCCESS(
f'\nDone. Created {total_incomes} income(s) and {total_budgets} budget item(s).'
))
else:
self.stdout.write(self.style.WARNING('\nDry run complete. Re-run without --dry-run to apply.'))
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-08 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('account_type', models.CharField(choices=[('asset', 'Asset Account (Bank/Cash)'), ('expense', 'Expense Account (Laden/Empfänger)'), ('revenue', 'Revenue Account (Einnahmequelle)')], default='asset', max_length=20)),
('balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12)),
('active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-08 17:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=255)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('date', models.DateField()),
('destination_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deposits', to='finance.account')),
('source_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdrawals', to='finance.account')),
],
),
]
+26
View File
@@ -0,0 +1,26 @@
# Generated by Django 6.0.3 on 2026-03-15 16:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0002_transaction'),
]
operations = [
migrations.CreateModel(
name='Budget',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('category', models.CharField(choices=[('housing', 'Wohnen'), ('food', 'Lebensmittel'), ('transport', 'Transport'), ('health', 'Gesundheit'), ('entertainment', 'Freizeit'), ('savings', 'Sparen'), ('other', 'Sonstiges')], default='other', max_length=50)),
('due_day', models.PositiveSmallIntegerField(help_text='Tag im Monat (1-31)')),
('active', models.BooleanField(default=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budgets', to='finance.account')),
],
),
]
@@ -0,0 +1,37 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0003_budget'),
]
operations = [
migrations.RemoveField(
model_name='budget',
name='due_day',
),
migrations.RenameField(
model_name='budget',
old_name='category',
new_name='main_category',
),
migrations.AlterField(
model_name='budget',
name='main_category',
field=models.CharField(
choices=[
('fixed_expenses', 'Fixe Ausgaben'),
('mobile_internet', 'Mobile & Internet'),
('leisure', 'Freizeit'),
('tax_reserves', 'Steuerrücklagen'),
('insurance', 'Versicherungen'),
('loans', 'Abzahlungen & Kredite'),
],
default='fixed_expenses',
max_length=50,
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-23 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0004_alter_budget'),
]
operations = [
migrations.AlterField(
model_name='budget',
name='main_category',
field=models.CharField(choices=[('fixed_expenses', 'Fixe Ausgaben'), ('mobile_internet', 'Mobile & Internet'), ('subscriptions', 'Abonnements'), ('leisure', 'Freizeit'), ('tax_reserves', 'Steuerrücklagen'), ('insurance', 'Versicherungen'), ('loans', 'Abzahlungen & Kredite')], default='fixed_expenses', max_length=50),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 6.0.3 on 2026-03-23 22:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0005_add_subscriptions_category'),
]
operations = [
migrations.CreateModel(
name='Expense',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('date', models.DateField()),
('category', models.CharField(choices=[('groceries', 'Groceries'), ('dining', 'Dining & Restaurants'), ('transport', 'Transport'), ('health', 'Health & Medical'), ('clothing', 'Clothing'), ('electronics', 'Electronics'), ('household', 'Household'), ('entertainment', 'Entertainment'), ('travel', 'Travel'), ('other', 'Other')], default='other', max_length=50)),
('notes', models.TextField(blank=True, default='')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='finance.account')),
],
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-24 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0006_add_expense_model'),
]
operations = [
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(blank=True, default='', max_length=100)),
('last_name', models.CharField(blank=True, default='', max_length=100)),
('email', models.EmailField(blank=True, default='', max_length=254)),
('avatar_color', models.CharField(default='#1A56DB', max_length=7)),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-24 19:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0007_add_profile_model'),
]
operations = [
migrations.AddField(
model_name='profile',
name='avatar_image',
field=models.ImageField(blank=True, null=True, upload_to='avatars/'),
),
]
@@ -0,0 +1,44 @@
# Generated by Django 6.0.3 on 2026-03-24 19:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def assign_legacy_user(apps, schema_editor):
User = apps.get_model('auth', 'User')
Account = apps.get_model('finance', 'Account')
Profile = apps.get_model('finance', 'Profile')
legacy_user, _ = User.objects.get_or_create(
username='legacy_user',
defaults={'email': '', 'is_active': True},
)
Account.objects.filter(user__isnull=True).update(user=legacy_user)
for profile in Profile.objects.filter(user__isnull=True):
profile.user = legacy_user
profile.save()
class Migration(migrations.Migration):
dependencies = [
('finance', '0008_add_avatar_image'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='profile',
name='user',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL),
),
migrations.RunPython(assign_legacy_user, migrations.RunPython.noop),
]
@@ -0,0 +1,37 @@
# Generated by Django 6.0.3 on 2026-03-24 20:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0009_add_user_fk'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='expense',
name='due_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='canton',
field=models.CharField(choices=[('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'), ('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'), ('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'), ('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'), ('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'), ('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'), ('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'), ('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'), ('ZG', 'Zug'), ('ZH', 'Zurich')], default='ZH', max_length=2),
),
migrations.CreateModel(
name='Deadline',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('date', models.DateField()),
('type', models.CharField(choices=[('tax', 'Tax'), ('insurance', 'Insurance'), ('invoice', 'Invoice'), ('personal', 'Personal'), ('other', 'Other')], default='other', max_length=20)),
('notes', models.TextField(blank=True, default='')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deadlines', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.4 on 2026-04-12 18:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0010_add_calendar_fields'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ReadEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(choices=[('deadline', 'Deadline'), ('expense', 'Expense')], max_length=20)),
('event_id', models.PositiveIntegerField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_events', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'event_type', 'event_id')},
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-12 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0011_readevent'),
]
operations = [
migrations.AddField(
model_name='profile',
name='language',
field=models.CharField(choices=[('de', 'Deutsch'), ('fr', 'Français'), ('it', 'Italiano'), ('en', 'English')], default='de', max_length=2),
),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0012_profile_language'),
]
operations = [
migrations.AddField(
model_name='profile',
name='totp_secret',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='profile',
name='totp_enabled',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,36 @@
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('finance', '0013_profile_totp'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='profile',
name='totp_last_used_code',
field=models.CharField(blank=True, default='', max_length=6),
),
migrations.CreateModel(
name='BackupCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code_hash', models.CharField(max_length=64)),
('used', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='backup_codes',
to=settings.AUTH_USER_MODEL,
)),
],
options={
'indexes': [models.Index(fields=['user', 'used'], name='finance_bac_user_id_idx')],
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0014_totp_security'),
]
operations = [
migrations.AddField(
model_name='profile',
name='recovery_email',
field=models.EmailField(blank=True, default=''),
),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0015_profile_recovery_email'),
]
operations = [
migrations.AddField(
model_name='profile',
name='recovery_code_hash',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='profile',
name='recovery_code_expires',
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,47 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('finance', '0016_profile_recovery_code'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='profile',
name='notif_deadlines',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='profile',
name='notif_budget_alerts',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='profile',
name='notif_monthly_summary',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='UserSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(max_length=64, unique=True)),
('refresh_jti', models.CharField(blank=True, default='', max_length=255)),
('device_name', models.CharField(blank=True, default='', max_length=200)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_active_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_sessions',
to=settings.AUTH_USER_MODEL,
)),
],
options={'ordering': ['-last_active_at']},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.4 on 2026-05-18 19:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0017_user_sessions_notifications'),
]
operations = [
migrations.RenameIndex(
model_name='backupcode',
new_name='finance_bac_user_id_7e357d_idx',
old_name='finance_bac_user_id_idx',
),
migrations.AddField(
model_name='profile',
name='savings_rate_goal',
field=models.PositiveSmallIntegerField(default=20),
),
migrations.AlterField(
model_name='profile',
name='canton',
field=models.CharField(choices=[('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'), ('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'), ('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'), ('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'), ('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'), ('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'), ('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'), ('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'), ('ZG', 'Zug'), ('ZH', 'Zürich')], default='ZH', max_length=2),
),
]
@@ -0,0 +1,89 @@
# Generated by Django 6.0.4 on 2026-05-18 20:16
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0018_profile_savings_rate_goal'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Household',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_households', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='FinancialYear',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveSmallIntegerField()),
('is_active', models.BooleanField(default=True)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to=settings.AUTH_USER_MODEL)),
('household', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to='finance.household')),
],
),
migrations.CreateModel(
name='HouseholdMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('left', 'Left')], default='pending', max_length=10)),
('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('effective_until_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='finance.household')),
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_invitations', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='household_memberships', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='YearlyBudgetItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('active', models.BooleanField(default=True)),
('notes', models.TextField(blank=True, default='')),
('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_items', to='finance.financialyear')),
],
),
migrations.CreateModel(
name='YearlyIncome',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('active', models.BooleanField(default=True)),
('notes', models.TextField(blank=True, default='')),
('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomes', to='finance.financialyear')),
('member', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='yearly_incomes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='financialyear',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('household__isnull', True), ('user__isnull', False)), models.Q(('household__isnull', False), ('user__isnull', True)), _connector='OR'), name='financial_year_owner_exclusive'),
),
migrations.AddConstraint(
model_name='financialyear',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'year'), name='unique_personal_financial_year'),
),
migrations.AddConstraint(
model_name='financialyear',
constraint=models.UniqueConstraint(condition=models.Q(('household__isnull', False)), fields=('household', 'year'), name='unique_household_financial_year'),
),
migrations.AlterUniqueTogether(
name='householdmembership',
unique_together={('household', 'user')},
),
]
@@ -0,0 +1,33 @@
# Generated by Django 6.0.4 on 2026-05-19 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0018_profile_savings_rate_goal'),
]
operations = [
migrations.AddField(
model_name='profile',
name='email_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='email_verify_token',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='profile',
name='password_reset_token_expires',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='password_reset_token_hash',
field=models.CharField(blank=True, default='', max_length=64),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-19 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0019_profile_email_verification_and_password_reset'),
]
operations = [
migrations.AddField(
model_name='profile',
name='email_verify_token_expires',
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-19 07:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0019_financial_year'),
]
operations = [
migrations.AddField(
model_name='householdmembership',
name='role',
field=models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], default='member', max_length=10),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-21 18:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0020_household_membership_role'),
]
operations = [
migrations.AddField(
model_name='account',
name='salary_months',
field=models.IntegerField(choices=[(12, 12), (13, 13)], default=12),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 6.0.4 on 2026-05-21 19:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0021_add_salary_months_to_account'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PendingHouseholdInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invited_email', models.EmailField(max_length=254)),
('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_invites', to='finance.household')),
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_pending_invitations', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('household', 'invited_email')},
},
),
]
@@ -0,0 +1,38 @@
# Generated by Django 6.0.4 on 2026-05-21 20:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0022_add_pending_household_invite'),
]
operations = [
migrations.AddField(
model_name='profile',
name='email_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='email_verify_token',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='profile',
name='email_verify_token_expires',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='password_reset_token_expires',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='password_reset_token_hash',
field=models.CharField(blank=True, default='', max_length=64),
),
]
+368
View File
@@ -0,0 +1,368 @@
from django.db import models
from django.conf import settings
class Account(models.Model):
# Typen basierend auf der Firefly III Logik
ACCOUNT_TYPES = [
('asset', 'Asset Account (Bank/Cash)'),
('expense', 'Expense Account (Laden/Empfänger)'),
('revenue', 'Revenue Account (Einnahmequelle)'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='accounts',
null=True,
)
name = models.CharField(max_length=100)
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
salary_months = models.IntegerField(default=12, choices=[(12, 12), (13, 13)])
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_account_type_display()})"
class Transaction(models.Model):
description = models.CharField(max_length=255)
amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField()
# Die Verknüpfung zu den Konten (Double-Entry Prinzip)
source_account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='withdrawals'
)
destination_account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='deposits'
)
def __str__(self):
return f"{self.date}: {self.description} ({self.amount}€)"
class Budget(models.Model):
MAIN_CATEGORY_CHOICES = [
('fixed_expenses', 'Fixe Ausgaben'),
('mobile_internet', 'Mobile & Internet'),
('subscriptions', 'Abonnements'),
('leisure', 'Freizeit'),
('tax_reserves', 'Steuerrücklagen'),
('insurance', 'Versicherungen'),
('loans', 'Abzahlungen & Kredite'),
]
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
main_category = models.CharField(max_length=50, choices=MAIN_CATEGORY_CHOICES, default='fixed_expenses')
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='budgets'
)
active = models.BooleanField(default=True)
def __str__(self):
return f"{self.name} ({self.amount} CHF)"
class Expense(models.Model):
CATEGORY_CHOICES = [
('groceries', 'Groceries'),
('dining', 'Dining & Restaurants'),
('transport', 'Transport'),
('health', 'Health & Medical'),
('clothing', 'Clothing'),
('electronics', 'Electronics'),
('household', 'Household'),
('entertainment', 'Entertainment'),
('travel', 'Travel'),
('other', 'Other'),
]
name = models.CharField(max_length=255)
amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField()
category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, default='other')
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='expenses'
)
notes = models.TextField(blank=True, default='')
due_date = models.DateField(blank=True, null=True)
def __str__(self):
return f"{self.date}: {self.name} ({self.amount} CHF)"
CANTON_CHOICES = [
('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'),
('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'),
('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'),
('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'),
('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'),
('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'),
('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'),
('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'),
('ZG', 'Zug'), ('ZH', 'Zürich'),
]
class Deadline(models.Model):
TYPE_CHOICES = [
('tax', 'Tax'),
('insurance', 'Insurance'),
('invoice', 'Invoice'),
('personal', 'Personal'),
('other', 'Other'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='deadlines',
)
title = models.CharField(max_length=200)
date = models.DateField()
type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='other')
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.date}: {self.title}"
class Profile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='profile',
null=True,
)
first_name = models.CharField(max_length=100, blank=True, default='')
last_name = models.CharField(max_length=100, blank=True, default='')
email = models.EmailField(blank=True, default='')
avatar_color = models.CharField(max_length=7, default='#1A56DB')
avatar_image = models.ImageField(upload_to='avatars/', blank=True, null=True)
canton = models.CharField(max_length=2, choices=CANTON_CHOICES, default='ZH')
language = models.CharField(max_length=2, choices=[('de','Deutsch'),('fr','Français'),('it','Italiano'),('en','English')], default='de')
totp_secret = models.CharField(max_length=64, blank=True, default='')
totp_enabled = models.BooleanField(default=False)
totp_last_used_code = models.CharField(max_length=6, blank=True, default='')
recovery_email = models.EmailField(blank=True, default='')
recovery_code_hash = models.CharField(max_length=64, blank=True, default='')
recovery_code_expires = models.DateTimeField(null=True, blank=True)
notif_deadlines = models.BooleanField(default=True)
notif_budget_alerts = models.BooleanField(default=True)
notif_monthly_summary = models.BooleanField(default=False)
savings_rate_goal = models.PositiveSmallIntegerField(default=20)
email_verified = models.BooleanField(default=False)
email_verify_token = models.CharField(max_length=64, blank=True, default='')
email_verify_token_expires = models.DateTimeField(null=True, blank=True)
password_reset_token_hash = models.CharField(max_length=64, blank=True, default='')
password_reset_token_expires = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"{self.first_name} {self.last_name}".strip() or 'Profile'
class UserSession(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user_sessions',
)
session_key = models.CharField(max_length=64, unique=True)
refresh_jti = models.CharField(max_length=255, blank=True, default='')
device_name = models.CharField(max_length=200, blank=True, default='')
ip_address = models.GenericIPAddressField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
last_active_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-last_active_at']
def __str__(self):
return f"{self.user} {self.device_name}"
class ReadEvent(models.Model):
EVENT_TYPES = [
('deadline', 'Deadline'),
('expense', 'Expense'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='read_events',
)
event_type = models.CharField(max_length=20, choices=EVENT_TYPES)
event_id = models.PositiveIntegerField()
class Meta:
unique_together = ['user', 'event_type', 'event_id']
def __str__(self):
return f"{self.user} {self.event_type} {self.event_id}"
class BackupCode(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='backup_codes',
)
code_hash = models.CharField(max_length=64)
used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=['user', 'used'])]
def __str__(self):
return f"{self.user} backup {'used' if self.used else 'active'}"
# ── FinancialYear ─────────────────────────────────────────────────────────────
class Household(models.Model):
name = models.CharField(max_length=100)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='created_households',
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class HouseholdMembership(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('active', 'Active'),
('left', 'Left'),
]
ROLE_CHOICES = [
('member', 'Member'),
('admin', 'Admin'),
]
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='household_memberships',
)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_invitations',
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='member')
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
effective_until_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'user']
def __str__(self):
return f"{self.user} in {self.household} ({self.status})"
class PendingHouseholdInvite(models.Model):
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='pending_invites')
invited_email = models.EmailField()
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_pending_invitations',
)
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'invited_email']
def __str__(self):
return f"Pending invite for {self.invited_email} to {self.household}"
class FinancialYear(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
household = models.ForeignKey(
Household,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
year = models.PositiveSmallIntegerField()
is_active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.CheckConstraint(
condition=(
models.Q(user__isnull=False, household__isnull=True) |
models.Q(user__isnull=True, household__isnull=False)
),
name='financial_year_owner_exclusive',
),
models.UniqueConstraint(
fields=['user', 'year'],
condition=models.Q(user__isnull=False),
name='unique_personal_financial_year',
),
models.UniqueConstraint(
fields=['household', 'year'],
condition=models.Q(household__isnull=False),
name='unique_household_financial_year',
),
]
def __str__(self):
owner = self.user or self.household
return f"{owner}{self.year}"
class YearlyIncome(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='incomes')
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='yearly_incomes',
)
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
class YearlyBudgetItem(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='budget_items')
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
+165
View File
@@ -0,0 +1,165 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import (
Account, Transaction, Budget, Expense, Profile, Deadline,
Household, HouseholdMembership, PendingHouseholdInvite,
FinancialYear, YearlyIncome, YearlyBudgetItem,
)
User = get_user_model()
class AccountSerializer(serializers.ModelSerializer):
class Meta:
model = Account
exclude = ['user']
class TransactionSerializer(serializers.ModelSerializer):
class Meta:
model = Transaction
fields = '__all__'
def validate(self, data):
request = self.context.get('request')
if not request:
return data
user = request.user
source = data.get('source_account') or (self.instance.source_account if self.instance else None)
dest = data.get('destination_account') or (self.instance.destination_account if self.instance else None)
if source and source.user != user:
raise serializers.ValidationError('Source account does not belong to you.')
if dest and dest.user != user:
raise serializers.ValidationError('Destination account does not belong to you.')
return data
class BudgetSerializer(serializers.ModelSerializer):
class Meta:
model = Budget
fields = '__all__'
class ExpenseSerializer(serializers.ModelSerializer):
class Meta:
model = Expense
fields = '__all__'
class ProfileSerializer(serializers.ModelSerializer):
totp_enabled = serializers.BooleanField(read_only=True)
email = serializers.SerializerMethodField()
def get_email(self, obj):
return obj.email or (obj.user.email if obj.user else '')
class Meta:
model = Profile
exclude = ['user', 'totp_secret', 'email_verify_token', 'email_verify_token_expires', 'password_reset_token_hash', 'password_reset_token_expires']
class DeadlineSerializer(serializers.ModelSerializer):
class Meta:
model = Deadline
exclude = ['user']
class HouseholdMembershipSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source='user.email', read_only=True)
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class Meta:
model = HouseholdMembership
fields = ['id', 'user', 'user_email', 'invited_by_email', 'status', 'role',
'effective_from_year', 'effective_until_year', 'created_at']
read_only_fields = ['id', 'user', 'user_email', 'invited_by_email', 'created_at']
class PendingHouseholdInviteSerializer(serializers.ModelSerializer):
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class Meta:
model = PendingHouseholdInvite
fields = ['id', 'invited_email', 'invited_by_email', 'effective_from_year', 'created_at']
read_only_fields = fields
class HouseholdSerializer(serializers.ModelSerializer):
memberships = HouseholdMembershipSerializer(many=True, read_only=True)
pending_invites = PendingHouseholdInviteSerializer(many=True, read_only=True)
created_by_email = serializers.EmailField(source='created_by.email', read_only=True)
class Meta:
model = Household
fields = ['id', 'name', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
read_only_fields = ['id', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
class YearlyIncomeSerializer(serializers.ModelSerializer):
member_email = serializers.EmailField(source='member.email', read_only=True)
class Meta:
model = YearlyIncome
fields = ['id', 'member', 'member_email', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id', 'member_email']
class YearlyBudgetItemSerializer(serializers.ModelSerializer):
class Meta:
model = YearlyBudgetItem
fields = ['id', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id']
class FinancialYearSerializer(serializers.ModelSerializer):
incomes = YearlyIncomeSerializer(many=True, read_only=True)
budget_items = YearlyBudgetItemSerializer(many=True, read_only=True)
total_income = serializers.SerializerMethodField()
total_fixed_costs = serializers.SerializerMethodField()
owner_type = serializers.SerializerMethodField()
class Meta:
model = FinancialYear
fields = ['id', 'year', 'is_active', 'notes', 'owner_type', 'household_id',
'total_income', 'total_fixed_costs', 'incomes', 'budget_items', 'created_at']
read_only_fields = ['id', 'created_at', 'owner_type', 'household_id', 'total_income', 'total_fixed_costs']
def get_total_income(self, obj):
return sum(i.amount for i in obj.incomes.filter(active=True))
def get_total_fixed_costs(self, obj):
return sum(b.amount for b in obj.budget_items.filter(active=True))
def get_owner_type(self, obj):
return 'household' if obj.household_id else 'personal'
class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(min_length=8, write_only=True)
def validate_email(self, value):
if User.objects.filter(email=value).exists():
raise serializers.ValidationError('Email already registered.')
return value
def create(self, validated_data):
email = validated_data['email']
user = User.objects.create_user(
username=email,
email=email,
password=validated_data['password'],
)
from .models import PendingHouseholdInvite, HouseholdMembership
for invite in PendingHouseholdInvite.objects.filter(invited_email__iexact=email):
HouseholdMembership.objects.get_or_create(
household=invite.household,
user=user,
defaults={
'invited_by': invite.invited_by,
'status': 'pending',
'effective_from_year': invite.effective_from_year,
},
)
invite.delete()
return user
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+19
View File
@@ -0,0 +1,19 @@
asgiref==3.11.1
Django==6.0.4
django-cors-headers==4.9.0
djangorestframework==3.17.1
djangorestframework_simplejwt==5.5.1
gunicorn==25.3.0
icalendar==7.0.3
packaging==26.0
pillow==12.2.0
psycopg2-binary==2.9.11
PyJWT==2.12.1
python-dateutil==2.9.0.post0
pyotp==2.9.0
python-dotenv==1.2.2
six==1.17.0
sqlparse==0.5.5
typing_extensions==4.15.0
tzdata==2026.1
fpdf2==2.8.7
@@ -0,0 +1,34 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium 2FA-Wiederherstellung{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
Du hast eine 2FA-Wiederherstellung für dein Armarium-Konto angefordert.
Gib den folgenden Code auf der Anmeldeseite ein:
</p>
<!-- Code Box -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td align="center" style="background-color:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:20px;">
<span style="font-size:28px;font-weight:700;letter-spacing:6px;color:#7c3aed;font-family:monospace;">{{ code }}</span>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Gültig für <strong>15 Minuten</strong> · Einmalig verwendbar
</p>
<p style="margin:0 0 24px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Konto ist weiterhin sicher.
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
Das Armarium-Team
</p>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
Armarium 2FA-Wiederherstellung
Hallo,
Du hast eine 2FA-Wiederherstellung für dein Armarium-Konto angefordert.
Dein Wiederherstellungscode lautet:
{{ code }}
Gib diesen Code auf der Anmeldeseite ein.
Er ist 15 Minuten gültig und kann nur einmal verwendet werden.
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Konto ist weiterhin sicher.
Das Armarium-Team
https://www.armarium.ch
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block subject %}Armarium{% endblock %}</title>
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f4f6;padding:40px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
<!-- Header -->
<tr>
<td style="background-color:#7c3aed;border-radius:12px 12px 0 0;padding:28px 40px;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:-0.3px;">Armarium</span>
</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color:#ffffff;padding:36px 40px;">
{% block body %}{% endblock %}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#f9fafb;border-top:1px solid #e5e7eb;border-radius:0 0 12px 12px;padding:20px 40px;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.6;">
Du erhältst diese E-Mail, weil du ein Konto bei
<a href="https://www.armarium.ch" style="color:#7c3aed;text-decoration:none;">armarium.ch</a> hast.
<br>Falls du diese E-Mail nicht erwartet hast, kannst du sie ignorieren.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,33 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Deine E-Mail-Adresse wurde geändert{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
Die E-Mail-Adresse deines Armarium-Kontos wurde geändert.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background-color:#f5f3ff;border-radius:8px;padding:16px 20px;">
<p style="margin:0 0 6px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;">Neue Adresse</p>
<p style="margin:0;font-size:15px;color:#374151;font-weight:600;">{{ new_email }}</p>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background-color:#fef2f2;border-left:3px solid #ef4444;border-radius:0 6px 6px 0;padding:14px 18px;">
<p style="margin:0;font-size:13px;color:#374151;line-height:1.6;">
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter
<a href="mailto:support@armarium.ch" style="color:#7c3aed;text-decoration:none;">support@armarium.ch</a>.
</p>
</td>
</tr>
</table>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,12 @@
Armarium Deine E-Mail-Adresse wurde geändert
Hallo,
Die E-Mail-Adresse deines Armarium-Kontos wurde geändert.
Neue Adresse: {{ new_email }}
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter support@armarium.ch.
Das Armarium-Team
https://www.armarium.ch
@@ -0,0 +1,34 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Einladung zum Haushalt{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo {{ invitee_name }},</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
<strong>{{ inviter_name }}</strong> hat dich eingeladen, dem Haushalt
<strong>{{ household_name }}</strong> auf Armarium beizutreten.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
<tr>
<td align="center">
<a href="{{ accept_url }}"
style="display:inline-block;background-color:#7c3aed;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:14px 32px;border-radius:8px;letter-spacing:0.1px;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</p>
<p style="margin:0 0 24px;font-size:13px;color:#7c3aed;line-height:1.6;word-break:break-all;">
{{ accept_url }}
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
Das Armarium-Team
</p>
{% endblock %}
@@ -0,0 +1,10 @@
Hallo {{ invitee_name }},
{{ inviter_name }} hat dich eingeladen, dem Haushalt "{{ household_name }}" auf Armarium beizutreten.
{{ cta_label }}:
{{ accept_url }}
Falls du diese Einladung nicht erwartet hast, kannst du sie ignorieren.
Das Armarium-Team
@@ -0,0 +1,24 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Dein Passwort wurde geändert{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
Dein Armarium-Passwort wurde erfolgreich geändert.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background-color:#f5f3ff;border-left:3px solid #7c3aed;border-radius:0 6px 6px 0;padding:14px 18px;">
<p style="margin:0;font-size:13px;color:#374151;line-height:1.6;">
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter
<a href="mailto:support@armarium.ch" style="color:#7c3aed;text-decoration:none;">support@armarium.ch</a>.
</p>
</td>
</tr>
</table>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,10 @@
Armarium Dein Passwort wurde geändert
Hallo,
Dein Armarium-Passwort wurde erfolgreich geändert.
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter support@armarium.ch.
Das Armarium-Team
https://www.armarium.ch
@@ -0,0 +1,35 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Passwort zurücksetzen{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Klicke auf den Button, um ein neues Passwort zu wählen:
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
<tr>
<td style="border-radius:8px;background-color:#7c3aed;">
<a href="{{ link }}" target="_blank"
style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">
Neues Passwort setzen
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Gültig für <strong>15 Minuten</strong>. Falls der Button nicht funktioniert, kopiere diesen Link:
</p>
<p style="margin:0 0 24px;font-size:12px;color:#7c3aed;line-height:1.6;word-break:break-all;">{{ link }}</p>
<p style="margin:0 0 24px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Passwort bleibt unverändert.
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,16 @@
Armarium Passwort zurücksetzen
Hallo,
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Link zum Zurücksetzen:
{{ link }}
Gültig für 15 Minuten.
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Passwort bleibt unverändert.
Das Armarium-Team
https://www.armarium.ch
@@ -0,0 +1,33 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium E-Mail-Adresse bestätigen{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
Willkommen bei Armarium! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
<tr>
<td style="border-radius:8px;background-color:#7c3aed;">
<a href="{{ link }}" target="_blank"
style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">
E-Mail-Adresse bestätigen
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Gültig für <strong>24 Stunden</strong>. Falls der Button nicht funktioniert, kopiere diesen Link:
</p>
<p style="margin:0 0 24px;font-size:12px;color:#7c3aed;line-height:1.6;word-break:break-all;">{{ link }}</p>
<p style="margin:0 0 24px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls du dieses Konto nicht erstellt hast, kannst du diese E-Mail ignorieren.
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,15 @@
Armarium E-Mail-Adresse bestätigen
Hallo,
Willkommen bei Armarium! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.
Link zur Bestätigung:
{{ link }}
Gültig für 24 Stunden.
Falls du dieses Konto nicht erstellt hast, kannst du diese E-Mail ignorieren.
Das Armarium-Team
https://www.armarium.ch
Submodule frontend deleted from e38e9877c0
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# BudgetFrontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+82
View File
@@ -0,0 +1,82 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "97da586f-bf37-4b8e-beae-24c4d04eb25b"
},
"newProjectRoot": "projects",
"projects": {
"budget-frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "budget-frontend:build:production"
},
"development": {
"buildTarget": "budget-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+10713
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "budget-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@fontsource/roboto": "^5.2.10",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@types/qrcode": "^1.5.6",
"apexcharts": "^3.46.0",
"jspdf": "^4.2.1",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.0",
"autoprefixer": "^10.4.27",
"jsdom": "^28.0.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+10
View File
@@ -0,0 +1,10 @@
{
"/api": {
"target": "http://localhost:8000",
"secure": false
},
"/media": {
"target": "http://localhost:8000",
"secure": false
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,227 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'accounts.title' | translate }}</h1>
</div>
<button (click)="openCreateModal()"
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/plus -->
<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="M5 12h14m-7 7V5"/>
</svg>
{{ 'accounts.add' | translate }}
</button>
</div>
<!-- Tabelle -->
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-5 py-3">{{ 'common.name' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'accounts.col_type' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'accounts.col_balance' | translate }}</th>
<th scope="col" class="px-5 py-3"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (account of accounts(); track account.id) {
<tr class="border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-5 py-3 font-medium text-gray-900 dark:text-white whitespace-nowrap">
{{ account.name }}
</td>
<td class="px-5 py-3">
@if (account.account_type === 'asset') {
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{{ 'accounts.type_asset' | translate }}
</span>
} @else {
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-300">
{{ 'accounts.type_revenue' | translate }}
</span>
}
</td>
<td class="px-5 py-3 font-semibold text-violet-600 dark:text-violet-400">
{{ account.balance | number:'1.2-2' }} CHF
</td>
<td class="px-5 py-3">
<div class="flex items-center justify-end gap-1">
<button (click)="openEditModal(account)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-white transition-colors">
<!-- Flowbite: outline/edit/pen-to-square -->
<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="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
</button>
<button (click)="openDeleteModal(account.id)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors">
<!-- Flowbite: outline/general/trash-bin -->
<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="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="px-5 py-10 text-center text-sm text-gray-400 dark:text-gray-500">
{{ 'accounts.no_accounts' | translate }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- CREATE MODAL -->
@if (showCreateModal()) {
<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)="closeCreateModal()"></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">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'accounts.create_title' | translate }}</h3>
<button type="button" (click)="closeCreateModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="newName" [placeholder]="'accounts.placeholder_name' | translate"
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>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_balance' | translate }}</label>
<input type="number" [(ngModel)]="newBalance" placeholder="0.00"
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>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_type' | translate }}</label>
<select [(ngModel)]="newType"
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:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="asset">{{ 'accounts.type_asset' | translate }}</option>
<option value="revenue">{{ 'accounts.type_revenue' | translate }}</option>
</select>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeCreateModal()"
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)="createAccount()"
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">
{{ 'common.create' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- EDIT MODAL -->
@if (showEditModal()) {
<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)="closeEditModal()"></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">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'accounts.edit_title' | translate }}</h3>
<button type="button" (click)="closeEditModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="editName"
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>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_balance' | translate }}</label>
<input type="number" [(ngModel)]="editBalance"
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>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_type' | translate }}</label>
<select [(ngModel)]="editType"
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:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="asset">{{ 'accounts.type_asset' | translate }}</option>
<option value="revenue">{{ 'accounts.type_revenue' | translate }}</option>
</select>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeEditModal()"
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)="updateAccount()"
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">
{{ 'common.save' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- DELETE 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">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<!-- Flowbite: outline/general/trash-bin -->
<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="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.delete_confirm_title' | translate }}</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.delete_confirm_text' | translate }}</p>
<div class="flex items-center 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()"
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">
{{ 'common.delete' | translate }}
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountList } from './account-list';
describe('AccountList', () => {
let component: AccountList;
let fixture: ComponentFixture<AccountList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AccountList],
}).compileComponents();
fixture = TestBed.createComponent(AccountList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,122 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
@Component({
selector: 'app-account-list',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './account-list.html',
styleUrl: './account-list.css',
})
export class AccountList implements OnInit {
accounts = signal<any[]>([]);
// Create Modal
showCreateModal = signal(false);
newName = '';
newBalance = 0;
newType = 'asset';
// Edit Modal
showEditModal = signal(false);
editId = 0;
// Delete Modal
showDeleteModal = signal(false);
deleteTargetId = 0;
editName = '';
editBalance = 0;
editType = 'asset';
constructor(private api: ApiService) {}
ngOnInit(): void {
this.loadAccounts();
}
loadAccounts() {
this.api.getAccounts().subscribe({
next: (data) => this.accounts.set(data.filter((a: any) => a.account_type === 'asset' || a.account_type === 'revenue')),
error: (err) => console.error('Fehler:', err)
});
}
// Create
openCreateModal() {
this.showCreateModal.set(true);
}
closeCreateModal() {
this.showCreateModal.set(false);
this.newName = '';
this.newBalance = 0;
this.newType = 'asset';
}
createAccount() {
if (!this.newName) return;
this.api.createAccount({
name: this.newName,
balance: this.newBalance,
account_type: this.newType
}).subscribe({
next: () => {
this.loadAccounts();
this.closeCreateModal();
},
error: (err) => console.error('Fehler beim Erstellen:', err)
});
}
// Edit
openEditModal(account: any) {
this.editId = account.id;
this.editName = account.name;
this.editBalance = account.balance;
this.editType = account.account_type;
this.showEditModal.set(true);
}
closeEditModal() {
this.showEditModal.set(false);
}
updateAccount() {
if (!this.editName) return;
this.api.updateAccount(this.editId, {
name: this.editName,
balance: this.editBalance,
account_type: this.editType
}).subscribe({
next: () => {
this.loadAccounts();
this.closeEditModal();
},
error: (err) => console.error('Fehler beim Bearbeiten:', err)
});
}
// Delete
openDeleteModal(id: number) {
this.deleteTargetId = id;
this.showDeleteModal.set(true);
}
closeDeleteModal() {
this.showDeleteModal.set(false);
this.deleteTargetId = 0;
}
confirmDelete() {
this.api.deleteAccount(this.deleteTargetId).subscribe({
next: () => {
this.loadAccounts();
this.closeDeleteModal();
},
error: (err) => console.error('Error deleting account:', err)
});
}
}
+44
View File
@@ -0,0 +1,44 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, importProvidersFrom, APP_INITIALIZER } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { TranslateModule, TranslateLoader, TranslateService } from '@ngx-translate/core';
import { TranslateHttpLoader, TRANSLATE_HTTP_LOADER_CONFIG } from '@ngx-translate/http-loader';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
const SUPPORTED_LANGS = ['de', 'fr', 'it', 'en'];
function preloadTranslations(translate: TranslateService): () => Promise<any> {
return () => {
const stored = localStorage.getItem('app_language');
const browser = navigator.language?.split('-')[0].toLowerCase();
const lang = SUPPORTED_LANGS.includes(stored ?? '') ? stored!
: SUPPORTED_LANGS.includes(browser) ? browser
: 'de';
translate.setDefaultLang('de');
return translate.use(lang).toPromise();
};
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
importProvidersFrom(
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateHttpLoader,
},
})
),
{ provide: TRANSLATE_HTTP_LOADER_CONFIG, useValue: { prefix: '/assets/i18n/', suffix: '.json' } },
{
provide: APP_INITIALIZER,
useFactory: preloadTranslations,
deps: [TranslateService],
multi: true,
},
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+42
View File
@@ -0,0 +1,42 @@
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
import { Shell } from './layout/shell/shell';
import { Login } from './auth/login/login';
import { Register } from './auth/register/register';
import { ForgotPassword } from './auth/forgot-password/forgot-password';
import { ResetPassword } from './auth/reset-password/reset-password';
import { VerifyEmail } from './auth/verify-email/verify-email';
import { Dashboard } from './dashboard/dashboard';
import { AccountList } from './accounts/account-list/account-list';
import { Budgets } from './budgets/budgets';
import { TransactionList } from './transactions/transaction-list/transaction-list';
import { ExpenseList } from './expenses/expense-list/expense-list';
import { Profile } from './profile/profile';
import { Settings } from './settings/settings';
import { Calendar } from './calendar/calendar';
import { FinancialYearComponent } from './financial-year/financial-year';
export const routes: Routes = [
{ path: 'login', component: Login },
{ path: 'register', component: Register },
{ path: 'forgot-password', component: ForgotPassword },
{ path: 'reset-password', component: ResetPassword },
{ path: 'verify-email', component: VerifyEmail },
{
path: '',
component: Shell,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: Dashboard },
{ path: 'accounts', component: AccountList },
{ path: 'budgets', component: Budgets },
{ path: 'expenses', component: ExpenseList },
{ path: 'transactions', component: TransactionList },
{ path: 'profile', component: Profile },
{ path: 'settings', component: Settings },
{ path: 'calendar', component: Calendar },
{ path: 'financial-year', component: FinancialYearComponent },
],
},
{ path: '**', redirectTo: 'dashboard' },
];
+23
View File
@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, budget-frontend');
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}
@@ -0,0 +1,96 @@
<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.forgot_password_tagline' | 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()) {
<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 {
<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>
@if (!sent()) {
<!-- Heading -->
<h1 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">
{{ 'auth.forgot_password' | translate }}
</h1>
<p class="mb-5 text-gray-500 dark:text-gray-400">
{{ 'auth.forgot_password_hint' | translate }}
</p>
<!-- 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" (keyup.enter)="submit()" 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>
<!-- 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">
<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>
}
<!-- Submit -->
<button (click)="submit()" [disabled]="loading()"
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.sending' | translate }}
</span>
} @else {
{{ 'auth.send_reset_link' | translate }}
}
</button>
} @else {
<!-- Sent state -->
<div class="text-center py-4">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-900">
<svg class="w-6 h-6 text-violet-600 dark:text-violet-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 0 0 2.22 0L21 8M5 19h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2Z"/>
</svg>
</div>
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ 'auth.reset_link_sent' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.reset_link_sent_hint' | translate }}</p>
</div>
}
<!-- Back to login -->
<p class="mt-5 text-center text-sm text-gray-500 dark:text-gray-400">
<a routerLink="/login" class="font-medium text-violet-700 hover:underline dark:text-violet-500">
{{ 'auth.back_to_login' | translate }}
</a>
</p>
</div>
</div>
</div>
@@ -0,0 +1,49 @@
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
import { signal } from '@angular/core';
@Component({
selector: 'app-forgot-password',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher],
templateUrl: './forgot-password.html',
})
export class ForgotPassword {
email = '';
loading = signal(false);
sent = signal(false);
error = signal('');
constructor(
private api: ApiService,
private langService: LanguageService,
public themeService: ThemeService,
) {
this.langService.init();
}
submit(): void {
this.error.set('');
if (!this.email.trim()) {
this.error.set('auth.errors.fields_required');
return;
}
this.loading.set(true);
this.api.requestPasswordReset(this.email.trim()).subscribe({
next: () => {
this.sent.set(true);
this.loading.set(false);
},
error: () => {
this.sent.set(true);
this.loading.set(false);
},
});
}
}
@@ -0,0 +1,74 @@
import { Component, signal, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LanguageService } from '../../services/language';
const LANGS = [
{ code: 'de', label: 'Deutsch' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
{ code: 'en', label: 'English' },
];
@Component({
selector: 'app-lang-switcher',
standalone: true,
imports: [CommonModule],
template: `
<div class="relative">
<button
(click)="open.set(!open())"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors shadow-sm">
<!-- Flowbite: outline/text/language -->
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m13 19 3.5-9 3.5 9m-6.125-2h5.25M3 7h7m0 0h2m-2 0c0 1.63-.793 3.926-2.239 5.655M7.5 6.818V5m.261 7.655C6.79 13.82 5.521 14.725 4 15m3.761-2.345L5 10m2.761 2.655L10.2 15"/>
</svg>
{{ current().toUpperCase() }}
<!-- Flowbite: outline/arrows/chevron-down -->
<svg class="w-3 h-3 text-gray-400 transition-transform" [class.rotate-180]="open()" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m8 10 4 4 4-4"/>
</svg>
</button>
@if (open()) {
<div class="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50">
@for (lang of langs; track lang.code) {
<button
(click)="select(lang.code)"
[class]="itemClass(lang.code)"
class="w-full text-left px-4 py-2 text-sm transition-colors">
{{ lang.label }}
</button>
}
</div>
}
</div>
`,
})
export class LangSwitcher {
protected readonly langs = LANGS;
protected readonly open = signal(false);
protected readonly current = signal('de');
constructor(private langService: LanguageService) {
this.current.set(langService.current);
}
protected itemClass(code: string): string {
return code === this.current()
? 'font-semibold text-violet-600 bg-violet-50'
: 'text-gray-700 hover:bg-gray-50';
}
protected select(code: string): void {
this.langService.setLanguage(code);
this.current.set(code);
this.open.set(false);
}
@HostListener('document:click', ['$event'])
onClickOutside(event: MouseEvent): void {
if (!(event.target as Element).closest('app-lang-switcher')) {
this.open.set(false);
}
}
}
+266
View File
@@ -0,0 +1,266 @@
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<img src="assets/Logo_vertikal.svg" alt="Armarium" class="h-20 mx-auto mb-3 dark:invert" />
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ 'auth.tagline_login' | translate }}</p>
</div>
<!-- Card -->
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-6">
<!-- Lang + Theme switcher -->
<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>
<!-- ── Step: Credentials ── -->
@if (step() === 'credentials') {
<div class="space-y-4">
<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" (keyup.enter)="submit()"
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"
placeholder="" />
</div>
<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" (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"
placeholder="" />
<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>
</div>
<div class="flex items-start gap-2">
<div class="flex h-5 items-center">
<input type="checkbox" [(ngModel)]="keepSignedIn"
class="h-4 w-4 rounded border border-gray-300 bg-gray-50 focus:ring-3 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-violet-600 cursor-pointer" />
</div>
<div class="text-sm">
<span class="block font-medium text-gray-700 dark:text-gray-300">{{ 'auth.keep_signed_in' | translate }}</span>
<span class="block text-xs text-gray-400 dark:text-gray-500">{{ 'auth.keep_signed_in_hint' | translate }}</span>
</div>
</div>
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400">{{ error() | translate }}</p>
}
<app-turnstile class="mt-4 block" (resolved)="turnstileToken = $event" />
<button type="button" (click)="submit()" [disabled]="loading() || !turnstileToken"
class="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">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.sign_in' | translate) }}
</button>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
<a routerLink="/forgot-password" class="font-medium text-violet-700 hover:underline dark:text-violet-500">{{ 'auth.forgot_password' | translate }}</a>
</p>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ 'auth.no_account' | translate }}
<a routerLink="/register" class="font-medium text-violet-700 hover:underline dark:text-violet-500">{{ 'auth.sign_up' | translate }}</a>
</p>
</div>
}
<!-- ── Step: TOTP ── -->
@if (step() === 'totp') {
<div>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-violet-100 dark:bg-violet-900 flex items-center justify-center shrink-0">
<!-- Flowbite: outline/general/shield-check -->
<svg class="w-5 h-5 text-violet-600 dark:text-violet-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.5 11.5 11 13l4-3.5M12 20a16.405 16.405 0 0 1-5.092-5.804A16.694 16.694 0 0 1 5 6.666L12 4l7 2.667a16.695 16.695 0 0 1-1.908 7.529A16.406 16.406 0 0 1 12 20Z"/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.totp_title' | translate }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.totp_hint' | translate }}</p>
</div>
<!-- Countdown ring -->
<div class="relative flex items-center justify-center w-10 h-10 shrink-0">
<svg class="w-10 h-10 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#e5e7eb" stroke-width="3" class="dark:stroke-gray-600"/>
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#7c3aed" stroke-width="3"
stroke-dasharray="100"
[attr.stroke-dashoffset]="countdownOffset()"
style="transition: stroke-dashoffset 1s linear;"/>
</svg>
<span class="absolute text-xs font-semibold text-violet-700 dark:text-violet-400">{{ totpCountdown() }}</span>
</div>
</div>
<!-- 6 digit inputs -->
<div class="flex justify-center gap-2 my-4 sm:gap-4">
@for (i of [0,1,2,3,4,5]; track i) {
<input
type="text"
inputmode="numeric"
maxlength="2"
class="otp-digit block h-10 w-10 sm:h-12 sm:w-12 rounded-lg border border-gray-300 bg-white py-3 text-center text-2xl font-extrabold text-gray-900 focus:border-violet-500 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 caret-transparent"
(input)="onDigitInput(i, $event)"
(keydown)="onDigitKeydown(i, $event)"
(paste)="onDigitPaste($event)" />
}
</div>
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400 text-center mb-3">{{ error() | translate }}</p>
}
@if (loading()) {
<p class="text-sm text-center text-gray-400 mb-2">{{ 'auth.signing_in' | translate }}</p>
}
<p class="mt-4 rounded-lg bg-gray-100 p-4 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{{ 'auth.totp_no_device' | translate }}
<button type="button" (click)="goToBackup()"
class="font-medium text-violet-700 underline hover:no-underline dark:text-violet-500">
{{ 'auth.totp_use_backup' | translate }}
</button>
</p>
</div>
<button type="button" (click)="goToCredentials()" class="flex items-center justify-center gap-1.5 w-full mt-4 px-4 py-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<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="m14 8-4 4 4 4"/>
</svg>
{{ 'auth.back_to_login' | translate }}
</button>
}
<!-- ── Step: Backup code ── -->
@if (step() === 'backup') {
<div>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/40 flex items-center justify-center shrink-0">
<!-- Flowbite: outline/general/lock -->
<svg class="w-5 h-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="M12 14v3m-3-6V7a3 3 0 1 1 6 0v4m-8 0h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.totp_use_backup' | translate }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.backup_format_hint' | translate }}</p>
</div>
</div>
<input type="text" [(ngModel)]="backupCode" (keyup.enter)="submitBackup()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm font-mono text-center tracking-widest uppercase 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"
/>
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400 text-center mt-2">{{ error() | translate }}</p>
}
<button type="button" (click)="submitBackup()" [disabled]="loading()"
class="w-full mt-4 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">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.sign_in' | translate) }}
</button>
<p class="mt-4 rounded-lg bg-gray-100 p-4 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{{ 'auth.totp_no_backup' | translate }}
<button type="button" (click)="goToRecovery()"
class="font-medium text-violet-700 underline hover:no-underline dark:text-violet-500">
{{ 'auth.recovery_title' | translate }}
</button>
</p>
</div>
<button type="button" (click)="goToCredentials()" class="flex items-center justify-center gap-1.5 w-full mt-4 px-4 py-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<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="m14 8-4 4 4 4"/>
</svg>
{{ 'auth.back_to_login' | translate }}
</button>
}
<!-- ── Step: Recovery email ── -->
@if (step() === 'recovery') {
<div>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center shrink-0">
<!-- Flowbite: outline/general/envelope -->
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m3.5 5.5 7.893 6.036a1 1 0 0 0 1.214 0L20.5 5.5M4 19h16a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1Z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.recovery_title' | translate }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.recovery_intro' | translate }}</p>
</div>
</div>
@if (!recoverySent()) {
<button type="button" (click)="sendRecovery()" [disabled]="loading()"
class="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">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.recovery_send' | translate) }}
</button>
} @else {
<div class="mb-4 rounded-lg bg-gray-100 p-4 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-400">
<p>{{ 'auth.recovery_sent' | translate }}</p>
@if (maskedEmail()) {
<p class="font-medium text-gray-900 dark:text-white mt-1">{{ maskedEmail() }}</p>
}
<p class="mt-1">{{ 'auth.recovery_spam_hint' | translate }}</p>
</div>
<input type="text" [(ngModel)]="recoveryCode" (keyup.enter)="submitRecoveryCode()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm font-mono text-center tracking-widest uppercase 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"
placeholder="XXXX-XXXX" maxlength="9" />
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400 text-center mt-2">{{ error() | translate }}</p>
}
<button type="button" (click)="submitRecoveryCode()" [disabled]="loading()"
class="w-full mt-3 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">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.recovery_confirm' | translate) }}
</button>
}
</div>
<button type="button" (click)="goToCredentials()" class="flex items-center justify-center gap-1.5 w-full mt-4 px-4 py-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<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="m14 8-4 4 4 4"/>
</svg>
{{ 'auth.back_to_login' | translate }}
</button>
}
</div>
</div>
</div>
+249
View File
@@ -0,0 +1,249 @@
import { Component, OnInit, OnDestroy, ViewChild, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from '../../services/auth';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
import { TurnstileComponent } from '../turnstile/turnstile';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher, TurnstileComponent],
templateUrl: './login.html',
})
export class Login implements OnInit, OnDestroy {
@ViewChild(TurnstileComponent) private turnstileComp?: TurnstileComponent;
turnstileToken = '';
email = '';
password = '';
keepSignedIn = true;
showPassword = signal(false);
error = signal('');
loading = signal(false);
step = signal<'credentials' | 'totp' | 'backup' | 'recovery'>('credentials');
// TOTP digit inputs
digits: string[] = ['', '', '', '', '', ''];
// Backup code
backupCode = '';
// Recovery
recoverySent = signal(false);
maskedEmail = signal('');
recoveryCode = '';
// Countdown
totpCountdown = signal(30);
private pendingTempToken = '';
private countdownInterval?: ReturnType<typeof setInterval>;
constructor(
private auth: AuthService,
private api: ApiService,
private router: Router,
private langService: LanguageService,
public themeService: ThemeService,
) {}
ngOnInit(): void {
this.langService.init();
}
ngOnDestroy(): void {
clearInterval(this.countdownInterval);
}
// ── Credentials step ────────────────────────────────────────────────────────
submit(): void {
this.error.set('');
if (!this.email || !this.password) {
this.error.set('auth.errors.enter_credentials');
return;
}
this.loading.set(true);
this.auth.login(this.email, this.password, this.keepSignedIn, this.turnstileToken).subscribe({
next: (res) => {
if (res['2fa_required']) {
this.pendingTempToken = res.temp_token;
this.step.set('totp');
this.loading.set(false);
this.startCountdown();
setTimeout(() => this.focusDigit(0), 50);
} else {
this.router.navigate(['/dashboard']);
}
},
error: (err) => {
this.error.set(err.status === 400 ? 'auth.errors.captcha_failed' : 'auth.errors.invalid_credentials');
this.turnstileToken = '';
this.turnstileComp?.reset();
this.loading.set(false);
},
});
}
// ── TOTP digit input handling ────────────────────────────────────────────────
onDigitInput(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const val = input.value.replace(/\D/g, '').slice(-1);
this.digits[index] = val;
input.value = val;
if (val) {
if (index < 5) {
this.focusDigit(index + 1);
} else if (this.digits.every(d => d !== '')) {
this.submitTotp(this.digits.join(''));
}
}
}
onDigitKeydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace' && !this.digits[index] && index > 0) {
this.digits[index - 1] = '';
const prev = this.getDigitInput(index - 1);
if (prev) { prev.value = ''; prev.focus(); }
}
if (event.key === 'ArrowLeft' && index > 0) this.focusDigit(index - 1);
if (event.key === 'ArrowRight' && index < 5) this.focusDigit(index + 1);
}
onDigitPaste(event: ClipboardEvent): void {
event.preventDefault();
const text = event.clipboardData?.getData('text') ?? '';
const nums = text.replace(/\D/g, '').slice(0, 6);
for (let i = 0; i < 6; i++) {
this.digits[i] = nums[i] ?? '';
const el = this.getDigitInput(i);
if (el) el.value = this.digits[i];
}
const next = Math.min(nums.length, 5);
this.focusDigit(next);
if (nums.length === 6) this.submitTotp(nums);
}
private focusDigit(index: number): void {
this.getDigitInput(index)?.focus();
}
private getDigitInput(index: number): HTMLInputElement | null {
return document.querySelectorAll<HTMLInputElement>('.otp-digit')[index] ?? null;
}
// ── TOTP submit ──────────────────────────────────────────────────────────────
private submitTotp(code: string): void {
this.error.set('');
if (code.length !== 6) return;
this.loading.set(true);
this.api.login2FA(this.pendingTempToken, code).subscribe({
next: (tokens) => {
clearInterval(this.countdownInterval);
this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key);
this.router.navigate(['/dashboard']);
},
error: () => {
this.error.set('auth.errors.invalid_totp');
this.loading.set(false);
this.digits = ['', '', '', '', '', ''];
document.querySelectorAll<HTMLInputElement>('.otp-digit').forEach(el => el.value = '');
setTimeout(() => this.focusDigit(0), 50);
},
});
}
// ── Backup code submit ───────────────────────────────────────────────────────
submitBackup(): void {
this.error.set('');
if (!this.backupCode.trim()) return;
this.loading.set(true);
this.api.login2FA(this.pendingTempToken, this.backupCode.trim()).subscribe({
next: (tokens) => {
this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key);
this.router.navigate(['/dashboard']);
},
error: () => {
this.error.set('auth.errors.invalid_totp');
this.loading.set(false);
},
});
}
// ── Recovery email ───────────────────────────────────────────────────────────
sendRecovery(): void {
this.loading.set(true);
this.api.request2FARecovery(this.pendingTempToken).subscribe({
next: (res) => {
this.maskedEmail.set(res?.masked_email ?? '');
this.recoverySent.set(true);
this.loading.set(false);
},
error: () => { this.recoverySent.set(true); this.loading.set(false); },
});
}
submitRecoveryCode(): void {
this.error.set('');
if (!this.recoveryCode.trim()) return;
this.loading.set(true);
this.api.confirm2FARecovery(this.pendingTempToken, this.recoveryCode.trim()).subscribe({
next: (tokens) => {
this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key);
this.router.navigate(['/dashboard']);
},
error: () => {
this.error.set('auth.errors.invalid_totp');
this.loading.set(false);
},
});
}
// ── Countdown ────────────────────────────────────────────────────────────────
private startCountdown(): void {
this.updateCountdown();
this.countdownInterval = setInterval(() => this.updateCountdown(), 1000);
}
private updateCountdown(): void {
this.totpCountdown.set(30 - (Math.floor(Date.now() / 1000) % 30));
}
countdownOffset(): number {
return Math.round(100 * (1 - this.totpCountdown() / 30));
}
// ── Navigation helpers ───────────────────────────────────────────────────────
goToBackup(): void {
this.error.set('');
this.backupCode = '';
this.step.set('backup');
}
goToRecovery(): void {
this.error.set('');
this.recoverySent.set(false);
this.recoveryCode = '';
this.step.set('recovery');
}
goToCredentials(): void {
clearInterval(this.countdownInterval);
this.error.set('');
this.digits = ['', '', '', '', '', ''];
this.backupCode = '';
this.recoverySent.set(false);
this.step.set('credentials');
}
}
@@ -0,0 +1,142 @@
<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>
@@ -0,0 +1,69 @@
import { Component, OnInit, ViewChild, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from '../../services/auth';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
import { TurnstileComponent } from '../turnstile/turnstile';
@Component({
selector: 'app-register',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher, TurnstileComponent],
templateUrl: './register.html',
})
export class Register implements OnInit {
@ViewChild(TurnstileComponent) private turnstileComp?: TurnstileComponent;
turnstileToken = '';
email = '';
password = '';
confirmPassword = '';
showPassword = signal(false);
showConfirmPassword = signal(false);
error = signal('');
loading = signal(false);
constructor(
private auth: AuthService,
private router: Router,
private langService: LanguageService,
public themeService: ThemeService,
) {}
ngOnInit(): void {
const detected = this.langService.detectBrowserLanguage();
this.langService.setLanguage(detected);
}
submit(): void {
this.error.set('');
if (!this.email || !this.password) {
this.error.set('auth.errors.fields_required');
return;
}
if (this.password !== this.confirmPassword) {
this.error.set('auth.errors.passwords_mismatch');
return;
}
if (this.password.length < 8) {
this.error.set('auth.errors.password_too_short');
return;
}
this.loading.set(true);
this.auth.register(this.email, this.password, this.turnstileToken).subscribe({
next: () => this.router.navigate(['/login']),
error: (err) => {
const data = err.error;
const msg = err.status === 400 && data?.detail === 'Captcha verification failed.'
? 'auth.errors.captcha_failed'
: (data?.email?.[0] || data?.password?.[0] || 'auth.errors.registration_failed');
this.error.set(msg);
this.turnstileToken = '';
this.turnstileComp?.reset();
this.loading.set(false);
},
});
}
}
@@ -0,0 +1,139 @@
<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.reset_password_tagline' | 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()) {
<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 {
<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>
@if (!success()) {
<!-- Heading -->
<h1 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">
{{ 'auth.reset_password' | translate }}
</h1>
<p class="mb-5 text-gray-500 dark:text-gray-400">
{{ 'auth.reset_password_hint' | translate }}
</p>
<div class="space-y-4">
<!-- New password -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'auth.new_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()) {
<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 {
<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()) {
<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 {
<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">
<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>
}
<!-- Submit -->
<button (click)="submit()" [disabled]="loading() || !!error() && error() === 'auth.errors.token_missing'"
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.resetting' | translate }}
</span>
} @else {
{{ 'auth.reset_password' | translate }}
}
</button>
} @else {
<!-- Success state -->
<div class="text-center py-4">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ 'auth.reset_success' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.recovery_redirecting' | translate }}</p>
</div>
}
<!-- Back to login -->
<p class="mt-5 text-center text-sm text-gray-500 dark:text-gray-400">
<a routerLink="/login" class="font-medium text-violet-700 hover:underline dark:text-violet-500">
{{ 'auth.back_to_login' | translate }}
</a>
</p>
</div>
</div>
</div>
@@ -0,0 +1,70 @@
import { Component, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
@Component({
selector: 'app-reset-password',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher],
templateUrl: './reset-password.html',
})
export class ResetPassword implements OnInit {
password = '';
confirmPassword = '';
showPassword = signal(false);
showConfirmPassword = signal(false);
loading = signal(false);
success = signal(false);
error = signal('');
private token = '';
constructor(
private api: ApiService,
private route: ActivatedRoute,
private router: Router,
private langService: LanguageService,
public themeService: ThemeService,
) {
this.langService.init();
}
ngOnInit(): void {
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
if (!this.token) {
this.error.set('auth.errors.token_missing');
}
}
submit(): void {
this.error.set('');
if (!this.password || !this.confirmPassword) {
this.error.set('auth.errors.fields_required');
return;
}
if (this.password !== this.confirmPassword) {
this.error.set('auth.errors.passwords_mismatch');
return;
}
if (this.password.length < 8) {
this.error.set('auth.errors.password_too_short');
return;
}
this.loading.set(true);
this.api.confirmPasswordReset(this.token, this.password).subscribe({
next: () => {
this.success.set(true);
this.loading.set(false);
setTimeout(() => this.router.navigate(['/login']), 3000);
},
error: () => {
this.error.set('auth.errors.reset_failed');
this.loading.set(false);
},
});
}
}
@@ -0,0 +1,53 @@
import { Component, Output, EventEmitter, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
@Component({
selector: 'app-turnstile',
standalone: true,
template: '<div #container></div>',
})
export class TurnstileComponent implements AfterViewInit, OnDestroy {
@ViewChild('container', { static: true }) private container!: ElementRef<HTMLDivElement>;
@Output() resolved = new EventEmitter<string>();
private widgetId = '';
private pollId?: ReturnType<typeof setTimeout>;
ngAfterViewInit(): void {
if (window.location.hostname === 'localhost') {
setTimeout(() => this.resolved.emit('dev-bypass'), 0);
return;
}
this.poll();
}
private poll(): void {
if ((window as any).turnstile) {
this.render();
} else {
this.pollId = setTimeout(() => this.poll(), 100);
}
}
private render(): void {
this.widgetId = (window as any).turnstile.render(this.container.nativeElement, {
sitekey: '0x4AAAAAADRzQr8OmvZ5s7NA',
theme: 'auto',
callback: (token: string) => this.resolved.emit(token),
'expired-callback': () => this.resolved.emit(''),
'error-callback': () => this.resolved.emit(''),
});
}
reset(): void {
if (this.widgetId && (window as any).turnstile) {
(window as any).turnstile.reset(this.widgetId);
}
}
ngOnDestroy(): void {
clearTimeout(this.pollId);
if (this.widgetId && (window as any).turnstile) {
(window as any).turnstile.remove(this.widgetId);
}
}
}

Some files were not shown because too many files have changed in this diff Show More