Compare commits

...

17 Commits

Author SHA1 Message Date
de28922784 V 2.1.1 Passwort-Sichtbarkeit im Login per Auge-Icon umschaltbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:40:09 +02:00
38c18a5ead V 2.1.0 Verbesserungen von Claud Code eingefügt 2026-04-27 10:39:09 +02:00
rxf
a7863c519f V 2.0.2 Nun mit Tabs
Anzahl in der Liste als const
2026-03-06 13:36:21 +01:00
rxf
204bf3bf8b V 1.2.2. Environment f. DB_PASS angepasst 2026-03-06 13:09:29 +01:00
rxf
46678cb644 V 1.2.1 Multiplatfoprm Build
Version auch auf der Login-Seite
2026-03-05 09:05:27 +01:00
74e5f76ec2 V 1.2.0 migration.tsx in proxy.tsx umbenannt
Loginpage eingebettet
2026-03-04 13:20:50 +00:00
90444b8f7d V 1.1.0 nur Version geändert 2026-03-01 11:49:36 +00:00
2a9ae7e806 Summen-Statistik der Kategorien
Eigenes 'Löschen' PopUp
2026-03-01 11:48:24 +00:00
ed6bc21248 Kategorien dazu 2026-03-01 11:26:44 +00:00
319ac8699e V 1.0.2. Keine vorausgefüllte Login-Seite mehr 2026-03-01 08:46:59 +00:00
8c6d1bcf6d V 1.0.1: Auth erfolgreich 2026-03-01 08:35:46 +00:00
1ccd66b307 weiter die Sache mit Auth 2026-03-01 07:52:31 +00:00
0678fdcaa7 mit Auth, aber ohne Hash 2026-02-28 15:34:37 +00:00
36f352de58 Auto 2026-02-27 20:08:32 +00:00
734dbfe24b Autovervollständigung 2026-02-27 20:07:45 +00:00
ba7082897f Keine Linie über dem Footer 2026-02-27 18:38:16 +00:00
5981a7a6db V1.0.0 Es funktioniert soweit Alles 2026-02-27 16:41:03 +00:00
32 changed files with 1333 additions and 211 deletions

View File

@@ -2,3 +2,13 @@ DB_HOST=localhost
DB_USER=root DB_USER=root
DB_PASSWORD=your_password DB_PASSWORD=your_password
DB_NAME=RXF DB_NAME=RXF
# Authentication Configuration
# Format: username:passwordHash,username2:passwordHash2 (max 5 users)
# Use 'node scripts/generate-password.js [password]' to generate hashes
# Leave empty to disable authentication
# Example hashes below (passwords: admin123, pass1):
AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW,user1:$2b$10$K613Z70Hodr6xyEh10Mw2uoRZMV3U4LIB09929JUWw2n/pXKoUqaW
# Secret key for JWT session encryption (change in production!)
AUTH_SECRET=your-super-secret-key-change-this-in-production

17
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Next.js Debug",
"runtimeExecutable": "node",
"args": [
"--inspect-brk",
"${workspaceFolder}/node_modules/.bin/next",
"dev"
],
"console": "integratedTerminal"
}
]
}

174
AUTH_README.md Normal file
View File

