Compare commits
13 Commits
auth
...
d3718da1f8
| Author | SHA1 | Date | |
|---|---|---|---|
| d3718da1f8 | |||
| de28922784 | |||
| 38c18a5ead | |||
| a7863c519f | |||
| 204bf3bf8b | |||
| 46678cb644 | |||
| 74e5f76ec2 | |||
| 90444b8f7d | |||
| 2a9ae7e806 | |||
| ed6bc21248 | |||
| 319ac8699e | |||
| 8c6d1bcf6d | |||
| 1ccd66b307 |
@@ -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
|
||||
|
||||
Vendored
+17
@@ -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
@@ -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>;
|
||||
}
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+38
-59
@@ -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,61 +62,34 @@ export default function Home() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
if (activeTab === SETTINGS_TAB) {
|
||||
return (
|
||||
<TabLayout activeTab={activeTab} onTabChange={setActiveTab}>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Eingabe</h2>
|
||||
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
|
||||
|
||||
<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>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4">Lade Daten...</div>
|
||||
) : (
|
||||
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-4">Einstellungen</h2>
|
||||
<Passkeys />
|
||||
</div>
|
||||
</TabLayout>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 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>
|
||||
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 {MAX_ENTRIES} Einträge</h3>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4">Lade Daten...</div>
|
||||
) : (
|
||||
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+66
-108
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 "=========================================="
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Generated
+306
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)$).*)',
|
||||
],
|
||||
};
|
||||
Executable
+61
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user