From c63d1a641fefc87ce6475a67db96732a72cf9754 Mon Sep 17 00:00:00 2001 From: linus Date: Mon, 27 Jul 2026 21:38:49 +0200 Subject: [PATCH] Add maintenance and trip history components, admin session management, and session activity tracking --- middleware.ts | 4 - src/app/(app)/admin/sessions/page.tsx | 184 ++++++++++++++++++ src/app/(app)/layout.tsx | 3 + .../[id]/maintenance/history/page.tsx | 91 +++++++++ src/app/(app)/motorcycles/[id]/page.tsx | 184 +++--------------- .../motorcycles/[id]/trips/history/page.tsx | 71 +++++++ src/app/actions/sessions.ts | 61 ++++++ .../api/auth/webauthn/login/verify/route.ts | 22 ++- src/app/api/session/activity/route.ts | 36 ++++ src/app/globals.css | 21 +- src/components/maintenance-history.tsx | 109 +++++++++++ src/components/session-activity-tracker.tsx | 27 +++ src/components/trip-history.tsx | 69 +++++++ src/lib/auth.ts | 93 ++++++++- src/lib/db.ts | 14 ++ src/lib/format.ts | 9 + 16 files changed, 822 insertions(+), 176 deletions(-) create mode 100644 src/app/(app)/admin/sessions/page.tsx create mode 100644 src/app/(app)/motorcycles/[id]/maintenance/history/page.tsx create mode 100644 src/app/(app)/motorcycles/[id]/trips/history/page.tsx create mode 100644 src/app/actions/sessions.ts create mode 100644 src/app/api/session/activity/route.ts create mode 100644 src/components/maintenance-history.tsx create mode 100644 src/components/session-activity-tracker.tsx create mode 100644 src/components/trip-history.tsx diff --git a/middleware.ts b/middleware.ts index 79ab172..2d82732 100644 --- a/middleware.ts +++ b/middleware.ts @@ -45,10 +45,6 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(new URL("/login", request.url)); } - if (validSession && pathname === "/login") { - return NextResponse.redirect(new URL("/", request.url)); - } - return NextResponse.next(); } diff --git a/src/app/(app)/admin/sessions/page.tsx b/src/app/(app)/admin/sessions/page.tsx new file mode 100644 index 0000000..3c26d38 --- /dev/null +++ b/src/app/(app)/admin/sessions/page.tsx @@ -0,0 +1,184 @@ +import Link from "next/link"; +import { + revokeSessionAction, + revokeUserSessionsAction, +} from "@/app/actions/sessions"; +import { Flash } from "@/components/flash"; +import { getCurrentSessionId, requireAdminUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; +import { formatDateTime, queryMessage } from "@/lib/format"; + +export const runtime = "nodejs"; + +type SessionRow = { + id: string; + user_id: number; + name: string; + email: string; + user_agent: string | null; + ip_address: string | null; + last_path: string | null; + created_at: string; + last_seen_at: string; + expires_at: string; + session_count: number; +}; + +function deviceLabel(userAgent: string | null) { + if (!userAgent) return "Unbekanntes Gerät"; + const browser = + /Edg\//.test(userAgent) ? "Edge" : + /Firefox\//.test(userAgent) ? "Firefox" : + /Chrome\//.test(userAgent) ? "Chrome" : + /Safari\//.test(userAgent) ? "Safari" : + "Unbekannter Browser"; + const system = + /Windows/.test(userAgent) ? "Windows" : + /Android/.test(userAgent) ? "Android" : + /iPhone|iPad/.test(userAgent) ? "iOS / iPadOS" : + /Mac OS X/.test(userAgent) ? "macOS" : + /Linux/.test(userAgent) ? "Linux" : + "Unbekanntes System"; + return `${browser} auf ${system}`; +} + +function activityLabel(lastSeenAt: string) { + const normalized = lastSeenAt.includes("T") + ? lastSeenAt + : `${lastSeenAt.replace(" ", "T")}Z`; + const elapsed = Date.now() - new Date(normalized).getTime(); + if (elapsed < 2 * 60_000) return "Gerade aktiv"; + if (elapsed < 15 * 60_000) return "Vor wenigen Minuten aktiv"; + return `Zuletzt ${formatDateTime(lastSeenAt)}`; +} + +export default async function AdminSessionsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requireAdminUser(); + initDatabase(); + const currentSessionId = await getCurrentSessionId(); + const params = await searchParams; + const sessions = db + .prepare( + `SELECT s.id, s.user_id, u.name, u.email, s.user_agent, s.ip_address, + s.last_path, s.created_at, s.last_seen_at, s.expires_at, + COUNT(*) OVER (PARTITION BY s.user_id) AS session_count + FROM user_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.revoked_at IS NULL + AND datetime(s.expires_at) > CURRENT_TIMESTAMP + AND u.active = 1 + ORDER BY s.last_seen_at DESC, s.created_at DESC`, + ) + .all() as SessionRow[]; + + return ( + <> +
+ Übersicht + / + Aktive Sitzungen +
+
+
+

Administration

+

Aktive Sitzungen

+

+ {sessions.length} aktive Anmeldung(en) von{" "} + {new Set(sessions.map((session) => session.user_id)).size} Benutzer(n) +

+
+
+ + +
+ {sessions.length === 0 ? ( +

Keine aktiven Sitzungen vorhanden.

+ ) : ( + + + + + + + + + + + + + + {sessions.map((session) => { + const isCurrent = session.id === currentSessionId; + return ( + + + + + + + + + + ); + })} + +
BenutzerGerätIP-AdresseAktivitätAngemeldet seitLetzter Bereich
+ {session.name} + {session.email} + {session.session_count > 1 && ( + {session.session_count} Geräte + )} + {isCurrent && Diese Sitzung} + + {deviceLabel(session.user_agent)} + {session.user_agent && ( +
+ Details + {session.user_agent} +
+ )} +
{session.ip_address ?? "—"} + + {activityLabel(session.last_seen_at)} + + {formatDateTime(session.created_at)}{session.last_path ?? "—"} +
+ + +
+
+ )} +
+ + {sessions.some((session) => session.session_count > 1) && ( +
+
+
+

Mehrfachanmeldungen

+

Alle Sitzungen eines Benutzers beenden

+
+
+
+ {Array.from( + new Map(sessions.map((session) => [session.user_id, session])).values(), + ).filter((session) => session.session_count > 1).map((session) => ( +
+ + {session.name} ({session.session_count}) + +
+ ))} +
+
+ )} + + ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index bb8bed9..a20551b 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { logoutAction } from "@/app/actions/auth"; +import { SessionActivityTracker } from "@/components/session-activity-tracker"; import { requireSessionUser } from "@/lib/auth"; export const runtime = "nodejs"; @@ -8,6 +9,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod const user = await requireSessionUser(); return (
+
FG @@ -22,6 +24,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod {user.role === "admin" && ( <> Benutzer + Sitzungen Datenbank )} diff --git a/src/app/(app)/motorcycles/[id]/maintenance/history/page.tsx b/src/app/(app)/motorcycles/[id]/maintenance/history/page.tsx new file mode 100644 index 0000000..b8ec6c5 --- /dev/null +++ b/src/app/(app)/motorcycles/[id]/maintenance/history/page.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + MaintenanceHistory, + type MaintenanceEventRow, + type MaintenanceItemRow, +} from "@/components/maintenance-history"; +import { requireSessionUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; +import { formatMotorcycleName } from "@/lib/format"; +import { motorcycleAccessFilter } from "@/lib/motorcycle-access"; + +export const runtime = "nodejs"; + +type MotorcycleRow = { + id: number; + brand: string; + model: string; +}; + +export default async function MaintenanceHistoryPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const user = await requireSessionUser(); + initDatabase(); + const { id } = await params; + const motorcycleId = Number(id); + if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound(); + + const visible = motorcycleAccessFilter(user); + const bike = db + .prepare( + `SELECT m.id, m.brand, m.model + FROM motorcycles m + WHERE m.id = ? AND ${visible.clause}`, + ) + .get(motorcycleId, ...visible.params) as MotorcycleRow | undefined; + if (!bike) notFound(); + + const eventRows = db + .prepare( + `SELECT e.id, e.description, e.event_date, e.km, + COALESCE(u.name, 'Gelöschter Benutzer') AS creator, + a.id AS attachment_id, a.original_name AS attachment_name + FROM maintenance_events e + LEFT JOIN users u ON u.id = e.created_by + LEFT JOIN attachments a ON a.maintenance_event_id = e.id + WHERE e.motorcycle_id = ? + ORDER BY e.event_date DESC, e.id DESC`, + ) + .all(motorcycleId) as Omit[]; + const items = db + .prepare( + `SELECT item.id, item.maintenance_event_id, item.type, item.custom_type, + item.cost_cents, item.due_date, item.due_km + FROM maintenance_event_items item + JOIN maintenance_events e ON e.id = item.maintenance_event_id + WHERE e.motorcycle_id = ? + ORDER BY e.event_date DESC, e.id DESC, item.position`, + ) + .all(motorcycleId) as MaintenanceItemRow[]; + const events: MaintenanceEventRow[] = eventRows.map((event) => ({ + ...event, + items: items.filter((item) => item.maintenance_event_id === event.id), + })); + + return ( + <> +
+ Übersicht + / + + {formatMotorcycleName(bike.brand, bike.model)} + + / + Alle Wartungen +
+
+
+

Werkstattbuch

+

Alle Wartungseinträge

+

{events.length} Einträge

+
+ Zurück zum Motorrad +
+ + + ); +} diff --git a/src/app/(app)/motorcycles/[id]/page.tsx b/src/app/(app)/motorcycles/[id]/page.tsx index c5e0400..3fb93d6 100644 --- a/src/app/(app)/motorcycles/[id]/page.tsx +++ b/src/app/(app)/motorcycles/[id]/page.tsx @@ -1,29 +1,32 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { - deleteMaintenanceAction, deleteMotorcycleAction, - deleteTripAction, revokeMotorcycleShareAction, shareMotorcycleAction, } from "@/app/actions/motorcycles"; import { Flash } from "@/components/flash"; import { InspectionSticker } from "@/components/inspection-sticker"; import { MaintenanceForm } from "@/components/maintenance-form"; +import { + MaintenanceHistory, + type MaintenanceEventRow, + type MaintenanceItemRow, +} from "@/components/maintenance-history"; import { StatusPill } from "@/components/status-pill"; import { TripForm } from "@/components/trip-form"; +import { TripHistory, type TripRow } from "@/components/trip-history"; import { requireSessionUser } from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; import { formatDate, formatKm, - formatMoney, formatMotorcycleName, queryMessage, } from "@/lib/format"; import { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access"; import { getDueStatus } from "@/lib/status"; -import { maintenanceLabels, type MaintenanceType } from "@/lib/types"; +import type { MaintenanceType } from "@/lib/types"; export const runtime = "nodejs"; @@ -51,37 +54,6 @@ type MotorcycleRow = { owner_name: string; }; -type EventRow = { - id: number; - description: string | null; - event_date: string; - km: number | null; - creator: string; - attachment_id: number | null; - attachment_name: string | null; - items: MaintenanceItemRow[]; -}; - -type MaintenanceItemRow = { - id: number; - maintenance_event_id: number; - type: MaintenanceType | null; - custom_type: string | null; - cost_cents: number | null; - due_date: string | null; - due_km: number | null; -}; - -type TripRow = { - id: number; - trip_date: string; - driver: string; - start_km: number; - end_km: number; - purpose: string | null; - notes: string | null; -}; - type ShareRow = { id: number; name: string; @@ -92,10 +64,6 @@ function latestDue(items: MaintenanceItemRow[], type: MaintenanceType) { return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null }; } -function maintenanceItemLabel(item: MaintenanceItemRow) { - return item.type ? maintenanceLabels[item.type] : item.custom_type ?? "Eigene Art"; -} - export default async function MotorcycleDetailPage({ params, searchParams, @@ -132,9 +100,13 @@ export default async function MotorcycleDetailPage({ LEFT JOIN users u ON u.id = e.created_by LEFT JOIN attachments a ON a.maintenance_event_id = e.id WHERE e.motorcycle_id = ? - ORDER BY e.event_date DESC, e.id DESC`, + ORDER BY e.event_date DESC, e.id DESC + LIMIT 1`, ) - .all(motorcycleId) as Omit[]; + .all(motorcycleId) as Omit[]; + const eventCount = (db + .prepare("SELECT COUNT(*) AS count FROM maintenance_events WHERE motorcycle_id = ?") + .get(motorcycleId) as { count: number }).count; const maintenanceItems = db .prepare( `SELECT item.id, item.maintenance_event_id, item.type, item.custom_type, @@ -145,7 +117,7 @@ export default async function MotorcycleDetailPage({ ORDER BY e.event_date DESC, e.id DESC, item.position`, ) .all(motorcycleId) as MaintenanceItemRow[]; - const events: EventRow[] = eventRows.map((event) => ({ + const events: MaintenanceEventRow[] = eventRows.map((event) => ({ ...event, items: maintenanceItems.filter((item) => item.maintenance_event_id === event.id), })); @@ -156,9 +128,13 @@ export default async function MotorcycleDetailPage({ FROM trips t LEFT JOIN users u ON u.id = t.driver_user_id WHERE t.motorcycle_id = ? - ORDER BY t.trip_date DESC, t.id DESC`, + ORDER BY t.trip_date DESC, t.id DESC + LIMIT 1`, ) .all(motorcycleId) as TripRow[]; + const tripCount = (db + .prepare("SELECT COUNT(*) AS count FROM trips WHERE motorcycle_id = ?") + .get(motorcycleId) as { count: number }).count; const drivers = db .prepare("SELECT id, name FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE") @@ -328,76 +304,13 @@ export default async function MotorcycleDetailPage({

Werkstattbuch

Wartungshistorie

- -
- {events.length === 0 ? ( -

Noch keine Wartungseinträge erfasst.

- ) : ( - events.map((event) => ( -
-
-
- {event.items.map((item) => ( - - {maintenanceItemLabel(item)} - - ))} -
-
- {formatDate(event.event_date)} - {formatKm(event.km)} - von {event.creator} - {event.attachment_id && ( - - 📎 {event.attachment_name} - - )} -
- {event.description &&

{event.description}

} -
- - - - - - - - - - {event.items.map((item) => ( - - - - - - ))} - -
ArtKostenNächste Fälligkeit
{maintenanceItemLabel(item)}{formatMoney(item.cost_cents)} - {item.due_date || item.due_km != null - ? `${formatDate(item.due_date)} · ${formatKm(item.due_km)}` - : "—"} -
-
-
-
- - Bearbeiten - -
- - -
-
-
- )) + {eventCount > 1 && ( + + Alle anzeigen ({eventCount}) + )}
+
Wartungseintrag hinzufügen @@ -410,52 +323,13 @@ export default async function MotorcycleDetailPage({

Fahrtenbuch

Fahrten

- -
- {trips.length === 0 ? ( -

Noch keine Fahrten erfasst.

- ) : ( - - - - - - - - - - - - - - {trips.map((trip) => ( - - - - - - - - - - ))} - -
DatumFahrerStartEndeDistanzZweck
{formatDate(trip.trip_date)}{trip.driver}{formatKm(trip.start_km)}{formatKm(trip.end_km)}{formatKm(trip.end_km - trip.start_km)}{trip.purpose ?? "—"} -
- - Bearbeiten - -
- - -
-
-
+ {tripCount > 1 && ( + + Alle anzeigen ({tripCount}) + )}
+
Fahrt eintragen ; +}) { + const user = await requireSessionUser(); + initDatabase(); + const { id } = await params; + const motorcycleId = Number(id); + if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound(); + + const visible = motorcycleAccessFilter(user); + const bike = db + .prepare( + `SELECT m.id, m.brand, m.model + FROM motorcycles m + WHERE m.id = ? AND ${visible.clause}`, + ) + .get(motorcycleId, ...visible.params) as MotorcycleRow | undefined; + if (!bike) notFound(); + + const trips = db + .prepare( + `SELECT t.id, t.trip_date, COALESCE(u.name, 'Gelöschter Benutzer') AS driver, + t.start_km, t.end_km, t.purpose, t.notes + FROM trips t + LEFT JOIN users u ON u.id = t.driver_user_id + WHERE t.motorcycle_id = ? + ORDER BY t.trip_date DESC, t.id DESC`, + ) + .all(motorcycleId) as TripRow[]; + + return ( + <> +
+ Übersicht + / + + {formatMotorcycleName(bike.brand, bike.model)} + + / + Alle Fahrten +
+
+
+

Fahrtenbuch

+

Alle Fahrten

+

{trips.length} Einträge

+
+ Zurück zum Motorrad +
+ + + ); +} diff --git a/src/app/actions/sessions.ts b/src/app/actions/sessions.ts new file mode 100644 index 0000000..9952019 --- /dev/null +++ b/src/app/actions/sessions.ts @@ -0,0 +1,61 @@ +"use server"; + +import { redirect, unstable_rethrow } from "next/navigation"; +import { requireAdminUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; +import { idValue, text } from "@/lib/validation"; + +function sessionsUrl(kind: "error" | "success", message: string) { + return `/admin/sessions?${kind}=${encodeURIComponent(message)}`; +} + +export async function revokeSessionAction(formData: FormData) { + await requireAdminUser(); + initDatabase(); + try { + const id = text(formData, "id", { required: true, max: 100 }); + const result = db + .prepare( + `UPDATE user_sessions + SET revoked_at = CURRENT_TIMESTAMP + WHERE id = ? AND revoked_at IS NULL`, + ) + .run(id); + if (result.changes === 0) throw new Error("Sitzung wurde nicht gefunden."); + redirect(sessionsUrl("success", "Sitzung wurde beendet.")); + } catch (error) { + unstable_rethrow(error); + redirect( + sessionsUrl( + "error", + error instanceof Error ? error.message : "Sitzung konnte nicht beendet werden.", + ), + ); + } +} + +export async function revokeUserSessionsAction(formData: FormData) { + await requireAdminUser(); + initDatabase(); + try { + const userId = idValue(formData, "user_id"); + const result = db + .prepare( + `UPDATE user_sessions + SET revoked_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND revoked_at IS NULL + AND datetime(expires_at) > CURRENT_TIMESTAMP`, + ) + .run(userId); + if (result.changes === 0) throw new Error("Keine aktive Sitzung gefunden."); + redirect(sessionsUrl("success", `${result.changes} Sitzung(en) wurden beendet.`)); + } catch (error) { + unstable_rethrow(error); + redirect( + sessionsUrl( + "error", + error instanceof Error ? error.message : "Sitzungen konnten nicht beendet werden.", + ), + ); + } +} diff --git a/src/app/api/auth/webauthn/login/verify/route.ts b/src/app/api/auth/webauthn/login/verify/route.ts index e8725b4..9ce494d 100644 --- a/src/app/api/auth/webauthn/login/verify/route.ts +++ b/src/app/api/auth/webauthn/login/verify/route.ts @@ -1,9 +1,12 @@ import { verifyAuthenticationResponse } from "@simplewebauthn/server"; import { NextResponse } from "next/server"; -import { SignJWT } from "jose"; -import { ADMIN_MFA_COOKIE, getPendingAdminMfaUserId, SESSION_COOKIE } from "@/lib/auth"; +import { + ADMIN_MFA_COOKIE, + createSessionToken, + getPendingAdminMfaUserId, + SESSION_COOKIE, +} from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; -import { getAuthSecret } from "@/lib/env"; import { consumeWebAuthnChallenge, getUserWebAuthnCredentials, @@ -72,12 +75,13 @@ export async function POST(request: Request) { .get(userId) as { id: number; name: string; email: string; role: "admin" } | undefined; if (!user) return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 401 }); - const secret = new TextEncoder().encode(getAuthSecret()); - const token = await new SignJWT({ id: user.id }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime(`${SESSION_TTL_SECONDS}s`) - .sign(secret); + const token = await createSessionToken(user, { + userAgent: request.headers.get("user-agent"), + ipAddress: + request.headers.get("x-forwarded-for")?.split(",")[0] ?? + request.headers.get("x-real-ip"), + lastPath: "/", + }); const response = NextResponse.json({ ok: true }); response.cookies.set(SESSION_COOKIE, token, { httpOnly: true, diff --git a/src/app/api/session/activity/route.ts b/src/app/api/session/activity/route.ts new file mode 100644 index 0000000..38ff0fb --- /dev/null +++ b/src/app/api/session/activity/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getCurrentSessionId, getSessionUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; + +export const runtime = "nodejs"; + +type ActivityBody = { + path?: unknown; +}; + +export async function POST(request: Request) { + const user = await getSessionUser(); + const sessionId = await getCurrentSessionId(); + if (!user || !sessionId) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + + const body = await request.json() as ActivityBody; + const path = typeof body.path === "string" ? body.path.trim() : ""; + if (!path.startsWith("/") || path.length > 500 || /[\u0000-\u001f]/.test(path)) { + return NextResponse.json({ error: "Ungültiger Pfad." }, { status: 400 }); + } + + initDatabase(); + const result = db + .prepare( + `UPDATE user_sessions + SET last_seen_at = CURRENT_TIMESTAMP, last_path = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL`, + ) + .run(path, sessionId, user.id); + if (result.changes === 0) { + return NextResponse.json({ error: "Sitzung wurde nicht gefunden." }, { status: 404 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 32c4364..3ebe14b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -273,7 +273,14 @@ footer { .section-block { margin-top: 2.2rem; } -.section-heading { margin-bottom: 0.9rem; } +.section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.9rem; +} /* ---------- Flash ---------- */ .flash { @@ -653,6 +660,18 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); } min-height: 180px; } +.session-table td { vertical-align: top; } +.session-detail { display: block; color: var(--steel); margin-top: 0.15rem; } +.session-user-agent { + display: block; + max-width: 360px; + margin-top: 0.35rem; + overflow-wrap: anywhere; + color: var(--steel); +} +.session-user-actions { display: flex; flex-direction: column; gap: 0.8rem; } +.session-user-actions .inline-form { justify-content: space-between; } + .inline-form { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; } .inline-form select { width: auto; } diff --git a/src/components/maintenance-history.tsx b/src/components/maintenance-history.tsx new file mode 100644 index 0000000..5ce8a24 --- /dev/null +++ b/src/components/maintenance-history.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import { deleteMaintenanceAction } from "@/app/actions/motorcycles"; +import { formatDate, formatKm, formatMoney } from "@/lib/format"; +import { maintenanceLabels, type MaintenanceType } from "@/lib/types"; + +export type MaintenanceItemRow = { + id: number; + maintenance_event_id: number; + type: MaintenanceType | null; + custom_type: string | null; + cost_cents: number | null; + due_date: string | null; + due_km: number | null; +}; + +export type MaintenanceEventRow = { + id: number; + description: string | null; + event_date: string; + km: number | null; + creator: string; + attachment_id: number | null; + attachment_name: string | null; + items: MaintenanceItemRow[]; +}; + +function maintenanceItemLabel(item: MaintenanceItemRow) { + return item.type ? maintenanceLabels[item.type] : item.custom_type ?? "Eigene Art"; +} + +export function MaintenanceHistory({ + motorcycleId, + events, +}: { + motorcycleId: number; + events: MaintenanceEventRow[]; +}) { + return ( +
+ {events.length === 0 ? ( +

Noch keine Wartungseinträge erfasst.

+ ) : ( + events.map((event) => ( +
+
+
+ {event.items.map((item) => ( + + {maintenanceItemLabel(item)} + + ))} +
+
+ {formatDate(event.event_date)} + {formatKm(event.km)} + von {event.creator} + {event.attachment_id && ( + + 📎 {event.attachment_name} + + )} +
+ {event.description &&

{event.description}

} +
+ + + + + + + + + + {event.items.map((item) => ( + + + + + + ))} + +
ArtKostenNächste Fälligkeit
{maintenanceItemLabel(item)}{formatMoney(item.cost_cents)} + {item.due_date || item.due_km != null + ? `${formatDate(item.due_date)} · ${formatKm(item.due_km)}` + : "—"} +
+
+
+
+ + Bearbeiten + +
+ + +
+
+
+ )) + )} +
+ ); +} diff --git a/src/components/session-activity-tracker.tsx b/src/components/session-activity-tracker.tsx new file mode 100644 index 0000000..52c4802 --- /dev/null +++ b/src/components/session-activity-tracker.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; + +export function SessionActivityTracker() { + const pathname = usePathname(); + + useEffect(() => { + const reportActivity = async () => { + const response = await fetch("/api/session/activity", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: pathname }), + keepalive: true, + }); + if (!response.ok) { + console.error("Sitzungsaktivität konnte nicht aktualisiert werden."); + } + }; + void reportActivity().catch((error: unknown) => { + console.error("Sitzungsaktivität konnte nicht aktualisiert werden.", error); + }); + }, [pathname]); + + return null; +} diff --git a/src/components/trip-history.tsx b/src/components/trip-history.tsx new file mode 100644 index 0000000..69c8b8d --- /dev/null +++ b/src/components/trip-history.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; +import { deleteTripAction } from "@/app/actions/motorcycles"; +import { formatDate, formatKm } from "@/lib/format"; + +export type TripRow = { + id: number; + trip_date: string; + driver: string; + start_km: number; + end_km: number; + purpose: string | null; + notes: string | null; +}; + +export function TripHistory({ + motorcycleId, + trips, +}: { + motorcycleId: number; + trips: TripRow[]; +}) { + return ( +
+ {trips.length === 0 ? ( +

Noch keine Fahrten erfasst.

+ ) : ( + + + + + + + + + + + + + + {trips.map((trip) => ( + + + + + + + + + + ))} + +
DatumFahrerStartEndeDistanzZweck
{formatDate(trip.trip_date)}{trip.driver}{formatKm(trip.start_km)}{formatKm(trip.end_km)}{formatKm(trip.end_km - trip.start_km)}{trip.purpose ?? "—"} +
+ + Bearbeiten + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4d6c25a..65940dc 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,7 @@ import { compareSync } from "bcryptjs"; +import { randomUUID } from "node:crypto"; import { jwtVerify, SignJWT } from "jose"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { db, initDatabase } from "./db"; import { getAuthSecret } from "./env"; @@ -8,6 +9,7 @@ import type { SessionUser, UserRole } from "./types"; type SessionPayload = { id: number; + sid: string; exp: number; }; @@ -26,8 +28,35 @@ function getSecretKey() { return new TextEncoder().encode(getAuthSecret()); } -export async function signSession(user: SessionUser) { - return new SignJWT({ id: user.id }) +type SessionMetadata = { + userAgent?: string | null; + ipAddress?: string | null; + lastPath?: string | null; +}; + +function cleanMetadata(value: string | null | undefined, max: number) { + const cleaned = value?.replace(/[\u0000-\u001f]/g, "").trim(); + return cleaned ? cleaned.slice(0, max) : null; +} + +export async function createSessionToken(user: SessionUser, metadata: SessionMetadata = {}) { + initDatabase(); + const sessionId = randomUUID(); + const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000).toISOString(); + db.prepare( + `INSERT INTO user_sessions + (id, user_id, user_agent, ip_address, last_path, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + sessionId, + user.id, + cleanMetadata(metadata.userAgent, 500), + cleanMetadata(metadata.ipAddress, 100), + cleanMetadata(metadata.lastPath, 500), + expiresAt, + ); + + return new SignJWT({ id: user.id, sid: sessionId }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(`${SESSION_TTL_SECONDS}s`) @@ -35,7 +64,14 @@ export async function signSession(user: SessionUser) { } export async function setSessionCookie(user: SessionUser) { - const token = await signSession(user); + const requestHeaders = await headers(); + const token = await createSessionToken(user, { + userAgent: requestHeaders.get("user-agent"), + ipAddress: + requestHeaders.get("x-forwarded-for")?.split(",")[0] ?? + requestHeaders.get("x-real-ip"), + lastPath: "/", + }); const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE, token, { httpOnly: true, @@ -48,7 +84,22 @@ export async function setSessionCookie(user: SessionUser) { } export async function clearSessionCookie() { - (await cookies()).delete(SESSION_COOKIE); + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE)?.value; + if (token) { + try { + const result = await jwtVerify(token, getSecretKey()); + const payload = result.payload as unknown as SessionPayload; + if (typeof payload.sid === "string") { + initDatabase(); + db.prepare("UPDATE user_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?") + .run(payload.sid); + } + } catch { + // The local cookie is removed even if it is already invalid. + } + } + cookieStore.delete(SESSION_COOKIE); } export async function setPendingAdminMfaCookie(userId: number) { @@ -93,18 +144,46 @@ export async function getSessionUser(): Promise { try { const result = await jwtVerify(token, getSecretKey()); const payload = result.payload as unknown as SessionPayload; + if (typeof payload.sid !== "string" || !payload.sid) return null; initDatabase(); const user = db - .prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1") - .get(Number(payload.id)) as + .prepare( + `SELECT u.id, u.name, u.email, u.role + FROM user_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.id = ? AND s.user_id = ? + AND s.revoked_at IS NULL + AND datetime(s.expires_at) > CURRENT_TIMESTAMP + AND u.active = 1`, + ) + .get(payload.sid, Number(payload.id)) as | { id: number; name: string; email: string; role: UserRole } | undefined; + if (!user) return null; + db.prepare( + `UPDATE user_sessions + SET last_seen_at = CURRENT_TIMESTAMP + WHERE id = ? + AND last_seen_at < datetime('now', '-30 seconds')`, + ).run(payload.sid); return user ?? null; } catch { return null; } } +export async function getCurrentSessionId() { + const token = (await cookies()).get(SESSION_COOKIE)?.value; + if (!token) return null; + try { + const result = await jwtVerify(token, getSecretKey()); + const payload = result.payload as unknown as SessionPayload; + return typeof payload.sid === "string" && payload.sid ? payload.sid : null; + } catch { + return null; + } +} + export async function requireSessionUser() { const user = await getSessionUser(); if (!user) redirect("/login"); diff --git a/src/lib/db.ts b/src/lib/db.ts index 7c79b69..4fe722f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -33,6 +33,18 @@ function ensureSchema() { created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; + CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_agent TEXT, + ip_address TEXT, + last_path TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + revoked_at TEXT + ) STRICT; + CREATE TABLE IF NOT EXISTS motorcycles ( id INTEGER PRIMARY KEY AUTOINCREMENT, nickname TEXT NOT NULL, @@ -161,6 +173,8 @@ function ensureSchema() { ON trips(motorcycle_id, trip_date DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user ON motorcycle_shares(user_id, motorcycle_id); + CREATE INDEX IF NOT EXISTS idx_user_sessions_active + ON user_sessions(user_id, revoked_at, expires_at, last_seen_at DESC); CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_lookup diff --git a/src/lib/format.ts b/src/lib/format.ts index 62d81e5..b70a9ab 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -3,6 +3,15 @@ export function formatDate(value: string | null | undefined) { return new Intl.DateTimeFormat("de-DE").format(new Date(`${value}T00:00:00`)); } +export function formatDateTime(value: string | null | undefined) { + if (!value) return "—"; + const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`; + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(normalized)); +} + export function formatMoney(cents: number | null | undefined) { if (cents == null) return "—"; return new Intl.NumberFormat("de-DE", {