Add maintenance and trip history components, admin session management, and session activity tracking

This commit is contained in:
2026-07-27 21:38:49 +02:00
parent 1218c13660
commit c63d1a641f
16 changed files with 822 additions and 176 deletions
-4
View File
@@ -45,10 +45,6 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(new URL("/login", request.url)); return NextResponse.redirect(new URL("/login", request.url));
} }
if (validSession && pathname === "/login") {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next(); return NextResponse.next();
} }
+184
View File
@@ -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<Record<string, string | string[] | undefined>>;
}) {
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 (
<>
<div className="breadcrumbs">
<Link href="/">Übersicht</Link>
<span>/</span>
Aktive Sitzungen
</div>
<div className="page-heading">
<div>
<p className="eyebrow">Administration</p>
<h1>Aktive Sitzungen</h1>
<p className="muted">
{sessions.length} aktive Anmeldung(en) von{" "}
{new Set(sessions.map((session) => session.user_id)).size} Benutzer(n)
</p>
</div>
</div>
<Flash error={queryMessage(params, "error")} success={queryMessage(params, "success")} />
<div className="panel table-wrap">
{sessions.length === 0 ? (
<p className="muted">Keine aktiven Sitzungen vorhanden.</p>
) : (
<table className="data-table session-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Gerät</th>
<th>IP-Adresse</th>
<th>Aktivität</th>
<th>Angemeldet seit</th>
<th>Letzter Bereich</th>
<th></th>
</tr>
</thead>
<tbody>
{sessions.map((session) => {
const isCurrent = session.id === currentSessionId;
return (
<tr key={session.id}>
<td>
<strong>{session.name}</strong>
<small className="session-detail">{session.email}</small>
{session.session_count > 1 && (
<span className="badge">{session.session_count} Geräte</span>
)}
{isCurrent && <span className="badge">Diese Sitzung</span>}
</td>
<td>
{deviceLabel(session.user_agent)}
{session.user_agent && (
<details>
<summary className="link-button">Details</summary>
<small className="session-user-agent mono">{session.user_agent}</small>
</details>
)}
</td>
<td className="mono">{session.ip_address ?? "—"}</td>
<td>
<span className="status-pill status-ok">
{activityLabel(session.last_seen_at)}
</span>
</td>
<td>{formatDateTime(session.created_at)}</td>
<td className="mono">{session.last_path ?? "—"}</td>
<td>
<form action={revokeSessionAction}>
<input type="hidden" name="id" value={session.id} />
<button className="link-button danger" type="submit">
Abmelden
</button>
</form>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{sessions.some((session) => session.session_count > 1) && (
<section className="section-block">
<div className="section-heading">
<div>
<p className="eyebrow">Mehrfachanmeldungen</p>
<h2>Alle Sitzungen eines Benutzers beenden</h2>
</div>
</div>
<div className="panel session-user-actions">
{Array.from(
new Map(sessions.map((session) => [session.user_id, session])).values(),
).filter((session) => session.session_count > 1).map((session) => (
<form action={revokeUserSessionsAction} className="inline-form" key={session.user_id}>
<input type="hidden" name="user_id" value={session.user_id} />
<span>{session.name} ({session.session_count})</span>
<button className="button button-danger" type="submit">
Alle abmelden
</button>
</form>
))}
</div>
</section>
)}
</>
);
}
+3
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { logoutAction } from "@/app/actions/auth"; import { logoutAction } from "@/app/actions/auth";
import { SessionActivityTracker } from "@/components/session-activity-tracker";
import { requireSessionUser } from "@/lib/auth"; import { requireSessionUser } from "@/lib/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -8,6 +9,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
const user = await requireSessionUser(); const user = await requireSessionUser();
return ( return (
<div className="app-frame"> <div className="app-frame">
<SessionActivityTracker />
<header className="topbar"> <header className="topbar">
<Link href="/" className="brand"> <Link href="/" className="brand">
<span className="brand-mark">FG</span> <span className="brand-mark">FG</span>
@@ -22,6 +24,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user.role === "admin" && ( {user.role === "admin" && (
<> <>
<Link href="/admin/users">Benutzer</Link> <Link href="/admin/users">Benutzer</Link>
<Link href="/admin/sessions">Sitzungen</Link>
<Link href="/admin/database">Datenbank</Link> <Link href="/admin/database">Datenbank</Link>
</> </>
)} )}
@@ -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<MaintenanceEventRow, "items">[];
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 (
<>
<div className="breadcrumbs">
<Link href="/">Übersicht</Link>
<span>/</span>
<Link href={`/motorcycles/${bike.id}`}>
{formatMotorcycleName(bike.brand, bike.model)}
</Link>
<span>/</span>
Alle Wartungen
</div>
<div className="page-heading">
<div>
<p className="eyebrow">Werkstattbuch</p>
<h1>Alle Wartungseinträge</h1>
<p className="muted">{events.length} Einträge</p>
</div>
<Link className="button" href={`/motorcycles/${bike.id}`}>Zurück zum Motorrad</Link>
</div>
<MaintenanceHistory motorcycleId={bike.id} events={events} />
</>
);
}
+29 -155
View File
@@ -1,29 +1,32 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
deleteMaintenanceAction,
deleteMotorcycleAction, deleteMotorcycleAction,
deleteTripAction,
revokeMotorcycleShareAction, revokeMotorcycleShareAction,
shareMotorcycleAction, shareMotorcycleAction,
} from "@/app/actions/motorcycles"; } from "@/app/actions/motorcycles";
import { Flash } from "@/components/flash"; import { Flash } from "@/components/flash";
import { InspectionSticker } from "@/components/inspection-sticker"; import { InspectionSticker } from "@/components/inspection-sticker";
import { MaintenanceForm } from "@/components/maintenance-form"; import { MaintenanceForm } from "@/components/maintenance-form";
import {
MaintenanceHistory,
type MaintenanceEventRow,
type MaintenanceItemRow,
} from "@/components/maintenance-history";
import { StatusPill } from "@/components/status-pill"; import { StatusPill } from "@/components/status-pill";
import { TripForm } from "@/components/trip-form"; import { TripForm } from "@/components/trip-form";
import { TripHistory, type TripRow } from "@/components/trip-history";
import { requireSessionUser } from "@/lib/auth"; import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db"; import { db, initDatabase } from "@/lib/db";
import { import {
formatDate, formatDate,
formatKm, formatKm,
formatMoney,
formatMotorcycleName, formatMotorcycleName,
queryMessage, queryMessage,
} from "@/lib/format"; } from "@/lib/format";
import { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access"; import { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access";
import { getDueStatus } from "@/lib/status"; import { getDueStatus } from "@/lib/status";
import { maintenanceLabels, type MaintenanceType } from "@/lib/types"; import type { MaintenanceType } from "@/lib/types";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -51,37 +54,6 @@ type MotorcycleRow = {
owner_name: string; 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 = { type ShareRow = {
id: number; id: number;
name: string; name: string;
@@ -92,10 +64,6 @@ function latestDue(items: MaintenanceItemRow[], type: MaintenanceType) {
return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null }; 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({ export default async function MotorcycleDetailPage({
params, params,
searchParams, searchParams,
@@ -132,9 +100,13 @@ export default async function MotorcycleDetailPage({
LEFT JOIN users u ON u.id = e.created_by LEFT JOIN users u ON u.id = e.created_by
LEFT JOIN attachments a ON a.maintenance_event_id = e.id LEFT JOIN attachments a ON a.maintenance_event_id = e.id
WHERE e.motorcycle_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<EventRow, "items">[]; .all(motorcycleId) as Omit<MaintenanceEventRow, "items">[];
const eventCount = (db
.prepare("SELECT COUNT(*) AS count FROM maintenance_events WHERE motorcycle_id = ?")
.get(motorcycleId) as { count: number }).count;
const maintenanceItems = db const maintenanceItems = db
.prepare( .prepare(
`SELECT item.id, item.maintenance_event_id, item.type, item.custom_type, `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`, ORDER BY e.event_date DESC, e.id DESC, item.position`,
) )
.all(motorcycleId) as MaintenanceItemRow[]; .all(motorcycleId) as MaintenanceItemRow[];
const events: EventRow[] = eventRows.map((event) => ({ const events: MaintenanceEventRow[] = eventRows.map((event) => ({
...event, ...event,
items: maintenanceItems.filter((item) => item.maintenance_event_id === event.id), items: maintenanceItems.filter((item) => item.maintenance_event_id === event.id),
})); }));
@@ -156,9 +128,13 @@ export default async function MotorcycleDetailPage({
FROM trips t FROM trips t
LEFT JOIN users u ON u.id = t.driver_user_id LEFT JOIN users u ON u.id = t.driver_user_id
WHERE t.motorcycle_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[]; .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 const drivers = db
.prepare("SELECT id, name FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE") .prepare("SELECT id, name FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE")
@@ -328,76 +304,13 @@ export default async function MotorcycleDetailPage({
<p className="eyebrow">Werkstattbuch</p> <p className="eyebrow">Werkstattbuch</p>
<h2>Wartungshistorie</h2> <h2>Wartungshistorie</h2>
</div> </div>
</div> {eventCount > 1 && (
<div className="panel timeline"> <Link className="button" href={`/motorcycles/${bike.id}/maintenance/history`}>
{events.length === 0 ? ( Alle anzeigen ({eventCount})
<p className="muted">Noch keine Wartungseinträge erfasst.</p> </Link>
) : (
events.map((event) => (
<article className="timeline-item" key={event.id}>
<div className="timeline-main">
<div className="timeline-head">
{event.items.map((item) => (
<span
className={`type-tag type-${item.type ?? "sonstiges"}`}
key={item.id}
>
{maintenanceItemLabel(item)}
</span>
))}
</div>
<div className="timeline-meta">
<strong>{formatDate(event.event_date)}</strong>
<span>{formatKm(event.km)}</span>
<span>von {event.creator}</span>
{event.attachment_id && (
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
📎 {event.attachment_name}
</a>
)}
</div>
{event.description && <p className="notes">{event.description}</p>}
<div className="table-wrap">
<table className="data-table maintenance-history-table">
<thead>
<tr>
<th>Art</th>
<th>Kosten</th>
<th>Nächste Fälligkeit</th>
</tr>
</thead>
<tbody>
{event.items.map((item) => (
<tr key={item.id}>
<td>{maintenanceItemLabel(item)}</td>
<td>{formatMoney(item.cost_cents)}</td>
<td>
{item.due_date || item.due_km != null
? `${formatDate(item.due_date)} · ${formatKm(item.due_km)}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="inline-form">
<Link
className="link-button"
href={`/motorcycles/${bike.id}/maintenance/${event.id}/edit`}
>
Bearbeiten
</Link>
<form action={deleteMaintenanceAction} className="inline-form">
<input type="hidden" name="id" value={event.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
</div>
</article>
))
)} )}
</div> </div>
<MaintenanceHistory motorcycleId={bike.id} events={events} />
<details className="add-block"> <details className="add-block">
<summary className="button button-primary">Wartungseintrag hinzufügen</summary> <summary className="button button-primary">Wartungseintrag hinzufügen</summary>
<MaintenanceForm motorcycleId={bike.id} /> <MaintenanceForm motorcycleId={bike.id} />
@@ -410,52 +323,13 @@ export default async function MotorcycleDetailPage({
<p className="eyebrow">Fahrtenbuch</p> <p className="eyebrow">Fahrtenbuch</p>
<h2>Fahrten</h2> <h2>Fahrten</h2>
</div> </div>
</div> {tripCount > 1 && (
<div className="panel table-wrap"> <Link className="button" href={`/motorcycles/${bike.id}/trips/history`}>
{trips.length === 0 ? ( Alle anzeigen ({tripCount})
<p className="muted">Noch keine Fahrten erfasst.</p> </Link>
) : (
<table className="data-table">
<thead>
<tr>
<th>Datum</th>
<th>Fahrer</th>
<th>Start</th>
<th>Ende</th>
<th>Distanz</th>
<th>Zweck</th>
<th></th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver}</td>
<td className="mono">{formatKm(trip.start_km)}</td>
<td className="mono">{formatKm(trip.end_km)}</td>
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
<td>{trip.purpose ?? "—"}</td>
<td>
<div className="inline-form">
<Link
className="link-button"
href={`/motorcycles/${bike.id}/trips/${trip.id}/edit`}
>
Bearbeiten
</Link>
<form action={deleteTripAction} className="inline-form">
<input type="hidden" name="id" value={trip.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
)} )}
</div> </div>
<TripHistory motorcycleId={bike.id} trips={trips} />
<details className="add-block"> <details className="add-block">
<summary className="button button-primary">Fahrt eintragen</summary> <summary className="button button-primary">Fahrt eintragen</summary>
<TripForm <TripForm
@@ -0,0 +1,71 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { TripHistory, type TripRow } from "@/components/trip-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 TripHistoryPage({
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 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 (
<>
<div className="breadcrumbs">
<Link href="/">Übersicht</Link>
<span>/</span>
<Link href={`/motorcycles/${bike.id}`}>
{formatMotorcycleName(bike.brand, bike.model)}
</Link>
<span>/</span>
Alle Fahrten
</div>
<div className="page-heading">
<div>
<p className="eyebrow">Fahrtenbuch</p>
<h1>Alle Fahrten</h1>
<p className="muted">{trips.length} Einträge</p>
</div>
<Link className="button" href={`/motorcycles/${bike.id}`}>Zurück zum Motorrad</Link>
</div>
<TripHistory motorcycleId={bike.id} trips={trips} />
</>
);
}
+61
View File
@@ -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.",
),
);
}
}
@@ -1,9 +1,12 @@
import { verifyAuthenticationResponse } from "@simplewebauthn/server"; import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { SignJWT } from "jose"; import {
import { ADMIN_MFA_COOKIE, getPendingAdminMfaUserId, SESSION_COOKIE } from "@/lib/auth"; ADMIN_MFA_COOKIE,
createSessionToken,
getPendingAdminMfaUserId,
SESSION_COOKIE,
} from "@/lib/auth";
import { db, initDatabase } from "@/lib/db"; import { db, initDatabase } from "@/lib/db";
import { getAuthSecret } from "@/lib/env";
import { import {
consumeWebAuthnChallenge, consumeWebAuthnChallenge,
getUserWebAuthnCredentials, getUserWebAuthnCredentials,
@@ -72,12 +75,13 @@ export async function POST(request: Request) {
.get(userId) as { id: number; name: string; email: string; role: "admin" } | undefined; .get(userId) as { id: number; name: string; email: string; role: "admin" } | undefined;
if (!user) return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 401 }); if (!user) return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 401 });
const secret = new TextEncoder().encode(getAuthSecret()); const token = await createSessionToken(user, {
const token = await new SignJWT({ id: user.id }) userAgent: request.headers.get("user-agent"),
.setProtectedHeader({ alg: "HS256" }) ipAddress:
.setIssuedAt() request.headers.get("x-forwarded-for")?.split(",")[0] ??
.setExpirationTime(`${SESSION_TTL_SECONDS}s`) request.headers.get("x-real-ip"),
.sign(secret); lastPath: "/",
});
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({ ok: true });
response.cookies.set(SESSION_COOKIE, token, { response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
+36
View File
@@ -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 });
}
+20 -1
View File
@@ -273,7 +273,14 @@ footer {
.section-block { margin-top: 2.2rem; } .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 ---------- */
.flash { .flash {
@@ -653,6 +660,18 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); }
min-height: 180px; 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 { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; }
.inline-form select { width: auto; } .inline-form select { width: auto; }
+109
View File
@@ -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 (
<div className="panel timeline">
{events.length === 0 ? (
<p className="muted">Noch keine Wartungseinträge erfasst.</p>
) : (
events.map((event) => (
<article className="timeline-item" key={event.id}>
<div className="timeline-main">
<div className="timeline-head">
{event.items.map((item) => (
<span
className={`type-tag type-${item.type ?? "sonstiges"}`}
key={item.id}
>
{maintenanceItemLabel(item)}
</span>
))}
</div>
<div className="timeline-meta">
<strong>{formatDate(event.event_date)}</strong>
<span>{formatKm(event.km)}</span>
<span>von {event.creator}</span>
{event.attachment_id && (
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
📎 {event.attachment_name}
</a>
)}
</div>
{event.description && <p className="notes">{event.description}</p>}
<div className="table-wrap">
<table className="data-table maintenance-history-table">
<thead>
<tr>
<th>Art</th>
<th>Kosten</th>
<th>Nächste Fälligkeit</th>
</tr>
</thead>
<tbody>
{event.items.map((item) => (
<tr key={item.id}>
<td>{maintenanceItemLabel(item)}</td>
<td>{formatMoney(item.cost_cents)}</td>
<td>
{item.due_date || item.due_km != null
? `${formatDate(item.due_date)} · ${formatKm(item.due_km)}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="inline-form">
<Link
className="link-button"
href={`/motorcycles/${motorcycleId}/maintenance/${event.id}/edit`}
>
Bearbeiten
</Link>
<form action={deleteMaintenanceAction} className="inline-form">
<input type="hidden" name="id" value={event.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
</div>
</article>
))
)}
</div>
);
}
@@ -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;
}
+69
View File
@@ -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 (
<div className="panel table-wrap">
{trips.length === 0 ? (
<p className="muted">Noch keine Fahrten erfasst.</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>Datum</th>
<th>Fahrer</th>
<th>Start</th>
<th>Ende</th>
<th>Distanz</th>
<th>Zweck</th>
<th></th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver}</td>
<td className="mono">{formatKm(trip.start_km)}</td>
<td className="mono">{formatKm(trip.end_km)}</td>
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
<td>{trip.purpose ?? "—"}</td>
<td>
<div className="inline-form">
<Link
className="link-button"
href={`/motorcycles/${motorcycleId}/trips/${trip.id}/edit`}
>
Bearbeiten
</Link>
<form action={deleteTripAction} className="inline-form">
<input type="hidden" name="id" value={trip.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+86 -7
View File
@@ -1,6 +1,7 @@
import { compareSync } from "bcryptjs"; import { compareSync } from "bcryptjs";
import { randomUUID } from "node:crypto";
import { jwtVerify, SignJWT } from "jose"; import { jwtVerify, SignJWT } from "jose";
import { cookies } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { db, initDatabase } from "./db"; import { db, initDatabase } from "./db";
import { getAuthSecret } from "./env"; import { getAuthSecret } from "./env";
@@ -8,6 +9,7 @@ import type { SessionUser, UserRole } from "./types";
type SessionPayload = { type SessionPayload = {
id: number; id: number;
sid: string;
exp: number; exp: number;
}; };
@@ -26,8 +28,35 @@ function getSecretKey() {
return new TextEncoder().encode(getAuthSecret()); return new TextEncoder().encode(getAuthSecret());
} }
export async function signSession(user: SessionUser) { type SessionMetadata = {
return new SignJWT({ id: user.id }) 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" }) .setProtectedHeader({ alg: "HS256" })
.setIssuedAt() .setIssuedAt()
.setExpirationTime(`${SESSION_TTL_SECONDS}s`) .setExpirationTime(`${SESSION_TTL_SECONDS}s`)
@@ -35,7 +64,14 @@ export async function signSession(user: SessionUser) {
} }
export async function setSessionCookie(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(); const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, { cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
@@ -48,7 +84,22 @@ export async function setSessionCookie(user: SessionUser) {
} }
export async function clearSessionCookie() { 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) { export async function setPendingAdminMfaCookie(userId: number) {
@@ -93,18 +144,46 @@ export async function getSessionUser(): Promise<SessionUser | null> {
try { try {
const result = await jwtVerify(token, getSecretKey()); const result = await jwtVerify(token, getSecretKey());
const payload = result.payload as unknown as SessionPayload; const payload = result.payload as unknown as SessionPayload;
if (typeof payload.sid !== "string" || !payload.sid) return null;
initDatabase(); initDatabase();
const user = db const user = db
.prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1") .prepare(
.get(Number(payload.id)) as `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 } | { id: number; name: string; email: string; role: UserRole }
| undefined; | 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; return user ?? null;
} catch { } catch {
return null; 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() { export async function requireSessionUser() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
+14
View File
@@ -33,6 +33,18 @@ function ensureSchema() {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT; ) 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 ( CREATE TABLE IF NOT EXISTS motorcycles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT NOT NULL, nickname TEXT NOT NULL,
@@ -161,6 +173,8 @@ function ensureSchema() {
ON trips(motorcycle_id, trip_date DESC, id DESC); ON trips(motorcycle_id, trip_date DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user
ON motorcycle_shares(user_id, motorcycle_id); 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 CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id); ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_lookup CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_lookup
+9
View File
@@ -3,6 +3,15 @@ export function formatDate(value: string | null | undefined) {
return new Intl.DateTimeFormat("de-DE").format(new Date(`${value}T00:00:00`)); 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) { export function formatMoney(cents: number | null | undefined) {
if (cents == null) return "—"; if (cents == null) return "—";
return new Intl.NumberFormat("de-DE", { return new Intl.NumberFormat("de-DE", {