Add maintenance and trip history components, admin session management, and session activity tracking
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="app-frame">
|
||||
<SessionActivityTracker />
|
||||
<header className="topbar">
|
||||
<Link href="/" className="brand">
|
||||
<span className="brand-mark">FG</span>
|
||||
@@ -22,6 +24,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
{user.role === "admin" && (
|
||||
<>
|
||||
<Link href="/admin/users">Benutzer</Link>
|
||||
<Link href="/admin/sessions">Sitzungen</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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<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
|
||||
.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({
|
||||
<p className="eyebrow">Werkstattbuch</p>
|
||||
<h2>Wartungshistorie</h2>
|
||||
</div>
|
||||
</div>
|
||||
<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/${bike.id}/maintenance/${event.id}/edit`}
|
||||
>
|
||||
Bearbeiten
|
||||
{eventCount > 1 && (
|
||||
<Link className="button" href={`/motorcycles/${bike.id}/maintenance/history`}>
|
||||
Alle anzeigen ({eventCount})
|
||||
</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>
|
||||
<MaintenanceHistory motorcycleId={bike.id} events={events} />
|
||||
<details className="add-block">
|
||||
<summary className="button button-primary">Wartungseintrag hinzufügen</summary>
|
||||
<MaintenanceForm motorcycleId={bike.id} />
|
||||
@@ -410,52 +323,13 @@ export default async function MotorcycleDetailPage({
|
||||
<p className="eyebrow">Fahrtenbuch</p>
|
||||
<h2>Fahrten</h2>
|
||||
</div>
|
||||
</div>
|
||||
<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/${bike.id}/trips/${trip.id}/edit`}
|
||||
>
|
||||
Bearbeiten
|
||||
{tripCount > 1 && (
|
||||
<Link className="button" href={`/motorcycles/${bike.id}/trips/history`}>
|
||||
Alle anzeigen ({tripCount})
|
||||
</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>
|
||||
<TripHistory motorcycleId={bike.id} trips={trips} />
|
||||
<details className="add-block">
|
||||
<summary className="button button-primary">Fahrt eintragen</summary>
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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<SessionUser | null> {
|
||||
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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user