feat: Armarium v1.1.0 — dashboard, auth, 2FA, SMTP, settings, deploy

Dashboard:
- ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart
- KPI cards: income, fixed costs, savings rate with configurable goal
- Greeting with time-of-day and locale-aware date/time display

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

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

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

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

Infrastructure:
- Forgejo Actions CI/CD pipeline (auto-deploy on push to main)
- Gunicorn + Nginx + PostgreSQL production setup
- Rate limiting, HSTS, secure cookies, CSRF protection
This commit is contained in:
Daniel Krähenbühl
2026-05-25 22:45:18 +02:00
parent 807ebc41a5
commit 1a7ef09805
150 changed files with 22862 additions and 3 deletions
@@ -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),
),
]