Compare commits

7 Commits

Author SHA1 Message Date
admin 7aa42c9952 Add phpMyAdmin service to local docker-compose
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:45:13 +02:00
admin e5868d904a Merge branch 'responsive-mobile-fixes'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 07:53:59 +02:00
admin dff78027b6 V 2.3.1 Formular und Liste responsiv gestaltet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 07:53:51 +02:00
admin cd96bdfc6a Formular und Liste responsiv gestaltet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:41:32 +02:00
admin 4acb1a5eca Version auf 2.3.0 erhöht
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 13:59:30 +02:00
admin bb35aa28c9 Merge branch 'entferne-login-logik'
Login-/Session-/Passkey-Logik entfernt, App läuft künftig nur noch lokal.
2026-08-21 13:59:15 +02:00
admin bacd11f722 Login-/Session-/Passkey-Logik vollständig entfernen
Die App läuft künftig nur noch lokal, daher ist Anmeldung nicht mehr
nötig: Proxy-Middleware, Login-Seite, Session-Handling, Passkey/WebAuthn
(inkl. DB-Tabelle ausgaben_passkeys), Logout-Button und der Passwort-Hash-
Generator (scripts/generate-password.js) entfallen. Der Einstellungen-Tab
enthielt ausschließlich Passkey-Verwaltung und wurde daher ebenfalls
entfernt. Nicht mehr benötigte Abhängigkeiten (@simplewebauthn/*,
bcryptjs, jose) sowie die zugehörigen Env-Variablen (AUTH_USERS,
AUTH_SECRET, AUSGABEN_RP_*) wurden aus package.json, Docker-Compose-
Dateien und Dokumentation gestrichen. AUTH_README.md komplett entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 13:58:46 +02:00
28 changed files with 210 additions and 1946 deletions
-26
View File
@@ -2,29 +2,3 @@ DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=RXF
# Authentication Configuration
# Format: username:passwordHash,username2:passwordHash2 (max 5 users)
# Use 'node scripts/generate-password.js [password]' to generate hashes
# Leave empty to disable authentication
# Example hashes below (passwords: admin123, pass1):
AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW,user1:$2b$10$K613Z70Hodr6xyEh10Mw2uoRZMV3U4LIB09929JUWw2n/pXKoUqaW
# 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
-203
View File
@@ -1,203 +0,0 @@
# 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,33 +66,6 @@ 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:
-21
View File
@@ -23,27 +23,6 @@ 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
-28
View File
@@ -1,28 +0,0 @@
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
@@ -1,36 +0,0 @@
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
@@ -1,40 +0,0 @@
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
@@ -1,28 +0,0 @@
'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
@@ -1,175 +0,0 @@
'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
@@ -1,83 +0,0 @@
'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>
);
}
+6 -16
View File
@@ -4,11 +4,11 @@ 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 TabLayout from '@/components/TabLayout';
import { AusgabenEntry } from '@/types/ausgaben';
const MAX_ENTRIES = 15;
const MOBILE_ENTRIES = 5;
export default function Home() {
const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat
@@ -19,7 +19,6 @@ export default function Home() {
useEffect(() => {
setSelectedEntry(null); // Clear selected entry when switching tabs
if (activeTab === SETTINGS_TAB) return; // Einstellungen laden keine Einträge
fetchRecentEntries();
}, [activeTab]);
@@ -62,17 +61,6 @@ export default function Home() {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (activeTab === SETTINGS_TAB) {
return (
<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>
@@ -82,11 +70,13 @@ export default function Home() {
<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 {MAX_ENTRIES} Einträge</h3>
<h3 className="text-xl font-semibold mb-4">
Letzte <span className="md:hidden">{MOBILE_ENTRIES}</span><span className="hidden md:inline">{MAX_ENTRIES}</span> Einträge
</h3>
{isLoading ? (
<div className="text-center py-4">Lade Daten...</div>
) : (
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} />
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} mobileLimit={MOBILE_ENTRIES} />
)}
</div>
</div>
+120 -118
View File
@@ -204,131 +204,133 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
setEditId(null);
};
const fieldInputClass = "w-full min-w-0 px-2 py-1 text-sm rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none";
return (
<div className="bg-[#CCCCFF] border border-black p-6 rounded-lg mb-6">
<div className="bg-[#CCCCFF] border border-black p-3 sm:p-6 rounded-lg mb-6">
{editId && (
<div className="mb-4 p-3 bg-blue-100 border border-blue-400 rounded text-sm text-blue-800">
<strong>Bearbeitungsmodus:</strong> Sie bearbeiten einen bestehenden Eintrag.
</div>
)}
<form onSubmit={handleSubmit}>
<table className="w-full text-center">
<thead>
<tr >
<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>
</tr>
</thead>
<tbody>
<tr>
<td className="p-2 w-32">
<input
type="date"
value={formData.Datum}
onChange={(e) => handleDateChange(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"
required
/>
</td>
<td className="p-2">
<input
type="text"
value={formData.Wo}
onChange={(e) => setFormData({ ...formData, Wo: 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"
placeholder="Geschäft/Ort"
list="wo-suggestions"
required
/>
<datalist id="wo-suggestions">
{autoCompleteWo.map((wo, index) => (
<option key={index} value={wo} />
{/* Wo/Was sind auf Mobil/Tablet volle Breite (mehr Platz zum Tippen), am Desktop feste Spaltenbreiten wie zuvor im Tabellen-Layout. */}
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-[110px_1fr_1fr_70px_100px_140px] gap-3 sm:gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-center">Datum</label>
<input
type="date"
value={formData.Datum}
onChange={(e) => handleDateChange(e.target.value)}
className={fieldInputClass}
required
/>
</div>
<div className="flex flex-col gap-1 col-span-2 sm:col-span-2 md:col-span-1">
<label className="text-xs font-medium text-center">Wo</label>
<input
type="text"
value={formData.Wo}
onChange={(e) => setFormData({ ...formData, Wo: e.target.value })}
className={fieldInputClass}
placeholder="Geschäft/Ort"
list="wo-suggestions"
required
/>
</div>
<div className="flex flex-col gap-1 col-span-2 sm:col-span-2 md:col-span-1">
<label className="text-xs font-medium text-center">Was</label>
<input
type="text"
value={formData.Was}
onChange={(e) => setFormData({ ...formData, Was: e.target.value })}
className={fieldInputClass}
placeholder="Beschreibung"
list="was-suggestions"
required
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-center">Kat.</label>
<div ref={katDropdownRef} className="relative w-full">
<button
type="button"
onClick={() => setKatDropdownOpen((o) => !o)}
className={`${fieldInputClass} text-left`}
>
{formData.Kat}
</button>
{katDropdownOpen && (
<ul className="absolute z-50 left-0 mt-1 w-48 max-w-[calc(100vw-2rem)] 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>
))}
</datalist>
</td>
<td className="p-2">
<input
type="text"
value={formData.Was}
onChange={(e) => setFormData({ ...formData, Was: 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"
placeholder="Beschreibung"
list="was-suggestions"
required
/>
<datalist id="was-suggestions">
{autoCompleteWas.map((was, index) => (
<option key={index} value={was} />
))}
</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="text"
inputMode="decimal"
value={formData.Wieviel}
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
/>
</td>
<td className="p-2 w-4 text-left"></td>
<td className="p-2 w-38">
<select
value={formData.Wie}
onChange={(e) => setFormData({ ...formData, Wie: 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"
required
>
{zahlungsarten.map((za) => (
<option key={za.value} value={za.value}>
{za.label}
</option>
))}
</select>
</td>
</tr>
</tbody>
</table>
</ul>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-center">
Wieviel
<span className="text-[10px] font-normal"> ()</span>
</label>
<input
type="text"
inputMode="decimal"
value={formData.Wieviel}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9.,]/g, '').replace(',', '.');
setFormData({ ...formData, Wieviel: val });
}}
className={fieldInputClass}
placeholder="0.00"
required
/>
</div>
<div className="flex flex-col gap-1 col-span-2 sm:col-span-2 md:col-span-1">
<label className="text-xs font-medium text-center">Wie</label>
<select
value={formData.Wie}
onChange={(e) => setFormData({ ...formData, Wie: e.target.value })}
className={fieldInputClass}
required
>
{zahlungsarten.map((za) => (
<option key={za.value} value={za.value}>
{za.label}
</option>
))}
</select>
</div>
</div>
<datalist id="wo-suggestions">
{autoCompleteWo.map((wo, index) => (
<option key={index} value={wo} />
))}
</datalist>
<datalist id="was-suggestions">
{autoCompleteWas.map((was, index) => (
<option key={index} value={was} />
))}
</datalist>
{/* Wochentag */}
<div className="mt-3 text-left pl-3">
@@ -336,7 +338,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
</div>
{/* Buttons */}
<div className="mb-3 flex gap-10 justify-center">
<div className="mb-3 flex gap-4 sm:gap-10 justify-center">
<button
type="submit"
disabled={isSubmitting}
+43 -3
View File
@@ -7,9 +7,10 @@ interface AusgabenListProps {
entries: AusgabenEntry[];
onDelete: (id: number) => void;
onEdit: (entry: AusgabenEntry) => void;
mobileLimit?: number;
}
export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) {
export default function AusgabenList({ entries, onDelete, onEdit, mobileLimit }: AusgabenListProps) {
const [confirmId, setConfirmId] = useState<number | null>(null);
const handleDeleteConfirmed = async (id: number) => {
@@ -39,7 +40,8 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
};
return (
<div className="overflow-x-auto">
<div>
<div className="hidden md:block overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-[#CCCCFF]">
@@ -93,11 +95,49 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
)}
</tbody>
</table>
</div>
<div className="md:hidden flex flex-col gap-2">
{entries.length === 0 ? (
<div className="text-center py-4 text-gray-500">Keine Einträge vorhanden</div>
) : (
(mobileLimit ? entries.slice(0, mobileLimit) : entries).map((entry, index) => (
<div
key={entry.ID || `entry-idx-${index}`}
className={`border border-gray-300 rounded-lg p-2 ${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}
>
<div className="flex justify-between items-center text-sm font-medium">
<span>{formatDate(entry.Datum)} ({entry.WochTag.slice(0, 2)}) · {formatAmount(entry.Wieviel)}</span>
<div className="flex gap-2">
<button
onClick={() => onEdit(entry)}
className="text-blue-600 hover:text-blue-800 text-xs"
>
Editieren
</button>
<button
onClick={() => setConfirmId(entry.ID)}
className="text-red-600 hover:text-red-800 text-xs"
>
Löschen
</button>
</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-700 mt-1">
<span>Wo: {entry.Wo}</span>
<span>Was: {entry.Was}</span>
<span>Kat: {entry.Kat}</span>
<span>Wie: {entry.Wie}</span>
</div>
</div>
))
)}
</div>
{/* 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">
<div className="bg-white border-2 border-black rounded-lg shadow-xl p-6 w-80 max-w-[calc(100vw-2rem)] mx-4">
<p className="text-lg font-semibold mb-6 text-center">Eintrag wirklich löschen?</p>
<div className="flex justify-center gap-4">
<button
-23
View File
@@ -1,23 +0,0 @@
'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>
);
}
+3 -3
View File
@@ -59,10 +59,10 @@ export default function MonatsStatistik({ typ, refreshKey }: MonatsStatistikProp
};
return (
<div className="mt-4 bg-[#E0E0FF] border border-black rounded-lg shadow-md p-4">
<div className="mt-4 bg-[#E0E0FF] border border-black rounded-lg shadow-md p-3 sm: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">
<div className="flex flex-wrap gap-x-4 gap-y-2 items-center">
<label className="font-semibold">Monat:</label>
<select
value={month}
@@ -88,7 +88,7 @@ export default function MonatsStatistik({ typ, refreshKey }: MonatsStatistikProp
type="number"
value={year}
onChange={(e) => setYear(e.target.value)}
className="border border-gray-400 rounded px-3 py-1 w-24"
className="border border-gray-400 rounded px-3 py-1 w-20"
min="2013"
max="2099"
/>
-151
View File
@@ -1,151 +0,0 @@
'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>
);
}
+21 -31
View File
@@ -1,7 +1,6 @@
'use client';
import { ReactNode } from 'react';
import LogoutButton from '@/components/LogoutButton';
import packageJson from '@/package.json';
interface Tab {
@@ -9,9 +8,6 @@ interface Tab {
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;
@@ -21,7 +17,6 @@ interface TabLayoutProps {
const TABS: Tab[] = [
{ label: 'Haushalt', index: 0 },
{ label: 'Privat', index: 1 },
{ label: 'Einstellungen', index: SETTINGS_TAB },
];
export default function TabLayout({ children, activeTab, onTabChange }: TabLayoutProps) {
@@ -31,9 +26,9 @@ export default function TabLayout({ children, activeTab, onTabChange }: TabLayou
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="min-h-screen py-8 px-4">
<div className="min-h-screen py-8 px-2 sm:px-4">
{/* Outer wrapper with border */}
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-3 sm:p-6">
{/* Page title */}
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">Ausgaben - Log</h1>
@@ -42,33 +37,28 @@ export default function TabLayout({ children, activeTab, onTabChange }: TabLayou
<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 className="flex items-end">
{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>
{/* Content panel */}
<main className="border-2 border-black rounded-b-lg rounded-tr-lg p-6 bg-[#FFFFDD]">
<main className="border-2 border-black rounded-b-lg rounded-tr-lg p-3 sm:p-6 bg-[#FFFFDD]">
{children}
</main>
-17
View File
@@ -22,20 +22,3 @@ 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;
+13 -8
View File
@@ -15,11 +15,16 @@ 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}
phpmyadmin:
image: phpmyadmin:latest
container_name: ausgaben-next-phpmyadmin
restart: unless-stopped
ports:
- "0.0.0.0:3006:80"
environment:
- PMA_HOST=${DB_HOST}
- PMA_PORT=3306
- PMA_USER=${DB_USER}
- PMA_PASSWORD=${DB_PASS}
- UPLOAD_LIMIT=256M
-9
View File
@@ -12,15 +12,6 @@ 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
-39
View File
@@ -1,39 +0,0 @@
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;
}
-146
View File
@@ -1,146 +0,0 @@
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
@@ -1,102 +0,0 @@
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
@@ -1,176 +0,0 @@
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 };
}
+2 -293
View File
@@ -1,17 +1,13 @@
{
"name": "ausgaben_next",
"version": "2.1.1",
"version": "2.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ausgaben_next",
"version": "2.1.1",
"version": "2.3.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",
@@ -19,7 +15,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -460,12 +455,6 @@
"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",
@@ -1034,12 +1023,6 @@
"@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",
@@ -1245,174 +1228,6 @@
"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",
@@ -1420,31 +1235,6 @@
"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",
@@ -1736,13 +1526,6 @@
"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",
@@ -2594,20 +2377,6 @@
"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",
@@ -2689,15 +2458,6 @@
"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",
@@ -4731,15 +4491,6 @@
"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",
@@ -5772,24 +5523,6 @@
"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",
@@ -5841,12 +5574,6 @@
"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",
@@ -6608,24 +6335,6 @@
"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",
+2 -8
View File
@@ -1,19 +1,14 @@
{
"name": "ausgaben_next",
"version": "2.2.1",
"version": "2.3.1",
"private": true,
"scripts": {
"dev": "next dev -p 3005",
"build": "next build",
"start": "next start -p 3005",
"lint": "eslint",
"generate-password": "node scripts/generate-password.js"
"lint": "eslint"
},
"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",
@@ -21,7 +16,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
-75
View File
@@ -1,75 +0,0 @@
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
@@ -1,61 +0,0 @@
#!/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);