@@ -0,0 +1,174 @@
# 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
### 2. UI Komponenten (wiederverwendbar)
- `/app/login/page.tsx` - Login-Seite
- `/app/login/actions.ts` - Server Actions für Login/Logout
- `/components/LogoutButton.tsx` - Logout-Button Komponente
## Installation in neuen Projekten
### 1. Dependencies installieren
```bash
npm install jose bcryptjs
npm install --save-dev @types/bcryptjs
```
### 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
```
### 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', '/public-page'];
```
## 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>;
}
```

View File

@@ -37,7 +37,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Copy necessary files # 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/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

5
add_kategorie.sql Normal file
View File

@@ -0,0 +1,5 @@
-- Migration: Kategorie-Spalte zur Ausgaben-Tabelle hinzufügen
-- Ausführen: mysql -u <user> -p <database> < add_kategorie.sql
ALTER TABLE Ausgaben
ADD COLUMN Kat VARCHAR(4) NOT NULL DEFAULT 'L' AFTER Was;

View File

@@ -10,13 +10,20 @@ export async function PUT(
try { try {
const { id } = await context.params; const { id } = await context.params;
const body = await request.json(); const body = await request.json();
const { Datum, Wo, Was, Wieviel, Wie, TYP, OK } = body; const { Datum, Wo, Was, Kat, Wieviel, Wie, TYP } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) {
return NextResponse.json(
{ success: false, error: 'Missing required fields' },
{ status: 400 }
);
}
const pool = getDbPool(); const pool = getDbPool();
const query = ` const query = `
UPDATE Ausgaben UPDATE Ausgaben
SET Datum = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, TYP = ?, OK = ? SET Datum = ?, Wo = ?, Was = ?, Kat = ?, Wieviel = ?, Wie = ?, TYP = ?
WHERE ID = ? WHERE ID = ?
`; `;
@@ -24,10 +31,10 @@ export async function PUT(
Datum, Datum,
Wo, Wo,
Was, Was,
Kat || 'L',
parseFloat(Wieviel), parseFloat(Wieviel),
Wie, Wie,
TYP, TYP,
OK || 0,
parseInt(id), parseInt(id),
]); ]);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { getDbPool } from '@/lib/db';
import { RowDataPacket } from 'mysql2';
// GET /api/ausgaben/autocomplete - Fetch unique Wo and Was values for autocomplete
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const typ = searchParams.get('typ');
const pool = getDbPool();
let query = 'SELECT DISTINCT Wo, Was FROM Ausgaben';
const params: any[] = [];
if (typ !== null && typ !== undefined) {
query += ' WHERE TYP = ?';
params.push(parseInt(typ));
}
query += ' ORDER BY Wo, Was';
const [rows] = await pool.query<RowDataPacket[]>(query, params);
// Extract unique Wo and Was values
const woSet = new Set<string>();
const wasSet = new Set<string>();
rows.forEach((row) => {
if (row.Wo) woSet.add(row.Wo);
if (row.Was) wasSet.add(row.Was);
});
const wo = Array.from(woSet).sort();
const was = Array.from(wasSet).sort();
return NextResponse.json({
success: true,
data: {
wo,
was,
},
});
} catch (error) {
console.error('Database error:', error);
return NextResponse.json(
{ success: false, error: 'Database error' },
{ status: 500 }
);
}
}

View File

@@ -14,7 +14,8 @@ export async function GET(request: Request) {
const pool = getDbPool(); const pool = getDbPool();
let query = `SELECT *, let query = `SELECT
ID, Datum, Wo, Was, Kat, Wieviel, Wie, TYP,
CASE DAYOFWEEK(Datum) CASE DAYOFWEEK(Datum)
WHEN 1 THEN 'Sonntag' WHEN 1 THEN 'Sonntag'
WHEN 2 THEN 'Montag' WHEN 2 THEN 'Montag'
@@ -67,7 +68,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { Datum, Wo, Was, Wieviel, Wie, TYP, OK } = body; const { Datum, Wo, Was, Kat, Wieviel, Wie, TYP } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) { if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) {
return NextResponse.json( return NextResponse.json(
@@ -79,7 +80,7 @@ export async function POST(request: Request) {
const pool = getDbPool(); const pool = getDbPool();
const query = ` const query = `
INSERT INTO Ausgaben (Datum, Wo, Was, Wieviel, Wie, TYP, OK) INSERT INTO Ausgaben (Datum, Wo, Was, Kat, Wieviel, Wie, TYP)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
@@ -87,10 +88,10 @@ export async function POST(request: Request) {
Datum, Datum,
Wo, Wo,
Was, Was,
Kat || 'L',
parseFloat(Wieviel), parseFloat(Wieviel),
Wie, Wie,
TYP, TYP,
OK || 0,
]); ]);
return NextResponse.json({ return NextResponse.json({

View File

@@ -56,6 +56,21 @@ export async function GET(request: Request) {
const data = rows[0] || {}; 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 // Convert string values from MySQL to numbers
const parsedData: any = { const parsedData: any = {
totalAusgaben: parseFloat(data.totalAusgaben) || 0, totalAusgaben: parseFloat(data.totalAusgaben) || 0,
@@ -77,7 +92,7 @@ export async function GET(request: Request) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: parsedData, data: { ...parsedData, katStats },
}); });
} catch (error) { } catch (error) {
console.error('Database error:', error); console.error('Database error:', error);

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export interface Category {
value: string;
label: string;
}
// GET /api/categories - Fetch categories from categories.txt
export async function GET() {
try {
const filePath = path.join(process.cwd(), 'categories.txt');
const content = fs.readFileSync(filePath, 'utf-8');
const categories: Category[] = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('='))
.map((line) => {
const [value, label] = line.split('=');
return { value: value.trim(), label: label.trim() };
});
return NextResponse.json({ success: true, data: categories });
} catch (error) {
console.error('Error reading categories:', error);
return NextResponse.json(
{ success: false, error: 'Could not load categories' },
{ status: 500 }
);
}
}

28
app/login/actions.ts Normal file
View File

@@ -0,0 +1,28 @@
'use server';
import { verifyCredentials } from '@/lib/auth';
import { createSession, deleteSession } from '@/lib/session';
import { redirect } from 'next/navigation';
export async function login(prevState: any, formData: FormData) {
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!username || !password) {
return { error: 'Bitte Benutzername und Passwort eingeben' };
}
const isValid = await verifyCredentials(username, password);
if (!isValid) {
return { error: 'Ungültige Anmeldedaten' };
}
await createSession(username);
redirect('/');
}
export async function logout() {
await deleteSession();
redirect('/login');
}

116
app/login/page.tsx Normal file
View File

@@ -0,0 +1,116 @@
'use client';
import { useActionState, useState } from 'react';
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 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>
</div>
{/* Footer */}
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 ">
<div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
mailto:rxf@gmx.de
</a>
</div>
<div className="text-right">
Version {version} - {buildDate}
</div>
</footer>
</main>
</div>
);
}

83
app/login/page.tsx_xx Normal file
View File

@@ -0,0 +1,83 @@
'use client';
import { useActionState } from 'react';
import { login } from './actions';
export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined);
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<h1 className="text-3xl font-bold mb-6">Ausgaben - Log</h1>
<div className="flex items-center justify-center py-8">
<div className="max-w-md w-full space-y-8 bg-white p-8 rounded-2xl shadow-xl">
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Anmeldung</h2>
<p className="text-gray-600">
Bitte melden Sie sich an, um fortzufahren
</p>
</div>
<form action={loginAction} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Benutzername
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="off"
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 bg-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Benutzername"
disabled={isPending}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="new-password"
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 bg-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Passwort"
disabled={isPending}
/>
</div>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</main>
</div>
);
}

View File

@@ -3,17 +3,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import AusgabenForm from '@/components/AusgabenForm'; import AusgabenForm from '@/components/AusgabenForm';
import AusgabenList from '@/components/AusgabenList'; import AusgabenList from '@/components/AusgabenList';
import MonatsStatistik from '@/components/MonatsStatistik';
import TabLayout from '@/components/TabLayout';
import { AusgabenEntry } from '@/types/ausgaben'; import { AusgabenEntry } from '@/types/ausgaben';
import packageJson from '@/package.json';
const MAX_ENTRIES = 15;
export default function Home() { export default function Home() {
const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat
const [entries, setEntries] = useState<AusgabenEntry[]>([]); const [entries, setEntries] = useState<AusgabenEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null); const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null);
const [statsRefreshKey, setStatsRefreshKey] = useState(0);
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' });
useEffect(() => { useEffect(() => {
fetchRecentEntries(); fetchRecentEntries();
@@ -23,12 +24,13 @@ export default function Home() {
const fetchRecentEntries = async () => { const fetchRecentEntries = async () => {
setIsLoading(true); setIsLoading(true);
try { 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', cache: 'no-store',
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
}, },
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setEntries(data.data); setEntries(data.data);
@@ -42,6 +44,7 @@ export default function Home() {
const handleSuccess = () => { const handleSuccess = () => {
setSelectedEntry(null); setSelectedEntry(null);
setStatsRefreshKey((k) => k + 1);
setTimeout(() => { setTimeout(() => {
fetchRecentEntries(); fetchRecentEntries();
}, 100); }, 100);
@@ -49,6 +52,7 @@ export default function Home() {
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
setEntries(entries.filter(entry => entry.ID !== id)); setEntries(entries.filter(entry => entry.ID !== id));
setStatsRefreshKey((k) => k + 1);
}; };
const handleEdit = (entry: AusgabenEntry) => { const handleEdit = (entry: AusgabenEntry) => {
@@ -57,40 +61,15 @@ export default function Home() {
}; };
return ( return (
<div className="min-h-screen bg-white py-4 px-4"> <TabLayout activeTab={activeTab} onTabChange={setActiveTab}>
<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>
<div> <div>
<h2 className="text-xl font-semibold mb-4">Eingabe</h2> <h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} /> <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"> <div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6">
<h3 className="text-xl font-semibold mb-4">Letzte 20 Einträge</h3> <h3 className="text-xl font-semibold mb-4">Letzte {MAX_ENTRIES} Einträge</h3>
{isLoading ? ( {isLoading ? (
<div className="text-center py-4">Lade Daten...</div> <div className="text-center py-4">Lade Daten...</div>
) : ( ) : (
@@ -98,19 +77,6 @@ export default function Home() {
)} )}
</div> </div>
</div> </div>
</TabLayout>
{/* Footer */}
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 border-t-2 border-black pt-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>
); );
} }

15
categories.txt Normal file
View File

@@ -0,0 +1,15 @@
R=Restaurant
L=Lebensmittel
H=Haushalt
Ku=Kultur
Kl=Kleidung
Dr=Drogerie
Ap=Apotheke
Ar=Arzt
Re=Reise
Au=Auto
El=Elektronik
Fr=Freizeit
Ge=Getränke
Ba=Bäckerei
So=Sonstiges

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT, MonthlyStats } from '@/types/ausgaben'; import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT } from '@/types/ausgaben';
import { Category } from '@/app/api/categories/route';
interface AusgabenFormProps { interface AusgabenFormProps {
onSuccess: () => void; onSuccess: () => void;
@@ -18,69 +19,59 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: '', WochTag: '',
Wo: '', Wo: '',
Was: '', Was: '',
Kat: 'L',
Wieviel: '', Wieviel: '',
Wie: defaultZahlungsart, Wie: defaultZahlungsart,
TYP: typ, TYP: typ,
OK: 0,
}); });
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [editId, setEditId] = useState<number | null>(null); const [editId, setEditId] = useState<number | null>(null);
// Monthly stats // Autocomplete data
const [stats, setStats] = useState<MonthlyStats | null>(null); const [autoCompleteWo, setAutoCompleteWo] = useState<string[]>([]);
const [month, setMonth] = useState(''); const [autoCompleteWas, setAutoCompleteWas] = useState<string[]>([]);
const [year, setYear] = useState(''); const [categories, setCategories] = useState<Category[]>([]);
const [isLoadingStats, setIsLoadingStats] = useState(false); const [katDropdownOpen, setKatDropdownOpen] = useState(false);
const katDropdownRef = useRef<HTMLDivElement>(null);
const fetchStats = useCallback(async (y: string, m: string) => { const fetchAutoComplete = useCallback(async () => {
if (!y || !m) return;
setIsLoadingStats(true);
try { try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}&typ=${typ}`); const response = await fetch(`/api/ausgaben/autocomplete?typ=${typ}`);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setStats(data.data); setAutoCompleteWo(data.data.wo);
setAutoCompleteWas(data.data.was);
} }
} catch (error) { } catch (error) {
console.error('Error fetching stats:', error); console.error('Error fetching autocomplete data:', error);
} finally {
setIsLoadingStats(false);
} }
}, [typ]); }, [typ]);
// Initialize month/year on first load // Fetch autocomplete data when typ changes
useEffect(() => { useEffect(() => {
const now = new Date(); fetchAutoComplete();
const currentMonth = String(now.getMonth() + 1).padStart(2, '0'); }, [typ, fetchAutoComplete]);
const currentYear = String(now.getFullYear());
setMonth(currentMonth); // Close Kat dropdown when clicking outside
setYear(currentYear); 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);
}, []); }, []);
// Fetch stats when month, year, or typ changes // Fetch categories once on mount
useEffect(() => { useEffect(() => {
if (month && year) { fetch('/api/categories')
fetchStats(year, month); .then((r) => r.json())
} .then((data) => { if (data.success) setCategories(data.data); })
}, [month, year, typ, fetchStats]); .catch(() => {});
}, []);
const handleMonthChange = (newMonth: string) => {
setMonth(newMonth);
};
const handleYearChange = (newYear: string) => {
setYear(newYear);
};
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(() => { useEffect(() => {
if (selectedEntry) { if (selectedEntry) {
@@ -92,29 +83,46 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: selectedEntry.WochTag, WochTag: selectedEntry.WochTag,
Wo: selectedEntry.Wo, Wo: selectedEntry.Wo,
Was: selectedEntry.Was, Was: selectedEntry.Was,
Kat: selectedEntry.Kat || 'L',
Wieviel: selectedEntry.Wieviel.toString(), Wieviel: selectedEntry.Wieviel.toString(),
Wie: selectedEntry.Wie, Wie: selectedEntry.Wie,
TYP: selectedEntry.TYP, TYP: selectedEntry.TYP,
OK: selectedEntry.OK || 0,
}); });
setEditId(selectedEntry.ID); // Handle both uppercase and lowercase ID field names
const entryId = (selectedEntry as any).id || selectedEntry.ID;
setEditId(entryId);
} else { } else {
// Initialize with current date for new entry // Reset form for new entry
const now = new Date(); const now = new Date();
const dateStr = now.toISOString().split('T')[0]; const dateStr = now.toISOString().split('T')[0];
const weekday = getWeekday(now); const weekday = getWeekday(now);
setFormData(prev => ({ setFormData({
...prev,
Datum: dateStr, Datum: dateStr,
WochTag: weekday, WochTag: weekday,
Wo: '',
Was: '',
Kat: 'L',
Wieviel: '',
Wie: defaultZahlungsart,
TYP: typ, TYP: typ,
})); });
setEditId(null); setEditId(null);
} }
}, [selectedEntry, typ]); }, [selectedEntry]);
// Update TYP when tab changes
useEffect(() => {
if (!selectedEntry) {
setFormData(prev => ({
...prev,
TYP: typ,
Wie: defaultZahlungsart,
}));
}
}, [typ]);
const getWeekday = (date: Date): string => { const getWeekday = (date: Date): string => {
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
@@ -143,19 +151,28 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
const url = editId ? `/api/ausgaben/${editId}` : '/api/ausgaben'; const url = editId ? `/api/ausgaben/${editId}` : '/api/ausgaben';
const method = editId ? 'PUT' : 'POST'; const method = editId ? 'PUT' : 'POST';
// Send only the fields we need, excluding any extra fields
const dataToSend = {
Datum: formData.Datum,
Wo: formData.Wo,
Was: formData.Was,
Kat: formData.Kat,
Wieviel: String(formData.Wieviel).replace(',', '.'),
Wie: formData.Wie,
TYP: formData.TYP,
};
const response = await fetch(url, { const response = await fetch(url, {
method: method, method: method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(formData), body: JSON.stringify(dataToSend),
}); });
if (response.ok) { if (response.ok) {
handleReset(); handleReset();
onSuccess(); onSuccess();
// Refresh stats after successful save
fetchStats(year, month);
} else { } else {
alert('Fehler beim Speichern!'); alert('Fehler beim Speichern!');
} }
@@ -177,10 +194,11 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: weekday, WochTag: weekday,
Wo: '', Wo: '',
Was: '', Was: '',
Kat: 'L',
Wieviel: '', Wieviel: '',
Wie: defaultZahlungsart, Wie: defaultZahlungsart,
TYP: typ, TYP: typ,
OK: 0,
}); });
setEditId(null); setEditId(null);
@@ -201,6 +219,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
<th className="p-2 w-32">Datum</th> <th className="p-2 w-32">Datum</th>
<th className="p-2">Wo</th> <th className="p-2">Wo</th>
<th className="p-2">Was</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-24">Wieviel</th>
<th className="p-2 w-4"></th> <th className="p-2 w-4"></th>
<th className="p-2 w-38 text-left">Wie</th> <th className="p-2 w-38 text-left">Wie</th>
@@ -224,8 +243,14 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
onChange={(e) => setFormData({ ...formData, Wo: e.target.value })} onChange={(e) => setFormData({ ...formData, Wo: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none" className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
placeholder="Geschäft/Ort" placeholder="Geschäft/Ort"
list="wo-suggestions"
required required
/> />
<datalist id="wo-suggestions">
{autoCompleteWo.map((wo, index) => (
<option key={index} value={wo} />
))}
</datalist>
</td> </td>
<td className="p-2"> <td className="p-2">
<input <input
@@ -234,16 +259,54 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
onChange={(e) => setFormData({ ...formData, Was: e.target.value })} onChange={(e) => setFormData({ ...formData, Was: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none" className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
placeholder="Beschreibung" placeholder="Beschreibung"
list="was-suggestions"
required required
/> />
<datalist id="was-suggestions">
{autoCompleteWas.map((was, index) => (
<option key={index} value={was} />
))}
</datalist>
</td>
<td className="p-2 w-12">
<div ref={katDropdownRef} className="relative w-full">
<button
type="button"
onClick={() => setKatDropdownOpen((o) => !o)}
className="w-full px-2 py-1 text-base text-left rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
>
{formData.Kat}
</button>
{katDropdownOpen && (
<ul className="absolute z-50 left-0 mt-1 w-48 bg-white border-2 border-gray-400 rounded shadow-lg max-h-60 overflow-y-auto text-left">
{categories.map((cat) => (
<li
key={cat.value}
className={`px-3 py-1 cursor-pointer hover:bg-blue-100 text-sm ${
formData.Kat === cat.value ? 'bg-blue-50 font-semibold' : ''
}`}
onMouseDown={() => {
setFormData({ ...formData, Kat: cat.value });
setKatDropdownOpen(false);
}}
>
{cat.value} - {cat.label}
</li>
))}
</ul>
)}
</div>
</td> </td>
<td className="p-2 w-24"> <td className="p-2 w-24">
<input <input
type="number" type="text"
step="0.01" inputMode="decimal"
value={formData.Wieviel} value={formData.Wieviel}
onChange={(e) => setFormData({ ...formData, Wieviel: e.target.value })} onChange={(e) => {
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" 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" placeholder="0.00"
required required
/> />
@@ -289,53 +352,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
Löschen Löschen
</button> </button>
</div> </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> </form>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState } from 'react';
import { AusgabenEntry } from '@/types/ausgaben'; import { AusgabenEntry } from '@/types/ausgaben';
interface AusgabenListProps { interface AusgabenListProps {
@@ -9,14 +10,12 @@ interface AusgabenListProps {
} }
export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) { export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) {
const handleDelete = async (id: number) => { const [confirmId, setConfirmId] = useState<number | null>(null);
if (!confirm('Wirklich löschen?')) return;
const handleDeleteConfirmed = async (id: number) => {
setConfirmId(null);
try { try {
const response = await fetch(`/api/ausgaben/${id}`, { const response = await fetch(`/api/ausgaben/${id}`, { method: 'DELETE' });
method: 'DELETE',
});
if (response.ok) { if (response.ok) {
onDelete(id); onDelete(id);
} else { } else {
@@ -29,12 +28,7 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const date = new Date(dateStr); return dateStr.toString().split('T')[0];
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}; };
const formatAmount = (amount: number) => { 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-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-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-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-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-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> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.length === 0 ? ( {entries.length === 0 ? (
<tr> <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 Keine Einträge vorhanden
</td> </td>
</tr> </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 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.Wo}</td>
<td className="border-y border-black p-2">{entry.Was}</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"> <td className="border-y border-black p-2 text-right">
{formatAmount(entry.Wieviel)} {formatAmount(entry.Wieviel)}
</td> </td>
@@ -83,10 +79,10 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
onClick={() => onEdit(entry)} onClick={() => onEdit(entry)}
className="text-blue-600 hover:text-blue-800 px-3 py-1 rounded text-sm mr-2" className="text-blue-600 hover:text-blue-800 px-3 py-1 rounded text-sm mr-2"
> >
Bearbeiten Editieren
</button> </button>
<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" className="text-red-600 hover:text-red-800 px-3 py-1 rounded text-sm"
> >
Löschen Löschen
@@ -97,6 +93,29 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
)} )}
</tbody> </tbody>
</table> </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> </div>
); );
} }

View File

@@ -0,0 +1,23 @@
'use client';
import { logout } from '@/app/login/actions';
interface LogoutButtonProps {
className?: string;
children?: React.ReactNode;
}
export default function LogoutButton({ className, children }: LogoutButtonProps) {
const handleLogout = async () => {
await logout();
};
return (
<button
onClick={handleLogout}
className={className || "px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"}
>
{children || 'Abmelden'}
</button>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { MonthlyStats } from '@/types/ausgaben';
import { Category } from '@/app/api/categories/route';
interface MonatsStatistikProps {
typ: number;
refreshKey?: number;
}
export default function MonatsStatistik({ typ, refreshKey }: MonatsStatistikProps) {
const [stats, setStats] = useState<MonthlyStats | null>(null);
const [month, setMonth] = useState('');
const [year, setYear] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
// Initialize month/year
useEffect(() => {
const now = new Date();
setMonth(String(now.getMonth() + 1).padStart(2, '0'));
setYear(String(now.getFullYear()));
}, []);
// Fetch categories once
useEffect(() => {
fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (data.success) setCategories(data.data); })
.catch(() => {});
}, []);
const fetchStats = useCallback(async (y: string, m: string) => {
if (!y || !m) return;
setIsLoading(true);
try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}&typ=${typ}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.success) setStats(data.data);
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setIsLoading(false);
}
}, [typ]);
useEffect(() => {
if (month && year) fetchStats(year, month);
}, [month, year, typ, refreshKey, fetchStats]);
const formatAmount = (amount: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
const getCatLabel = (code: string) => {
const cat = categories.find((c) => c.value === code);
return cat ? `${cat.label}` : code;
};
return (
<div className="mt-4 bg-[#E0E0FF] border border-black rounded-lg shadow-md p-4">
{/* Zeile 1: Monat/Jahr + Gesamtsumme */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex gap-4 items-center">
<label className="font-semibold">Monat:</label>
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="border border-gray-400 rounded px-3 py-1"
>
<option value="01">Januar</option>
<option value="02">Februar</option>
<option value="03">März</option>
<option value="04">April</option>
<option value="05">Mai</option>
<option value="06">Juni</option>
<option value="07">Juli</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<label className="font-semibold">Jahr:</label>
<input
type="number"
value={year}
onChange={(e) => setYear(e.target.value)}
className="border border-gray-400 rounded px-3 py-1 w-24"
min="2013"
max="2099"
/>
</div>
<div>
{isLoading ? (
<span>Lade...</span>
) : stats ? (
<span className="font-bold text-lg">
Summe: {formatAmount(stats.totalAusgaben)}
</span>
) : null}
</div>
</div>
{/* Zeile 2+: Kategorien */}
{!isLoading && stats?.katStats && Object.keys(stats.katStats).length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-400 flex flex-wrap gap-x-6 gap-y-1">
{Object.entries(stats.katStats).map(([code, total]) => (
<div key={code} className="flex gap-2 text-sm">
<span className="font-medium">{getCatLabel(code)}:</span>
<span>{formatAmount(total)}</span>
</div>
))}
</div>
)}
</div>
);
}

82
components/TabLayout.tsx Normal file
View File

@@ -0,0 +1,82 @@
'use client';
import { ReactNode } from 'react';
import LogoutButton from '@/components/LogoutButton';
import packageJson from '@/package.json';
interface Tab {
label: string;
index: number;
}
interface TabLayoutProps {
children: ReactNode;
activeTab: number;
onTabChange: (index: number) => void;
}
const TABS: Tab[] = [
{ label: 'Haushalt', index: 0 },
{ label: 'Privat', index: 1 },
];
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>
);
}

View File

@@ -15,34 +15,39 @@ FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y) BUILD_DATE=$(date +%d.%m.%Y)
echo "==========================================" echo "=========================================="
echo "Ausgaben-Next Deploy Script" echo "ausgaben-next Deploy Script"
echo "==========================================" echo "=========================================="
echo "Registry: ${REGISTRY}" echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}" echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}" echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}" echo "Build-Datum: ${BUILD_DATE}"
echo "==========================================" echo "=========================================="
echo "" echo ""
# 1. Docker Image bauen # 1. Login zur Registry (falls noch nicht eingeloggt)
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)
echo ">>> Login zu ${REGISTRY}..." echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}" docker login "${REGISTRY}"
echo "" echo ""
# 3. Image pushen # 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
echo ">>> Pushe Image zu ${REGISTRY}..." echo ">>> Richte Multiplatform Builder ein..."
docker push "${FULL_IMAGE}" 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 ""
echo "==========================================" echo "=========================================="

