2 Commits

Author SHA1 Message Date
Daniel Krähenbühl c03d2a97ab feat: insurance section — overview, documents, analysis, KVG premium comparison
- Insurance overview page (/insurance): current policies table with type,
  provider, premium, franchise, coverage, and document links
- Documents page: upload and manage insurance documents
- Analysis page: coverage gap analysis per insurance type
- Priminfo integration (/insurance/priminfo): KVG premium comparison by
  insurer, model (TAR/HMO/etc.), franchise level, and accident coverage
  via embedded Priminfo iframe (no public API available)
- Backend: Insurance, PraemienEntry, PraemienPolice models with migrations
- Sidebar: insurance nav group with flyout and dropdown
- i18n: all keys in DE/EN/FR/IT
2026-05-25 22:46:31 +02:00
Daniel Krähenbühl 1a7ef09805 feat: Armarium v1.1.0 — dashboard, auth, 2FA, SMTP, settings, deploy
Dashboard:
- ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart
- KPI cards: income, fixed costs, savings rate with configurable goal
- Greeting with time-of-day and locale-aware date/time display

Authentication & security:
- Email-based login (no username), case-insensitive lookup
- JWT access/refresh tokens with rotation and blacklist
- TOTP 2FA with QR code, backup codes (copy + PDF export)
- 2FA recovery via email code
- Cloudflare Turnstile CAPTCHA on login and register

Email flows:
- Email verification on registration (24h token)
- Password reset flow (15min token, anti-enumeration)
- Brevo SMTP integration with HTML + plaintext email templates
- Notification emails: 2FA recovery, password changed, email changed

Settings page:
- 2FA management (enable/disable, QR, backup codes)
- Active sessions list with per-device revoke
- Data export: ZIP with 6 PDFs via fpdf2
- Notification preferences (3 toggles)
- Danger zone: account deletion with mandatory export + confirmation phrase

UI & layout:
- Sidebar with collapsible/flyout mode, Angular signal-based dropdowns
- Dark mode (class-based), language switcher (DE/FR/IT/EN)
- Mobile-responsive layout with touch-friendly targets
- Roboto font via @fontsource (GDPR-compliant, no Google CDN)
- Pure Tailwind CSS v3

