feat: Armarium v1.1.0 — dashboard, auth, 2FA, SMTP, settings, deploy
Dashboard: - ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart - KPI cards: income, fixed costs, savings rate with configurable goal - Greeting with time-of-day and locale-aware date/time display Authentication & security: - Email-based login (no username), case-insensitive lookup - JWT access/refresh tokens with rotation and blacklist - TOTP 2FA with QR code, backup codes (copy + PDF export) - 2FA recovery via email code - Cloudflare Turnstile CAPTCHA on login and register Email flows: - Email verification on registration (24h token) - Password reset flow (15min token, anti-enumeration) - Brevo SMTP integration with HTML + plaintext email templates - Notification emails: 2FA recovery, password changed, email changed Settings page: - 2FA management (enable/disable, QR, backup codes) - Active sessions list with per-device revoke - Data export: ZIP with 6 PDFs via fpdf2 - Notification preferences (3 toggles) - Danger zone: account deletion with mandatory export + confirmation phrase UI & layout: - Sidebar with collapsible/flyout mode, Angular signal-based dropdowns - Dark mode (class-based), language switcher (DE/FR/IT/EN) - Mobile-responsive layout with touch-friendly targets - Roboto font via @fontsource (GDPR-compliant, no Google CDN) - Pure Tailwind CSS v3 Infrastructure: - Forgejo Actions CI/CD pipeline (auto-deploy on push to main) - Gunicorn + Nginx + PostgreSQL production setup - Rate limiting, HSTS, secure cookies, CSRF protection
This commit is contained in:
@@ -0,0 +1,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
|
||||||
@@ -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.zipZone.Identifier
|
||||||
|
start.sh
|
||||||
|
|||||||
+141
-1
@@ -5,7 +5,147 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.1.0] - 2026-05-19
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
-1
Submodule backend deleted from 980535b2a4
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
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')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()),
|
||||||
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Account, Transaction
|
||||||
|
|
||||||
|
admin.site.register(Account)
|
||||||
|
admin.site.register(Transaction)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceConfig(AppConfig):
|
||||||
|
name = 'finance'
|
||||||
@@ -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
|
||||||
@@ -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,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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,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,226 @@
|
|||||||
|
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)
|
||||||
|
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'}"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import Account, Transaction, Budget, Expense, Profile, Deadline
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 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']
|
||||||
|
return User.objects.create_user(
|
||||||
|
username=email,
|
||||||
|
email=email,
|
||||||
|
password=validated_data['password'],
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1,999 @@
|
|||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
logger = logging.getLogger('armarium')
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model, authenticate
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from icalendar import Calendar as iCalendar, Event as iCalEvent
|
||||||
|
|
||||||
|
from rest_framework import viewsets, views, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
from rest_framework_simplejwt.exceptions import TokenError
|
||||||
|
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession
|
||||||
|
from .serializers import (
|
||||||
|
AccountSerializer, TransactionSerializer, BudgetSerializer,
|
||||||
|
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_turnstile(token: str, remote_ip: str = '') -> bool:
|
||||||
|
if settings.DEBUG:
|
||||||
|
return True
|
||||||
|
if not token or not settings.TURNSTILE_SECRET_KEY:
|
||||||
|
return False
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
'secret': settings.TURNSTILE_SECRET_KEY,
|
||||||
|
'response': token,
|
||||||
|
'remoteip': remote_ip,
|
||||||
|
}).encode()
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
data=data,
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return json.loads(resp.read()).get('success', False)
|
||||||
|
except Exception:
|
||||||
|
logger.warning('Turnstile verification request failed')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ical_token(user_id: int) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
settings.SECRET_KEY.encode(),
|
||||||
|
str(user_id).encode(),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||||
|
|
||||||
|
|
||||||
|
class AuthThrottle(AnonRateThrottle):
|
||||||
|
rate = '5/min'
|
||||||
|
|
||||||
|
|
||||||
|
class AccountViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = AccountSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Account.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = TransactionSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Transaction.objects.filter(source_account__user=self.request.user)
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
context['request'] = self.request
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = BudgetSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Budget.objects.filter(account__user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = ExpenseSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Expense.objects.filter(account__user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = DeadlineSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Deadline.objects.filter(user=self.request.user).order_by('date')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileView(views.APIView):
|
||||||
|
def get(self, request):
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=request.user)
|
||||||
|
return Response(ProfileSerializer(profile).data)
|
||||||
|
|
||||||
|
def put(self, request):
|
||||||
|
from .email import send_email
|
||||||
|
|
||||||
|
avatar = request.FILES.get('avatar_image')
|
||||||
|
if avatar and avatar.size > MAX_AVATAR_SIZE_BYTES:
|
||||||
|
return Response({'detail': 'Image must be smaller than 2 MB.'}, status=400)
|
||||||
|
|
||||||
|
recovery_email = request.data.get('recovery_email', '').strip().lower()
|
||||||
|
if recovery_email and recovery_email == request.user.email.lower():
|
||||||
|
return Response(
|
||||||
|
{'recovery_email': 'Recovery email must differ from your login email.'},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
old_email = request.user.email
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=request.user)
|
||||||
|
serializer = ProfileSerializer(profile, data=request.data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
new_email = request.user.email
|
||||||
|
if new_email != old_email:
|
||||||
|
send_email(
|
||||||
|
'email_changed',
|
||||||
|
{'new_email': new_email},
|
||||||
|
'Armarium – Deine E-Mail-Adresse wurde geändert',
|
||||||
|
old_email,
|
||||||
|
)
|
||||||
|
return Response(serializer.data)
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
def delete(self, request):
|
||||||
|
password = request.data.get('password', '')
|
||||||
|
if not password or not request.user.check_password(password):
|
||||||
|
return Response({'detail': 'Passwort ungültig.'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
request.user.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(views.APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from .email import send_email
|
||||||
|
|
||||||
|
if not _verify_turnstile(
|
||||||
|
request.data.get('cf_turnstile_response', ''),
|
||||||
|
request.META.get('REMOTE_ADDR', ''),
|
||||||
|
):
|
||||||
|
return Response({'detail': 'Captcha verification failed.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
serializer = RegisterSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
user = serializer.save()
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=user)
|
||||||
|
profile.email_verify_token = token_hash
|
||||||
|
profile.email_verify_token_expires = timezone.now() + timedelta(hours=24)
|
||||||
|
profile.save(update_fields=['email_verify_token', 'email_verify_token_expires'])
|
||||||
|
link = f"{settings.FRONTEND_URL}/verify-email?token={token}"
|
||||||
|
send_email('registration_confirm', {'link': link}, 'Armarium – E-Mail-Adresse bestätigen', user.email)
|
||||||
|
return Response({'detail': 'Account created.'}, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutView(views.APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
refresh_token = request.data.get('refresh')
|
||||||
|
if not refresh_token:
|
||||||
|
return Response({'detail': 'Refresh token required.'}, status=400)
|
||||||
|
try:
|
||||||
|
token = RefreshToken(refresh_token)
|
||||||
|
jti = token.payload.get('jti', '')
|
||||||
|
token.blacklist()
|
||||||
|
if jti:
|
||||||
|
UserSession.objects.filter(refresh_jti=jti).delete()
|
||||||
|
except TokenError:
|
||||||
|
pass # already invalid, treat as success
|
||||||
|
|
||||||
|
session_key = request.headers.get('X-Session-Key', '')
|
||||||
|
if session_key:
|
||||||
|
UserSession.objects.filter(session_key=session_key).delete()
|
||||||
|
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordView(views.APIView):
|
||||||
|
def post(self, request):
|
||||||
|
from .email import send_email
|
||||||
|
|
||||||
|
password = request.data.get('password', '')
|
||||||
|
if len(password) < 8:
|
||||||
|
return Response({'detail': 'Password must be at least 8 characters.'}, status=400)
|
||||||
|
request.user.set_password(password)
|
||||||
|
request.user.save()
|
||||||
|
|
||||||
|
current_key = request.headers.get('X-Session-Key', '')
|
||||||
|
other_sessions = UserSession.objects.filter(user=request.user).exclude(session_key=current_key)
|
||||||
|
for session in other_sessions:
|
||||||
|
_blacklist_session(session)
|
||||||
|
|
||||||
|
send_email('password_changed', {}, 'Armarium – Dein Passwort wurde geändert', request.user.email)
|
||||||
|
return Response({'detail': 'Password updated.'})
|
||||||
|
|
||||||
|
|
||||||
|
class SearchView(views.APIView):
|
||||||
|
"""Global search across all user resources."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
q = request.query_params.get('q', '').strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return Response({})
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
accounts = Account.objects.filter(user=user, name__icontains=q)[:5]
|
||||||
|
if accounts:
|
||||||
|
results['accounts'] = [
|
||||||
|
{'id': a.id, 'title': a.name, 'subtitle': a.get_account_type_display()}
|
||||||
|
for a in accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
budgets = Budget.objects.filter(account__user=user, name__icontains=q)[:5]
|
||||||
|
if budgets:
|
||||||
|
results['budgets'] = [
|
||||||
|
{'id': b.id, 'title': b.name, 'subtitle': f'CHF {b.amount}'}
|
||||||
|
for b in budgets
|
||||||
|
]
|
||||||
|
|
||||||
|
expenses = Expense.objects.filter(account__user=user, name__icontains=q)[:5]
|
||||||
|
if expenses:
|
||||||
|
results['expenses'] = [
|
||||||
|
{'id': e.id, 'title': e.name, 'subtitle': f'{e.date} · CHF {e.amount}'}
|
||||||
|
for e in expenses
|
||||||
|
]
|
||||||
|
|
||||||
|
transactions = Transaction.objects.filter(
|
||||||
|
source_account__user=user, description__icontains=q
|
||||||
|
)[:5]
|
||||||
|
if transactions:
|
||||||
|
results['transactions'] = [
|
||||||
|
{'id': t.id, 'title': t.description, 'subtitle': f'{t.date} · CHF {t.amount}'}
|
||||||
|
for t in transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
deadlines = Deadline.objects.filter(user=user, title__icontains=q)[:5]
|
||||||
|
if deadlines:
|
||||||
|
results['deadlines'] = [
|
||||||
|
{'id': d.id, 'title': d.title, 'subtitle': str(d.date), 'date': str(d.date)}
|
||||||
|
for d in deadlines
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response(results)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsView(views.APIView):
|
||||||
|
"""Returns all unread active events (date <= today) for the authenticated user."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from datetime import date
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
read_deadlines = set(
|
||||||
|
ReadEvent.objects.filter(user=request.user, event_type='deadline')
|
||||||
|
.values_list('event_id', flat=True)
|
||||||
|
)
|
||||||
|
read_expenses = set(
|
||||||
|
ReadEvent.objects.filter(user=request.user, event_type='expense')
|
||||||
|
.values_list('event_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
|
||||||
|
for d in Deadline.objects.filter(user=request.user, date__lte=today):
|
||||||
|
if d.id not in read_deadlines:
|
||||||
|
notifications.append({
|
||||||
|
'event_type': 'deadline',
|
||||||
|
'event_id': d.id,
|
||||||
|
'title': d.title,
|
||||||
|
'date': str(d.date),
|
||||||
|
})
|
||||||
|
|
||||||
|
for e in Expense.objects.filter(account__user=request.user, due_date__lte=today):
|
||||||
|
if e.id not in read_expenses:
|
||||||
|
notifications.append({
|
||||||
|
'event_type': 'expense',
|
||||||
|
'event_id': e.id,
|
||||||
|
'title': e.name,
|
||||||
|
'date': str(e.due_date),
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.sort(key=lambda x: x['date'])
|
||||||
|
return Response(notifications)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""Mark a single event as read."""
|
||||||
|
event_type = request.data.get('event_type')
|
||||||
|
event_id = request.data.get('event_id')
|
||||||
|
if event_type not in ('deadline', 'expense') or not event_id:
|
||||||
|
return Response({'detail': 'Invalid payload.'}, status=400)
|
||||||
|
ReadEvent.objects.get_or_create(
|
||||||
|
user=request.user, event_type=event_type, event_id=event_id
|
||||||
|
)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class ICalUrlView(views.APIView):
|
||||||
|
"""Returns the personal iCal feed URL for the authenticated user."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
token = generate_ical_token(request.user.id)
|
||||||
|
base_url = request.build_absolute_uri('/')
|
||||||
|
url = f"{base_url}api/calendar/ical/{request.user.id}/{token}/"
|
||||||
|
return Response({'url': url})
|
||||||
|
|
||||||
|
|
||||||
|
class ICalFeedView(views.APIView):
|
||||||
|
"""Serves the iCal feed. Token acts as authentication — no JWT required."""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, user_id, token):
|
||||||
|
expected = generate_ical_token(user_id)
|
||||||
|
if not hmac.compare_digest(expected, token):
|
||||||
|
return HttpResponse(status=404)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return HttpResponse(status=404)
|
||||||
|
|
||||||
|
cal = iCalendar()
|
||||||
|
cal.add('prodid', '-//Budget App//EN')
|
||||||
|
cal.add('version', '2.0')
|
||||||
|
cal.add('x-wr-calname', 'Budget App')
|
||||||
|
cal.add('x-wr-timezone', 'Europe/Zurich')
|
||||||
|
|
||||||
|
# Deadlines
|
||||||
|
for deadline in Deadline.objects.filter(user=user):
|
||||||
|
event = iCalEvent()
|
||||||
|
event.add('summary', f'[{deadline.get_type_display()}] {deadline.title}')
|
||||||
|
event.add('dtstart', deadline.date)
|
||||||
|
event.add('dtend', deadline.date)
|
||||||
|
event.add('uid', f'deadline-{deadline.id}@budget-app')
|
||||||
|
if deadline.notes:
|
||||||
|
event.add('description', deadline.notes)
|
||||||
|
cal.add_component(event)
|
||||||
|
|
||||||
|
# Expense due dates
|
||||||
|
for expense in Expense.objects.filter(account__user=user, due_date__isnull=False):
|
||||||
|
event = iCalEvent()
|
||||||
|
event.add('summary', f'[Invoice] {expense.name} – CHF {expense.amount}')
|
||||||
|
event.add('dtstart', expense.due_date)
|
||||||
|
event.add('dtend', expense.due_date)
|
||||||
|
event.add('uid', f'expense-{expense.id}@budget-app')
|
||||||
|
if expense.notes:
|
||||||
|
event.add('description', expense.notes)
|
||||||
|
cal.add_component(event)
|
||||||
|
|
||||||
|
response = HttpResponse(cal.to_ical(), content_type='text/calendar; charset=utf-8')
|
||||||
|
response['Content-Disposition'] = 'attachment; filename="budget-app.ics"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2FA helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_2fa_token(user_id: int) -> str:
|
||||||
|
"""Create a short-lived signed token binding step-1 to step-2 of login."""
|
||||||
|
payload = f"{user_id}:{int(time.time())}"
|
||||||
|
sig = hmac.new(settings.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_2fa_token(token: str, max_age: int = 300) -> int | None:
|
||||||
|
"""Return user_id if token is valid and not expired, else None."""
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(token.encode()).decode()
|
||||||
|
*payload_parts, sig = decoded.split(':')
|
||||||
|
payload = ':'.join(payload_parts)
|
||||||
|
user_id_str, ts_str = payload_parts
|
||||||
|
expected = hmac.new(settings.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(expected, sig):
|
||||||
|
return None
|
||||||
|
if int(time.time()) - int(ts_str) > max_age:
|
||||||
|
return None
|
||||||
|
return int(user_id_str)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_backup_codes(user, count: int = 8) -> list[str]:
|
||||||
|
"""Invalidate all old backup codes and return a fresh set of plain-text codes."""
|
||||||
|
BackupCode.objects.filter(user=user).delete()
|
||||||
|
plain = []
|
||||||
|
for _ in range(count):
|
||||||
|
code = f"{secrets.token_hex(4).upper()}-{secrets.token_hex(4).upper()}"
|
||||||
|
BackupCode.objects.create(
|
||||||
|
user=user,
|
||||||
|
code_hash=hashlib.sha256(code.encode()).hexdigest(),
|
||||||
|
)
|
||||||
|
plain.append(code)
|
||||||
|
return plain
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_totp_with_replay_check(profile, code: str) -> bool:
|
||||||
|
"""Verify TOTP code and reject replay within the same 30-second window."""
|
||||||
|
if profile.totp_last_used_code == code:
|
||||||
|
return False
|
||||||
|
totp = pyotp.TOTP(profile.totp_secret)
|
||||||
|
if not totp.verify(code, valid_window=1):
|
||||||
|
return False
|
||||||
|
profile.totp_last_used_code = code
|
||||||
|
profile.save(update_fields=['totp_last_used_code'])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2FA views ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LoginView(views.APIView):
|
||||||
|
"""Replaces TokenObtainPairView. Returns a short-lived temp_token when 2FA is required."""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if not _verify_turnstile(
|
||||||
|
request.data.get('cf_turnstile_response', ''),
|
||||||
|
request.META.get('REMOTE_ADDR', ''),
|
||||||
|
):
|
||||||
|
return Response({'detail': 'Captcha verification failed.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
email = request.data.get('username', '')
|
||||||
|
password = request.data.get('password', '')
|
||||||
|
user = authenticate(request, username=email, password=password)
|
||||||
|
if user is None:
|
||||||
|
return Response({'detail': 'No active account found with the given credentials.'}, status=401)
|
||||||
|
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=user)
|
||||||
|
if profile.totp_enabled:
|
||||||
|
return Response({'2fa_required': True, 'temp_token': _make_2fa_token(user.id)}, status=200)
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
session_key = _create_session(user, request, refresh)
|
||||||
|
return Response({'access': str(refresh.access_token), 'refresh': str(refresh), 'session_key': session_key})
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorLoginView(views.APIView):
|
||||||
|
"""Step 2 of login — accepts TOTP code or backup code, returns JWT tokens."""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
temp_token = request.data.get('temp_token', '')
|
||||||
|
code = str(request.data.get('code', '')).strip()
|
||||||
|
|
||||||
|
user_id = _verify_2fa_token(temp_token)
|
||||||
|
if user_id is None:
|
||||||
|
return Response({'detail': 'Session expired. Please log in again.'}, status=401)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({'detail': 'Invalid credentials.'}, status=401)
|
||||||
|
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=user)
|
||||||
|
if not profile.totp_enabled or not profile.totp_secret:
|
||||||
|
return Response({'detail': 'Invalid credentials.'}, status=401)
|
||||||
|
|
||||||
|
if code.isdigit() and len(code) == 6:
|
||||||
|
if not _verify_totp_with_replay_check(profile, code):
|
||||||
|
return Response({'detail': 'Invalid or already used code.'}, status=400)
|
||||||
|
else:
|
||||||
|
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||||||
|
backup = BackupCode.objects.filter(user=user, code_hash=code_hash, used=False).first()
|
||||||
|
if backup is None:
|
||||||
|
return Response({'detail': 'Invalid backup code.'}, status=400)
|
||||||
|
backup.used = True
|
||||||
|
backup.save(update_fields=['used'])
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
session_key = _create_session(user, request, refresh)
|
||||||
|
return Response({'access': str(refresh.access_token), 'refresh': str(refresh), 'session_key': session_key})
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorSetupView(views.APIView):
|
||||||
|
"""Generates a fresh TOTP secret and returns the otpauth:// URI for QR display."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=request.user)
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
profile.totp_secret = secret
|
||||||
|
profile.totp_enabled = False
|
||||||
|
profile.save(update_fields=['totp_secret', 'totp_enabled'])
|
||||||
|
email = request.user.email or request.user.username
|
||||||
|
uri = pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name='Armarium')
|
||||||
|
return Response({'uri': uri})
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorEnableView(views.APIView):
|
||||||
|
"""Verifies the first TOTP code, activates 2FA and returns one-time backup codes."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
code = str(request.data.get('code', '')).strip()
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=request.user)
|
||||||
|
if not profile.totp_secret:
|
||||||
|
return Response({'detail': 'Run setup first.'}, status=400)
|
||||||
|
if not _verify_totp_with_replay_check(profile, code):
|
||||||
|
return Response({'detail': 'Invalid code.'}, status=400)
|
||||||
|
profile.totp_enabled = True
|
||||||
|
profile.save(update_fields=['totp_enabled'])
|
||||||
|
backup_codes = _generate_backup_codes(request.user)
|
||||||
|
return Response({'detail': '2FA enabled.', 'backup_codes': backup_codes})
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDisableView(views.APIView):
|
||||||
|
"""Disables 2FA — accepts TOTP code or a backup code as proof."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
code = str(request.data.get('code', '')).strip()
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=request.user)
|
||||||
|
if not profile.totp_enabled:
|
||||||
|
return Response({'detail': '2FA is not enabled.'}, status=400)
|
||||||
|
|
||||||
|
authenticated = False
|
||||||
|
if code.isdigit() and len(code) == 6:
|
||||||
|
authenticated = _verify_totp_with_replay_check(profile, code)
|
||||||
|
else:
|
||||||
|
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||||||
|
backup = BackupCode.objects.filter(user=request.user, code_hash=code_hash, used=False).first()
|
||||||
|
if backup:
|
||||||
|
backup.used = True
|
||||||
|
backup.save(update_fields=['used'])
|
||||||
|
authenticated = True
|
||||||
|
|
||||||
|
if not authenticated:
|
||||||
|
return Response({'detail': 'Invalid code.'}, status=400)
|
||||||
|
|
||||||
|
profile.totp_enabled = False
|
||||||
|
profile.totp_secret = ''
|
||||||
|
profile.totp_last_used_code = ''
|
||||||
|
profile.save(update_fields=['totp_enabled', 'totp_secret', 'totp_last_used_code'])
|
||||||
|
BackupCode.objects.filter(user=request.user).delete()
|
||||||
|
return Response({'detail': '2FA disabled.'})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Recovery email helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _mask_email(email: str) -> str:
|
||||||
|
if '@' not in email:
|
||||||
|
return '***'
|
||||||
|
local, domain = email.split('@', 1)
|
||||||
|
return f"{local[0]}{'*' * min(len(local) - 1, 18)}@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_recovery_code() -> str:
|
||||||
|
"""Generate a human-readable 8-character code in XXXX-XXXX format."""
|
||||||
|
alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # no O/0, I/1 confusion
|
||||||
|
part = lambda: ''.join(secrets.choice(alphabet) for _ in range(4))
|
||||||
|
return f"{part()}-{part()}"
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorRecoverRequestView(views.APIView):
|
||||||
|
"""Generate a recovery code, store its hash in Profile and email the plain code."""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from .email import send_email
|
||||||
|
|
||||||
|
temp_token = request.data.get('temp_token', '')
|
||||||
|
user_id = _verify_2fa_token(temp_token)
|
||||||
|
if user_id is None:
|
||||||
|
return Response({'detail': 'ok'})
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.filter(pk=user_id).first()
|
||||||
|
if not user:
|
||||||
|
return Response({'detail': 'ok'})
|
||||||
|
|
||||||
|
profile = Profile.objects.filter(user=user).first()
|
||||||
|
if not profile or not profile.recovery_email:
|
||||||
|
return Response({'detail': 'ok'})
|
||||||
|
|
||||||
|
plain_code = _generate_recovery_code()
|
||||||
|
profile.recovery_code_hash = hashlib.sha256(plain_code.encode()).hexdigest()
|
||||||
|
profile.recovery_code_expires = timezone.now() + timedelta(minutes=15)
|
||||||
|
profile.save(update_fields=['recovery_code_hash', 'recovery_code_expires'])
|
||||||
|
|
||||||
|
sent = send_email(
|
||||||
|
template_name='2fa_recovery',
|
||||||
|
context={'code': plain_code},
|
||||||
|
subject='Armarium – 2FA-Wiederherstellung',
|
||||||
|
to=profile.recovery_email,
|
||||||
|
)
|
||||||
|
if not sent:
|
||||||
|
return Response({'detail': 'Failed to send recovery email.'}, status=500)
|
||||||
|
|
||||||
|
return Response({'detail': 'ok', 'masked_email': _mask_email(profile.recovery_email)})
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorRecoverConfirmView(views.APIView):
|
||||||
|
"""Verify the recovery code, disable 2FA and return JWT tokens."""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
temp_token = request.data.get('temp_token', '')
|
||||||
|
user_id = _verify_2fa_token(temp_token)
|
||||||
|
if user_id is None:
|
||||||
|
return Response({'detail': 'Session expired. Please log in again.'}, status=401)
|
||||||
|
|
||||||
|
recovery_code = str(request.data.get('recovery_code', '')).strip().upper()
|
||||||
|
if not recovery_code:
|
||||||
|
return Response({'detail': 'Code required.'}, status=400)
|
||||||
|
|
||||||
|
code_hash = hashlib.sha256(recovery_code.encode()).hexdigest()
|
||||||
|
profile = Profile.objects.filter(
|
||||||
|
user_id=user_id,
|
||||||
|
recovery_code_hash=code_hash,
|
||||||
|
recovery_code_expires__gt=timezone.now(),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
return Response({'detail': 'Invalid or expired recovery code.'}, status=400)
|
||||||
|
|
||||||
|
profile.totp_enabled = False
|
||||||
|
profile.totp_secret = ''
|
||||||
|
profile.totp_last_used_code = ''
|
||||||
|
profile.recovery_code_hash = ''
|
||||||
|
profile.recovery_code_expires = None
|
||||||
|
profile.save(update_fields=[
|
||||||
|
'totp_enabled', 'totp_secret', 'totp_last_used_code',
|
||||||
|
'recovery_code_hash', 'recovery_code_expires',
|
||||||
|
])
|
||||||
|
BackupCode.objects.filter(user=profile.user).delete()
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(profile.user)
|
||||||
|
session_key = _create_session(profile.user, request, refresh)
|
||||||
|
return Response({'access': str(refresh.access_token), 'refresh': str(refresh), 'session_key': session_key})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse_device(ua: str) -> str:
|
||||||
|
ua = ua.lower()
|
||||||
|
if 'iphone' in ua: return 'iPhone'
|
||||||
|
if 'ipad' in ua: return 'iPad'
|
||||||
|
if 'android' in ua and 'mobile' in ua: return 'Android (Phone)'
|
||||||
|
if 'android' in ua: return 'Android (Tablet)'
|
||||||
|
if 'macintosh' in ua or 'mac os x' in ua: return 'Mac'
|
||||||
|
if 'windows nt' in ua: return 'Windows'
|
||||||
|
if 'linux' in ua: return 'Linux'
|
||||||
|
return 'Unbekanntes Gerät'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request) -> str | None:
|
||||||
|
forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(',')[0].strip()
|
||||||
|
return request.META.get('REMOTE_ADDR') or None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session(user, request, refresh_token: RefreshToken) -> str:
|
||||||
|
session_key = secrets.token_urlsafe(32)
|
||||||
|
UserSession.objects.create(
|
||||||
|
user=user,
|
||||||
|
session_key=session_key,
|
||||||
|
refresh_jti=str(refresh_token.payload.get('jti', '')),
|
||||||
|
device_name=_parse_device(request.META.get('HTTP_USER_AGENT', '')),
|
||||||
|
ip_address=_get_client_ip(request),
|
||||||
|
)
|
||||||
|
return session_key
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session views ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SessionListView(views.APIView):
|
||||||
|
def get(self, request):
|
||||||
|
current_key = request.headers.get('X-Session-Key', '')
|
||||||
|
sessions = UserSession.objects.filter(user=request.user)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'session_key': s.session_key,
|
||||||
|
'device_name': s.device_name,
|
||||||
|
'ip_address': s.ip_address,
|
||||||
|
'created_at': s.created_at,
|
||||||
|
'last_active_at': s.last_active_at,
|
||||||
|
'is_current': s.session_key == current_key,
|
||||||
|
}
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRevokeView(views.APIView):
|
||||||
|
def delete(self, request, session_key):
|
||||||
|
session = UserSession.objects.filter(user=request.user, session_key=session_key).first()
|
||||||
|
if not session:
|
||||||
|
return Response({'detail': 'Not found.'}, status=404)
|
||||||
|
_blacklist_session(session)
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRevokeAllView(views.APIView):
|
||||||
|
def delete(self, request):
|
||||||
|
current_key = request.headers.get('X-Session-Key', '')
|
||||||
|
sessions = UserSession.objects.filter(user=request.user).exclude(session_key=current_key)
|
||||||
|
for session in sessions:
|
||||||
|
_blacklist_session(session)
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
def _blacklist_session(session: UserSession) -> None:
|
||||||
|
if session.refresh_jti:
|
||||||
|
try:
|
||||||
|
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken, BlacklistedToken
|
||||||
|
token = OutstandingToken.objects.get(jti=session.refresh_jti)
|
||||||
|
BlacklistedToken.objects.get_or_create(token=token)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session.delete()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data export ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DataExportView(views.APIView):
|
||||||
|
def get(self, request):
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from datetime import date
|
||||||
|
from fpdf import FPDF
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
profile = Profile.objects.filter(user=user).first()
|
||||||
|
today = date.today().strftime('%d.%m.%Y')
|
||||||
|
export_date = date.today().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
VIOLET = (124, 58, 237)
|
||||||
|
HEADER_BG = (243, 240, 255)
|
||||||
|
ALT_ROW = (249, 249, 252)
|
||||||
|
TEXT_DARK = (30, 30, 40)
|
||||||
|
TEXT_GRAY = (120, 120, 135)
|
||||||
|
|
||||||
|
def safe(text: str) -> str:
|
||||||
|
return str(text).encode('latin-1', errors='replace').decode('latin-1')
|
||||||
|
|
||||||
|
class ArmPDF(FPDF):
|
||||||
|
def __init__(self, section_title):
|
||||||
|
super().__init__()
|
||||||
|
self.section_title = section_title
|
||||||
|
self.set_auto_page_break(auto=True, margin=18)
|
||||||
|
|
||||||
|
def header(self):
|
||||||
|
self.set_fill_color(*VIOLET)
|
||||||
|
self.rect(0, 0, 210, 10, 'F')
|
||||||
|
self.set_xy(14, 13)
|
||||||
|
self.set_font('Helvetica', 'B', 15)
|
||||||
|
self.set_text_color(*TEXT_DARK)
|
||||||
|
self.cell(0, 7, safe(f'Armarium - {self.section_title}'), ln=True)
|
||||||
|
self.set_font('Helvetica', '', 8)
|
||||||
|
self.set_text_color(*TEXT_GRAY)
|
||||||
|
self.set_x(14)
|
||||||
|
self.cell(0, 5, safe(f'Export vom {today}'), ln=True)
|
||||||
|
self.ln(3)
|
||||||
|
|
||||||
|
def footer(self):
|
||||||
|
self.set_y(-12)
|
||||||
|
self.set_font('Helvetica', '', 7)
|
||||||
|
self.set_text_color(*TEXT_GRAY)
|
||||||
|
self.cell(0, 5, safe(f'Armarium - {today} - Seite {self.page_no()}'), align='C')
|
||||||
|
|
||||||
|
def table_header(self, cols):
|
||||||
|
self.set_fill_color(*HEADER_BG)
|
||||||
|
self.set_font('Helvetica', 'B', 8)
|
||||||
|
self.set_text_color(*VIOLET)
|
||||||
|
for label, width in cols:
|
||||||
|
self.cell(width, 7, safe(label), border=0, fill=True, align='L')
|
||||||
|
self.ln()
|
||||||
|
self.set_draw_color(*VIOLET)
|
||||||
|
self.set_line_width(0.4)
|
||||||
|
x = self.get_x()
|
||||||
|
y = self.get_y()
|
||||||
|
self.line(14, y, 196, y)
|
||||||
|
|
||||||
|
def table_row(self, values, cols, fill=False):
|
||||||
|
if fill:
|
||||||
|
self.set_fill_color(*ALT_ROW)
|
||||||
|
self.set_font('Helvetica', '', 8)
|
||||||
|
self.set_text_color(*TEXT_DARK)
|
||||||
|
for (label, width), val in zip(cols, values):
|
||||||
|
self.cell(width, 6, safe(val), border=0, fill=fill)
|
||||||
|
self.ln()
|
||||||
|
|
||||||
|
def make_pdf(title, build_fn):
|
||||||
|
pdf = ArmPDF(title)
|
||||||
|
pdf.add_page()
|
||||||
|
build_fn(pdf)
|
||||||
|
return pdf.output()
|
||||||
|
|
||||||
|
# ── Profil ────────────────────────────────────────────────────────────
|
||||||
|
def build_profile(pdf):
|
||||||
|
name = f"{profile.first_name} {profile.last_name}".strip() if profile else ''
|
||||||
|
rows = [
|
||||||
|
('Name', name or '-'),
|
||||||
|
('E-Mail', user.email or '-'),
|
||||||
|
('Kanton', profile.canton if profile else '-'),
|
||||||
|
('Sprache', profile.language if profile else '-'),
|
||||||
|
('2FA', 'Aktiviert' if (profile and profile.totp_enabled) else 'Deaktiviert'),
|
||||||
|
]
|
||||||
|
cols = [('Feld', 60), ('Wert', 120)]
|
||||||
|
pdf.table_header(cols)
|
||||||
|
for i, (field, val) in enumerate(rows):
|
||||||
|
pdf.table_row([field, val], cols, fill=i % 2 == 1)
|
||||||
|
|
||||||
|
# ── Konten ────────────────────────────────────────────────────────────
|
||||||
|
def build_accounts(pdf):
|
||||||
|
cols = [('Name', 90), ('Typ', 60), ('Saldo (CHF)', 42)]
|
||||||
|
pdf.table_header(cols)
|
||||||
|
for i, acc in enumerate(Account.objects.filter(user=user)):
|
||||||
|
pdf.table_row([acc.name, acc.account_type, f'{acc.balance:,.2f}'], cols, fill=i % 2 == 1)
|
||||||
|
|
||||||
|
# ── Budgets ───────────────────────────────────────────────────────────
|
||||||
|
def build_budgets(pdf):
|
||||||
|
cols = [('Name', 80), ('Kategorie', 60), ('Betrag (CHF)', 42), ('Aktiv', 10)]
|
||||||
|
pdf.table_header(cols)
|
||||||
|
for i, b in enumerate(Budget.objects.filter(account__user=user).order_by('main_category', 'name')):
|
||||||
|
pdf.table_row([b.name, b.main_category, f'{b.amount:,.2f}', 'Ja' if b.active else 'Nein'], cols, fill=i % 2 == 1)
|
||||||
|
|
||||||
|
# ── Ausgaben ──────────────────────────────────────────────────────────
|
||||||
|
def build_expenses(pdf):
|
||||||
|
cols = [('Datum', 26), ('Name', 70), ('Kategorie', 46), ('Konto', 30), ('CHF', 20)]
|
||||||
|
pdf.table_header(cols)
|
||||||
|
for i, e in enumerate(Expense.objects.filter(account__user=user).order_by('-date')):
|
||||||
|
pdf.table_row([
|
||||||
|
e.date.strftime('%d.%m.%Y'), e.name, e.category,
|
||||||
|
e.account.name, f'{e.amount:,.2f}'
|
||||||
|
], cols, fill=i % 2 == 1)
|
||||||
|
|
||||||
|
# ── Transaktionen ─────────────────────────────────────────────────────
|
||||||
|
def build_transactions(pdf):
|
||||||
|
cols = [('Datum', 26), ('Beschreibung', 70), ('Von', 38), ('Nach', 38), ('CHF', 20)]
|
||||||
|
pdf.table_header(cols)
|
||||||
|
qs = Transaction.objects.filter(source_account__user=user).order_by('-date').select_related('source_account', 'destination_account')
|
||||||
|
for i, t in enumerate(qs):
|
||||||
|
pdf.table_row([
|
||||||
|
t.date.strftime('%d.%m.%Y'), t.description,
|
||||||
|
t.source_account.name, t.destination_account.name, f'{t.amount:,.2f}'
|
||||||
|
], cols, fill=i % 2 == 1)
|
||||||
|
|
||||||
|
# ── Termine ───────────────────────────────────────────────────────────
|
||||||
|
def build_deadlines(pdf):
|
||||||
|
cols = [('Datum', 30), ('Titel', 100), ('Typ', 42), ('Notizen', 20)]
|
||||||
|
pdf.table_header(cols)
|
||||||
|
for i, d in enumerate(Deadline.objects.filter(user=user).order_by('date')):
|
||||||
|
pdf.table_row([d.date.strftime('%d.%m.%Y'), d.title, d.type, d.notes[:20]], cols, fill=i % 2 == 1)
|
||||||
|
|
||||||
|
pdfs = [
|
||||||
|
('profil.pdf', 'Profil', build_profile),
|
||||||
|
('konten.pdf', 'Konten', build_accounts),
|
||||||
|
('budgets.pdf', 'Budgets', build_budgets),
|
||||||
|
('ausgaben.pdf', 'Ausgaben', build_expenses),
|
||||||
|
('transaktionen.pdf', 'Transaktionen', build_transactions),
|
||||||
|
('termine.pdf', 'Termine', build_deadlines),
|
||||||
|
]
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for filename, title, build_fn in pdfs:
|
||||||
|
zf.writestr(filename, bytes(make_pdf(title, build_fn)))
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
|
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="armarium-export-{export_date}.zip"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notification preferences ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NotificationPrefsView(views.APIView):
|
||||||
|
def patch(self, request):
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=request.user)
|
||||||
|
fields = ['notif_deadlines', 'notif_budget_alerts', 'notif_monthly_summary']
|
||||||
|
changed = []
|
||||||
|
for field in fields:
|
||||||
|
if field in request.data:
|
||||||
|
setattr(profile, field, bool(request.data[field]))
|
||||||
|
changed.append(field)
|
||||||
|
if changed:
|
||||||
|
profile.save(update_fields=changed)
|
||||||
|
return Response({
|
||||||
|
'notif_deadlines': profile.notif_deadlines,
|
||||||
|
'notif_budget_alerts': profile.notif_budget_alerts,
|
||||||
|
'notif_monthly_summary': profile.notif_monthly_summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailView(views.APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
token = request.data.get('token', '').strip()
|
||||||
|
if not token:
|
||||||
|
return Response({'detail': 'Token required.'}, status=400)
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
profile = Profile.objects.filter(
|
||||||
|
email_verify_token=token_hash,
|
||||||
|
email_verify_token_expires__gt=timezone.now(),
|
||||||
|
).first()
|
||||||
|
if not profile:
|
||||||
|
return Response({'detail': 'Invalid or expired token.'}, status=400)
|
||||||
|
profile.email_verified = True
|
||||||
|
profile.email_verify_token = ''
|
||||||
|
profile.email_verify_token_expires = None
|
||||||
|
profile.save(update_fields=['email_verified', 'email_verify_token', 'email_verify_token_expires'])
|
||||||
|
return Response({'detail': 'Email verified.'})
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequestView(views.APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from .email import send_email
|
||||||
|
|
||||||
|
email = request.data.get('email', '').strip().lower()
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.filter(email=email).first()
|
||||||
|
if user:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=user)
|
||||||
|
profile.password_reset_token_hash = token_hash
|
||||||
|
profile.password_reset_token_expires = timezone.now() + timedelta(minutes=15)
|
||||||
|
profile.save(update_fields=['password_reset_token_hash', 'password_reset_token_expires'])
|
||||||
|
link = f"{settings.FRONTEND_URL}/reset-password?token={token}"
|
||||||
|
send_email('password_reset', {'link': link}, 'Armarium – Passwort zurücksetzen', user.email)
|
||||||
|
return Response({'detail': 'Wenn ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'})
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetConfirmView(views.APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
throttle_classes = [AuthThrottle]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
token = request.data.get('token', '').strip()
|
||||||
|
password = request.data.get('password', '')
|
||||||
|
if not token:
|
||||||
|
return Response({'detail': 'Token required.'}, status=400)
|
||||||
|
if len(password) < 8:
|
||||||
|
return Response({'detail': 'Password must be at least 8 characters.'}, status=400)
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
profile = Profile.objects.filter(
|
||||||
|
password_reset_token_hash=token_hash,
|
||||||
|
password_reset_token_expires__gt=timezone.now(),
|
||||||
|
).first()
|
||||||
|
if not profile:
|
||||||
|
return Response({'detail': 'Invalid or expired token.'}, status=400)
|
||||||
|
user = profile.user
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
profile.password_reset_token_hash = ''
|
||||||
|
profile.password_reset_token_expires = None
|
||||||
|
profile.save(update_fields=['password_reset_token_hash', 'password_reset_token_expires'])
|
||||||
|
for session in UserSession.objects.filter(user=user):
|
||||||
|
_blacklist_session(session)
|
||||||
|
return Response({'detail': 'Password updated.'})
|
||||||
@@ -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()
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -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,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
|
||||||
-1
Submodule frontend deleted from e38e9877c0
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
Vendored
+20
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://angular.dev/ai/mcp
|
||||||
|
"servers": {
|
||||||
|
"angular-cli": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@angular/cli", "mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+42
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10713
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet />
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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';
|
||||||
|
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: '**', redirectTo: 'dashboard' },
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<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" />
|
||||||
|
</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-end mb-5">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="text-center py-4">
|
||||||
|
@if (state() === 'loading') {
|
||||||
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 animate-spin text-violet-600" 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>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300">{{ 'auth.verifying' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (state() === 'success') {
|
||||||
|
<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.email_verified' | translate }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.recovery_redirecting' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (state() === 'error') {
|
||||||
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
|
||||||
|
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ 'auth.verify_email_error' | translate }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.errors.verify_failed' | 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,40 @@
|
|||||||
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
|
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';
|
||||||
|
@Component({
|
||||||
|
selector: 'app-verify-email',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterModule, TranslateModule],
|
||||||
|
templateUrl: './verify-email.html',
|
||||||
|
})
|
||||||
|
export class VerifyEmail implements OnInit {
|
||||||
|
state = signal<'loading' | 'success' | 'error'>('loading');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private api: ApiService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private langService: LanguageService,
|
||||||
|
public themeService: ThemeService,
|
||||||
|
) {
|
||||||
|
this.langService.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const token = this.route.snapshot.queryParamMap.get('token') ?? '';
|
||||||
|
if (!token) {
|
||||||
|
this.state.set('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.verifyEmail(token).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.state.set('success');
|
||||||
|
setTimeout(() => this.router.navigate(['/login']), 3000);
|
||||||
|
},
|
||||||
|
error: () => this.state.set('error'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'budgets.title' | translate }}</h1>
|
||||||
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ 'budgets.subtitle' | translate }}<span class="font-semibold text-violet-600">{{ grandTotal() | number:'1.2-2' }} CHF</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kategorie-Gruppen -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
@for (group of categoryGroups; track group.key) {
|
||||||
|
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Gruppen-Header -->
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">{{ group.label | translate }}</h2>
|
||||||
|
@if (budgetsForCategory(group.key).length > 0) {
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
{{ budgetsForCategory(group.key).length }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-semibold text-violet-600 dark:text-violet-400">
|
||||||
|
{{ totalForCategory(group.key) | number:'1.2-2' }} CHF
|
||||||
|
</span>
|
||||||
|
<button (click)="openCreateModal(group.key)"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-violet-700 px-3 py-1.5 text-xs 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-3.5 h-3.5" 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>
|
||||||
|
{{ 'budgets.add' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Einträge -->
|
||||||
|
@if (budgetsForCategory(group.key).length > 0) {
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
@for (budget of budgetsForCategory(group.key); track budget.id) {
|
||||||
|
<div class="flex items-center justify-between px-5 py-3">
|
||||||
|
<div class="flex items-center gap-3 min-w-0 flex-1 mr-3">
|
||||||
|
<span class="w-2 h-2 rounded-full shrink-0"
|
||||||
|
[class]="budget.active ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-600'">
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="text-sm font-medium text-gray-800 dark:text-white truncate block">{{ budget.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 truncate block">{{ accountName(budget.account) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
{{ budget.amount | number:'1.2-2' }} CHF
|
||||||
|
</span>
|
||||||
|
<button (click)="openEditModal(budget)"
|
||||||
|
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-700 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(budget.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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="px-5 py-4 text-sm text-gray-400 dark:text-gray-500 italic">
|
||||||
|
{{ 'budgets.no_entries' | translate }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- NO ACCOUNTS MODAL -->
|
||||||
|
@if (showNoAccountsModal()) {
|
||||||
|
<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)="closeNoAccountsModal()"></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">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/40 shrink-0">
|
||||||
|
<!-- Flowbite: outline/general/info-circle -->
|
||||||
|
<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-linejoin="round" stroke-width="2" d="M10 11h2v5m-2 0h4m-2.592-8.5h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.no_accounts_title' | translate }}</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" (click)="closeNoAccountsModal()"
|
||||||
|
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 -->
|
||||||
|
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.no_accounts_text' | translate }}</p>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
|
||||||
|
<button (click)="closeNoAccountsModal()"
|
||||||
|
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>
|
||||||
|
<a routerLink="/accounts"
|
||||||
|
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.go_to_accounts' | translate }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{{ 'budgets.new_entry' | translate: { category: (labelForCategory(newCategory) | 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">
|
||||||
|
|
||||||
|
<!-- Vorschläge -->
|
||||||
|
@if (currentSuggestions.length > 0) {
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">{{ 'budgets.label_suggestions' | translate }}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@for (s of currentSuggestions; track s) {
|
||||||
|
<button (click)="applySuggestion(s)"
|
||||||
|
class="rounded-full border border-violet-300 px-3 py-1 text-xs font-medium text-violet-700 hover:bg-violet-50 dark:border-violet-600 dark:text-violet-400 dark:hover:bg-violet-900/30 transition-colors">
|
||||||
|
{{ s }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<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]="'budgets.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">{{ 'budgets.label_amount' | translate }}</label>
|
||||||
|
<input type="number" [(ngModel)]="newAmount" placeholder="0.00" step="0.01"
|
||||||
|
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">{{ 'budgets.label_account' | translate }}</label>
|
||||||
|
<select [(ngModel)]="newAccountId"
|
||||||
|
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">
|
||||||
|
@for (account of accounts(); track account.id) {
|
||||||
|
<option [value]="account.id">{{ account.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="newActive" [(ngModel)]="newActive"
|
||||||
|
class="h-4 w-4 rounded 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" />
|
||||||
|
<label for="newActive" class="text-sm font-medium text-gray-900 dark:text-white cursor-pointer">{{ 'budgets.label_active' | translate }}</label>
|
||||||
|
</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)="createBudget()"
|
||||||
|
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">{{ 'budgets.edit_entry' | 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">{{ 'budgets.label_amount' | translate }}</label>
|
||||||
|
<input type="number" [(ngModel)]="editAmount" step="0.01"
|
||||||
|
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">{{ 'budgets.label_category' | translate }}</label>
|
||||||
|
<select [(ngModel)]="editCategory"
|
||||||
|
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">
|
||||||
|
@for (group of categoryGroups; track group.key) {
|
||||||
|
<option [value]="group.key">{{ group.label | translate }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'budgets.label_account' | translate }}</label>
|
||||||
|
<select [(ngModel)]="editAccountId"
|
||||||
|
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">
|
||||||
|
@for (account of accounts(); track account.id) {
|
||||||
|
<option [value]="account.id">{{ account.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="editActive" [(ngModel)]="editActive"
|
||||||
|
class="h-4 w-4 rounded 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" />
|
||||||
|
<label for="editActive" class="text-sm font-medium text-gray-900 dark:text-white cursor-pointer">{{ 'budgets.label_active' | translate }}</label>
|
||||||
|
</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)="updateBudget()"
|
||||||
|
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 { Budgets } from './budgets';
|
||||||
|
|
||||||
|
describe('Budgets', () => {
|
||||||
|
let component: Budgets;
|
||||||
|
let fixture: ComponentFixture<Budgets>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Budgets],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Budgets);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { Component, OnInit, signal, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ApiService } from '../services/api';
|
||||||
|
|
||||||
|
export const CATEGORY_GROUPS = [
|
||||||
|
{
|
||||||
|
key: 'fixed_expenses',
|
||||||
|
label: 'budgets.categories.fixed_expenses',
|
||||||
|
suggestions: ['Miete / Wohnung', 'Nahrungsmittel', 'Strom'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mobile_internet',
|
||||||
|
label: 'budgets.categories.mobile_internet',
|
||||||
|
suggestions: ['Mobile', 'Internet'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'subscriptions',
|
||||||
|
label: 'budgets.categories.subscriptions',
|
||||||
|
suggestions: ['Netflix', 'Disney+', 'Prime', 'Youtube'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'leisure',
|
||||||
|
label: 'budgets.categories.leisure',
|
||||||
|
suggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tax_reserves',
|
||||||
|
label: 'budgets.categories.tax_reserves',
|
||||||
|
suggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'insurance',
|
||||||
|
label: 'budgets.categories.insurance',
|
||||||
|
suggestions: ['3. Säule', 'Privathaftpflicht'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loans',
|
||||||
|
label: 'budgets.categories.loans',
|
||||||
|
suggestions: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-budgets',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, RouterModule, TranslateModule],
|
||||||
|
templateUrl: './budgets.html',
|
||||||
|
styleUrl: './budgets.css',
|
||||||
|
})
|
||||||
|
export class Budgets implements OnInit {
|
||||||
|
budgets = signal<any[]>([]);
|
||||||
|
accounts = signal<any[]>([]);
|
||||||
|
|
||||||
|
categoryGroups = CATEGORY_GROUPS;
|
||||||
|
|
||||||
|
// No Accounts Modal
|
||||||
|
showNoAccountsModal = signal(false);
|
||||||
|
|
||||||
|
// Create Modal
|
||||||
|
showCreateModal = signal(false);
|
||||||
|
newName = '';
|
||||||
|
newAmount = 0;
|
||||||
|
newCategory = 'fixed_expenses';
|
||||||
|
newAccountId: number | null = null;
|
||||||
|
newActive = true;
|
||||||
|
currentSuggestions: string[] = [];
|
||||||
|
|
||||||
|
// Edit Modal
|
||||||
|
showEditModal = signal(false);
|
||||||
|
editId = 0;
|
||||||
|
|
||||||
|
// Delete Modal
|
||||||
|
showDeleteModal = signal(false);
|
||||||
|
deleteTargetId = 0;
|
||||||
|
editName = '';
|
||||||
|
editAmount = 0;
|
||||||
|
editCategory = 'fixed_expenses';
|
||||||
|
editAccountId: number | null = null;
|
||||||
|
editActive = true;
|
||||||
|
|
||||||
|
constructor(private api: ApiService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadBudgets();
|
||||||
|
this.loadAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBudgets() {
|
||||||
|
this.api.getBudgets().subscribe({
|
||||||
|
next: (data) => this.budgets.set(data),
|
||||||
|
error: (err) => console.error('Fehler:', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAccounts() {
|
||||||
|
this.api.getAccounts().subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.accounts.set(data.filter((a) => a.active));
|
||||||
|
},
|
||||||
|
error: (err) => console.error('Fehler:', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetsForCategory(categoryKey: string): any[] {
|
||||||
|
return this.budgets().filter((b) => b.main_category === categoryKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalForCategory(categoryKey: string): number {
|
||||||
|
return this.budgetsForCategory(categoryKey).reduce(
|
||||||
|
(sum, b) => sum + parseFloat(b.amount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
grandTotal(): number {
|
||||||
|
return this.budgets().reduce((sum, b) => sum + parseFloat(b.amount), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
accountName(id: number): string {
|
||||||
|
return this.accounts().find((a) => a.id === id)?.name ?? '–';
|
||||||
|
}
|
||||||
|
|
||||||
|
labelForCategory(key: string): string {
|
||||||
|
return CATEGORY_GROUPS.find((g) => g.key === key)?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeNoAccountsModal() {
|
||||||
|
this.showNoAccountsModal.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create
|
||||||
|
openCreateModal(categoryKey: string) {
|
||||||
|
if (this.accounts().length === 0) {
|
||||||
|
this.showNoAccountsModal.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.newCategory = categoryKey;
|
||||||
|
this.newName = '';
|
||||||
|
this.newAmount = 0;
|
||||||
|
this.newAccountId = this.accounts()[0].id;
|
||||||
|
this.newActive = true;
|
||||||
|
this.currentSuggestions =
|
||||||
|
CATEGORY_GROUPS.find((g) => g.key === categoryKey)?.suggestions ?? [];
|
||||||
|
this.showCreateModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCreateModal() {
|
||||||
|
this.showCreateModal.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
applySuggestion(name: string) {
|
||||||
|
this.newName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
createBudget() {
|
||||||
|
if (!this.newName || !this.newAccountId) return;
|
||||||
|
this.api
|
||||||
|
.createBudget({
|
||||||
|
name: this.newName,
|
||||||
|
amount: this.newAmount,
|
||||||
|
main_category: this.newCategory,
|
||||||
|
account: this.newAccountId,
|
||||||
|
active: this.newActive,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadBudgets();
|
||||||
|
this.closeCreateModal();
|
||||||
|
},
|
||||||
|
error: (err) => console.error('Fehler beim Erstellen:', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
openEditModal(budget: any) {
|
||||||
|
this.editId = budget.id;
|
||||||
|
this.editName = budget.name;
|
||||||
|
this.editAmount = budget.amount;
|
||||||
|
this.editCategory = budget.main_category;
|
||||||
|
this.editAccountId = budget.account;
|
||||||
|
this.editActive = budget.active;
|
||||||
|
this.showEditModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEditModal() {
|
||||||
|
this.showEditModal.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBudget() {
|
||||||
|
if (!this.editName || !this.editAccountId) return;
|
||||||
|
this.api
|
||||||
|
.updateBudget(this.editId, {
|
||||||
|
name: this.editName,
|
||||||
|
amount: this.editAmount,
|
||||||
|
main_category: this.editCategory,
|
||||||
|
account: this.editAccountId,
|
||||||
|
active: this.editActive,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadBudgets();
|
||||||
|
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.deleteBudget(this.deleteTargetId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadBudgets();
|
||||||
|
this.closeDeleteModal();
|
||||||
|
},
|
||||||
|
error: (err) => console.error('Error deleting budget:', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||||
|
|
||||||
|
<!-- Year navigation -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button (click)="prevYear()"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<!-- Flowbite: outline/arrows/chevron-left -->
|
||||||
|
<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 8-4 4 4 4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white w-16 text-center">{{ currentYear() }}</h1>
|
||||||
|
<button (click)="nextYear()"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<!-- Flowbite: outline/arrows/chevron-right -->
|
||||||
|
<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="m10 16 4-4-4-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canton selector -->
|
||||||
|
<select [ngModel]="canton()" (ngModelChange)="onCantonChange($event)"
|
||||||
|
class="rounded-lg border border-gray-300 bg-gray-50 px-3 py-1.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">
|
||||||
|
@for (c of cantons; track c.code) {
|
||||||
|
<option [value]="c.code">{{ c.code }} – {{ ('canton_names.' + c.code) | translate }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<button (click)="toggleFilter('holidays')"
|
||||||
|
[class]="filters().holidays ? 'bg-orange-100 text-orange-700 border-orange-300 dark:bg-orange-900/30 dark:text-orange-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-orange-400 shrink-0"></span>
|
||||||
|
{{ 'calendar.filter_holidays' | translate }}
|
||||||
|
</button>
|
||||||
|
<button (click)="toggleFilter('school')"
|
||||||
|
[class]="filters().school ? 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400 shrink-0"></span>
|
||||||
|
{{ 'calendar.filter_school' | translate }}
|
||||||
|
</button>
|
||||||
|
<button (click)="toggleFilter('expenses')"
|
||||||
|
[class]="filters().expenses ? 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-900/30 dark:text-violet-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-violet-400 shrink-0"></span>
|
||||||
|
{{ 'calendar.filter_invoices' | translate }}
|
||||||
|
</button>
|
||||||
|
<button (click)="toggleFilter('deadlines')"
|
||||||
|
[class]="filters().deadlines ? 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-400 shrink-0"></span>
|
||||||
|
{{ 'calendar.filter_deadlines' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (viewMode() === 'month') {
|
||||||
|
<button (click)="backToYear()"
|
||||||
|
class="ml-auto inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors">
|
||||||
|
<!-- Flowbite: outline/arrows/chevron-left -->
|
||||||
|
<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 8-4 4 4 4"/>
|
||||||
|
</svg>
|
||||||
|
{{ 'calendar.year_view' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- iCal Subscribe -->
|
||||||
|
@if (icalOpen()) {
|
||||||
|
<div class="fixed inset-0 z-40" (click)="closeIcal()"></div>
|
||||||
|
}
|
||||||
|
<div class="relative" [class.ml-auto]="viewMode() !== 'month'">
|
||||||
|
<button (click)="toggleIcal()"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-violet-300 px-3 py-1.5 text-sm font-medium text-violet-700 hover:bg-violet-50 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-violet-700 dark:text-violet-400 dark:hover:bg-violet-900/20 transition-colors">
|
||||||
|
<!-- Flowbite: outline/general/calendar (with date dots) -->
|
||||||
|
<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="M4 10h16m-8-3V4M7 7V4m10 3V4M5 20h14a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1Zm3-7h.01v.01H8V13Zm4 0h.01v.01H12V13Zm4 0h.01v.01H16V13Zm-8 4h.01v.01H8V17Zm4 0h.01v.01H12V17Zm4 0h.01v.01H16V17Z"/>
|
||||||
|
</svg>
|
||||||
|
{{ 'calendar.subscribe' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (icalOpen()) {
|
||||||
|
<div class="absolute right-0 top-10 z-50 w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ 'calendar.ical_title' | translate }}</p>
|
||||||
|
<button (click)="closeIcal()"
|
||||||
|
class="inline-flex items-center rounded-lg bg-transparent p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
|
||||||
|
<svg class="h-4 w-4" 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>
|
||||||
|
<p class="mb-3 text-xs text-gray-400 dark:text-gray-500">{{ 'calendar.ical_desc' | translate }}</p>
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<input [value]="icalUrl()" readonly
|
||||||
|
class="flex-1 truncate rounded-lg border border-gray-300 bg-gray-50 px-2 py-1.5 text-xs text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300" />
|
||||||
|
<button (click)="copyICalUrl()"
|
||||||
|
class="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white transition-colors"
|
||||||
|
[class]="icalCopied() ? 'bg-green-500' : 'bg-violet-700 hover:bg-violet-800'">
|
||||||
|
{{ icalCopied() ? ('calendar.ical_copied' | translate) : ('calendar.ical_copy' | translate) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button (click)="closeIcal()"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-900 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
{{ 'common.close' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YEAR VIEW -->
|
||||||
|
@if (viewMode() === 'year') {
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
@for (m of getMonthsData(); track m.month) {
|
||||||
|
<div (click)="openMonth(m.month)"
|
||||||
|
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:border-violet-400 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-violet-500">
|
||||||
|
<p class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ m.name | translate }}
|
||||||
|
</p>
|
||||||
|
<!-- Day headers -->
|
||||||
|
<div class="grid grid-cols-7 mb-1">
|
||||||
|
@for (dl of dayLabels; track dl) {
|
||||||
|
<div class="text-center text-xs font-medium text-gray-400 dark:text-gray-500">{{ dl | translate }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Day cells -->
|
||||||
|
<div class="grid grid-cols-7 gap-px">
|
||||||
|
@for (cell of m.cells; track $index) {
|
||||||
|
@if (cell.date) {
|
||||||
|
<button (click)="selectDay(cell); $event.stopPropagation()"
|
||||||
|
[class.ring-2]="cell.isToday || selectedDate() === cell.date"
|
||||||
|
[class.ring-violet-600]="cell.isToday"
|
||||||
|
[class.ring-violet-400]="!cell.isToday && selectedDate() === cell.date"
|
||||||
|
[class.bg-violet-50]="cell.isToday"
|
||||||
|
[class.dark:bg-violet-900]="cell.isToday"
|
||||||
|
[class.text-violet-700]="cell.isToday && !cell.isWeekend"
|
||||||
|
[class.text-gray-400]="cell.isWeekend && !cell.isToday"
|
||||||
|
[class.text-gray-700]="!cell.isWeekend && !cell.isToday"
|
||||||
|
[class.dark:text-gray-300]="!cell.isToday"
|
||||||
|
class="relative flex min-h-[28px] flex-col items-center justify-start rounded py-0.5 text-xs transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<span>{{ cell.day }}</span>
|
||||||
|
@if (cell.events.length > 0) {
|
||||||
|
<div class="mt-0.5 flex flex-wrap justify-center gap-px">
|
||||||
|
@for (type of uniqueEventTypes(cell.events); track type) {
|
||||||
|
<span class="h-1 w-1 rounded-full {{ dotClasses(type) }}"></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- MONTH VIEW -->
|
||||||
|
@if (viewMode() === 'month') {
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<!-- Month nav -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||||
|
<button (click)="prevMonth()"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<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 8-4 4 4 4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ monthNames[currentMonth()-1] | translate }} {{ currentYear() }}
|
||||||
|
</span>
|
||||||
|
<button (click)="nextMonth()"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<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="m10 16 4-4-4-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- Day headers -->
|
||||||
|
<div class="grid grid-cols-7 mb-2">
|
||||||
|
@for (dl of dayLabels; track dl) {
|
||||||
|
<div class="py-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400">{{ dl | translate }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Day cells -->
|
||||||
|
<div class="grid grid-cols-7 gap-1">
|
||||||
|
@for (cell of buildMonthGrid(currentYear(), currentMonth()); track $index) {
|
||||||
|
@if (cell.date) {
|
||||||
|
<button (click)="selectDay(cell)"
|
||||||
|
[class.ring-2]="cell.isToday || selectedDate() === cell.date"
|
||||||
|
[class.ring-violet-600]="cell.isToday && selectedDate() !== cell.date"
|
||||||
|
[class.ring-violet-500]="selectedDate() === cell.date"
|
||||||
|
[class.bg-violet-50]="cell.isToday && selectedDate() !== cell.date"
|
||||||
|
[class.bg-violet-100]="selectedDate() === cell.date"
|
||||||
|
[class.text-violet-700]="cell.isToday && !cell.isWeekend"
|
||||||
|
[class.text-gray-400]="cell.isWeekend && !cell.isToday"
|
||||||
|
[class.text-gray-700]="!cell.isWeekend && !cell.isToday"
|
||||||
|
[class.dark:text-gray-200]="!cell.isToday"
|
||||||
|
class="min-h-[48px] sm:min-h-[64px] rounded-lg border border-transparent p-1 sm:p-1.5 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<span class="text-sm font-medium">{{ cell.day }}</span>
|
||||||
|
<div class="mt-1 space-y-0.5">
|
||||||
|
@for (event of cell.events.slice(0, 3); track $index) {
|
||||||
|
<div class="truncate rounded px-1 py-0.5 text-xs"
|
||||||
|
[style.background-color]="event.color + '22'"
|
||||||
|
[style.color]="event.color">
|
||||||
|
{{ event.title }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (cell.events.length > 3) {
|
||||||
|
<div class="text-xs text-gray-400">{{ 'calendar.more_events' | translate: { count: cell.events.length - 3 } }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div class="min-h-[48px] sm:min-h-[64px]"></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- DAY DETAIL DRAWER -->
|
||||||
|
@if (selectedDate()) {
|
||||||
|
<div class="fixed inset-0 z-40 bg-black/30 dark:bg-black/50"
|
||||||
|
(click)="selectedDate.set(null); showAddDeadline.set(false)">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div [class]="selectedDate() ? 'translate-x-0' : 'translate-x-full'"
|
||||||
|
class="fixed right-0 top-0 z-50 flex h-full w-full sm:w-80 flex-col bg-white shadow-2xl transition-transform duration-300 ease-in-out dark:bg-gray-800">
|
||||||
|
|
||||||
|
<!-- Drawer Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ selectedDate() }}</h2>
|
||||||
|
<button (click)="selectedDate.set(null); showAddDeadline.set(false)"
|
||||||
|
class="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>
|
||||||
|
|
||||||
|
<!-- Drawer Body -->
|
||||||
|
<div class="flex-1 space-y-2 overflow-y-auto px-5 py-4">
|
||||||
|
@if (selectedEvents().length === 0 && !showAddDeadline()) {
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">{{ 'calendar.no_events' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (event of selectedEvents(); track $index) {
|
||||||
|
<div class="flex items-start justify-between rounded-lg p-3"
|
||||||
|
[style.background-color]="event.color + '15'">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" [style.background-color]="event.color"></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ event.title }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ eventTypeLabel(event.type) | translate }}</p>
|
||||||
|
@if (event.amount) {
|
||||||
|
<p class="mt-0.5 text-xs font-semibold" [style.color]="event.color">{{ event.amount | number:'1.2-2' }} CHF</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (event.type === 'deadline') {
|
||||||
|
<button (click)="deleteDeadline(event.id!)"
|
||||||
|
class="ml-2 shrink-0 text-xs text-red-400 hover:text-red-600 transition-colors">
|
||||||
|
{{ 'common.delete' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Add Deadline form -->
|
||||||
|
@if (showAddDeadline()) {
|
||||||
|
<div class="border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ 'calendar.add_deadline' | translate }}</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input type="text" [(ngModel)]="newDeadlineTitle" [placeholder]="'calendar.label_title' | translate"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 placeholder:text-gray-400 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-500 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
|
||||||
|
<input type="date" [(ngModel)]="newDeadlineDate" [attr.lang]="translate.currentLang"
|
||||||
|
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" />
|
||||||
|
<select [(ngModel)]="newDeadlineType"
|
||||||
|
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="" disabled hidden>{{ 'calendar.select_type' | translate }}</option>
|
||||||
|
@for (t of deadlineTypes; track t.key) {
|
||||||
|
<option [value]="t.key">{{ t.label | translate }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button (click)="showAddDeadline.set(false)"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
{{ 'common.cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button (click)="saveDeadline()"
|
||||||
|
class="rounded-lg bg-violet-700 px-3 py-1.5 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>
|
||||||
|
|
||||||
|
<!-- Drawer Footer -->
|
||||||
|
<div class="border-t border-gray-200 px-5 py-4 dark:border-gray-700">
|
||||||
|
<button (click)="openAddDeadline()"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-violet-700 px-3 py-2 text-sm font-medium text-white transition-colors 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">
|
||||||
|
<!-- Flowbite: outline/general/calendar (with date dots) -->
|
||||||
|
<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="M4 10h16m-8-3V4M7 7V4m10 3V4M5 20h14a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1Zm3-7h.01v.01H8V13Zm4 0h.01v.01H12V13Zm4 0h.01v.01H16V13Zm-8 4h.01v.01H8V17Zm4 0h.01v.01H12V17Zm4 0h.01v.01H16V17Z"/>
|
||||||
|
</svg>
|
||||||
|
{{ 'calendar.add_deadline' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, effect, signal } from '@angular/core';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { ApiService } from '../services/api';
|
||||||
|
import { HolidaysService } from '../services/holidays';
|
||||||
|
import { CalendarEvent, CANTONS } from '../data/swiss-holidays';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'calendar.months.1','calendar.months.2','calendar.months.3','calendar.months.4',
|
||||||
|
'calendar.months.5','calendar.months.6','calendar.months.7','calendar.months.8',
|
||||||
|
'calendar.months.9','calendar.months.10','calendar.months.11','calendar.months.12',
|
||||||
|
];
|
||||||
|
const DAY_LABELS = [
|
||||||
|
'calendar.weekdays.0','calendar.weekdays.1','calendar.weekdays.2','calendar.weekdays.3',
|
||||||
|
'calendar.weekdays.4','calendar.weekdays.5','calendar.weekdays.6',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface DayCell {
|
||||||
|
date: string | null; // 'YYYY-MM-DD' or null for padding
|
||||||
|
day: number | null;
|
||||||
|
isToday: boolean;
|
||||||
|
isWeekend: boolean;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-calendar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, TranslateModule],
|
||||||
|
templateUrl: './calendar.html',
|
||||||
|
})
|
||||||
|
export class Calendar implements OnInit, OnDestroy {
|
||||||
|
readonly cantons = CANTONS;
|
||||||
|
readonly monthNames = MONTH_NAMES;
|
||||||
|
readonly dayLabels = DAY_LABELS;
|
||||||
|
|
||||||
|
currentYear = signal(new Date().getFullYear());
|
||||||
|
viewMode = signal<'year' | 'month'>('year');
|
||||||
|
currentMonth = signal(1);
|
||||||
|
selectedDate = signal<string | null>(null);
|
||||||
|
canton = signal('ZH');
|
||||||
|
|
||||||
|
filters = signal({ holidays: true, school: true, expenses: true, deadlines: true });
|
||||||
|
|
||||||
|
holidays = signal<CalendarEvent[]>([]);
|
||||||
|
schoolHolidays = signal<CalendarEvent[]>([]);
|
||||||
|
currentLang = signal(typeof window !== 'undefined' ? (localStorage.getItem('lang') ?? 'de') : 'de');
|
||||||
|
private langSub?: Subscription;
|
||||||
|
|
||||||
|
expenses = signal<any[]>([]);
|
||||||
|
deadlines = signal<any[]>([]);
|
||||||
|
|
||||||
|
// Add deadline form
|
||||||
|
showAddDeadline = signal(false);
|
||||||
|
newDeadlineTitle = '';
|
||||||
|
newDeadlineDate = '';
|
||||||
|
newDeadlineType = 'other';
|
||||||
|
newDeadlineNotes = '';
|
||||||
|
|
||||||
|
// iCal
|
||||||
|
icalUrl = signal<string | null>(null);
|
||||||
|
icalOpen = signal(false);
|
||||||
|
icalCopied = signal(false);
|
||||||
|
|
||||||
|
toggleIcal() {
|
||||||
|
if (!this.icalUrl()) {
|
||||||
|
this.api.getICalUrl().subscribe({ next: (r) => {
|
||||||
|
this.icalUrl.set(r.url);
|
||||||
|
this.icalOpen.set(true);
|
||||||
|
}});
|
||||||
|
} else {
|
||||||
|
this.icalOpen.update(v => !v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeIcal() { this.icalOpen.set(false); }
|
||||||
|
|
||||||
|
copyICalUrl() {
|
||||||
|
const url = this.icalUrl();
|
||||||
|
if (!url) return;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
this.icalCopied.set(true);
|
||||||
|
setTimeout(() => this.icalCopied.set(false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Edit deadline
|
||||||
|
editDeadlineId: number | null = null;
|
||||||
|
showEditDeadline = signal(false);
|
||||||
|
editDeadlineTitle = '';
|
||||||
|
editDeadlineDate = '';
|
||||||
|
editDeadlineType = 'other';
|
||||||
|
|
||||||
|
deadlineTypes = [
|
||||||
|
{ key: 'tax', label: 'calendar.deadline_types.tax' },
|
||||||
|
{ key: 'insurance', label: 'calendar.deadline_types.insurance' },
|
||||||
|
{ key: 'invoice', label: 'calendar.deadline_types.invoice' },
|
||||||
|
{ key: 'personal', label: 'calendar.deadline_types.personal' },
|
||||||
|
{ key: 'other', label: 'calendar.deadline_types.other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private api: ApiService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private holidaysService: HolidaysService,
|
||||||
|
public translate: TranslateService,
|
||||||
|
) {
|
||||||
|
effect(() => {
|
||||||
|
this.loadHolidays(this.currentYear(), this.canton(), this.currentLang());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.langSub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadHolidays(year: number, canton: string, lang: string): void {
|
||||||
|
this.holidays.set([]);
|
||||||
|
this.schoolHolidays.set([]);
|
||||||
|
this.holidaysService.getPublicHolidays(year, canton, lang).subscribe(events => this.holidays.set(events));
|
||||||
|
this.holidaysService.getSchoolHolidays(year, canton, lang).subscribe(events => this.schoolHolidays.set(events));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.api.getProfile().subscribe({ next: (p) => { if (p.canton) this.canton.set(p.canton); } });
|
||||||
|
this.api.getExpenses().subscribe({ next: (d) => this.expenses.set(d) });
|
||||||
|
this.api.getDeadlines().subscribe({ next: (d) => this.deadlines.set(d) });
|
||||||
|
this.langSub = this.translate.onLangChange.subscribe(e => this.currentLang.set(e.lang));
|
||||||
|
|
||||||
|
this.route.queryParams.subscribe(params => {
|
||||||
|
if (params['year']) this.currentYear.set(+params['year']);
|
||||||
|
if (params['month']) {
|
||||||
|
this.currentMonth.set(+params['month']);
|
||||||
|
this.viewMode.set('month');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event Map ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private buildEventMap(year: number): Map<string, CalendarEvent[]> {
|
||||||
|
const map = new Map<string, CalendarEvent[]>();
|
||||||
|
const add = (e: CalendarEvent) => {
|
||||||
|
if (!map.has(e.date)) map.set(e.date, []);
|
||||||
|
map.get(e.date)!.push(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const f = this.filters();
|
||||||
|
if (f.holidays) {
|
||||||
|
this.holidays().forEach(add);
|
||||||
|
}
|
||||||
|
if (f.school) {
|
||||||
|
this.schoolHolidays().forEach(add);
|
||||||
|
}
|
||||||
|
if (f.expenses) {
|
||||||
|
this.expenses()
|
||||||
|
.filter((e) => e.due_date && e.due_date.startsWith(String(year)))
|
||||||
|
.forEach((e) => add({ date: e.due_date, title: e.name, type: 'expense', amount: parseFloat(e.amount), color: '#8b5cf6', id: e.id }));
|
||||||
|
}
|
||||||
|
if (f.deadlines) {
|
||||||
|
this.deadlines()
|
||||||
|
.filter((d) => d.date.startsWith(String(year)))
|
||||||
|
.forEach((d) => add({ date: d.date, title: d.title, type: 'deadline', color: '#3b82f6', id: d.id }));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Month Grid ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildMonthGrid(year: number, month: number): DayCell[] {
|
||||||
|
const map = this.buildEventMap(year);
|
||||||
|
const cells: DayCell[] = [];
|
||||||
|
const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun
|
||||||
|
const offset = (firstDay + 6) % 7; // Monday-based offset
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
for (let i = 0; i < offset; i++) cells.push({ date: null, day: null, isToday: false, isWeekend: false, events: [] });
|
||||||
|
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
const dow = (offset + d - 1) % 7; // 0=Mon, 5=Sat, 6=Sun
|
||||||
|
cells.push({
|
||||||
|
date: dateStr,
|
||||||
|
day: d,
|
||||||
|
isToday: dateStr === this.today,
|
||||||
|
isWeekend: dow >= 5,
|
||||||
|
events: map.get(dateStr) ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonthsData(): { month: number; name: string; cells: DayCell[] }[] {
|
||||||
|
return Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: i + 1,
|
||||||
|
name: MONTH_NAMES[i],
|
||||||
|
cells: this.buildMonthGrid(this.currentYear(), i + 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
prevYear() { this.currentYear.update((y) => y - 1); this.selectedDate.set(null); }
|
||||||
|
nextYear() { this.currentYear.update((y) => y + 1); this.selectedDate.set(null); }
|
||||||
|
|
||||||
|
openMonth(month: number) {
|
||||||
|
this.currentMonth.set(month);
|
||||||
|
this.viewMode.set('month');
|
||||||
|
this.selectedDate.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
backToYear() {
|
||||||
|
this.viewMode.set('year');
|
||||||
|
this.selectedDate.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevMonth() {
|
||||||
|
if (this.currentMonth() === 1) { this.currentMonth.set(12); this.currentYear.update((y) => y - 1); }
|
||||||
|
else this.currentMonth.update((m) => m - 1);
|
||||||
|
this.selectedDate.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextMonth() {
|
||||||
|
if (this.currentMonth() === 12) { this.currentMonth.set(1); this.currentYear.update((y) => y + 1); }
|
||||||
|
else this.currentMonth.update((m) => m + 1);
|
||||||
|
this.selectedDate.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Day selection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
selectDay(cell: DayCell) {
|
||||||
|
if (!cell.date) return;
|
||||||
|
this.selectedDate.set(cell.date === this.selectedDate() ? null : cell.date);
|
||||||
|
this.showAddDeadline.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedEvents(): CalendarEvent[] {
|
||||||
|
const d = this.selectedDate();
|
||||||
|
if (!d) return [];
|
||||||
|
const map = this.buildEventMap(this.currentYear());
|
||||||
|
return map.get(d) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter & Canton ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
toggleFilter(key: keyof ReturnType<typeof this.filters>) {
|
||||||
|
this.filters.update((f) => ({ ...f, [key]: !f[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onCantonChange(code: string) {
|
||||||
|
this.canton.set(code);
|
||||||
|
this.api.updateProfile({ canton: code }).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deadline CRUD ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
openAddDeadline() {
|
||||||
|
this.newDeadlineTitle = '';
|
||||||
|
this.newDeadlineDate = this.selectedDate() ?? '';
|
||||||
|
this.newDeadlineType = '';
|
||||||
|
this.newDeadlineNotes = '';
|
||||||
|
this.showAddDeadline.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDeadline() {
|
||||||
|
if (!this.newDeadlineTitle || !this.newDeadlineDate) return;
|
||||||
|
this.api.createDeadline({
|
||||||
|
title: this.newDeadlineTitle,
|
||||||
|
date: this.newDeadlineDate,
|
||||||
|
type: this.newDeadlineType || 'other',
|
||||||
|
notes: this.newDeadlineNotes,
|
||||||
|
}).subscribe({ next: (d) => { this.deadlines.update((dl) => [...dl, d]); this.showAddDeadline.set(false); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
openEditDeadline(event: CalendarEvent) {
|
||||||
|
this.editDeadlineId = event.id!;
|
||||||
|
this.editDeadlineTitle = event.title;
|
||||||
|
this.editDeadlineDate = event.date;
|
||||||
|
this.editDeadlineType = 'other';
|
||||||
|
this.showEditDeadline.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDeadline() {
|
||||||
|
if (!this.editDeadlineId) return;
|
||||||
|
this.api.updateDeadline(this.editDeadlineId, {
|
||||||
|
title: this.editDeadlineTitle,
|
||||||
|
date: this.editDeadlineDate,
|
||||||
|
type: this.editDeadlineType,
|
||||||
|
}).subscribe({ next: (updated) => {
|
||||||
|
this.deadlines.update((dl) => dl.map((d) => d.id === this.editDeadlineId ? updated : d));
|
||||||
|
this.showEditDeadline.set(false);
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDeadline(id: number) {
|
||||||
|
this.api.deleteDeadline(id).subscribe({ next: () => {
|
||||||
|
this.deadlines.update((dl) => dl.filter((d) => d.id !== id));
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
eventTypeLabel(type: string): string {
|
||||||
|
const keys: Record<string, string> = {
|
||||||
|
national: 'calendar.filter_holidays',
|
||||||
|
canton: 'calendar.filter_holidays',
|
||||||
|
school: 'calendar.filter_school',
|
||||||
|
deadline: 'calendar.filter_deadlines',
|
||||||
|
expense: 'calendar.filter_invoices',
|
||||||
|
};
|
||||||
|
return keys[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
dotClasses(type: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
national: 'bg-orange-400', canton: 'bg-red-400',
|
||||||
|
school: 'bg-emerald-400', deadline: 'bg-blue-400', expense: 'bg-violet-400',
|
||||||
|
};
|
||||||
|
return colors[type] ?? 'bg-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueEventTypes(events: CalendarEvent[]): string[] {
|
||||||
|
return [...new Set(events.map((e) => e.type))];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-light text-gray-900 dark:text-white">{{ greeting() }}</h1>
|
||||||
|
<p class="text-base text-gray-500 dark:text-gray-400 mt-1">{{ dateTimeDisplay() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-6 lg:grid-cols-4">
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.total_income' | translate }}</p>
|
||||||
|
<p class="mt-1 text-xl sm:text-2xl font-bold text-emerald-600">{{ totalIncome() | number:'1.2-2' }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.per_month' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.fixed_costs' | translate }}</p>
|
||||||
|
<p class="mt-1 text-xl sm:text-2xl font-bold text-violet-600">{{ totalFixedCosts() | number:'1.2-2' }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.per_month' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.expenses' | translate }}</p>
|
||||||
|
<p class="mt-1 text-xl sm:text-2xl font-bold text-red-500">{{ totalExpenses() | number:'1.2-2' }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.chf_total' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.balance' | translate }}</p>
|
||||||
|
<p class="mt-1 text-xl sm:text-2xl font-bold"
|
||||||
|
[class.text-emerald-600]="balance() >= 0"
|
||||||
|
[class.text-red-500]="balance() < 0">
|
||||||
|
{{ balance() | number:'1.2-2' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.chf_remaining' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 mb-6 lg:grid-cols-3">
|
||||||
|
|
||||||
|
<!-- Bar Chart: Income vs Expenses -->
|
||||||
|
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 md:p-6">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between pb-4 mb-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="flex items-center min-w-0">
|
||||||
|
<div class="w-12 h-12 bg-violet-50 dark:bg-violet-900/30 border border-violet-200 dark:border-violet-800 flex items-center justify-center rounded-full me-3 flex-shrink-0">
|
||||||
|
<svg class="w-7 h-7 text-violet-600 dark:text-violet-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15v4m6-6v6m6-4v4m6-6v6M3 11l6-5 6 5 5.5-5.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'dashboard.income_vs_expenses' | translate: { year: selectedYear() } }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div id="bar-chart"></div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center pt-4 md:pt-6">
|
||||||
|
|
||||||
|
<!-- Year dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button (click)="toggleYearDropdown()" type="button"
|
||||||
|
class="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white inline-flex items-center">
|
||||||
|
{{ selectedYear() }}
|
||||||
|
<svg class="w-4 h-4 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
@if (yearDropdownOpen()) {
|
||||||
|
<div class="absolute bottom-full mb-1 z-10 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg w-32">
|
||||||
|
<ul class="p-2 text-sm text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
@for (year of availableYears(); track year) {
|
||||||
|
<li>
|
||||||
|
<button (click)="selectYear(year)" type="button"
|
||||||
|
class="w-full text-left p-2 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded">
|
||||||
|
{{ year }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pie Chart: Fixed Costs Breakdown -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 md:p-6">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-white min-w-0 me-2">{{ 'dashboard.fixed_costs_breakdown' | translate }}</h2>
|
||||||
|
<button (click)="toggleDonut()" type="button"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/30 transition-colors">
|
||||||
|
@if (donutExpanded()) {
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pie Chart -->
|
||||||
|
<div id="donut-chart" [class.hidden]="donutExpanded()"></div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div class="flex flex-col gap-2.5 overflow-y-auto max-h-80"
|
||||||
|
[class.hidden]="!donutExpanded()">
|
||||||
|
@for (item of donutItems(); track item.name) {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="flex-1 text-sm text-gray-700 dark:text-gray-200 truncate">{{ item.name }}</span>
|
||||||
|
<span class="text-sm text-gray-900 dark:text-white whitespace-nowrap">CHF {{ item.amount | number:'1.2-2' }}</span>
|
||||||
|
<span class="text-xs text-gray-400 w-10 text-right">{{ item.pct }}%</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
|
||||||
|
<!-- Savings Rate -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 md:p-6">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'dashboard.savings_rate' | translate }}</h2>
|
||||||
|
<button (click)="toggleSavingsSettings()" type="button"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/30 transition-colors">
|
||||||
|
@if (savingsSettingsOpen()) {
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m7.171 12.906-2.153 6.411 2.672-.89 1.568 2.34 1.825-5.183m5.73-2.678 2.154 6.411-2.673-.89-1.568 2.34-1.825-5.183M9.165 4.3c.58.068 1.153-.17 1.515-.628a1.681 1.681 0 0 1 2.64 0 1.68 1.68 0 0 0 1.515.628 1.681 1.681 0 0 1 1.866 1.866c-.068.58.17 1.154.628 1.516a1.681 1.681 0 0 1 0 2.639 1.682 1.682 0 0 0-.628 1.515 1.681 1.681 0 0 1-1.866 1.866 1.681 1.681 0 0 0-1.516.628 1.681 1.681 0 0 1-2.639 0 1.681 1.681 0 0 0-1.515-.628 1.681 1.681 0 0 1-1.867-1.866 1.681 1.681 0 0 0-.627-1.515 1.681 1.681 0 0 1 0-2.64c.458-.361.696-.935.627-1.515A1.681 1.681 0 0 1 9.165 4.3ZM14 9a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hauptansicht -->
|
||||||
|
@if (!savingsSettingsOpen()) {
|
||||||
|
<div class="flex items-end gap-2 mb-3">
|
||||||
|
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ savingsRate() }}%</span>
|
||||||
|
<span class="text-sm text-gray-400 mb-1">{{ 'dashboard.of_income' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-3 relative">
|
||||||
|
<div class="h-3 rounded-full transition-all duration-500"
|
||||||
|
[class]="savingsRateColor()"
|
||||||
|
[style.width.%]="savingsRate()">
|
||||||
|
</div>
|
||||||
|
<div class="absolute -top-1 h-5 w-0.5 bg-violet-600 dark:bg-violet-400 rounded-full -translate-x-1/2 transition-all duration-300"
|
||||||
|
[style.left.%]="savingsGoal()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mt-3 text-xs text-gray-400">
|
||||||
|
<span>0%</span>
|
||||||
|
<span class="text-violet-600 dark:text-violet-400 font-medium">{{ 'dashboard.goal' | translate }}: {{ savingsGoal() }}%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Einstellungsansicht -->
|
||||||
|
} @else {
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{{ 'dashboard.goal' | translate }} (%)
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="number"
|
||||||
|
[value]="goalInputValue()"
|
||||||
|
(input)="goalInputValue.set(+$any($event.target).value)"
|
||||||
|
(keyup.enter)="saveGoal()"
|
||||||
|
min="1" max="100"
|
||||||
|
class="w-24 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 px-3 py-2 focus:outline-none">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-400">{{ 'dashboard.goal_hint' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview der neuen Zielposition -->
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-3 relative">
|
||||||
|
<div class="absolute -top-1 h-5 w-0.5 bg-violet-600 dark:bg-violet-400 rounded-full -translate-x-1/2 transition-all duration-300"
|
||||||
|
[style.left.%]="goalInputValue()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>0%</span>
|
||||||
|
<span class="text-violet-600 dark:text-violet-400 font-medium">{{ goalInputValue() }}%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button (click)="saveGoal()" type="button"
|
||||||
|
class="flex-1 text-white bg-violet-600 hover:bg-violet-700 focus:ring-4 focus:ring-violet-300 dark:focus:ring-violet-800 font-medium rounded-lg text-sm px-4 py-2 focus:outline-none transition-colors">
|
||||||
|
{{ 'common.save' | translate }}
|
||||||
|
</button>
|
||||||
|
<button (click)="toggleSavingsSettings()" type="button"
|
||||||
|
class="flex-1 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 font-medium rounded-lg text-sm px-4 py-2 focus:outline-none transition-colors">
|
||||||
|
{{ 'common.cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Expenses -->
|
||||||
|
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-base font-medium text-gray-900 dark:text-white mb-4">{{ 'dashboard.recent_expenses' | translate }}</h2>
|
||||||
|
@if (recentExpenses().length === 0) {
|
||||||
|
<p class="text-sm text-gray-400">{{ 'dashboard.no_expenses' | translate }}</p>
|
||||||
|
} @else {
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
@for (expense of recentExpenses(); track expense.id) {
|
||||||
|
<div class="flex items-center justify-between py-2.5">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-white">{{ expense.name }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ expense.date | date:'dd.MM.yyyy' }} · {{ ('expenses.categories.' + expense.category) | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold text-red-500">-{{ expense.amount | number:'1.2-2' }} CHF</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user