View File

@@ -15,3 +15,5 @@ services:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET}

View File

@@ -12,6 +12,8 @@ services:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.ausgaben.entrypoints=http - traefik.http.routers.ausgaben.entrypoints=http

34
lib/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
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);
}
export function isAuthEnabled(): boolean {
return !!process.env.AUTH_USERS;
}

View File

@@ -7,7 +7,7 @@ export function getDbPool() {
pool = mysql.createPool({ pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root', 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', database: process.env.DB_NAME || 'RXF',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,

102
lib/session.ts Normal file
View File

@@ -0,0 +1,102 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'auth_session';
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
export interface SessionData {
username: string;
isAuthenticated: boolean;
expiresAt: number;
}
/**
* Encrypt session data to JWT
*/
async function encrypt(payload: SessionData): Promise<string> {
return await new SignJWT(payload as any)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(key);
}
/**
* Decrypt JWT to session data
*/
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'],
});
return {
username: payload.username as string,
isAuthenticated: payload.isAuthenticated as boolean,
expiresAt: payload.expiresAt as number,
};
} catch (error) {
return null;
}
}
/**
* Create a new session
*/
export async function createSession(username: string): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION;
const session: SessionData = {
username,
isAuthenticated: true,
expiresAt,
};
const encryptedSession = await encrypt(session);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
/**
* Get current session
*/
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!cookie?.value) {
return null;
}
const session = await decrypt(cookie.value);
if (!session || session.expiresAt < Date.now()) {
return null;
}
return session;
}
/**
* Delete session (logout)
*/
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
/**
* Verify if user is authenticated
*/
export async function isAuthenticated(): Promise<boolean> {
const session = await getSession();
return session?.isAuthenticated ?? false;
}