Infrastructure:
- Forgejo Actions CI/CD pipeline (auto-deploy on push to main)
- Gunicorn + Nginx + PostgreSQL production setup
- Rate limiting, HSTS, secure cookies, CSRF protection
2026-05-25 22:46:30 +02:00
164 changed files with 25274 additions and 3 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: production
steps:
- name: Pull latest code
run: |
cd /home/armarium/armarium-suite
git fetch origin
git reset --hard origin/main
- name: Backend — install dependencies
run: |
cd /home/armarium/armarium-suite/backend
source venv/bin/activate
pip install -r requirements.txt --quiet
- name: Backend — run migrations
run: |
cd /home/armarium/armarium-suite/backend
source venv/bin/activate
python manage.py migrate --no-input
- name: Frontend — build
run: |
cd /home/armarium/armarium-suite/frontend
npm install --silent
npm run build -- --configuration production
- name: Restart service
run: sudo systemctl restart armarium
+7
View File
@@ -1,6 +1,11 @@
# ── Temporäre Upload-Ordner ───────────────────────────────────────────────────
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) ──────────────────────────
backend/commands.md
backend/tasks.json
@@ -17,3 +22,5 @@ Thumbs.db
# ── Claude Code (lokale Konfiguration) ───────────────────────────────────────
.claude/
.mcp.json
flowbite-admin-dashboard-v2.2.0.zipZone.Identifier
start.sh
+141 -1
View File
@@ -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/),
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
---
+35
View File
@@ -0,0 +1,35 @@
# Armarium — Claude Code Instructions
## Icons
**Immer inline SVG verwenden — kein Flowbite, kein fb-icon, keine externe Icon-Komponente.**
SVG-Icons direkt aus [Flowbite Icons](https://flowbite.com/icons/) oder ähnlichen Quellen kopieren und inline einbetten:
```html
<!-- Beispiel: Search-Icon -->
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<!-- Beispiel: Trash-Icon in rot -->
<svg class="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
```
- Farbe via Tailwind-Klassen: `text-gray-400`, `text-red-500`, etc.
- Grösse via `w-X h-X` Klassen
- `fill="none"` + `stroke="currentColor"` für Outline-Icons
- `fill="currentColor"` für Solid-Icons
---
## Stack
- **Frontend:** Angular 21, Standalone Components, Signals, Tailwind CSS v3, ngx-translate
- **Backend:** Django 5, DRF, PostgreSQL
- **i18n:** DE / FR / IT / EN — immer alle 4 Sprachen gleichzeitig aktualisieren
- **CSS:** Reines Tailwind v3 — kein Flowbite, kein @tailwindcss/postcss
Submodule backend deleted from 980535b2a4
+24
View File
@@ -0,0 +1,24 @@
SECRET_KEY=your-secret-key-here
DEBUG=False
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DB_NAME=budget_db
DB_USER=budget_user
DB_PASSWORD=your-db-password
DB_HOST=localhost
DB_PORT=5432
# Cloudflare Turnstile (https://dash.cloudflare.com → Turnstile)
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
# Email / Brevo SMTP (https://app.brevo.com → SMTP & API → SMTP)
EMAIL_HOST=smtp-relay.brevo.com
EMAIL_PORT=587
EMAIL_HOST_USER=your-brevo-smtp-login
EMAIL_HOST_PASSWORD=your-brevo-smtp-password
EMAIL_USE_TLS=True
DEFAULT_FROM_EMAIL=noreply@armarium.ch
# Frontend URL (used in emails for links)
FRONTEND_URL=https://app.armarium.ch
+20
View File
@@ -0,0 +1,20 @@
# Python
__pycache__/
*.py[cod]
*$py.class
venv/
.venv/
env/
# Django
db.sqlite3
.env
static/
media/
# Logs
*.log
logs/
# Betriebssystem
.DS_Store
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for core project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application()
+169
View File
@@ -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')
+58
View File
@@ -0,0 +1,58 @@
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, InsuranceViewSet, PraemienView, PraemienVergleichView, 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')
router.register(r'insurances', InsuranceViewSet, basename='insurance')
_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/praemien/', PraemienView.as_view()),
path('api/praemien/vergleich/', PraemienVergleichView.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)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()
View File
+5
View File
@@ -0,0 +1,5 @@
from django.contrib import admin
from .models import Account, Transaction
admin.site.register(Account)
admin.site.register(Transaction)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class FinanceConfig(AppConfig):
name = 'finance'
+14
View File
@@ -0,0 +1,14 @@
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
class EmailAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
User = get_user_model()
try:
user = User.objects.get(email__iexact=username)
except (User.DoesNotExist, User.MultipleObjectsReturned):
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
+33
View File
@@ -0,0 +1,33 @@
import logging
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
def send_email(template_name: str, context: dict, subject: str, to: str | list[str]) -> bool:
"""
Render and send an HTML email with plaintext fallback.
Returns True on success, False on failure.
"""
if isinstance(to, str):
to = [to]
html = render_to_string(f'emails/{template_name}.html', context)
text = render_to_string(f'emails/{template_name}.txt', context)
msg = EmailMultiAlternatives(
subject=subject,
body=text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=to,
)
msg.attach_alternative(html, 'text/html')
try:
msg.send()
return True
except Exception:
logger.exception('Failed to send email "%s" to %s', template_name, to)
return False
@@ -0,0 +1,309 @@
"""
Management command: import_praemien
Imports Swiss KVG premium data from two BAG sources:
1. praemienregionen_{year}.xlsx (Priminfo)
PLZ → BFS-Nr, Gemeinde, Kanton, Prämienregion (0-3) + Ø-Monatsrämien
→ PraemienEntry model
2. Prämien_CH.csv (opendata.bagnet.ch / opendata.swiss)
Full per-insurer, per-model, per-franchise granular premiums
→ PraemienPolice model
Usage:
python manage.py import_praemien # import both, latest year
python manage.py import_praemien --year 2025
python manage.py import_praemien --skip-policen # only PLZ/region data
python manage.py import_praemien --skip-regionen # only granular policen data
"""
import csv
import io
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from decimal import Decimal, InvalidOperation
from django.core.management.base import BaseCommand, CommandError
from finance.models import PraemienEntry, PraemienPolice
NS = {'x': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
REGIONEN_URL = 'https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx'
POLICEN_URL = 'https://opendata.bagnet.ch/?r=/download&path=L1ByYWVtaWVuL1Byw6RtaWVuX0NILmNzdg%3D%3D'
LATEST_YEAR = 2025 # praemienregionen: update each September when BAG publishes new file
POLICEN_YEAR = 2026 # Prämien_CH.csv always contains the upcoming business year
# BAG insurer ID → display name (stable, regulated by BAG)
INSURER_NAMES: dict[int, str] = {
8: 'Helsana AG',
32: 'KPT/CPT',
134: 'CSS Versicherung AG',
194: 'Concordia',
246: 'Groupe Mutuel',
290: 'Sanitas Krankenversicherung',
312: 'SWICA Krankenversicherung',
343: 'Visana AG',
360: 'Atupri Krankenkasse',
376: 'Kolping Krankenkasse',
455: 'EGK-Gesundheitskasse',
509: 'Galenos AG',
780: 'Luzerner Hinterland Krankenkasse (LHK)',
820: 'Krankenkasse Steffisburg',
881: 'sodalis gesundheitsgruppe',
923: 'Vivao Sympany AG',
941: 'Birchmeier Krankenkasse',
966: 'Krankenkasse Wädenswil',
1040: 'ÖKK',
1113: 'Agrisano Krankenkasse',
1318: 'Mutuel Assurance',
1322: 'Provita Gesundheitsversicherung AG',
1384: 'Sanagate AG',
1386: 'Aquilana Versicherungen',
1401: 'Easy Sana Assurance Maladie SA',
1479: 'Caisse-maladie Philos',
1507: 'Scheidegg Krankenkasse',
1509: 'Sana24 AG',
1535: 'rhenusana',
1542: 'Caisse-maladie de la Vallée SA',
1555: 'KLuG Krankenkasse',
1560: 'Krankenkasse Institut Ingenbohl',
1562: 'Sumiswalder Krankenkasse',
1568: 'avanto health AG',
}
# Franchise code → CHF value
FRANCHISE_CHF: dict[str, int] = {
'FRA-0': 0,
'FRA-100': 100,
'FRA-200': 200,
'FRA-300': 300,
'FRA-400': 400,
'FRA-500': 500,
'FRA-600': 600,
'FRA-1000': 1000,
'FRA-1500': 1500,
'FRA-2000': 2000,
'FRA-2500': 2500,
}
# ─────────────────────────────────────────────────────────
# XLSX helpers (for praemienregionen)
# ─────────────────────────────────────────────────────────
def _cell_value(cell):
is_el = cell.find('x:is/x:t', NS)
if is_el is not None:
return (is_el.text or '').strip().replace('\n', ' ')
v_el = cell.find('x:v', NS)
if v_el is not None and v_el.text:
return v_el.text.strip()
return None
def _parse_rows(ws):
for row in ws.findall('.//x:row', NS):
yield [_cell_value(c) for c in row.findall('x:c', NS)]
def _parse_regionen_xlsx(data: bytes, year: int) -> list[dict]:
zf = zipfile.ZipFile(io.BytesIO(data))
rels_xml = ET.fromstring(zf.read('xl/_rels/workbook.xml.rels'))
wb_xml = ET.fromstring(zf.read('xl/workbook.xml'))
wb_ns = {'x': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
rid_to_path = {
r.get('Id'): r.get('Target').lstrip('/')
for r in rels_xml if 'worksheet' in r.get('Type', '')
}
rid_to_name = {
s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'): s.get('name')
for s in wb_xml.findall('.//x:sheet', wb_ns)
}
name_to_path = {rid_to_name[rid]: path for rid, path in rid_to_path.items() if rid in rid_to_name}
# D_PRIM: BFS-Nr → avg premiums
ws_dprim = ET.fromstring(zf.read(name_to_path['D_PRIM']))
premiums = {}
header_found = False
for row_vals in _parse_rows(ws_dprim):
if not header_found:
flat = ' '.join(str(v) for v in row_vals if v)
if 'BFS-Nr' in flat or 'No OFS' in flat:
header_found = True
continue
if not row_vals or not row_vals[0]:
continue
try:
bfs_nr = int(row_vals[0])
region = int(row_vals[3]) if row_vals[3] is not None else 0
avg_child = Decimal(str(row_vals[4]).replace("'", '')) if row_vals[4] else Decimal('0')
avg_young = Decimal(str(row_vals[5]).replace("'", '')) if row_vals[5] else Decimal('0')
avg_adult = Decimal(str(row_vals[6]).replace("'", '')) if row_vals[6] else Decimal('0')
premiums[bfs_nr] = (region, avg_child, avg_young, avg_adult)
except (ValueError, InvalidOperation, IndexError):
continue
# B_NPA: PLZ → BFS-Nr (PLZ at index 1, flag column at index 0)
ws_bnpa = ET.fromstring(zf.read(name_to_path['B_NPA']))
entries = []
header_found = False
for row_vals in _parse_rows(ws_bnpa):
if not header_found:
flat = ' '.join(str(v) for v in row_vals if v)
if 'PLZ' in flat and 'BFS' in flat:
header_found = True
continue
if len(row_vals) < 6:
continue
plz_raw = str(row_vals[1] or '').replace("'", '').strip()
if not plz_raw.isdigit():
continue
try:
plz = plz_raw.zfill(4)
ort = str(row_vals[2] or '').replace("'", '').strip()
kanton = str(row_vals[3] or '').replace("'", '').strip()
bfs_nr_raw = row_vals[5]
if bfs_nr_raw is None:
continue
bfs_nr = int(str(bfs_nr_raw).replace("'", '').strip())
gemeinde = str(row_vals[6] or '').replace("'", '').strip() if len(row_vals) > 6 else ''
bezirk = str(row_vals[7] or '').replace("'", '').strip() if len(row_vals) > 7 else ''
if bfs_nr not in premiums:
continue
region, avg_child, avg_young, avg_adult = premiums[bfs_nr]
entries.append({
'plz': plz, 'ort': ort, 'kanton': kanton, 'region': region,
'bfs_nr': bfs_nr, 'gemeinde': gemeinde, 'bezirk': bezirk,
'avg_adult': avg_adult, 'avg_young_adult': avg_young,
'avg_child': avg_child, 'data_year': year,
})
except (ValueError, InvalidOperation, IndexError):
continue
return entries
# ─────────────────────────────────────────────────────────
# CSV helpers (for Prämien_CH.csv)
# ─────────────────────────────────────────────────────────
def _parse_policen_csv(data: bytes, data_year: int) -> list[dict]:
"""Parse Prämien_CH.csv → list of PraemienPolice dicts."""
text = data.decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(text))
entries = []
for row in reader:
try:
versicherer_id = int(row['Versicherer'])
kanton = row['Kanton'].strip()
if kanton not in ('AG','AI','AR','BE','BL','BS','FR','GE','GL','GR',
'JU','LU','NE','NW','OW','SG','SH','SO','SZ','TG',
'TI','UR','VD','VS','ZG','ZH'):
continue # skip EU/EFTA rows
region_code = row['Region'].strip() # PR-REG CH0 … CH3
try:
region = int(region_code.split('CH')[1])
except (IndexError, ValueError):
continue
altersklasse = row['Altersklasse'].strip()
unfalleinschluss = row['Unfalleinschluss'].strip()
tariftyp = row['Tariftyp'].strip()
tarifbezeichnung = row['Tarifbezeichnung'].strip()
franchisestufe = row['Franchisestufe'].strip()
franchise_code = row['Franchise'].strip()
franchise_chf = FRANCHISE_CHF.get(franchise_code, 0)
praemie = Decimal(row['Prämie'].strip())
entries.append({
'versicherer_id': versicherer_id,
'kanton': kanton,
'region': region,
'altersklasse': altersklasse,
'unfalleinschluss': unfalleinschluss,
'tariftyp': tariftyp,
'tarifbezeichnung': tarifbezeichnung,
'franchisestufe': franchisestufe,
'franchise_chf': franchise_chf,
'praemie': praemie,
'data_year': data_year,
})
except (ValueError, InvalidOperation, KeyError):
continue
return entries
class Command(BaseCommand):
help = 'Import Swiss KVG premium data from BAG/Priminfo (PLZ regions + granular policen)'
def add_arguments(self, parser):
parser.add_argument('--year', type=int, default=LATEST_YEAR,
help='Year for praemienregionen XLSX (default: %(default)s)')
parser.add_argument('--policen-year', type=int, default=POLICEN_YEAR,
help='Business year in Prämien_CH.csv (default: %(default)s)')
parser.add_argument('--skip-regionen', action='store_true',
help='Skip PLZ/region import (praemienregionen XLSX)')
parser.add_argument('--skip-policen', action='store_true',
help='Skip granular policen import (Prämien_CH.csv)')
def handle(self, *args, **options):
year = options['year']
policen_year = options['policen_year']
# ── 1. PLZ / Prämienregionen ──────────────────────────────────────
if not options['skip_regionen']:
url = REGIONEN_URL.format(year=year)
self.stdout.write(f'[1/2] Downloading praemienregionen {year}: {url}')
try:
with urllib.request.urlopen(url, timeout=30) as resp:
data = resp.read()
except Exception as e:
raise CommandError(f'Download failed: {e}')
self.stdout.write(f' Parsing XLSX ({len(data):,} bytes)…')
entries = _parse_regionen_xlsx(data, year)
self.stdout.write(f' Parsed {len(entries):,} PLZ entries.')
if not entries:
raise CommandError('No PLZ data parsed — check XLSX structure.')
deleted, _ = PraemienEntry.objects.filter(data_year=year).delete()
self.stdout.write(f' Cleared {deleted} old entries.')
objs = [PraemienEntry(**e) for e in entries]
for i in range(0, len(objs), 1000):
PraemienEntry.objects.bulk_create(objs[i:i+1000], ignore_conflicts=True)
self.stdout.write(self.style.SUCCESS(f'{len(objs):,} PLZ entries imported.'))
# ── 2. Granular Prämien_CH.csv ────────────────────────────────────
if not options['skip_policen']:
self.stdout.write(f'[2/2] Downloading Prämien_CH.csv (business year {policen_year})…')
try:
with urllib.request.urlopen(POLICEN_URL, timeout=120) as resp:
data = resp.read()
except Exception as e:
raise CommandError(f'Download failed: {e}')
self.stdout.write(f' Parsing CSV ({len(data):,} bytes)…')
entries = _parse_policen_csv(data, policen_year)
self.stdout.write(f' Parsed {len(entries):,} policen rows.')
if not entries:
raise CommandError('No policen data parsed — check CSV structure.')
deleted, _ = PraemienPolice.objects.filter(data_year=policen_year).delete()
self.stdout.write(f' Cleared {deleted} old entries.')
objs = [PraemienPolice(**e) for e in entries]
created = 0
for i in range(0, len(objs), 2000):
PraemienPolice.objects.bulk_create(objs[i:i+2000], ignore_conflicts=True)
created += min(2000, len(objs) - i)
self.stdout.write(f' {created:,} / {len(objs):,}', ending='\r')
self.stdout.write('')
self.stdout.write(self.style.SUCCESS(f'{len(objs):,} policen rows imported.'))
self.stdout.write(self.style.SUCCESS('Done.'))
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-08 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('account_type', models.CharField(choices=[('asset', 'Asset Account (Bank/Cash)'), ('expense', 'Expense Account (Laden/Empfänger)'), ('revenue', 'Revenue Account (Einnahmequelle)')], default='asset', max_length=20)),
('balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12)),
('active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-08 17:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=255)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('date', models.DateField()),
('destination_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deposits', to='finance.account')),
('source_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdrawals', to='finance.account')),
],
),
]
+26
View File
@@ -0,0 +1,26 @@
# Generated by Django 6.0.3 on 2026-03-15 16:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0002_transaction'),
]
operations = [
migrations.CreateModel(
name='Budget',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('category', models.CharField(choices=[('housing', 'Wohnen'), ('food', 'Lebensmittel'), ('transport', 'Transport'), ('health', 'Gesundheit'), ('entertainment', 'Freizeit'), ('savings', 'Sparen'), ('other', 'Sonstiges')], default='other', max_length=50)),
('due_day', models.PositiveSmallIntegerField(help_text='Tag im Monat (1-31)')),
('active', models.BooleanField(default=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budgets', to='finance.account')),
],
),
]
@@ -0,0 +1,37 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0003_budget'),
]
operations = [
migrations.RemoveField(
model_name='budget',
name='due_day',
),
migrations.RenameField(
model_name='budget',
old_name='category',
new_name='main_category',
),
migrations.AlterField(
model_name='budget',
name='main_category',
field=models.CharField(
choices=[
('fixed_expenses', 'Fixe Ausgaben'),
('mobile_internet', 'Mobile & Internet'),
('leisure', 'Freizeit'),
('tax_reserves', 'Steuerrücklagen'),
('insurance', 'Versicherungen'),
('loans', 'Abzahlungen & Kredite'),
],
default='fixed_expenses',
max_length=50,
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-23 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0004_alter_budget'),
]
operations = [
migrations.AlterField(
model_name='budget',
name='main_category',
field=models.CharField(choices=[('fixed_expenses', 'Fixe Ausgaben'), ('mobile_internet', 'Mobile & Internet'), ('subscriptions', 'Abonnements'), ('leisure', 'Freizeit'), ('tax_reserves', 'Steuerrücklagen'), ('insurance', 'Versicherungen'), ('loans', 'Abzahlungen & Kredite')], default='fixed_expenses', max_length=50),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 6.0.3 on 2026-03-23 22:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0005_add_subscriptions_category'),
]
operations = [
migrations.CreateModel(
name='Expense',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('date', models.DateField()),
('category', models.CharField(choices=[('groceries', 'Groceries'), ('dining', 'Dining & Restaurants'), ('transport', 'Transport'), ('health', 'Health & Medical'), ('clothing', 'Clothing'), ('electronics', 'Electronics'), ('household', 'Household'), ('entertainment', 'Entertainment'), ('travel', 'Travel'), ('other', 'Other')], default='other', max_length=50)),
('notes', models.TextField(blank=True, default='')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='finance.account')),
],
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-24 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0006_add_expense_model'),
]
operations = [
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(blank=True, default='', max_length=100)),
('last_name', models.CharField(blank=True, default='', max_length=100)),
('email', models.EmailField(blank=True, default='', max_length=254)),
('avatar_color', models.CharField(default='#1A56DB', max_length=7)),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-24 19:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0007_add_profile_model'),
]
operations = [
migrations.AddField(
model_name='profile',
name='avatar_image',
field=models.ImageField(blank=True, null=True, upload_to='avatars/'),
),
]
@@ -0,0 +1,44 @@
# Generated by Django 6.0.3 on 2026-03-24 19:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def assign_legacy_user(apps, schema_editor):
User = apps.get_model('auth', 'User')
Account = apps.get_model('finance', 'Account')
Profile = apps.get_model('finance', 'Profile')
legacy_user, _ = User.objects.get_or_create(
username='legacy_user',
defaults={'email': '', 'is_active': True},
)
Account.objects.filter(user__isnull=True).update(user=legacy_user)
for profile in Profile.objects.filter(user__isnull=True):
profile.user = legacy_user
profile.save()
class Migration(migrations.Migration):
dependencies = [
('finance', '0008_add_avatar_image'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='profile',
name='user',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL),
),
migrations.RunPython(assign_legacy_user, migrations.RunPython.noop),
]
@@ -0,0 +1,37 @@
# Generated by Django 6.0.3 on 2026-03-24 20:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0009_add_user_fk'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='expense',
name='due_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='canton',
field=models.CharField(choices=[('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'), ('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'), ('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'), ('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'), ('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'), ('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'), ('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'), ('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'), ('ZG', 'Zug'), ('ZH', 'Zurich')], default='ZH', max_length=2),
),
migrations.CreateModel(
name='Deadline',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('date', models.DateField()),
('type', models.CharField(choices=[('tax', 'Tax'), ('insurance', 'Insurance'), ('invoice', 'Invoice'), ('personal', 'Personal'), ('other', 'Other')], default='other', max_length=20)),
('notes', models.TextField(blank=True, default='')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deadlines', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.4 on 2026-04-12 18:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0010_add_calendar_fields'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ReadEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(choices=[('deadline', 'Deadline'), ('expense', 'Expense')], max_length=20)),
('event_id', models.PositiveIntegerField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_events', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'event_type', 'event_id')},
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-12 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0011_readevent'),
]
operations = [
migrations.AddField(
model_name='profile',
name='language',
field=models.CharField(choices=[('de', 'Deutsch'), ('fr', 'Français'), ('it', 'Italiano'), ('en', 'English')], default='de', max_length=2),
),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0012_profile_language'),
]
operations = [
migrations.AddField(
model_name='profile',
name='totp_secret',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='profile',
name='totp_enabled',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,36 @@
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('finance', '0013_profile_totp'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='profile',
name='totp_last_used_code',
field=models.CharField(blank=True, default='', max_length=6),
),
migrations.CreateModel(
name='BackupCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code_hash', models.CharField(max_length=64)),
('used', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='backup_codes',
to=settings.AUTH_USER_MODEL,
)),
],
options={
'indexes': [models.Index(fields=['user', 'used'], name='finance_bac_user_id_idx')],
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0014_totp_security'),
]
operations = [
migrations.AddField(
model_name='profile',
name='recovery_email',
field=models.EmailField(blank=True, default=''),
),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0015_profile_recovery_email'),
]
operations = [
migrations.AddField(
model_name='profile',
name='recovery_code_hash',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='profile',
name='recovery_code_expires',
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,47 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('finance', '0016_profile_recovery_code'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='profile',
name='notif_deadlines',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='profile',
name='notif_budget_alerts',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='profile',
name='notif_monthly_summary',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='UserSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(max_length=64, unique=True)),
('refresh_jti', models.CharField(blank=True, default='', max_length=255)),
('device_name', models.CharField(blank=True, default='', max_length=200)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_active_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_sessions',
to=settings.AUTH_USER_MODEL,
)),
],
options={'ordering': ['-last_active_at']},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.4 on 2026-05-18 19:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0017_user_sessions_notifications'),
]
operations = [
migrations.RenameIndex(
model_name='backupcode',
new_name='finance_bac_user_id_7e357d_idx',
old_name='finance_bac_user_id_idx',
),
migrations.AddField(
model_name='profile',
name='savings_rate_goal',
field=models.PositiveSmallIntegerField(default=20),
),
migrations.AlterField(
model_name='profile',
name='canton',
field=models.CharField(choices=[('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'), ('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'), ('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'), ('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'), ('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'), ('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'), ('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'), ('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'), ('ZG', 'Zug'), ('ZH', 'Zürich')], default='ZH', max_length=2),
),
]
@@ -0,0 +1,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,37 @@
# Generated by Django 6.0.4 on 2026-05-24 10:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0020_email_verify_token_expiry'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Insurance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('insurance_type', models.CharField(choices=[('kvg', 'Krankenkasse Grundversicherung (KVG)'), ('kk_zusatz', 'KK-Zusatzversicherung'), ('nbu', 'Nicht-Berufsunfallversicherung (NBU)'), ('haftpflicht', 'Privathaftpflicht'), ('hausrat', 'Hausrat'), ('mfz', 'MFZ-Haftpflicht'), ('rechtsschutz', 'Rechtsschutz'), ('saule_3a', 'Säule 3a'), ('leben', 'Lebensversicherung'), ('reise', 'Reiseversicherung'), ('other', 'Sonstiges')], max_length=30)),
('insurer', models.CharField(max_length=200)),
('policy_number', models.CharField(blank=True, default='', max_length=100)),
('premium', models.DecimalField(decimal_places=2, max_digits=10)),
('premium_period', models.CharField(choices=[('monthly', 'Monatlich'), ('quarterly', 'Vierteljährlich'), ('semi_annual', 'Halbjährlich'), ('annual', 'Jährlich')], default='monthly', max_length=20)),
('coverage_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('deductible', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('valid_from', models.DateField(blank=True, null=True)),
('valid_until', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='insurances', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['insurance_type'],
},
),
]
@@ -0,0 +1,34 @@
# Generated by Django 6.0.4 on 2026-05-24 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0021_add_insurance_model'),
]
operations = [
migrations.CreateModel(
name='PraemienEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plz', models.CharField(db_index=True, max_length=10)),
('ort', models.CharField(max_length=200)),
('kanton', models.CharField(max_length=2)),
('region', models.PositiveSmallIntegerField()),
('bfs_nr', models.PositiveIntegerField(db_index=True)),
('gemeinde', models.CharField(max_length=200)),
('bezirk', models.CharField(blank=True, default='', max_length=200)),
('avg_adult', models.DecimalField(decimal_places=2, max_digits=8)),
('avg_young_adult', models.DecimalField(decimal_places=2, max_digits=8)),
('avg_child', models.DecimalField(decimal_places=2, max_digits=8)),
('data_year', models.PositiveSmallIntegerField(db_index=True)),
],
options={
'ordering': ['kanton', 'ort'],
'unique_together': {('plz', 'ort', 'data_year')},
},
),
]
@@ -0,0 +1,34 @@
# Generated by Django 6.0.4 on 2026-05-24 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0022_add_praemien_entry'),
]
operations = [
migrations.CreateModel(
name='PraemienPolice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('versicherer_id', models.PositiveIntegerField(db_index=True)),
('kanton', models.CharField(max_length=2)),
('region', models.PositiveSmallIntegerField()),
('altersklasse', models.CharField(max_length=10)),
('unfalleinschluss', models.CharField(max_length=10)),
('tariftyp', models.CharField(max_length=10)),
('tarifbezeichnung', models.CharField(max_length=200)),
('franchisestufe', models.CharField(max_length=10)),
('franchise_chf', models.PositiveSmallIntegerField()),
('praemie', models.DecimalField(decimal_places=2, max_digits=8)),
('data_year', models.PositiveSmallIntegerField(db_index=True)),
],
options={
'indexes': [models.Index(fields=['kanton', 'region', 'altersklasse', 'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year'], name='finance_pra_kanton_e430cb_idx')],
'unique_together': {('versicherer_id', 'kanton', 'region', 'altersklasse', 'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year')},
},
),
]
+334
View File
@@ -0,0 +1,334 @@
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'}"
class Insurance(models.Model):
INSURANCE_TYPES = [
('kvg', 'Krankenkasse Grundversicherung (KVG)'),
('kk_zusatz', 'KK-Zusatzversicherung'),
('nbu', 'Nicht-Berufsunfallversicherung (NBU)'),
('haftpflicht', 'Privathaftpflicht'),
('hausrat', 'Hausrat'),
('mfz', 'MFZ-Haftpflicht'),
('rechtsschutz', 'Rechtsschutz'),
('saule_3a', 'Säule 3a'),
('leben', 'Lebensversicherung'),
('reise', 'Reiseversicherung'),
('other', 'Sonstiges'),
]
PERIOD_CHOICES = [
('monthly', 'Monatlich'),
('quarterly', 'Vierteljährlich'),
('semi_annual', 'Halbjährlich'),
('annual', 'Jährlich'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='insurances',
)
insurance_type = models.CharField(max_length=30, choices=INSURANCE_TYPES)
insurer = models.CharField(max_length=200)
policy_number = models.CharField(max_length=100, blank=True, default='')
premium = models.DecimalField(max_digits=10, decimal_places=2)
premium_period = models.CharField(max_length=20, choices=PERIOD_CHOICES, default='monthly')
coverage_amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
deductible = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
valid_from = models.DateField(null=True, blank=True)
valid_until = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['insurance_type']
def __str__(self):
return f"{self.get_insurance_type_display()} {self.insurer}"
class PraemienEntry(models.Model):
"""
Swiss health insurance average premium data from BAG / Priminfo.
Populated via management command: python manage.py import_praemien [year]
Source: https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx
"""
plz = models.CharField(max_length=10, db_index=True)
ort = models.CharField(max_length=200)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # Prämienregion 0, 1, 2, or 3
bfs_nr = models.PositiveIntegerField(db_index=True)
gemeinde = models.CharField(max_length=200)
bezirk = models.CharField(max_length=200, blank=True, default='')
avg_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_young_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_child = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = ['plz', 'ort', 'data_year']
ordering = ['kanton', 'ort']
def __str__(self):
return f"{self.plz} {self.ort} ({self.kanton}) Region {self.region} {self.data_year}"
class PraemienPolice(models.Model):
"""
Granular KVG premium data per insurer, canton, region, age class, model, franchise.
Populated via management command: python manage.py import_praemien [year]
Source: https://opendata.bagnet.ch (Prämien_CH.csv)
~217k rows for a full year.
"""
versicherer_id = models.PositiveIntegerField(db_index=True)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # 0, 1, 2, 3
altersklasse = models.CharField(max_length=10) # AKL-ERW / AKL-JUG / AKL-KIN
unfalleinschluss = models.CharField(max_length=10) # MIT-UNF / OHN-UNF
tariftyp = models.CharField(max_length=10) # TAR-BASE / TAR-HAM / TAR-HMO / TAR-DIV
tarifbezeichnung = models.CharField(max_length=200)
franchisestufe = models.CharField(max_length=10) # FRAST1 … FRAST7
franchise_chf = models.PositiveSmallIntegerField() # e.g. 300, 500, 1000 …
praemie = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = [
'versicherer_id', 'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]
indexes = [
models.Index(fields=[
'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]),
]
def __str__(self):
return (f"V{self.versicherer_id} {self.kanton} R{self.region} "
f"{self.altersklasse} {self.tariftyp} {self.franchisestufe}{self.praemie} CHF")
+80
View File
@@ -0,0 +1,80 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, Insurance
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 InsuranceSerializer(serializers.ModelSerializer):
class Meta:
model = Insurance
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'],
)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+19
View File
@@ -0,0 +1,19 @@
asgiref==3.11.1
Django==6.0.4
django-cors-headers==4.9.0
djangorestframework==3.17.1
djangorestframework_simplejwt==5.5.1
gunicorn==25.3.0
icalendar==7.0.3
packaging==26.0
pillow==12.2.0
psycopg2-binary==2.9.11
PyJWT==2.12.1
python-dateutil==2.9.0.post0
pyotp==2.9.0
python-dotenv==1.2.2
six==1.17.0
sqlparse==0.5.5
typing_extensions==4.15.0
tzdata==2026.1
fpdf2==2.8.7
@@ -0,0 +1,34 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium 2FA-Wiederherstellung{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
Du hast eine 2FA-Wiederherstellung für dein Armarium-Konto angefordert.
Gib den folgenden Code auf der Anmeldeseite ein:
</p>
<!-- Code Box -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td align="center" style="background-color:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:20px;">
<span style="font-size:28px;font-weight:700;letter-spacing:6px;color:#7c3aed;font-family:monospace;">{{ code }}</span>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Gültig für <strong>15 Minuten</strong> · Einmalig verwendbar
</p>
<p style="margin:0 0 24px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Konto ist weiterhin sicher.
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
Das Armarium-Team
</p>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
Armarium 2FA-Wiederherstellung
Hallo,
Du hast eine 2FA-Wiederherstellung für dein Armarium-Konto angefordert.
Dein Wiederherstellungscode lautet:
{{ code }}
Gib diesen Code auf der Anmeldeseite ein.
Er ist 15 Minuten gültig und kann nur einmal verwendet werden.
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Konto ist weiterhin sicher.
Das Armarium-Team
https://www.armarium.ch
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block subject %}Armarium{% endblock %}</title>
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f4f6;padding:40px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
<!-- Header -->
<tr>
<td style="background-color:#7c3aed;border-radius:12px 12px 0 0;padding:28px 40px;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:-0.3px;">Armarium</span>
</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color:#ffffff;padding:36px 40px;">
{% block body %}{% endblock %}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#f9fafb;border-top:1px solid #e5e7eb;border-radius:0 0 12px 12px;padding:20px 40px;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.6;">
Du erhältst diese E-Mail, weil du ein Konto bei
<a href="https://www.armarium.ch" style="color:#7c3aed;text-decoration:none;">armarium.ch</a> hast.
<br>Falls du diese E-Mail nicht erwartet hast, kannst du sie ignorieren.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,33 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Deine E-Mail-Adresse wurde geändert{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
Die E-Mail-Adresse deines Armarium-Kontos wurde geändert.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background-color:#f5f3ff;border-radius:8px;padding:16px 20px;">
<p style="margin:0 0 6px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;">Neue Adresse</p>
<p style="margin:0;font-size:15px;color:#374151;font-weight:600;">{{ new_email }}</p>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background-color:#fef2f2;border-left:3px solid #ef4444;border-radius:0 6px 6px 0;padding:14px 18px;">
<p style="margin:0;font-size:13px;color:#374151;line-height:1.6;">
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter
<a href="mailto:support@armarium.ch" style="color:#7c3aed;text-decoration:none;">support@armarium.ch</a>.
</p>
</td>
</tr>
</table>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,12 @@
Armarium Deine E-Mail-Adresse wurde geändert
Hallo,
Die E-Mail-Adresse deines Armarium-Kontos wurde geändert.
Neue Adresse: {{ new_email }}
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter support@armarium.ch.
Das Armarium-Team
https://www.armarium.ch
@@ -0,0 +1,24 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Dein Passwort wurde geändert{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
Dein Armarium-Passwort wurde erfolgreich geändert.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background-color:#f5f3ff;border-left:3px solid #7c3aed;border-radius:0 6px 6px 0;padding:14px 18px;">
<p style="margin:0;font-size:13px;color:#374151;line-height:1.6;">
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter
<a href="mailto:support@armarium.ch" style="color:#7c3aed;text-decoration:none;">support@armarium.ch</a>.
</p>
</td>
</tr>
</table>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,10 @@
Armarium Dein Passwort wurde geändert
Hallo,
Dein Armarium-Passwort wurde erfolgreich geändert.
Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter support@armarium.ch.
Das Armarium-Team
https://www.armarium.ch
@@ -0,0 +1,35 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Passwort zurücksetzen{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Klicke auf den Button, um ein neues Passwort zu wählen:
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
<tr>
<td style="border-radius:8px;background-color:#7c3aed;">
<a href="{{ link }}" target="_blank"
style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">
Neues Passwort setzen
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Gültig für <strong>15 Minuten</strong>. Falls der Button nicht funktioniert, kopiere diesen Link:
</p>
<p style="margin:0 0 24px;font-size:12px;color:#7c3aed;line-height:1.6;word-break:break-all;">{{ link }}</p>
<p style="margin:0 0 24px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Passwort bleibt unverändert.
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,16 @@
Armarium Passwort zurücksetzen
Hallo,
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.
Link zum Zurücksetzen:
{{ link }}
Gültig für 15 Minuten.
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Passwort bleibt unverändert.
Das Armarium-Team
https://www.armarium.ch
@@ -0,0 +1,33 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium E-Mail-Adresse bestätigen{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo,</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
Willkommen bei Armarium! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
<tr>
<td style="border-radius:8px;background-color:#7c3aed;">
<a href="{{ link }}" target="_blank"
style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">
E-Mail-Adresse bestätigen
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Gültig für <strong>24 Stunden</strong>. Falls der Button nicht funktioniert, kopiere diesen Link:
</p>
<p style="margin:0 0 24px;font-size:12px;color:#7c3aed;line-height:1.6;word-break:break-all;">{{ link }}</p>
<p style="margin:0 0 24px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls du dieses Konto nicht erstellt hast, kannst du diese E-Mail ignorieren.
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;"> Das Armarium-Team</p>
{% endblock %}
@@ -0,0 +1,15 @@
Armarium E-Mail-Adresse bestätigen
Hallo,
Willkommen bei Armarium! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.
Link zur Bestätigung:
{{ link }}
Gültig für 24 Stunden.
Falls du dieses Konto nicht erstellt hast, kannst du diese E-Mail ignorieren.
Das Armarium-Team
https://www.armarium.ch
Submodule frontend deleted from e38e9877c0
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# BudgetFrontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+82
View File
@@ -0,0 +1,82 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "97da586f-bf37-4b8e-beae-24c4d04eb25b"
},
"newProjectRoot": "projects",
"projects": {
"budget-frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "budget-frontend:build:production"
},
"development": {
"buildTarget": "budget-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+10713
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "budget-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@fontsource/roboto": "^5.2.10",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@types/qrcode": "^1.5.6",
"apexcharts": "^3.46.0",
"jspdf": "^4.2.1",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.0",
"autoprefixer": "^10.4.27",
"jsdom": "^28.0.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+10
View File
@@ -0,0 +1,10 @@
{
"/api": {
"target": "http://localhost:8000",
"secure": false
},
"/media": {
"target": "http://localhost:8000",
"secure": false
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

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