Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7863c519f | |||
| 204bf3bf8b | |||
| 46678cb644 | |||
| 74e5f76ec2 | |||
| 90444b8f7d | |||
| 2a9ae7e806 | |||
| ed6bc21248 | |||
| 319ac8699e | |||
| 8c6d1bcf6d | |||
| 1ccd66b307 |
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal 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
174
AUTH_README.md
Normal 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>;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
5
add_kategorie.sql
Normal 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;
|
||||||
@@ -10,7 +10,7 @@ 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 } = 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(
|
||||||
@@ -23,7 +23,7 @@ export async function PUT(
|
|||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE Ausgaben
|
UPDATE Ausgaben
|
||||||
SET Datum = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, TYP = ?
|
SET Datum = ?, Wo = ?, Was = ?, Kat = ?, Wieviel = ?, Wie = ?, TYP = ?
|
||||||
WHERE ID = ?
|
WHERE ID = ?
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ export async function PUT(
|
|||||||
Datum,
|
Datum,
|
||||||
Wo,
|
Wo,
|
||||||
Was,
|
Was,
|
||||||
|
Kat || 'L',
|
||||||
parseFloat(Wieviel),
|
parseFloat(Wieviel),
|
||||||
Wie,
|
Wie,
|
||||||
TYP,
|
TYP,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET(request: Request) {
|
|||||||
const pool = getDbPool();
|
const pool = getDbPool();
|
||||||
|
|
||||||
let query = `SELECT
|
let query = `SELECT
|
||||||
ID, Datum, Wo, Was, Wieviel, Wie, TYP,
|
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'
|
||||||
@@ -68,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 } = 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(
|
||||||
@@ -80,14 +80,15 @@ 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)
|
INSERT INTO Ausgaben (Datum, Wo, Was, Kat, Wieviel, Wie, TYP)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [result] = await pool.query<ResultSetHeader>(query, [
|
const [result] = await pool.query<ResultSetHeader>(query, [
|
||||||
Datum,
|
Datum,
|
||||||
Wo,
|
Wo,
|
||||||
Was,
|
Was,
|
||||||
|
Kat || 'L',
|
||||||
parseFloat(Wieviel),
|
parseFloat(Wieviel),
|
||||||
Wie,
|
Wie,
|
||||||
TYP,
|
TYP,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
33
app/api/categories/route.ts
Normal file
33
app/api/categories/route.ts
Normal 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
28
app/login/actions.ts
Normal 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 = verifyCredentials(username, password);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return { error: 'Ungültige Anmeldedaten' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession(username);
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await deleteSession();
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
95
app/login/page.tsx
Normal file
95
app/login/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
import { login } from './actions';
|
||||||
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [state, loginAction, isPending] = useActionState(login, undefined);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
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="Passwort"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</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
83
app/login/page.tsx_xx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
app/page.tsx
81
app/page.tsx
@@ -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,7 +24,7 @@ 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',
|
||||||
@@ -42,6 +43,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 +51,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,60 +60,22 @@ 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]">
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-center mb-6">Ausgaben - Log</h1>
|
<h2 className="text-xl font-semibold mb-4">Eingabe</h2>
|
||||||
|
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
|
||||||
{/* 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>
|
<MonatsStatistik typ={activeTab} refreshKey={statsRefreshKey} />
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
<div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6">
|
||||||
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 ">
|
<h3 className="text-xl font-semibold mb-4">Letzte {MAX_ENTRIES} Einträge</h3>
|
||||||
<div>
|
{isLoading ? (
|
||||||
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
|
<div className="text-center py-4">Lade Daten...</div>
|
||||||
mailto:rxf@gmx.de
|
) : (
|
||||||
</a>
|
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} />
|
||||||
</div>
|
)}
|
||||||
<div className="text-right">
|
</div>
|
||||||
Version {version} - {buildDate}
|
</div>
|
||||||
</div>
|
</TabLayout>
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
15
categories.txt
Normal file
15
categories.txt
Normal 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
|
||||||
@@ -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,6 +19,7 @@ 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,
|
||||||
@@ -26,32 +28,12 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
|
|||||||
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
|
|
||||||
const [stats, setStats] = useState<MonthlyStats | null>(null);
|
|
||||||
const [month, setMonth] = useState('');
|
|
||||||
const [year, setYear] = useState('');
|
|
||||||
const [isLoadingStats, setIsLoadingStats] = useState(false);
|
|
||||||
|
|
||||||
// Autocomplete data
|
// Autocomplete data
|
||||||
const [autoCompleteWo, setAutoCompleteWo] = useState<string[]>([]);
|
const [autoCompleteWo, setAutoCompleteWo] = useState<string[]>([]);
|
||||||
const [autoCompleteWas, setAutoCompleteWas] = useState<string[]>([]);
|
const [autoCompleteWas, setAutoCompleteWas] = useState<string[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const fetchStats = useCallback(async (y: string, m: string) => {
|
const [katDropdownOpen, setKatDropdownOpen] = useState(false);
|
||||||
if (!y || !m) return;
|
const katDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
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 fetchAutoComplete = useCallback(async () => {
|
const fetchAutoComplete = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -66,42 +48,30 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
|
|||||||
}
|
}
|
||||||
}, [typ]);
|
}, [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
|
// Fetch autocomplete data when typ changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAutoComplete();
|
fetchAutoComplete();
|
||||||
}, [typ, fetchAutoComplete]);
|
}, [typ, fetchAutoComplete]);
|
||||||
|
|
||||||
const handleMonthChange = (newMonth: string) => {
|
// Close Kat dropdown when clicking outside
|
||||||
setMonth(newMonth);
|
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) => {
|
// Fetch categories once on mount
|
||||||
setYear(newYear);
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedEntry) {
|
if (selectedEntry) {
|
||||||
@@ -113,6 +83,7 @@ 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,
|
||||||
@@ -132,6 +103,7 @@ 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,
|
||||||
@@ -184,7 +156,8 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
|
|||||||
Datum: formData.Datum,
|
Datum: formData.Datum,
|
||||||
Wo: formData.Wo,
|
Wo: formData.Wo,
|
||||||
Was: formData.Was,
|
Was: formData.Was,
|
||||||
Wieviel: formData.Wieviel,
|
Kat: formData.Kat,
|
||||||
|
Wieviel: String(formData.Wieviel).replace(',', '.'),
|
||||||
Wie: formData.Wie,
|
Wie: formData.Wie,
|
||||||
TYP: formData.TYP,
|
TYP: formData.TYP,
|
||||||
};
|
};
|
||||||
@@ -200,8 +173,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
|
|||||||
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!');
|
||||||
}
|
}
|
||||||
@@ -223,6 +194,7 @@ 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,
|
||||||
@@ -247,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>
|
||||||
@@ -295,13 +268,45 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
|
|||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
</td>
|
</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">
|
<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
|
||||||
/>
|
/>
|
||||||
@@ -347,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
components/LogoutButton.tsx
Normal file
23
components/LogoutButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
components/MonatsStatistik.tsx
Normal file
120
components/MonatsStatistik.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
'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}`);
|
||||||
|
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
82
components/TabLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
deploy.sh
36
deploy.sh
@@ -15,7 +15,7 @@ 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}"
|
||||||
@@ -24,25 +24,29 @@ 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 "=========================================="
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
50
lib/auth.ts
Normal file
50
lib/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Reusable authentication library
|
||||||
|
* Configure users via environment variables in .env:
|
||||||
|
* AUTH_USERS=user1:$2a$10$hash1,user2:$2a$10$hash2
|
||||||
|
*
|
||||||
|
* Use scripts/generate-password.js to generate password hashes
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse users from environment variable
|
||||||
|
* Format: username:password,username2:password2
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify user credentials
|
||||||
|
*/
|
||||||
|
export function verifyCredentials(username: string, password: string): boolean {
|
||||||
|
const users = getUsers();
|
||||||
|
const user = users.find(u => u.username === username);
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.password === password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if authentication is enabled
|
||||||
|
*/
|
||||||
|
export function isAuthEnabled(): boolean {
|
||||||
|
return !!process.env.AUTH_USERS;
|
||||||
|
}
|
||||||
@@ -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
102
lib/session.ts
Normal 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;
|
||||||
|
}
|
||||||
45
package-lock.json
generated
45
package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "ausgaben_next",
|
"name": "ausgaben_next",
|
||||||
"version": "1.0.1",
|
"version": "2.0.2",
|
||||||
"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
74
proxy.ts
Normal 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
61
scripts/generate-password.js
Executable 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);
|
||||||
@@ -6,6 +6,7 @@ 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;
|
||||||
@@ -16,6 +17,7 @@ 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;
|
||||||
@@ -33,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user