View File

@@ -2,6 +2,20 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', 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; export default nextConfig;

45
package-lock.json generated
View File

@@ -1,13 +1,15 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.0", "version": "1.2.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.1.3",
"mysql2": "^3.17.4", "mysql2": "^3.17.4",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
@@ -15,6 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -68,6 +71,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1525,6 +1529,13 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1551,6 +1562,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz",
"integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1561,6 +1573,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1620,6 +1633,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -2145,6 +2159,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2453,6 +2468,15 @@
"node": ">=6.0.0" "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": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2497,6 +2521,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3073,6 +3098,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3258,6 +3284,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -4483,6 +4510,15 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5541,6 +5577,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -5550,6 +5587,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -6259,6 +6297,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -6421,6 +6460,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -6695,6 +6735,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -1,14 +1,17 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.0", "version": "2.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3005", "dev": "next dev -p 3005",
"build": "next build", "build": "next build",
"start": "next start -p 3005", "start": "next start -p 3005",
"lint": "eslint" "lint": "eslint",
"generate-password": "node scripts/generate-password.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.1.3",
"mysql2": "^3.17.4", "mysql2": "^3.17.4",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
@@ -16,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

74
proxy.ts Normal file
View File

@@ -0,0 +1,74 @@
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
const publicPaths = ['/login'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
if (isPublicPath) {
return NextResponse.next();
}
// Check for session cookie
const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Verify session token
try {
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
const { payload } = await jwtVerify(sessionCookie.value, key, {
algorithms: ['HS256'],
});
// Check if session is expired
if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete(SESSION_COOKIE_NAME);
return response;
}
return NextResponse.next();
} catch (error) {
// Invalid token - redirect to login
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete(SESSION_COOKIE_NAME);
return response;
}
}
export default proxy;
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};

