feat: Armarium v1.1.0 — dashboard, auth, 2FA, SMTP, settings, deploy
Dashboard: - ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart - KPI cards: income, fixed costs, savings rate with configurable goal - Greeting with time-of-day and locale-aware date/time display Authentication & security: - Email-based login (no username), case-insensitive lookup - JWT access/refresh tokens with rotation and blacklist - TOTP 2FA with QR code, backup codes (copy + PDF export) - 2FA recovery via email code - Cloudflare Turnstile CAPTCHA on login and register Email flows: - Email verification on registration (24h token) - Password reset flow (15min token, anti-enumeration) - Brevo SMTP integration with HTML + plaintext email templates - Notification emails: 2FA recovery, password changed, email changed Settings page: - 2FA management (enable/disable, QR, backup codes) - Active sessions list with per-device revoke - Data export: ZIP with 6 PDFs via fpdf2 - Notification preferences (3 toggles) - Danger zone: account deletion with mandatory export + confirmation phrase UI & layout: - Sidebar with collapsible/flyout mode, Angular signal-based dropdowns - Dark mode (class-based), language switcher (DE/FR/IT/EN) - Mobile-responsive layout with touch-friendly targets - Roboto font via @fontsource (GDPR-compliant, no Google CDN) - Pure Tailwind CSS v3 Infrastructure: - Forgejo Actions CI/CD pipeline (auto-deploy on push to main) - Gunicorn + Nginx + PostgreSQL production setup - Rate limiting, HSTS, secure cookies, CSRF protection
This commit is contained in:
@@ -0,0 +1,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)
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user