13 Commits

Author SHA1 Message Date
admin d3718da1f8 V 2.2.1 Anmeldung per Passkey (WebAuthn)
Passkey-Login analog zu werte-next: auf der Login-Seite "Mit Passkey
anmelden", Verwaltung der eigenen Passkeys im neuen Tab Einstellungen.
Das Passwort bleibt als Alternative bestehen.

- lib/webauthn.ts, lib/passkeys.ts sowie API-Routen unter /api/passkey
- proxy.ts: /api/passkey/authenticate ohne Session erreichbar
- TabLayout: dritter Tab Einstellungen (Index-Tabs statt Route wie werte)
- RP-Konfiguration ueber AUSGABEN_RP_*, da sich die .env auf dem Server
  mit anderen Apps teilt, die RP_ID/RP_ORIGIN bereits belegen
- Eigene Tabelle ausgaben_passkeys aus demselben Grund: werte-next nutzt
  in der gemeinsamen Datenbank RXF bereits die Tabelle passkeys
- eslint.config.mjs auf die Flat-Config von eslint-config-next umgestellt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:35:35 +02:00
admin de28922784 V 2.1.1 Passwort-Sichtbarkeit im Login per Auge-Icon umschaltbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:40:09 +02:00
admin 38c18a5ead V 2.1.0 Verbesserungen von Claud Code eingefügt 2026-04-27 10:39:09 +02:00
admin a7863c519f V 2.0.2 Nun mit Tabs
Anzahl in der Liste als const
2026-03-06 13:36:21 +01:00
admin 204bf3bf8b V 1.2.2. Environment f. DB_PASS angepasst 2026-03-06 13:09:29 +01:00
admin 46678cb644 V 1.2.1 Multiplatfoprm Build
Version auch auf der Login-Seite
2026-03-05 09:05:27 +01:00
admin 74e5f76ec2 V 1.2.0 migration.tsx in proxy.tsx umbenannt
Loginpage eingebettet
2026-03-04 13:20:50 +00:00
admin 90444b8f7d V 1.1.0 nur Version geändert 2026-03-01 11:49:36 +00:00
admin 2a9ae7e806 Summen-Statistik der Kategorien
Eigenes 'Löschen' PopUp
2026-03-01 11:48:24 +00:00
admin ed6bc21248 Kategorien dazu 2026-03-01 11:26:44 +00:00
admin 319ac8699e V 1.0.2. Keine vorausgefüllte Login-Seite mehr 2026-03-01 08:46:59 +00:00
admin 8c6d1bcf6d V 1.0.1: Auth erfolgreich 2026-03-01 08:35:46 +00:00
admin 1ccd66b307 weiter die Sache mit Auth 2026-03-01 07:52:31 +00:00
41 changed files with 2273 additions and 225 deletions
+16
View File
@@ -12,3 +12,19 @@ AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW,us
# Secret key for JWT session encryption (change in production!)
AUTH_SECRET=your-super-secret-key-change-this-in-production
# Passkeys (WebAuthn) — Relying Party
# RP_ID = nur der Host (ohne Protokoll/Port), RP_ORIGIN = volle URL
#
# Bei "npm run dev" liest Next.js RP_ID/RP_ORIGIN/RP_NAME direkt aus dieser Datei.
# Ohne Angabe gilt localhost / http://localhost:3005 — für lokale Entwicklung
# können die Zeilen also entfallen.
# RP_ID=localhost
# RP_ORIGIN=http://localhost:3005
# Bei Docker Compose werden die AUSGABEN_RP_*-Variablen ausgewertet und im
# Container auf RP_* abgebildet. Eigene Namen deshalb, weil die .env auf dem
# Server mit anderen Apps geteilt wird, die RP_ID/RP_ORIGIN schon belegen.
AUSGABEN_RP_ID=ausgaben.fuerst-stuttgart.de
AUSGABEN_RP_ORIGIN=https://ausgaben.fuerst-stuttgart.de
AUSGABEN_RP_NAME=Ausgaben-Log
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Next.js Debug",
"runtimeExecutable": "node",
"args": [
"--inspect-brk",
"${workspaceFolder}/node_modules/.bin/next",
"dev"
],
"console": "integratedTerminal"
}
]
}
+203
View File
@@ -0,0 +1,203 @@
# Wiederverwendbare Authentifizierung
Diese Authentifizierungslösung kann einfach in andere Next.js Projekte übernommen werden.
## Komponenten
### 1. Core Libraries (wiederverwendbar)
- `/lib/auth.ts` - Authentifizierungslogik (Benutzerverwaltung über .env)
- `/lib/session.ts` - JWT-basiertes Session-Management
- `/middleware.ts` - Route-Schutz Middleware
- `/lib/webauthn.ts` - Passkey-Logik (Registrierung & Anmeldung, WebAuthn)
- `/lib/passkeys.ts` - Datenbankzugriff auf die Tabelle `ausgaben_passkeys`
### 2. UI Komponenten (wiederverwendbar)
- `/app/login/page.tsx` - Login-Seite (Passwort + „Mit Passkey anmelden")
- `/app/login/actions.ts` - Server Actions für Login/Logout
- `/components/LogoutButton.tsx` - Logout-Button Komponente
- `/components/Passkeys.tsx` - Verwaltung der eigenen Passkeys (Tab *Einstellungen*)
### 3. API-Routen (Passkeys)
- `/app/api/passkey/route.ts` - eigene Passkeys auflisten (GET) / löschen (DELETE)
- `/app/api/passkey/register/route.ts` - Passkey registrieren (Session erforderlich)
- `/app/api/passkey/authenticate/route.ts` - Anmeldung per Passkey (öffentlich)
## Installation in neuen Projekten
### 1. Dependencies installieren
```bash
npm install jose bcryptjs
npm install --save-dev @types/bcryptjs
# Für Passkeys (WebAuthn):
npm install @simplewebauthn/server @simplewebauthn/browser
```
### 2. Dateien kopieren
Kopiere folgende Dateien in dein neues Projekt:
- `lib/auth.ts`
- `lib/session.ts`
- `middleware.ts`
- `app/login/` (gesamter Ordner)
- `scripts/generate-password.js` (Passwort-Hash Generator)
- `components/LogoutButton.tsx` (optional)
### 3. Passwort-Hashes generieren
Verwende das mitgelieferte Script, um sichere Passwort-Hashes zu erstellen:
```bash
# Interactive Mode
node scripts/generate-password.js
# Mit Passwort als Argument
node scripts/generate-password.js meinPasswort123
```
Das Script gibt einen bcrypt-Hash aus, den du in der `.env` verwenden kannst.
### 4. Umgebungsvariablen einrichten
Füge zu deiner `.env` hinzu:
```env
# Authentifizierung
# Format: username:passwordHash,username2:passwordHash2
# Verwende 'node scripts/generate-password.js' um Hashes zu generieren
AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro...,user1:$2b$10$K613Z70Hodr6xyEh10Mw2u...
# Secret Key für JWT (unbedingt ändern in Production!)
AUTH_SECRET=your-super-secret-key-change-this
# Passkeys (WebAuthn) — RP_ID = nur der Host, RP_ORIGIN = volle URL
RP_ID=ausgaben.fuerst-stuttgart.de
RP_ORIGIN=https://ausgaben.fuerst-stuttgart.de
RP_NAME=Ausgaben-Log
```
### 5. Logout-Button einbinden (optional)
```tsx
import LogoutButton from '@/components/LogoutButton';
// In deiner Komponente:
<LogoutButton />
```
## Konfiguration
### Benutzer hinzufügen/entfernen
1. Generiere einen Passwort-Hash:
```bash
node scripts/generate-password.js neuesPasswort
```
2. Editiere die `AUTH_USERS` Variable in der `.env`:
```env
AUTH_USERS=user1:$2b$10$hash1...,user2:$2b$10$hash2...,user3:$2b$10$hash3...
```
### Authentifizierung deaktivieren
Entferne die `AUTH_USERS` Variable oder setze sie auf einen leeren String:
```env
AUTH_USERS=
```
### Session-Dauer anpassen
Editiere in `lib/session.ts`:
```ts
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 Tage
```
### Geschützte Routen anpassen
Editiere in `middleware.ts` die `publicPaths`:
```ts
const publicPaths = ['/login', '/api/passkey/authenticate', '/public-page'];
```
Die Passkey-Anmeldung muss ohne Session erreichbar sein.
### Passkeys verwalten
- **Registrieren**: angemeldet im Tab *Einstellungen* → „Passkey hinzufügen".
- **Anmelden**: auf der Login-Seite „Mit Passkey anmelden".
- **Speicherung**: Tabelle `ausgaben_passkeys` (siehe `create_table.sql`), wird beim
ersten Zugriff automatisch angelegt, sofern der DB-Benutzer CREATE-Rechte hat.
**Wichtig bei Übernahme in ein anderes Projekt**: Teilen sich mehrere Apps eine
Datenbank, braucht jede App einen eigenen Tabellennamen (`TABLE` in
`lib/passkeys.ts`) — sonst sehen und löschen sie gegenseitig ihre Passkeys.
- `RP_ID` muss exakt dem Hostnamen entsprechen (ohne Protokoll/Port). Ändert sich
`RP_ID`, werden bereits registrierte Passkeys ungültig.
- WebAuthn funktioniert nur über **HTTPS** oder auf `localhost`.
## Sicherheitshinweise
1. **AUTH_SECRET ändern**: Verwende in Production einen starken, zufälligen Schlüssel
2. **HTTPS verwenden**: In Production immer HTTPS aktivieren
3. **Passwort-Hashing**: Passwörter werden mit bcrypt gehashed (10 Salt Rounds)
4. **Keine Klartext-Passwörter**: Verwende immer das Script zur Hash-Generierung
## Passwort-Hash Generator
Das Script `scripts/generate-password.js` verwendet bcrypt mit 10 Salt Rounds, um sichere Passwort-Hashes zu erstellen.
### Verwendung
Interactive Mode (empfohlen für sensible Passwörter):
```bash
npm run generate-password
# oder
node scripts/generate-password.js
# Passwort wird interaktiv abgefragt
```
Mit Argument:
```bash
npm run generate-password -- meinPasswort
# oder
node scripts/generate-password.js meinPasswort
```
### Ausgabe
```
🔐 Generiere Passwort-Hash...
✅ Hash generiert:
────────────────────────────────────────────────────────────────────────────────
$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW
────────────────────────────────────────────────────────────────────────────────
📝 Verwende diesen Hash in der .env Datei:
AUTH_USERS=username:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW
```
## Erweiterte Verwendung
### Session-Informationen abrufen
```ts
import { getSession } from '@/lib/session';
const session = await getSession();
if (session) {
console.log('Eingeloggt als:', session.username);
}
```
### Programmatisch prüfen, ob authentifiziert
```ts
import { isAuthenticated } from '@/lib/session';
const authenticated = await isAuthenticated();
```
### In Server Components
```tsx
import { getSession } from '@/lib/session';
import { redirect } from 'next/navigation';
export default async function ProtectedPage() {
const session = await getSession();
if (!session) {
redirect('/login');
}
return <div>Hallo {session.username}!</div>;
}
```
+27
View File
@@ -66,6 +66,33 @@ Falls die Tabelle `Ausgaben` noch nicht existiert:
mysql -u root -p RXF < create_table.sql
```
Das Script legt auch die Tabelle `ausgaben_passkeys` für die Passkey-Anmeldung an. Die
Anwendung erstellt diese Tabelle beim ersten Zugriff selbst — hat der DB-Benutzer
keine CREATE-Rechte, das SQL aus `create_table.sql` manuell ausführen.
### Passkeys (WebAuthn) konfigurieren
Im Container erwartet die Anwendung `RP_ID`, `RP_ORIGIN` und `RP_NAME`. In der
Compose-Datei werden diese aus den **app-eigenen** Variablen `AUSGABEN_RP_*`
befüllt — mit den Produktionswerten als Default:
```yaml
- RP_ID=${AUSGABEN_RP_ID:-ausgaben.fuerst-stuttgart.de}
- RP_ORIGIN=${AUSGABEN_RP_ORIGIN:-https://ausgaben.fuerst-stuttgart.de}
- RP_NAME=${AUSGABEN_RP_NAME:-Ausgaben-Log}
```
**Warum eigene Namen**: Auf dem Server (`/opt/stacks/myapp/`) laufen mehrere Apps im
selben Stack und teilen sich eine `.env`. Dort sind `RP_ID`/`RP_ORIGIN` bereits für
werte-next gesetzt. Würde diese App dieselben Namen lesen, bekäme sie werte's Host —
und die Passkey-Anmeldung schlüge fehl. Die Defaults oben greifen, solange in der
geteilten `.env` keine `AUSGABEN_RP_*` stehen; ein Eintrag dort überschreibt sie.
**Wichtig**: `RP_ID` muss exakt dem Hostnamen entsprechen (ohne `https://` und ohne
Port), `RP_ORIGIN` der vollen URL. Passen die Werte nicht zur aufgerufenen Adresse,
schlägt die Passkey-Anmeldung fehl. Bereits registrierte Passkeys werden bei einer
Änderung von `RP_ID` ungültig. WebAuthn setzt HTTPS voraus (via Traefik).
### Datenbank-Verbindung
Die Anwendung verwendet die gleiche MySQL-Datenbank wie die alte Ausgaben-Anwendung:
+1 -1
View File
@@ -37,7 +37,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
# COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+21
View File
@@ -23,6 +23,27 @@ Dies ist die modernisierte Version des alten PHP/jQuery-basierten Ausgaben-Progr
- Bearbeiten-Funktion: Klick auf Eintrag lädt ihn ins Formular
- Filterung nach aktivem TYP (Haushalt/Privat)
- **Anmeldung per Passkey** (WebAuthn) alternativ zu Benutzername/Passwort
## Anmeldung & Passkeys
Benutzer und bcrypt-Passworthashes stehen in `AUTH_USERS`
(Format `name:hash,name2:hash2`), die Session ist ein JWT-Cookie (`AUTH_SECRET`).
Zusätzlich kann sich jeder Benutzer per **Passkey** anmelden (Fingerabdruck,
Gesichtserkennung oder Geräte-PIN). Das Passwort bleibt als Alternative bestehen.
- **Registrieren**: nach der Anmeldung im Tab *Einstellungen* → „Passkey hinzufügen".
Der Passkey wird dem angemeldeten Benutzer zugeordnet; mehrere Geräte sind möglich.
- **Anmelden**: auf der Login-Seite „Mit Passkey anmelden".
- **Speicherung**: Tabelle `ausgaben_passkeys` (siehe `create_table.sql`); sie wird beim
ersten Zugriff automatisch angelegt, falls der DB-Benutzer CREATE-Rechte hat. Der
app-spezifische Name ist nötig, weil sich mehrere Apps auf dem Server die Datenbank
`RXF` teilen und werte-next dort die Tabelle `passkeys` belegt.
- **Konfiguration**: `RP_ID` (nur der Host), `RP_ORIGIN` (volle URL), `RP_NAME`.
Lokal sind `localhost` / `http://localhost:3005` voreingestellt.
- **Wichtig**: WebAuthn funktioniert nur über **HTTPS** oder auf `localhost`.
## Technologie-Stack
- **Frontend**: Next.js 16, React 19, TypeScript
+5
View File
@@ -0,0 +1,5 @@
-- Migration: Kategorie-Spalte zur Ausgaben-Tabelle hinzufügen
-- Ausführen: mysql -u <user> -p <database> < add_kategorie.sql
ALTER TABLE Ausgaben
ADD COLUMN Kat VARCHAR(4) NOT NULL DEFAULT 'L' AFTER Was;
+3 -2
View File
@@ -10,7 +10,7 @@ export async function PUT(
try {
const { id } = await context.params;
const body = await request.json();
const { Datum, Wo, Was, Wieviel, Wie, TYP } = body;
const { Datum, Wo, Was, Kat, Wieviel, Wie, TYP } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) {
return NextResponse.json(
@@ -23,7 +23,7 @@ export async function PUT(
const query = `
UPDATE Ausgaben
SET Datum = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, TYP = ?
SET Datum = ?, Wo = ?, Was = ?, Kat = ?, Wieviel = ?, Wie = ?, TYP = ?
WHERE ID = ?
`;
@@ -31,6 +31,7 @@ export async function PUT(
Datum,
Wo,
Was,
Kat || 'L',
parseFloat(Wieviel),
Wie,
TYP,
+5 -4
View File
@@ -15,7 +15,7 @@ export async function GET(request: Request) {
const pool = getDbPool();
let query = `SELECT
ID, Datum, Wo, Was, Wieviel, Wie, TYP,
ID, Datum, Wo, Was, Kat, Wieviel, Wie, TYP,
CASE DAYOFWEEK(Datum)
WHEN 1 THEN 'Sonntag'
WHEN 2 THEN 'Montag'
@@ -68,7 +68,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
const { Datum, Wo, Was, Wieviel, Wie, TYP } = body;
const { Datum, Wo, Was, Kat, Wieviel, Wie, TYP } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) {
return NextResponse.json(
@@ -80,14 +80,15 @@ export async function POST(request: Request) {
const pool = getDbPool();
const query = `
INSERT INTO Ausgaben (Datum, Wo, Was, Wieviel, Wie, TYP)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO Ausgaben (Datum, Wo, Was, Kat, Wieviel, Wie, TYP)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await pool.query<ResultSetHeader>(query, [
Datum,
Wo,
Was,
Kat || 'L',
parseFloat(Wieviel),
Wie,
TYP,
+16 -1
View File
@@ -56,6 +56,21 @@ export async function GET(request: Request) {
const data = rows[0] || {};
// Per-category breakdown
const [katRows] = await pool.query<RowDataPacket[]>(
`SELECT Kat, SUM(Wieviel) as total
FROM Ausgaben
WHERE YEAR(Datum) = ? AND MONTH(Datum) = ? AND TYP = ?
GROUP BY Kat
HAVING total > 0
ORDER BY total DESC`,
[year, month, parseInt(typ)]
);
const katStats: Record<string, number> = {};
for (const row of katRows) {
katStats[row.Kat] = parseFloat(row.total) || 0;
}
// Convert string values from MySQL to numbers
const parsedData: any = {
totalAusgaben: parseFloat(data.totalAusgaben) || 0,
@@ -77,7 +92,7 @@ export async function GET(request: Request) {
return NextResponse.json({
success: true,
data: parsedData,
data: { ...parsedData, katStats },
});
} catch (error) {
console.error('Database error:', error);
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export interface Category {
value: string;
label: string;
}
// GET /api/categories - Fetch categories from categories.txt
export async function GET() {
try {
const filePath = path.join(process.cwd(), 'categories.txt');
const content = fs.readFileSync(filePath, 'utf-8');
const categories: Category[] = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('='))
.map((line) => {
const [value, label] = line.split('=');
return { value: value.trim(), label: label.trim() };
});
return NextResponse.json({ success: true, data: categories });
} catch (error) {
console.error('Error reading categories:', error);
return NextResponse.json(
{ success: false, error: 'Could not load categories' },
{ status: 500 }
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildAuthenticationOptions, finishAuthentication } from '@/lib/webauthn';
import { createSession } from '@/lib/session';
// Öffentlich (kein Login): Optionen für die Anmeldung per Passkey.
export async function GET() {
try {
return NextResponse.json(await buildAuthenticationOptions());
} catch (error) {
console.error('GET /api/passkey/authenticate:', error);
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
}
}
// Öffentlich: Verifiziert die Anmelde-Antwort und erstellt bei Erfolg die Session.
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const result = await finishAuthentication(body?.response);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 401 });
await createSession(result.username);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('POST /api/passkey/authenticate:', error);
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { buildRegistrationOptions, finishRegistration } from '@/lib/webauthn';
// Optionen für die Registrierung eines neuen Passkeys (Session erforderlich).
export async function GET() {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try {
return NextResponse.json(await buildRegistrationOptions(session.username));
} catch (error) {
console.error('GET /api/passkey/register:', error);
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
}
}
// Verifiziert die Registrierungs-Antwort und speichert den Passkey.
export async function POST(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try {
const body = await request.json();
const result = await finishRegistration(
session.username,
body?.response,
String(body?.label ?? '')
);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
return NextResponse.json({ ok: true });
} catch (error) {
console.error('POST /api/passkey/register:', error);
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { listPasskeys, deletePasskey } from '@/lib/passkeys';
// Liste der eigenen Passkeys (ohne öffentlichen Schlüssel).
export async function GET() {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try {
const passkeys = (await listPasskeys(session.username)).map((pk) => ({
credentialId: pk.credentialId,
label: pk.label,
createdAt: pk.createdAt,
lastUsedAt: pk.lastUsedAt,
}));
return NextResponse.json({ passkeys });
} catch (error) {
console.error('GET /api/passkey:', error);
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
}
}
// Löscht einen eigenen Passkey anhand seiner credentialId (?id=...).
export async function DELETE(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const id = request.nextUrl.searchParams.get('id');
if (!id) return NextResponse.json({ error: 'Keine ID angegeben.' }, { status: 400 });
try {
const removed = await deletePasskey(id, session.username);
if (!removed) return NextResponse.json({ error: 'Passkey nicht gefunden.' }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (error) {
console.error('DELETE /api/passkey:', error);
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
'use server';
import { verifyCredentials } from '@/lib/auth';
import { createSession, deleteSession } from '@/lib/session';
import { redirect } from 'next/navigation';
export async function login(prevState: any, formData: FormData) {
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!username || !password) {
return { error: 'Bitte Benutzername und Passwort eingeben' };
}
const isValid = await verifyCredentials(username, password);
if (!isValid) {
return { error: 'Ungültige Anmeldedaten' };
}
await createSession(username);
redirect('/');
}
export async function logout() {
await deleteSession();
redirect('/login');
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { useActionState, useState } from 'react';
import { startAuthentication } from '@simplewebauthn/browser';
import { login } from './actions';
import packageJson from '@/package.json';
export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined);
const [showPassword, setShowPassword] = useState(false);
const [passkeyBusy, setPasskeyBusy] = useState(false);
const [passkeyError, setPasskeyError] = useState<string | null>(null);
async function handlePasskeyLogin() {
setPasskeyError(null);
setPasskeyBusy(true);
try {
const optRes = await fetch('/api/passkey/authenticate');
if (!optRes.ok) throw new Error('Optionen konnten nicht geladen werden.');
const optionsJSON = await optRes.json();
const response = await startAuthentication({ optionsJSON });
const verifyRes = await fetch('/api/passkey/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response }),
});
if (!verifyRes.ok) {
const data = await verifyRes.json().catch(() => null);
throw new Error(data?.error ?? 'Anmeldung mit Passkey fehlgeschlagen.');
}
window.location.href = '/';
} catch (err) {
// Abbruch durch Nutzer (NotAllowedError) nicht als Fehler anzeigen.
if (err instanceof Error && err.name === 'NotAllowedError') {
setPasskeyError(null);
} else {
setPasskeyError(err instanceof Error ? err.message : 'Anmeldung mit Passkey fehlgeschlagen.');
}
} finally {
setPasskeyBusy(false);
}
}
const version = packageJson.version;
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Ausgaben - Log</h1>
</div>
<div className="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
<form action={loginAction} className="space-y-5">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Benutzername
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="off"
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Benutzername"
disabled={isPending}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Passwort
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
autoComplete="new-password"
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Passwort"
disabled={isPending}
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
tabIndex={-1}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
<div className="flex items-center gap-3 my-5">
<div className="h-px flex-1 bg-gray-200" />
<span className="text-xs text-gray-400">oder</span>
<div className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={handlePasskeyLogin}
disabled={passkeyBusy}
className="w-full py-2 px-4 flex items-center justify-center gap-2 border-2 border-gray-400 hover:border-blue-500 text-gray-900 font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{passkeyBusy ? 'Anmeldung läuft...' : 'Mit Passkey anmelden'}
</button>
{passkeyError && (
<div className="mt-4 bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{passkeyError}
</div>
)}
</div>
</div>
{/* Footer */}
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 ">
<div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
mailto:rxf@gmx.de
</a>
</div>
<div className="text-right">
Version {version} - {buildDate}
</div>
</footer>
</main>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useActionState } from 'react';
import { login } from './actions';
export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined);
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<h1 className="text-3xl font-bold mb-6">Ausgaben - Log</h1>
<div className="flex items-center justify-center py-8">
<div className="max-w-md w-full space-y-8 bg-white p-8 rounded-2xl shadow-xl">
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Anmeldung</h2>
<p className="text-gray-600">
Bitte melden Sie sich an, um fortzufahren
</p>
</div>
<form action={loginAction} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Benutzername
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="off"
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 bg-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Benutzername"
disabled={isPending}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="new-password"
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 bg-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Passwort"
disabled={isPending}
/>
</div>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</main>
</div>
);
}
+26 -47
View File
@@ -3,32 +3,36 @@
import { useState, useEffect } from 'react';
import AusgabenForm from '@/components/AusgabenForm';
import AusgabenList from '@/components/AusgabenList';
import MonatsStatistik from '@/components/MonatsStatistik';
import TabLayout, { SETTINGS_TAB } from '@/components/TabLayout';
import Passkeys from '@/components/Passkeys';
import { AusgabenEntry } from '@/types/ausgaben';
import packageJson from '@/package.json';
const MAX_ENTRIES = 15;
export default function Home() {
const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat
const [entries, setEntries] = useState<AusgabenEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null);
const version = packageJson.version;
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
const [statsRefreshKey, setStatsRefreshKey] = useState(0);
useEffect(() => {
fetchRecentEntries();
setSelectedEntry(null); // Clear selected entry when switching tabs
if (activeTab === SETTINGS_TAB) return; // Einstellungen laden keine Einträge
fetchRecentEntries();
}, [activeTab]);
const fetchRecentEntries = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/ausgaben?limit=20&typ=${activeTab}`, {
const response = await fetch(`/api/ausgaben?limit=${MAX_ENTRIES}&typ=${activeTab}`, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.success) {
setEntries(data.data);
@@ -42,6 +46,7 @@ export default function Home() {
const handleSuccess = () => {
setSelectedEntry(null);
setStatsRefreshKey((k) => k + 1);
setTimeout(() => {
fetchRecentEntries();
}, 100);
@@ -49,6 +54,7 @@ export default function Home() {
const handleDelete = (id: number) => {
setEntries(entries.filter(entry => entry.ID !== id));
setStatsRefreshKey((k) => k + 1);
};
const handleEdit = (entry: AusgabenEntry) => {
@@ -56,41 +62,27 @@ export default function Home() {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (activeTab === SETTINGS_TAB) {
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<h1 className="text-3xl font-bold text-center mb-6">Ausgaben - Log</h1>
{/* Tab Navigation */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab(0)}
className={`flex-1 py-3 px-6 rounded-lg font-semibold transition-colors ${
activeTab === 0
? 'bg-[#85B7D7] text-black'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Haushalt
</button>
<button
onClick={() => setActiveTab(1)}
className={`flex-1 py-3 px-6 rounded-lg font-semibold transition-colors ${
activeTab === 1
? 'bg-[#85B7D7] text-black'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Privat
</button>
<TabLayout activeTab={activeTab} onTabChange={setActiveTab}>
<div>
<h2 className="text-xl font-semibold mb-4">Einstellungen</h2>
<Passkeys />
</div>
</TabLayout>
);
}
return (
<TabLayout activeTab={activeTab} onTabChange={setActiveTab}>
<div>
<h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
<MonatsStatistik typ={activeTab} refreshKey={statsRefreshKey} />
<div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6">
<h3 className="text-xl font-semibold mb-4">Letzte 20 Einträge</h3>
<h3 className="text-xl font-semibold mb-4">Letzte {MAX_ENTRIES} Einträge</h3>
{isLoading ? (
<div className="text-center py-4">Lade Daten...</div>
) : (
@@ -98,19 +90,6 @@ export default function Home() {
)}
</div>
</div>
{/* Footer */}
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 ">
<div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
mailto:rxf@gmx.de
</a>
</div>
<div className="text-right">
Version {version} - {buildDate}
</div>
</footer>
</main>
</div>
</TabLayout>
);
}
+15
View File
@@ -0,0 +1,15 @@
R=Restaurant
L=Lebensmittel
H=Haushalt
Ku=Kultur
Kl=Kleidung
Dr=Drogerie
Ap=Apotheke
Ar=Arzt
Re=Reise
Au=Auto
El=Elektronik
Fr=Freizeit
Ge=Getränke
Ba=Bäckerei
So=Sonstiges
+65 -107
View File
@@ -1,7 +1,8 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT, MonthlyStats } from '@/types/ausgaben';
import { useState, useEffect, useCallback, useRef } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT } from '@/types/ausgaben';
import { Category } from '@/app/api/categories/route';
interface AusgabenFormProps {
onSuccess: () => void;
@@ -18,6 +19,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: '',
Wo: '',
Was: '',
Kat: 'L',
Wieviel: '',
Wie: defaultZahlungsart,
TYP: typ,
@@ -26,32 +28,12 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
const [isSubmitting, setIsSubmitting] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
// Monthly stats
const [stats, setStats] = useState<MonthlyStats | null>(null);
const [month, setMonth] = useState('');
const [year, setYear] = useState('');
const [isLoadingStats, setIsLoadingStats] = useState(false);
// Autocomplete data
const [autoCompleteWo, setAutoCompleteWo] = useState<string[]>([]);
const [autoCompleteWas, setAutoCompleteWas] = useState<string[]>([]);
const fetchStats = useCallback(async (y: string, m: string) => {
if (!y || !m) return;
setIsLoadingStats(true);
try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}&typ=${typ}`);
const data = await response.json();
if (data.success) {
setStats(data.data);
}
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setIsLoadingStats(false);
}
}, [typ]);
const [categories, setCategories] = useState<Category[]>([]);
const [katDropdownOpen, setKatDropdownOpen] = useState(false);
const katDropdownRef = useRef<HTMLDivElement>(null);
const fetchAutoComplete = useCallback(async () => {
try {
@@ -66,42 +48,30 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
}
}, [typ]);
// Initialize month/year on first load
useEffect(() => {
const now = new Date();
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
const currentYear = String(now.getFullYear());
setMonth(currentMonth);
setYear(currentYear);
}, []);
// Fetch stats when month, year, or typ changes
useEffect(() => {
if (month && year) {
fetchStats(year, month);
}
}, [month, year, typ, fetchStats]);
// Fetch autocomplete data when typ changes
useEffect(() => {
fetchAutoComplete();
}, [typ, fetchAutoComplete]);
const handleMonthChange = (newMonth: string) => {
setMonth(newMonth);
// Close Kat dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (katDropdownRef.current && !katDropdownRef.current.contains(e.target as Node)) {
setKatDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleYearChange = (newYear: string) => {
setYear(newYear);
};
// Fetch categories once on mount
useEffect(() => {
fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (data.success) setCategories(data.data); })
.catch(() => {});
}, []);
const formatAmount = (amount: number | null) => {
if (amount === null || amount === undefined) return '0,00 €';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
useEffect(() => {
if (selectedEntry) {
@@ -113,6 +83,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: selectedEntry.WochTag,
Wo: selectedEntry.Wo,
Was: selectedEntry.Was,
Kat: selectedEntry.Kat || 'L',
Wieviel: selectedEntry.Wieviel.toString(),
Wie: selectedEntry.Wie,
TYP: selectedEntry.TYP,
@@ -132,6 +103,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: weekday,
Wo: '',
Was: '',
Kat: 'L',
Wieviel: '',
Wie: defaultZahlungsart,
TYP: typ,
@@ -184,7 +156,8 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
Datum: formData.Datum,
Wo: formData.Wo,
Was: formData.Was,
Wieviel: formData.Wieviel,
Kat: formData.Kat,
Wieviel: String(formData.Wieviel).replace(',', '.'),
Wie: formData.Wie,
TYP: formData.TYP,
};
@@ -200,8 +173,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
if (response.ok) {
handleReset();
onSuccess();
// Refresh stats after successful save
fetchStats(year, month);
} else {
alert('Fehler beim Speichern!');
}
@@ -223,6 +194,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: weekday,
Wo: '',
Was: '',
Kat: 'L',
Wieviel: '',
Wie: defaultZahlungsart,
TYP: typ,
@@ -247,6 +219,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
<th className="p-2 w-32">Datum</th>
<th className="p-2">Wo</th>
<th className="p-2">Was</th>
<th className="p-2 w-12">Kat.</th>
<th className="p-2 w-24">Wieviel</th>
<th className="p-2 w-4"></th>
<th className="p-2 w-38 text-left">Wie</th>
@@ -295,13 +268,45 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
))}
</datalist>
</td>
<td className="p-2 w-12">
<div ref={katDropdownRef} className="relative w-full">
<button
type="button"
onClick={() => setKatDropdownOpen((o) => !o)}
className="w-full px-2 py-1 text-base text-left rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
>
{formData.Kat}
</button>
{katDropdownOpen && (
<ul className="absolute z-50 left-0 mt-1 w-48 bg-white border-2 border-gray-400 rounded shadow-lg max-h-60 overflow-y-auto text-left">
{categories.map((cat) => (
<li
key={cat.value}
className={`px-3 py-1 cursor-pointer hover:bg-blue-100 text-sm ${
formData.Kat === cat.value ? 'bg-blue-50 font-semibold' : ''
}`}
onMouseDown={() => {
setFormData({ ...formData, Kat: cat.value });
setKatDropdownOpen(false);
}}
>
{cat.value} - {cat.label}
</li>
))}
</ul>
)}
</div>
</td>
<td className="p-2 w-24">
<input
type="number"
step="0.01"
type="text"
inputMode="decimal"
value={formData.Wieviel}
onChange={(e) => setFormData({ ...formData, Wieviel: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
onChange={(e) => {
const val = e.target.value.replace(/[^0-9.,]/g, '').replace(',', '.');
setFormData({ ...formData, Wieviel: val });
}}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
placeholder="0.00"
required
/>
@@ -347,53 +352,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
Löschen
</button>
</div>
{/* Monatsstatistiken */}
<div className="mt-6 pt-4 pb-6 -mb-6 border-t border-black -mx-6 px-6 bg-[#E0E0FF]">
<div className="flex items-center justify-between pt-1">
<div className="flex gap-4 items-center">
<label className="font-semibold">Monat:</label>
<select
value={month}
onChange={(e) => handleMonthChange(e.target.value)}
className="border border-gray-400 rounded px-3 py-1"
>
<option value="01">Januar</option>
<option value="02">Februar</option>
<option value="03">März</option>
<option value="04">April</option>
<option value="05">Mai</option>
<option value="06">Juni</option>
<option value="07">Juli</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<label className="font-semibold">Jahr:</label>
<input
type="number"
value={year}
onChange={(e) => handleYearChange(e.target.value)}
className="border border-gray-400 rounded px-3 py-1 w-24"
min="2013"
max="2099"
/>
</div>
<div>
{isLoadingStats ? (
<span>Lade...</span>
) : stats ? (
<span className="font-bold text-lg">
Summe: {formatAmount(stats.totalAusgaben)}
</span>
) : null}
</div>
</div>
</div>
</form>
</div>
);
+35 -16
View File
@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { AusgabenEntry } from '@/types/ausgaben';
interface AusgabenListProps {
@@ -9,14 +10,12 @@ interface AusgabenListProps {
}
export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) {
const handleDelete = async (id: number) => {
if (!confirm('Wirklich löschen?')) return;
const [confirmId, setConfirmId] = useState<number | null>(null);
const handleDeleteConfirmed = async (id: number) => {
setConfirmId(null);
try {
const response = await fetch(`/api/ausgaben/${id}`, {
method: 'DELETE',
});
const response = await fetch(`/api/ausgaben/${id}`, { method: 'DELETE' });
if (response.ok) {
onDelete(id);
} else {
@@ -29,12 +28,7 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return dateStr.toString().split('T')[0];
};
const formatAmount = (amount: number) => {
@@ -53,15 +47,16 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
<th className="border-b-2 border-black p-2 w-12">Tag</th>
<th className="border-b-2 border-black p-2 w-36">Wo</th>
<th className="border-b-2 border-black p-2 w-48">Was</th>
<th className="border-b-2 border-black p-2 w-12">Kat.</th>
<th className="border-b-2 border-black p-2 w-8">Betrag</th>
<th className="border-b-2 border-black p-2 w-16">Wie</th>
<th className="border-b-2 border-black p-2 w-38">Aktion</th>
<th className="border-b-2 border-black p-2 w-48">Aktion</th>
</tr>
</thead>
<tbody>
{entries.length === 0 ? (
<tr>
<td colSpan={7} className="text-center p-4 text-gray-500">
<td colSpan={8} className="text-center p-4 text-gray-500">
Keine Einträge vorhanden
</td>
</tr>
@@ -74,6 +69,7 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
<td className="border-y border-black p-2 text-center">{entry.WochTag.slice(0, 2)}</td>
<td className="border-y border-black p-2">{entry.Wo}</td>
<td className="border-y border-black p-2">{entry.Was}</td>
<td className="border-y border-black p-2 text-center">{entry.Kat}</td>
<td className="border-y border-black p-2 text-right">
{formatAmount(entry.Wieviel)}
</td>
@@ -83,10 +79,10 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
onClick={() => onEdit(entry)}
className="text-blue-600 hover:text-blue-800 px-3 py-1 rounded text-sm mr-2"
>
Bearbeiten
Editieren
</button>
<button
onClick={() => handleDelete(entry.ID)}
onClick={() => setConfirmId(entry.ID)}
className="text-red-600 hover:text-red-800 px-3 py-1 rounded text-sm"
>
Löschen
@@ -97,6 +93,29 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
)}
</tbody>
</table>
{/* Bestätigungs-Modal */}
{confirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white border-2 border-black rounded-lg shadow-xl p-6 w-80">
<p className="text-lg font-semibold mb-6 text-center">Eintrag wirklich löschen?</p>
<div className="flex justify-center gap-4">
<button
onClick={() => handleDeleteConfirmed(confirmId)}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-6 rounded-lg transition-colors"
>
Löschen
</button>
<button
onClick={() => setConfirmId(null)}
className="bg-gray-200 hover:bg-gray-300 text-black font-medium py-2 px-6 rounded-lg transition-colors"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { logout } from '@/app/login/actions';
interface LogoutButtonProps {
className?: string;
children?: React.ReactNode;
}
export default function LogoutButton({ className, children }: LogoutButtonProps) {
const handleLogout = async () => {
await logout();
};
return (
<button
onClick={handleLogout}
className={className || "px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"}
>
{children || 'Abmelden'}
</button>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { MonthlyStats } from '@/types/ausgaben';
import { Category } from '@/app/api/categories/route';
interface MonatsStatistikProps {
typ: number;
refreshKey?: number;
}
export default function MonatsStatistik({ typ, refreshKey }: MonatsStatistikProps) {
const [stats, setStats] = useState<MonthlyStats | null>(null);
const [month, setMonth] = useState('');
const [year, setYear] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
// Initialize month/year
useEffect(() => {
const now = new Date();
setMonth(String(now.getMonth() + 1).padStart(2, '0'));
setYear(String(now.getFullYear()));
}, []);
// Fetch categories once
useEffect(() => {
fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (data.success) setCategories(data.data); })
.catch(() => {});
}, []);
const fetchStats = useCallback(async (y: string, m: string) => {
if (!y || !m) return;
setIsLoading(true);
try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}&typ=${typ}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.success) setStats(data.data);
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setIsLoading(false);
}
}, [typ]);
useEffect(() => {
if (month && year) fetchStats(year, month);
}, [month, year, typ, refreshKey, fetchStats]);
const formatAmount = (amount: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
const getCatLabel = (code: string) => {
const cat = categories.find((c) => c.value === code);
return cat ? `${cat.label}` : code;
};
return (
<div className="mt-4 bg-[#E0E0FF] border border-black rounded-lg shadow-md p-4">
{/* Zeile 1: Monat/Jahr + Gesamtsumme */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex gap-4 items-center">
<label className="font-semibold">Monat:</label>
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="border border-gray-400 rounded px-3 py-1"
>
<option value="01">Januar</option>
<option value="02">Februar</option>
<option value="03">März</option>
<option value="04">April</option>
<option value="05">Mai</option>
<option value="06">Juni</option>
<option value="07">Juli</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<label className="font-semibold">Jahr:</label>
<input
type="number"
value={year}
onChange={(e) => setYear(e.target.value)}
className="border border-gray-400 rounded px-3 py-1 w-24"
min="2013"
max="2099"
/>
</div>
<div>
{isLoading ? (
<span>Lade...</span>
) : stats ? (
<span className="font-bold text-lg">
Summe: {formatAmount(stats.totalAusgaben)}
</span>
) : null}
</div>
</div>
{/* Zeile 2+: Kategorien */}
{!isLoading && stats?.katStats && Object.keys(stats.katStats).length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-400 flex flex-wrap gap-x-6 gap-y-1">
{Object.entries(stats.katStats).map(([code, total]) => (
<div key={code} className="flex gap-2 text-sm">
<span className="font-medium">{getCatLabel(code)}:</span>
<span>{formatAmount(total)}</span>
</div>
))}
</div>
)}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
'use client';
import { useEffect, useState } from 'react';
import { startRegistration } from '@simplewebauthn/browser';
interface Passkey {
credentialId: string;
label: string;
createdAt: string | null;
lastUsedAt: string | null;
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export default function Passkeys() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [label, setLabel] = useState('');
function load() {
return fetch('/api/passkey')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error())))
.then((data) => setPasskeys(data.passkeys ?? []))
.catch(() => setError('Passkeys konnten nicht geladen werden.'))
.finally(() => setLoading(false));
}
useEffect(() => {
load();
}, []);
async function handleRegister() {
setError(null);
setBusy(true);
try {
const optRes = await fetch('/api/passkey/register');
if (!optRes.ok) throw new Error('Optionen konnten nicht geladen werden.');
const optionsJSON = await optRes.json();
const response = await startRegistration({ optionsJSON });
const verifyRes = await fetch('/api/passkey/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response, label }),
});
if (!verifyRes.ok) {
const data = await verifyRes.json().catch(() => null);
throw new Error(data?.error ?? 'Passkey konnte nicht registriert werden.');
}
setLabel('');
await load();
} catch (err) {
if (err instanceof Error && err.name === 'NotAllowedError') {
setError(null); // Abbruch durch Nutzer
} else {
setError(err instanceof Error ? err.message : 'Passkey konnte nicht registriert werden.');
}
} finally {
setBusy(false);
}
}
async function handleDelete(credentialId: string) {
if (!confirm('Diesen Passkey wirklich entfernen?')) return;
setError(null);
try {
const res = await fetch(`/api/passkey?id=${encodeURIComponent(credentialId)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await load();
} catch {
setError('Passkey konnte nicht entfernt werden.');
}
}
return (
<div className="max-w-md">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Passkeys</h3>
<p className="text-xs text-gray-500 mb-3">
Melde dich künftig per Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Das Passwort
bleibt als Alternative bestehen.
</p>
{loading ? (
<p className="text-sm text-gray-500">Lädt</p>
) : passkeys.length === 0 ? (
<p className="text-sm text-gray-500 mb-3">Noch keine Passkeys registriert.</p>
) : (
<ul className="space-y-2 mb-4">
{passkeys.map((pk) => (
<li
key={pk.credentialId}
className="flex items-center justify-between gap-3 border-2 border-gray-300 rounded-lg px-3 py-2 bg-white"
>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{pk.label}</div>
<div className="text-xs text-gray-500">
Erstellt {formatDate(pk.createdAt)} · Zuletzt {formatDate(pk.lastUsedAt)}
</div>
</div>
<button
onClick={() => handleDelete(pk.credentialId)}
className="text-xs px-2 py-1 text-red-600 hover:text-red-800 shrink-0"
>
Entfernen
</button>
</li>
))}
</ul>
)}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Bezeichnung (optional)
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="z. B. iPhone"
maxLength={80}
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<button
onClick={handleRegister}
disabled={busy}
className="px-4 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm shrink-0"
>
{busy ? 'Läuft…' : 'Passkey hinzufügen'}
</button>
</div>
{error && (
<div className="mt-3 bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{error}
</div>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { ReactNode } from 'react';
import LogoutButton from '@/components/LogoutButton';
import packageJson from '@/package.json';
interface Tab {
label: string;
index: number;
}
/** Tab-Index der Einstellungen (0/1 sind die Ausgaben-Typen Haushalt/Privat). */
export const SETTINGS_TAB = 2;
interface TabLayoutProps {
children: ReactNode;
activeTab: number;
onTabChange: (index: number) => void;
}
const TABS: Tab[] = [
{ label: 'Haushalt', index: 0 },
{ label: 'Privat', index: 1 },
{ label: 'Einstellungen', index: SETTINGS_TAB },
];
export default function TabLayout({ children, activeTab, onTabChange }: TabLayoutProps) {
const version = packageJson.version;
const buildDate =
process.env.NEXT_PUBLIC_BUILD_DATE ||
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="min-h-screen py-8 px-4">
{/* Outer wrapper with border */}
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
{/* Page title */}
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">Ausgaben - Log</h1>
{/* Inner content */}
<div className="max-w-6xl mx-auto">
{/* Tab bar */}
<div className="flex justify-between items-end">
<div className="flex">
{TABS.map(tab => {
const isActive = activeTab === tab.index;
return (
<button
key={tab.index}
onClick={() => onTabChange(tab.index)}
className="px-6 py-2 text-sm font-semibold border-t-2 border-l-2 border-r-2 rounded-tl-lg rounded-tr-lg mr-1 transition-colors"
style={
isActive
? { backgroundColor: '#FFFFDD', color: '#000000', borderColor: '#000000', borderBottom: '2px solid #FFFFDD', marginBottom: '-2px', position: 'relative', zIndex: 10 }
: { backgroundColor: '#85B7D7', color: '#374151', borderColor: '#000000' }
}
>
{tab.label}
</button>
);
})}
</div>
<div className="pb-1">
<LogoutButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg shadow-md" />
</div>
</div>
{/* Content panel */}
<main className="border-2 border-black rounded-b-lg rounded-tr-lg p-6 bg-[#FFFFDD]">
{children}
</main>
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
<a href="mailto:rxf@gmx.de" className="hover:underline">
mailto:rxf@gmx.de
</a>
<div>Version {version} - {buildDate}</div>
</footer>
</div>
</div>
</div>
);
}
+17
View File
@@ -22,3 +22,20 @@ ALTER TABLE `Ausgaben` ADD INDEX IF NOT EXISTS `idx_typ` (`TYP`);
-- Remove WochTag column if it exists (no longer stored in DB, calculated from Datum)
-- ALTER TABLE `Ausgaben` DROP COLUMN IF EXISTS `WochTag`;
-- WebAuthn-Passkeys je Benutzer (Benutzer stammen aus AUTH_USERS).
-- Die Anwendung legt diese Tabelle beim ersten Zugriff selbst an (lib/passkeys.ts),
-- sofern der DB-Benutzer CREATE-Rechte hat.
-- Der Name ist bewusst app-spezifisch: Auf dem Server teilen sich mehrere Apps
-- die Datenbank RXF, werte-next belegt dort bereits die Tabelle `passkeys`.
CREATE TABLE IF NOT EXISTS `ausgaben_passkeys` (
`credential_id` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
`username` VARCHAR(64) NOT NULL,
`public_key` VARBINARY(1024) NOT NULL,
`counter` INT UNSIGNED NOT NULL DEFAULT 0,
`transports` VARCHAR(255) NOT NULL DEFAULT '[]',
`label` VARCHAR(80) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME NULL,
INDEX `idx_ausgaben_passkeys_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+21 -16
View File
@@ -15,34 +15,39 @@ FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "Ausgaben-Next Deploy Script"
echo "ausgaben-next Deploy Script"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
# 1. Docker Image bauen
echo ">>> Baue Docker Image..."
docker build \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${IMAGE_NAME}:${TAG}" \
-t "${FULL_IMAGE}" \
.
echo ">>> Build erfolgreich!"
echo ""
# 2. Login zur Registry (falls noch nicht eingeloggt)
# 1. Login zur Registry (falls noch nicht eingeloggt)
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
# 3. Image pushen
echo ">>> Pushe Image zu ${REGISTRY}..."
docker push "${FULL_IMAGE}"
# 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
echo ">>> Richte Multiplatform Builder ein..."
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
fi
docker buildx use multiplatform-builder
echo ""
# 3. Docker Image bauen und pushen (Multiplatform)
echo ">>> Baue Multiplatform Docker Image und pushe zu Registry..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${FULL_IMAGE}" \
--push \
.
echo ">>> Build und Push erfolgreich!"
echo ""
echo "=========================================="
+8
View File
@@ -15,3 +15,11 @@ services:
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET}
# Passkeys (WebAuthn) — WebAuthn braucht HTTPS oder localhost.
# App-eigene Namen (AUSGABEN_RP_*) wie in docker-compose.prod.yml, damit
# eine geteilte .env mit anderen Apps nicht kollidiert.
- RP_ID=${AUSGABEN_RP_ID:-localhost}
- RP_ORIGIN=${AUSGABEN_RP_ORIGIN:-http://localhost:3005}
- RP_NAME=${AUSGABEN_RP_NAME:-Ausgaben-Log}
+9
View File
@@ -12,6 +12,15 @@ services:
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET}
# Passkeys (WebAuthn): Host bzw. volle URL der Anwendung.
# Achtung: Die .env auf dem Server wird von mehreren Apps im selben Stack
# geteilt und belegt RP_ID/RP_ORIGIN bereits für werte-next. Deshalb hier
# die app-eigenen Namen AUSGABEN_RP_* verwenden.
- RP_ID=${AUSGABEN_RP_ID:-ausgaben.fuerst-stuttgart.de}
- RP_ORIGIN=${AUSGABEN_RP_ORIGIN:-https://ausgaben.fuerst-stuttgart.de}
- RP_NAME=${AUSGABEN_RP_NAME:-Ausgaben-Log}
labels:
- traefik.enable=true
- traefik.http.routers.ausgaben.entrypoints=http
+15 -13
View File
@@ -1,16 +1,18 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+39
View File
@@ -0,0 +1,39 @@
import bcrypt from 'bcryptjs';
export interface User {
username: string;
password: string;
}
export function getUsers(): User[] {
const usersString = process.env.AUTH_USERS || '';
if (!usersString) {
console.warn('AUTH_USERS not configured in .env');
return [];
}
return usersString
.split(',')
.map((userPair) => {
const [username, password] = userPair.trim().split(':');
return { username: username?.trim(), password: password?.trim() };
})
.filter((user) => user.username && user.password);
}
export async function verifyCredentials(username: string, password: string): Promise<boolean> {
const users = getUsers();
const user = users.find(u => u.username === username);
if (!user) {
return false;
}
return bcrypt.compare(password, user.password);
}
/** Prüft, ob der Benutzername (noch) in AUTH_USERS hinterlegt ist. */
export function isKnownUser(username: string): boolean {
return getUsers().some(u => u.username === username);
}
export function isAuthEnabled(): boolean {
return !!process.env.AUTH_USERS;
}
+1 -1
View File
@@ -7,7 +7,7 @@ export function getDbPool() {
pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
password: process.env.DB_PASS || process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'RXF',
waitForConnections: true,
connectionLimit: 10,
+146
View File
@@ -0,0 +1,146 @@
import { getDbPool } from './db';
import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
// Eigene Tabelle je Anwendung: Auf dem Server teilen sich mehrere Apps die
// Datenbank RXF. werte-next verwendet `passkeys` — würde diese App denselben
// Namen nutzen, sähe sie fremde Passkeys, könnte sie löschen und über
// excludeCredentials die eigene Registrierung blockieren.
const TABLE = 'ausgaben_passkeys';
/** Ein gespeicherter Passkey (WebAuthn-Credential), einem Benutzer zugeordnet. */
export interface PasskeyRecord {
credentialId: string; // base64url
username: string;
publicKey: Uint8Array<ArrayBuffer>;
counter: number;
transports: string[];
label: string;
createdAt: Date;
lastUsedAt: Date | null;
}
interface PasskeyRow extends RowDataPacket {
credential_id: string;
username: string;
public_key: Buffer;
counter: number;
transports: string;
label: string;
created_at: Date;
last_used_at: Date | null;
}
let schemaReady: Promise<void> | null = null;
/**
* Legt die Passkey-Tabelle beim ersten Zugriff an (einmal pro Prozess).
* Das Schema steht zusätzlich in create_table.sql.
*/
function ensureSchema(): Promise<void> {
if (!schemaReady) {
schemaReady = getDbPool()
.query(
`CREATE TABLE IF NOT EXISTS ${TABLE} (
credential_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
username VARCHAR(64) NOT NULL,
public_key VARBINARY(1024) NOT NULL,
counter INT UNSIGNED NOT NULL DEFAULT 0,
transports VARCHAR(255) NOT NULL DEFAULT '[]',
label VARCHAR(80) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
INDEX idx_ausgaben_passkeys_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
)
.then(() => undefined)
.catch((error) => {
schemaReady = null; // nächster Aufruf darf es erneut versuchen
throw error;
});
}
return schemaReady;
}
function fromRow(row: PasskeyRow): PasskeyRecord {
let transports: string[] = [];
try {
const parsed = JSON.parse(row.transports);
if (Array.isArray(parsed)) transports = parsed;
} catch {
/* fehlerhafte transports ignorieren */
}
return {
credentialId: row.credential_id,
username: row.username,
// Uint8Array.from kopiert in einen frischen ArrayBuffer (passt zum von
// @simplewebauthn erwarteten Uint8Array<ArrayBuffer>).
publicKey: Uint8Array.from(row.public_key),
counter: row.counter,
transports,
label: row.label,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
};
}
/** Alle Passkeys eines Benutzers, neueste zuerst. */
export async function listPasskeys(username: string): Promise<PasskeyRecord[]> {
await ensureSchema();
const [rows] = await getDbPool().execute<PasskeyRow[]>(
`SELECT * FROM ${TABLE} WHERE username = ? ORDER BY created_at DESC`,
[username]
);
return rows.map(fromRow);
}
/** Passkey anhand der Credential-ID — benutzerübergreifend, für die Anmeldung. */
export async function getPasskey(credentialId: string): Promise<PasskeyRecord | undefined> {
await ensureSchema();
const [rows] = await getDbPool().execute<PasskeyRow[]>(
`SELECT * FROM ${TABLE} WHERE credential_id = ?`,
[credentialId]
);
return rows.length > 0 ? fromRow(rows[0]) : undefined;
}
export async function insertPasskey(record: {
credentialId: string;
username: string;
publicKey: Uint8Array;
counter: number;
transports: string[];
label: string;
}): Promise<void> {
await ensureSchema();
await getDbPool().execute(
`INSERT INTO ${TABLE} (credential_id, username, public_key, counter, transports, label)
VALUES (?, ?, ?, ?, ?, ?)`,
[
record.credentialId,
record.username,
Buffer.from(record.publicKey),
record.counter,
JSON.stringify(record.transports ?? []),
record.label,
]
);
}
/** Aktualisiert Counter und last_used_at nach erfolgreicher Anmeldung (Replay-Schutz). */
export async function updatePasskeyUsage(credentialId: string, counter: number): Promise<void> {
await ensureSchema();
await getDbPool().execute(
`UPDATE ${TABLE} SET counter = ?, last_used_at = NOW() WHERE credential_id = ?`,
[counter, credentialId]
);
}
/** Löscht einen Passkey — nur wenn er dem angegebenen Benutzer gehört. */
export async function deletePasskey(credentialId: string, username: string): Promise<boolean> {
await ensureSchema();
const [result] = await getDbPool().execute<ResultSetHeader>(
`DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`,
[credentialId, username]
);
return result.affectedRows > 0;
}
+102
View File
@@ -0,0 +1,102 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'auth_session';
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
export interface SessionData {
username: string;
isAuthenticated: boolean;
expiresAt: number;
}
/**
* Encrypt session data to JWT
*/
async function encrypt(payload: SessionData): Promise<string> {
return await new SignJWT(payload as any)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(key);
}
/**
* Decrypt JWT to session data
*/
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'],
});
return {
username: payload.username as string,
isAuthenticated: payload.isAuthenticated as boolean,
expiresAt: payload.expiresAt as number,
};
} catch (error) {
return null;
}
}
/**
* Create a new session
*/
export async function createSession(username: string): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION;
const session: SessionData = {
username,
isAuthenticated: true,
expiresAt,
};
const encryptedSession = await encrypt(session);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
/**
* Get current session
*/
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!cookie?.value) {
return null;
}
const session = await decrypt(cookie.value);
if (!session || session.expiresAt < Date.now()) {
return null;
}
return session;
}
/**
* Delete session (logout)
*/
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
/**
* Verify if user is authenticated
*/
export async function isAuthenticated(): Promise<boolean> {
const session = await getSession();
return session?.isAuthenticated ?? false;
}
+176
View File
@@ -0,0 +1,176 @@
import { cookies } from 'next/headers';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { isKnownUser } from './auth';
import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys';
const REG_CHALLENGE_COOKIE = 'ausgaben_pk_reg';
const AUTH_CHALLENGE_COOKIE = 'ausgaben_pk_auth';
const CHALLENGE_TTL_S = 300; // 5 Minuten
/**
* Relying-Party-Konfiguration. In Produktion über Env-Vars gesetzt
* (RP_ID = Host, RP_ORIGIN = volle URL). Default-Werte für lokale Entwicklung.
*/
export function getRpConfig() {
const rpID = process.env.RP_ID || 'localhost';
const rpName = process.env.RP_NAME || 'Ausgaben-Log';
const origin = process.env.RP_ORIGIN || 'http://localhost:3005';
return { rpID, rpName, origin };
}
// --- Challenge-Cookie (kurzlebig, httpOnly) ------------------------------
async function setChallengeCookie(name: string, challenge: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(name, challenge, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: CHALLENGE_TTL_S,
});
}
async function takeChallengeCookie(name: string): Promise<string | null> {
const cookieStore = await cookies();
const value = cookieStore.get(name)?.value ?? null;
cookieStore.delete(name);
return value;
}
// --- Registrierung (Enrollment, Session erforderlich) --------------------
export async function buildRegistrationOptions(username: string) {
const { rpID, rpName } = getRpConfig();
const existing = await listPasskeys(username);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: username,
userDisplayName: username,
userID: new TextEncoder().encode(username),
attestationType: 'none',
excludeCredentials: existing.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await setChallengeCookie(REG_CHALLENGE_COOKIE, options.challenge);
return options;
}
/** Verifiziert die Registrierungs-Antwort und speichert den Passkey für den Benutzer. */
export async function finishRegistration(
username: string,
response: RegistrationResponseJSON,
label: string
): Promise<{ ok: true } | { ok: false; error: string }> {
const expectedChallenge = await takeChallengeCookie(REG_CHALLENGE_COOKIE);
if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
const { rpID, origin } = getRpConfig();
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch {
return { ok: false, error: 'Passkey konnte nicht verifiziert werden.' };
}
if (!verification.verified || !verification.registrationInfo) {
return { ok: false, error: 'Passkey-Registrierung fehlgeschlagen.' };
}
const { credential } = verification.registrationInfo;
if (await getPasskey(credential.id)) {
return { ok: false, error: 'Dieser Passkey ist bereits registriert.' };
}
await insertPasskey({
credentialId: credential.id,
username,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports ?? [],
label: label.trim().slice(0, 80) || 'Passkey',
});
return { ok: true };
}
// --- Anmeldung (öffentlich, keine Session) -------------------------------
export async function buildAuthenticationOptions() {
const { rpID } = getRpConfig();
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// allowCredentials leer lassen → erlaubt Discoverable Credentials / Browser-Auswahl.
});
await setChallengeCookie(AUTH_CHALLENGE_COOKIE, options.challenge);
return options;
}
/**
* Verifiziert die Anmelde-Antwort gegen einen gespeicherten Passkey und liefert
* den zugehörigen Benutzer zurück.
*/
export async function finishAuthentication(
response: AuthenticationResponseJSON
): Promise<{ ok: true; username: string } | { ok: false; error: string }> {
const expectedChallenge = await takeChallengeCookie(AUTH_CHALLENGE_COOKIE);
if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
const passkey = await getPasskey(response.id);
if (!passkey) return { ok: false, error: 'Unbekannter Passkey.' };
// Benutzer könnte inzwischen aus AUTH_USERS entfernt worden sein.
if (!isKnownUser(passkey.username)) {
return { ok: false, error: 'Benutzer existiert nicht mehr.' };
}
const { rpID, origin } = getRpConfig();
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
credential: {
id: passkey.credentialId,
publicKey: passkey.publicKey,
counter: passkey.counter,
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch {
return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
}
if (!verification.verified) return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
await updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter);
return { ok: true, username: passkey.username };
}
+14
View File
@@ -2,6 +2,20 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
};
export default nextConfig;
+306 -2
View File
@@ -1,13 +1,17 @@
{
"name": "ausgaben_next",
"version": "1.0.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ausgaben_next",
"version": "1.0.0",
"version": "2.1.1",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"bcryptjs": "^3.0.3",
"jose": "^6.1.3",
"mysql2": "^3.17.4",
"next": "16.1.6",
"react": "19.2.3",
@@ -15,6 +19,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -68,6 +73,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -454,6 +460,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1022,6 +1034,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -1227,6 +1245,174 @@
"node": ">=12.4.0"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1234,6 +1420,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1525,6 +1736,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1551,6 +1769,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz",
"integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1561,6 +1780,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1620,6 +1840,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -2145,6 +2366,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2372,6 +2594,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -2453,6 +2689,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2497,6 +2742,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3073,6 +3319,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3258,6 +3505,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4483,6 +4731,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5515,6 +5772,24 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5541,6 +5816,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5550,6 +5826,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5564,6 +5841,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6259,6 +6542,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6324,6 +6608,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6421,6 +6723,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6695,6 +6998,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+8 -2
View File
@@ -1,14 +1,19 @@
{
"name": "ausgaben_next",
"version": "1.0.1",
"version": "2.2.1",
"private": true,
"scripts": {
"dev": "next dev -p 3005",
"build": "next build",
"start": "next start -p 3005",
"lint": "eslint"
"lint": "eslint",
"generate-password": "node scripts/generate-password.js"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"bcryptjs": "^3.0.3",
"jose": "^6.1.3",
"mysql2": "^3.17.4",
"next": "16.1.6",
"react": "19.2.3",
@@ -16,6 +21,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+75
View File
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'auth_session';
/**
* Proxy to protect routes with authentication
* Reusable for other projects - just copy this file
*/
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if authentication is enabled
const authEnabled = !!process.env.AUTH_USERS;
// If auth is not enabled, allow all requests
if (!authEnabled) {
return NextResponse.next();
}
// Public paths that don't require authentication
// (Passkey-Anmeldung muss ohne Session erreichbar sein)
const publicPaths = ['/login', '/api/passkey/authenticate'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
if (isPublicPath) {
return NextResponse.next();
}
// Check for session cookie
const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Verify session token
try {
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
const { payload } = await jwtVerify(sessionCookie.value, key, {
algorithms: ['HS256'],
});
// Check if session is expired
if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete(SESSION_COOKIE_NAME);
return response;
}
return NextResponse.next();
} catch (error) {
// Invalid token - redirect to login
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete(SESSION_COOKIE_NAME);
return response;
}
}
export default proxy;
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Password Hash Generator
*
* Usage:
* node scripts/generate-password.js [password]
*
* If no password is provided, you'll be prompted to enter one.
*/
const bcrypt = require('bcryptjs');
const readline = require('readline');
function generateHash(password) {
const saltRounds = 10;
const hash = bcrypt.hashSync(password, saltRounds);
return hash;
}
function promptPassword() {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Passwort eingeben: ', (password) => {
rl.close();
resolve(password);
});
});
}
async function main() {
let password = process.argv[2];
if (!password) {
password = await promptPassword();
}
if (!password) {
console.error('❌ Kein Passwort angegeben!');
process.exit(1);
}
console.log('\n🔐 Generiere Passwort-Hash...\n');
const hash = generateHash(password);
console.log('✅ Hash generiert:');
console.log('─'.repeat(80));
console.log(hash);
console.log('─'.repeat(80));
console.log('\n📝 Verwende diesen Hash in der .env Datei:');
console.log(`AUTH_USERS=username:${hash}`);
console.log('\n💡 Beispiel für mehrere Benutzer:');
console.log(`AUTH_USERS=admin:${hash},user2:$2a$10$...\n`);
}
main().catch(console.error);
+3
View File
@@ -6,6 +6,7 @@ export interface AusgabenEntry {
WochTag: string;
Wo: string;
Was: string;
Kat: string;
Wieviel: number;
Wie: string;
TYP: number;
@@ -16,6 +17,7 @@ export interface CreateAusgabenEntry {
WochTag: string;
Wo: string;
Was: string;
Kat: string;
Wieviel: string | number;
Wie: string;
TYP: number;
@@ -33,6 +35,7 @@ export interface MonthlyStats {
MASTER?: number;
Einnahmen: number;
Ueberweisungen: number;
katStats?: Record<string, number>;
}
// Haushalt Zahlungsarten (TYP = 0)