61
scripts/generate-password.js Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Password Hash Generator
*
* Usage:
* node scripts/generate-password.js [password]
*
* If no password is provided, you'll be prompted to enter one.
*/
const bcrypt = require('bcryptjs');
const readline = require('readline');
function generateHash(password) {
const saltRounds = 10;
const hash = bcrypt.hashSync(password, saltRounds);
return hash;
}
function promptPassword() {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Passwort eingeben: ', (password) => {
rl.close();
resolve(password);
});
});
}
async function main() {
let password = process.argv[2];
if (!password) {
password = await promptPassword();
}
if (!password) {
console.error('❌ Kein Passwort angegeben!');
process.exit(1);
}
console.log('\n🔐 Generiere Passwort-Hash...\n');
const hash = generateHash(password);
console.log('✅ Hash generiert:');
console.log('─'.repeat(80));
console.log(hash);
console.log('─'.repeat(80));
console.log('\n📝 Verwende diesen Hash in der .env Datei:');
console.log(`AUTH_USERS=username:${hash}`);
console.log('\n💡 Beispiel für mehrere Benutzer:');
console.log(`AUTH_USERS=admin:${hash},user2:$2a$10$...\n`);
}
main().catch(console.error);

View File

@@ -6,10 +6,10 @@ export interface AusgabenEntry {
WochTag: string; WochTag: string;
Wo: string; Wo: string;
Was: string; Was: string;
Kat: string;
Wieviel: number; Wieviel: number;
Wie: string; Wie: string;
TYP: number; TYP: number;
OK?: number;
} }
export interface CreateAusgabenEntry { export interface CreateAusgabenEntry {
@@ -17,10 +17,10 @@ export interface CreateAusgabenEntry {
WochTag: string; WochTag: string;
Wo: string; Wo: string;
Was: string; Was: string;
Kat: string;
Wieviel: string | number; Wieviel: string | number;
Wie: string; Wie: string;
TYP: number; TYP: number;
OK?: number;
} }
export interface MonthlyStats { export interface MonthlyStats {
@@ -35,6 +35,7 @@ export interface MonthlyStats {
MASTER?: number; MASTER?: number;
Einnahmen: number; Einnahmen: number;
Ueberweisungen: number; Ueberweisungen: number;
katStats?: Record<string, number>;
} }
// Haushalt Zahlungsarten (TYP = 0) // Haushalt Zahlungsarten (TYP = 0)