Initiale Arbeitszeiterfassung (Next.js 16 + SQLite)
Web-App zur Erfassung der Arbeitszeit, Layout/Architektur an Projekt logbuch angelehnt, mit grüner Grundfarbe und SQLite3 statt MySQL. - Eingabemaske: Datum (mit Wochentag), Ort (Kunde FFM/Homeoffice/Andrena), Beginn/Ende, Reisezeit, Vertriebsunterstützung (default 2h), Kommentar (max 500) - Berechnung: Arbeitszeit = Ende-Beginn; Mehrarbeit = Arbeitszeit - 8h - Pause - Pause als globale Einstellung (Einstellungen-Tab) - Tabs Eingabe/Liste/Einstellungen, letzte 10 Einträge unter der Maske - Single-User-Login (Benutzer Birgit + Passwort fest in .env), JWT-Cookie via jose - better-sqlite3, Docker-Setup für docker.citysensor.de Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
// Single-User-Authentifizierung: Benutzername und Passwort fest in .env hinterlegt.
|
||||
|
||||
const DEFAULT_USERNAME = 'Birgit';
|
||||
|
||||
export function getConfiguredUsername(): string {
|
||||
return process.env.APP_USERNAME || DEFAULT_USERNAME;
|
||||
}
|
||||
|
||||
export function verifyCredentials(username: string, password: string): boolean {
|
||||
const expectedUser = getConfiguredUsername();
|
||||
const expectedPw = process.env.APP_PASSWORD;
|
||||
if (!expectedPw) throw new Error('APP_PASSWORD Umgebungsvariable ist nicht gesetzt!');
|
||||
// Benutzername case-insensitiv, Passwort exakt.
|
||||
return username.trim().toLowerCase() === expectedUser.toLowerCase() && password === expectedPw;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Reine Hilfsfunktionen für Datum-/Zeit-Berechnungen (ohne DB-Zugriff).
|
||||
|
||||
const WOCHENTAGE = [
|
||||
'Sonntag',
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
];
|
||||
|
||||
/** "HH:MM" → Minuten seit Mitternacht, oder null bei ungültiger Eingabe. */
|
||||
export function parseHM(hm: string): number | null {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(hm ?? '');
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
return h * 60 + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Differenz zwischen Beginn und Ende in dezimalen Stunden.
|
||||
* Gibt 0 zurück, wenn eine Zeit ungültig ist. Ende vor Beginn ⇒ 0.
|
||||
*/
|
||||
export function toDecimalHours(beginn: string, ende: string): number {
|
||||
const b = parseHM(beginn);
|
||||
const e = parseHM(ende);
|
||||
if (b === null || e === null) return 0;
|
||||
const diff = e - b;
|
||||
if (diff <= 0) return 0;
|
||||
return diff / 60;
|
||||
}
|
||||
|
||||
/** Mehrarbeit = Arbeitszeit − 8 Stunden − Pause (kann negativ sein). */
|
||||
export function mehrarbeit(arbeitszeitStd: number, pauseStd: number): number {
|
||||
return arbeitszeitStd - 8 - pauseStd;
|
||||
}
|
||||
|
||||
/** Deutscher Wochentagsname für ein "YYYY-MM-DD"-Datum, oder "" bei ungültig. */
|
||||
export function weekdayName(datum: string): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datum ?? '');
|
||||
if (!m) return '';
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return WOCHENTAGE[d.getDay()];
|
||||
}
|
||||
|
||||
/** Dezimalstunden deutsch formatieren, z.B. 0.5 → "0,5". */
|
||||
export function formatHours(value: number): string {
|
||||
return value.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
function dbPath(): string {
|
||||
return process.env.DB_PATH || './data/arbeitszeit.db';
|
||||
}
|
||||
|
||||
/** Singleton-Zugriff auf die SQLite-Datenbank. Legt Datei + Schema beim ersten Aufruf an. */
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const path = dbPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
|
||||
db = new Database(path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS arbeitszeit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
datum TEXT NOT NULL,
|
||||
ort TEXT NOT NULL,
|
||||
beginn TEXT NOT NULL,
|
||||
ende TEXT NOT NULL,
|
||||
reisezeit REAL NOT NULL DEFAULT 0,
|
||||
vertriebsunterstuetzung REAL NOT NULL DEFAULT 2,
|
||||
kommentar TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_arbeitszeit_datum ON arbeitszeit (datum);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Standard-Pause beim ersten Start setzen (0,5 h)
|
||||
db.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES ('pause', '0.5')
|
||||
ON CONFLICT(key) DO NOTHING`
|
||||
).run();
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// In-memory rate limiter – funktioniert pro Prozess (single Docker container).
|
||||
// Erlaubt MAX_ATTEMPTS Versuche pro IP innerhalb WINDOW_MS Millisekunden.
|
||||
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 15 * 60 * 1000; // 15 Minuten
|
||||
|
||||
interface Entry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry>();
|
||||
|
||||
// Aufräumen abgelaufener Einträge alle 5 Minuten
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt < now) store.delete(key);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export function checkRateLimit(ip: string): { allowed: boolean; remainingMs: number } {
|
||||
const now = Date.now();
|
||||
const entry = store.get(ip);
|
||||
|
||||
if (!entry || entry.resetAt < now) {
|
||||
store.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return { allowed: true, remainingMs: 0 };
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
|
||||
if (entry.count > MAX_ATTEMPTS) {
|
||||
return { allowed: false, remainingMs: entry.resetAt - now };
|
||||
}
|
||||
|
||||
return { allowed: true, remainingMs: 0 };
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { getDb } from './db';
|
||||
import type { ArbeitszeitEintrag, ArbeitszeitInput } from '@/types/arbeitszeit';
|
||||
|
||||
interface ListParams {
|
||||
limit: number;
|
||||
offset: number;
|
||||
month?: string; // "YYYY-MM"
|
||||
}
|
||||
|
||||
interface ListResult {
|
||||
entries: ArbeitszeitEintrag[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Einträge absteigend nach Datum (neueste zuerst), optional nach Monat gefiltert. */
|
||||
export function listArbeitszeit({ limit, offset, month }: ListParams): ListResult {
|
||||
const db = getDb();
|
||||
const where = month ? `WHERE substr(datum, 1, 7) = ?` : '';
|
||||
const args = month ? [month] : [];
|
||||
|
||||
const total = (
|
||||
db.prepare(`SELECT COUNT(*) AS c FROM arbeitszeit ${where}`).get(...args) as { c: number }
|
||||
).c;
|
||||
|
||||
const entries = db
|
||||
.prepare(
|
||||
`SELECT * FROM arbeitszeit ${where}
|
||||
ORDER BY datum DESC, beginn DESC, id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...args, limit, offset) as ArbeitszeitEintrag[];
|
||||
|
||||
return { entries, total };
|
||||
}
|
||||
|
||||
export function getArbeitszeit(id: number): ArbeitszeitEintrag | undefined {
|
||||
return getDb().prepare('SELECT * FROM arbeitszeit WHERE id = ?').get(id) as
|
||||
| ArbeitszeitEintrag
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function createArbeitszeit(input: ArbeitszeitInput): ArbeitszeitEintrag {
|
||||
const db = getDb();
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO arbeitszeit (datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar)
|
||||
VALUES (@datum, @ort, @beginn, @ende, @reisezeit, @vertriebsunterstuetzung, @kommentar)`
|
||||
)
|
||||
.run(input);
|
||||
return getArbeitszeit(Number(info.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
export function updateArbeitszeit(
|
||||
id: number,
|
||||
input: ArbeitszeitInput
|
||||
): ArbeitszeitEintrag | undefined {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE arbeitszeit SET
|
||||
datum = @datum, ort = @ort, beginn = @beginn, ende = @ende,
|
||||
reisezeit = @reisezeit, vertriebsunterstuetzung = @vertriebsunterstuetzung,
|
||||
kommentar = @kommentar
|
||||
WHERE id = @id`
|
||||
).run({ ...input, id });
|
||||
return getArbeitszeit(id);
|
||||
}
|
||||
|
||||
export function deleteArbeitszeit(id: number): boolean {
|
||||
return getDb().prepare('DELETE FROM arbeitszeit WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// --- Einstellungen -------------------------------------------------------
|
||||
|
||||
export function getPause(): number {
|
||||
const row = getDb().prepare(`SELECT value FROM settings WHERE key = 'pause'`).get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const v = row ? parseFloat(row.value) : 0.5;
|
||||
return isNaN(v) ? 0.5 : v;
|
||||
}
|
||||
|
||||
export function setPause(value: number): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES ('pause', @value)
|
||||
ON CONFLICT(key) DO UPDATE SET value = @value`
|
||||
)
|
||||
.run({ value: String(value) });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'arbeitszeit_session';
|
||||
const SESSION_DURATION = 8 * 60 * 60 * 1000; // 8 Stunden
|
||||
|
||||
function getKey(): Uint8Array {
|
||||
const secretKey = process.env.AUTH_SECRET;
|
||||
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
|
||||
return new TextEncoder().encode(secretKey);
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
username: string;
|
||||
isAuthenticated: boolean;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
async function encrypt(payload: SessionData): Promise<string> {
|
||||
return new SignJWT(payload as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(new Date(payload.expiresAt))
|
||||
.sign(getKey());
|
||||
}
|
||||
|
||||
async function decrypt(token: string): Promise<SessionData | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
|
||||
return payload as unknown as SessionData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(data: Omit<SessionData, 'expiresAt'>): Promise<void> {
|
||||
const expiresAt = Date.now() + SESSION_DURATION;
|
||||
const session: SessionData = { ...data, expiresAt };
|
||||
const encrypted = await encrypt(session);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, encrypted, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
expires: expiresAt,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function deleteSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ORTE, type ArbeitszeitInput, type Ort } from '@/types/arbeitszeit';
|
||||
import { parseHM } from './calc';
|
||||
|
||||
/**
|
||||
* Validiert und normalisiert einen JSON-Body zu einem ArbeitszeitInput.
|
||||
* Wirft Error mit deutscher Meldung bei ungültigen Daten.
|
||||
*/
|
||||
export function parseArbeitszeitInput(body: unknown): ArbeitszeitInput {
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
|
||||
const datum = String(b.datum ?? '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datum)) throw new Error('Ungültiges Datum.');
|
||||
|
||||
const ort = String(b.ort ?? '') as Ort;
|
||||
if (!ORTE.includes(ort)) throw new Error('Ungültiger Ort.');
|
||||
|
||||
const beginn = String(b.beginn ?? '');
|
||||
const ende = String(b.ende ?? '');
|
||||
if (parseHM(beginn) === null) throw new Error('Ungültige Startzeit.');
|
||||
if (parseHM(ende) === null) throw new Error('Ungültige Endzeit.');
|
||||
|
||||
const reisezeit = toNumber(b.reisezeit, 0);
|
||||
const vertriebsunterstuetzung = toNumber(b.vertriebsunterstuetzung, 2);
|
||||
if (reisezeit < 0 || vertriebsunterstuetzung < 0) {
|
||||
throw new Error('Zeitwerte dürfen nicht negativ sein.');
|
||||
}
|
||||
|
||||
const kommentar = String(b.kommentar ?? '').slice(0, 500);
|
||||
|
||||
return { datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar };
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback: number): number {
|
||||
if (value === '' || value === null || value === undefined) return fallback;
|
||||
const n = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.'));
|
||||
return isNaN(n) ? fallback : n;
|
||||
}
|
||||
|
||||
export { toNumber };
|
||||
Reference in New Issue
Block a user