Upgrade dependencies in package.json and update next, eslint, and related packages
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
npm-debug.log*
|
||||||
|
hs_err_pid*
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ADMIN_PASSWORD=change-this-password
|
||||||
|
ADMIN_NAME=Familien-Admin
|
||||||
|
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
|
||||||
|
WEBAUTHN_RP_ID=
|
||||||
|
WEBAUTHN_ORIGIN=
|
||||||
|
PORT=3000
|
||||||
@@ -34,6 +34,8 @@ yarn-error.log*
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
.package-lock.json
|
||||||
|
|
||||||
# runtime data
|
# runtime data
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
@@ -43,3 +45,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
.junie
|
||||||
|
|||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="moped-tracker" uuid="a7c0e633-7798-4f7e-85f6-ebc305e5bc17">
|
||||||
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/moped-tracker.sqlite</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/lib/motorcycle-access.ts" dialect="GenericSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
FROM node:24-alpine AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
AUTH_SECRET=build-only-auth-secret-not-used-at-runtime \
|
||||||
|
ADMIN_PASSWORD=build-only-password \
|
||||||
|
ADMIN_NAME=Build \
|
||||||
|
DATA_DIR=/tmp/moped-tracker-build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
HOSTNAME=0.0.0.0 \
|
||||||
|
PORT=3000 \
|
||||||
|
DATA_DIR=/app/data \
|
||||||
|
UPLOAD_DIR=/app/data/uploads
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs \
|
||||||
|
&& mkdir -p /app/data/uploads \
|
||||||
|
&& chown -R nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
ADMIN_PASSWORD: "${ADMIN_PASSWORD:?ADMIN_PASSWORD muss gesetzt sein}"
|
||||||
|
ADMIN_NAME: "${ADMIN_NAME:?ADMIN_NAME muss gesetzt sein}"
|
||||||
|
AUTH_SECRET: "${AUTH_SECRET:?AUTH_SECRET muss gesetzt sein}"
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: "${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY:?NEXT_SERVER_ACTIONS_ENCRYPTION_KEY muss gesetzt sein}"
|
||||||
|
WEBAUTHN_RP_ID: "${WEBAUTHN_RP_ID:-}"
|
||||||
|
WEBAUTHN_ORIGIN: "${WEBAUTHN_ORIGIN:-}"
|
||||||
|
DATA_DIR: /app/data
|
||||||
|
UPLOAD_DIR: /app/data/uploads
|
||||||
|
volumes:
|
||||||
|
- moped-data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
moped-data:
|
||||||
Generated
+10801
-3765
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -13,7 +13,7 @@
|
|||||||
"@simplewebauthn/server": "^13.3.2",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"next": "16.2.12",
|
"next": "^9.3.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^10.8.0",
|
||||||
"eslint-config-next": "16.2.12",
|
"eslint-config-next": "^0.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Flash } from "@/components/flash";
|
||||||
|
import { MaintenanceSpecsForm } from "@/components/maintenance-specs-form";
|
||||||
|
import { requireSessionUser } from "@/lib/auth";
|
||||||
|
import { db, initDatabase } from "@/lib/db";
|
||||||
|
import { queryMessage } from "@/lib/format";
|
||||||
|
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type MaintenanceEditRow = {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
year: number | null;
|
||||||
|
tire_pressure_front_bar: number | null;
|
||||||
|
tire_pressure_rear_bar: number | null;
|
||||||
|
chain_tension_min_mm: number | null;
|
||||||
|
chain_tension_max_mm: number | null;
|
||||||
|
oil_type: string | null;
|
||||||
|
oil_capacity_liters: number | null;
|
||||||
|
oil_check_temp: "warm" | "cold" | null;
|
||||||
|
oil_check_run_minutes: number | null;
|
||||||
|
oil_check_wait_min_minutes: number | null;
|
||||||
|
oil_check_wait_max_minutes: number | null;
|
||||||
|
oil_check_method: "dipstick" | "sight_glass" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function MotorcycleMaintenanceEditPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
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 id, nickname, brand, model, year,
|
||||||
|
tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm,
|
||||||
|
oil_type, oil_capacity_liters,
|
||||||
|
oil_check_temp, oil_check_run_minutes, oil_check_wait_min_minutes, oil_check_wait_max_minutes, oil_check_method
|
||||||
|
FROM motorcycles m
|
||||||
|
WHERE m.id = ? AND ${visible.clause}`,
|
||||||
|
)
|
||||||
|
.get(motorcycleId, ...visible.params) as MaintenanceEditRow | undefined;
|
||||||
|
if (!bike) notFound();
|
||||||
|
|
||||||
|
const params_ = await searchParams;
|
||||||
|
const maintenanceForForm = {
|
||||||
|
id: bike.id,
|
||||||
|
tire_pressure_front_bar: bike.tire_pressure_front_bar,
|
||||||
|
tire_pressure_rear_bar: bike.tire_pressure_rear_bar,
|
||||||
|
chain_tension_min_mm: bike.chain_tension_min_mm,
|
||||||
|
chain_tension_max_mm: bike.chain_tension_max_mm,
|
||||||
|
oil_type: bike.oil_type,
|
||||||
|
oil_capacity_liters: bike.oil_capacity_liters,
|
||||||
|
oil_check_temp: bike.oil_check_temp,
|
||||||
|
oil_check_run_minutes: bike.oil_check_run_minutes,
|
||||||
|
oil_check_wait_min_minutes: bike.oil_check_wait_min_minutes,
|
||||||
|
oil_check_wait_max_minutes: bike.oil_check_wait_max_minutes,
|
||||||
|
oil_check_method: bike.oil_check_method,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="breadcrumbs">
|
||||||
|
<Link href="/">Übersicht</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/motorcycles/${bike.id}/maintenance`}>Wartungsansicht</Link>
|
||||||
|
<span>/</span>
|
||||||
|
Werte bearbeiten
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||||
|
<h1>Wartungswerte bearbeiten</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Flash error={queryMessage(params_, "error")} success={queryMessage(params_, "success")} />
|
||||||
|
<MaintenanceSpecsForm motorcycle={maintenanceForForm} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { requireSessionUser } from "@/lib/auth";
|
||||||
|
import { db, initDatabase } from "@/lib/db";
|
||||||
|
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type MotorcycleMaintenanceRow = {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
year: number | null;
|
||||||
|
tire_pressure_front_bar: number | null;
|
||||||
|
tire_pressure_rear_bar: number | null;
|
||||||
|
chain_tension_min_mm: number | null;
|
||||||
|
chain_tension_max_mm: number | null;
|
||||||
|
oil_type: string | null;
|
||||||
|
oil_capacity_liters: number | null;
|
||||||
|
oil_check_temp: "warm" | "cold" | null;
|
||||||
|
oil_check_run_minutes: number | null;
|
||||||
|
oil_check_wait_min_minutes: number | null;
|
||||||
|
oil_check_wait_max_minutes: number | null;
|
||||||
|
oil_check_method: "dipstick" | "sight_glass" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChecklistItem = {
|
||||||
|
label: string;
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBar(value: number | null) {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return `${new Intl.NumberFormat("de-DE", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value)} bar`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oilTempLabel(value: MotorcycleMaintenanceRow["oil_check_temp"]) {
|
||||||
|
if (value === "warm") return "Warm";
|
||||||
|
if (value === "cold") return "Kalt";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function oilMethodLabel(value: MotorcycleMaintenanceRow["oil_check_method"]) {
|
||||||
|
if (value === "dipstick") return "Peilstab";
|
||||||
|
if (value === "sight_glass") return "Schauglas";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function oilInstruction(bike: MotorcycleMaintenanceRow) {
|
||||||
|
const tempText =
|
||||||
|
bike.oil_check_temp === "warm"
|
||||||
|
? "bei warmem Motor"
|
||||||
|
: bike.oil_check_temp === "cold"
|
||||||
|
? "bei kaltem Motor"
|
||||||
|
: null;
|
||||||
|
const methodText =
|
||||||
|
bike.oil_check_method === "dipstick"
|
||||||
|
? "mit Peilstab"
|
||||||
|
: bike.oil_check_method === "sight_glass"
|
||||||
|
? "über Schauglas"
|
||||||
|
: null;
|
||||||
|
if (!tempText && !methodText) return "Keine Anleitung hinterlegt.";
|
||||||
|
const runText =
|
||||||
|
bike.oil_check_temp === "warm" && bike.oil_check_run_minutes != null
|
||||||
|
? `Motor mindestens ${bike.oil_check_run_minutes} Min. laufen lassen, `
|
||||||
|
: "";
|
||||||
|
const waitText =
|
||||||
|
bike.oil_check_temp === "warm" &&
|
||||||
|
bike.oil_check_wait_min_minutes != null &&
|
||||||
|
bike.oil_check_wait_max_minutes != null
|
||||||
|
? `danach ${bike.oil_check_wait_min_minutes}-${bike.oil_check_wait_max_minutes} Min. warten, `
|
||||||
|
: "";
|
||||||
|
return `${runText}${waitText}Ölstand ${tempText ?? "im passenden Motorzustand"} messen ${methodText ?? "Methode offen"}.`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function maintenanceChecklist(bike: MotorcycleMaintenanceRow): ChecklistItem[] {
|
||||||
|
return [
|
||||||
|
{ label: "Reifendruck vorne/hinten hinterlegt", ok: bike.tire_pressure_front_bar != null && bike.tire_pressure_rear_bar != null },
|
||||||
|
{ label: "Kettenspannung von/bis hinterlegt", ok: bike.chain_tension_min_mm != null && bike.chain_tension_max_mm != null },
|
||||||
|
{ label: "Öltyp und Gesamtmenge hinterlegt", ok: bike.oil_type != null && bike.oil_capacity_liters != null },
|
||||||
|
{ label: "Ölprüfung (Temperatur + Methode) hinterlegt", ok: bike.oil_check_temp != null && bike.oil_check_method != null },
|
||||||
|
{
|
||||||
|
label: "Warm-Messung vollständig (Laufzeit + Wartefenster)",
|
||||||
|
ok:
|
||||||
|
bike.oil_check_temp !== "warm" ||
|
||||||
|
(bike.oil_check_run_minutes != null &&
|
||||||
|
bike.oil_check_wait_min_minutes != null &&
|
||||||
|
bike.oil_check_wait_max_minutes != null),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MotorcycleMaintenancePage({
|
||||||
|
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 id, nickname, brand, model, year,
|
||||||
|
tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm,
|
||||||
|
oil_type, oil_capacity_liters,
|
||||||
|
oil_check_temp, oil_check_run_minutes, oil_check_wait_min_minutes, oil_check_wait_max_minutes, oil_check_method
|
||||||
|
FROM motorcycles m
|
||||||
|
WHERE m.id = ? AND ${visible.clause}`,
|
||||||
|
)
|
||||||
|
.get(motorcycleId, ...visible.params) as MotorcycleMaintenanceRow | undefined;
|
||||||
|
if (!bike) notFound();
|
||||||
|
const checklist = maintenanceChecklist(bike);
|
||||||
|
const allComplete = checklist.every((item) => item.ok);
|
||||||
|
const missing = checklist.filter((item) => !item.ok);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="breadcrumbs">
|
||||||
|
<Link href="/">Übersicht</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/motorcycles/${bike.id}`}>{bike.nickname}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
Wartungsansicht
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||||
|
<h1>Wartungsansicht</h1>
|
||||||
|
</div>
|
||||||
|
<div className="heading-actions">
|
||||||
|
<Link className="button" href={`/motorcycles/${bike.id}`}>Zurück</Link>
|
||||||
|
<Link className="button button-primary" href={`/motorcycles/${bike.id}/maintenance/edit`}>Werte bearbeiten</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="panel">
|
||||||
|
<h3 className="panel-title">Wartungsprofil-Status</h3>
|
||||||
|
<p className={`status-pill ${allComplete ? "status-ok" : "status-soon"}`}>
|
||||||
|
{allComplete ? "Vollständig" : `Unvollständig (${missing.length} offen)`}
|
||||||
|
</p>
|
||||||
|
{!allComplete && (
|
||||||
|
<div className="notes">
|
||||||
|
<strong>Fehlende Angaben:</strong>
|
||||||
|
<ul>
|
||||||
|
{missing.map((item) => (
|
||||||
|
<li key={item.label}>{item.label}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="detail-grid">
|
||||||
|
<div className="panel">
|
||||||
|
<h3 className="panel-title">Reifen & Kette</h3>
|
||||||
|
<dl className="data-list">
|
||||||
|
<div><dt>Reifendruck vorne</dt><dd>{formatBar(bike.tire_pressure_front_bar)}</dd></div>
|
||||||
|
<div><dt>Reifendruck hinten</dt><dd>{formatBar(bike.tire_pressure_rear_bar)}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Kettenspannung</dt>
|
||||||
|
<dd>
|
||||||
|
{bike.chain_tension_min_mm != null && bike.chain_tension_max_mm != null
|
||||||
|
? `${bike.chain_tension_min_mm}-${bike.chain_tension_max_mm} mm`
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h3 className="panel-title">Ölstand-Kontrolle</h3>
|
||||||
|
<dl className="data-list">
|
||||||
|
<div><dt>Benötigtes Öl</dt><dd>{bike.oil_type ?? "—"}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Ölmenge gesamt</dt>
|
||||||
|
<dd>{bike.oil_capacity_liters != null ? `${new Intl.NumberFormat("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(bike.oil_capacity_liters)} l` : "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Motorzustand</dt><dd>{oilTempLabel(bike.oil_check_temp)}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Laufzeit vor Messung</dt>
|
||||||
|
<dd>{bike.oil_check_temp === "warm" && bike.oil_check_run_minutes != null ? `${bike.oil_check_run_minutes} Min.` : "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Wartezeitfenster</dt>
|
||||||
|
<dd>
|
||||||
|
{bike.oil_check_temp === "warm" &&
|
||||||
|
bike.oil_check_wait_min_minutes != null &&
|
||||||
|
bike.oil_check_wait_max_minutes != null
|
||||||
|
? `${bike.oil_check_wait_min_minutes}-${bike.oil_check_wait_max_minutes} Min.`
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Methode</dt><dd>{oilMethodLabel(bike.oil_check_method)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p className="notes">{oilInstruction(bike)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { clearPendingAdminMfaCookie, getPendingAdminMfaUserId } from "@/lib/auth";
|
||||||
|
import { db, initDatabase } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
createWebAuthnChallenge,
|
||||||
|
getUserWebAuthnCredentials,
|
||||||
|
webauthnRpId,
|
||||||
|
} from "@/lib/webauthn";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
initDatabase();
|
||||||
|
const userId = await getPendingAdminMfaUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "MFA-Sitzung ist abgelaufen." }, { status: 401 });
|
||||||
|
|
||||||
|
const user = db
|
||||||
|
.prepare("SELECT id FROM users WHERE id = ? AND active = 1 AND role = 'admin'")
|
||||||
|
.get(userId) as { id: number } | undefined;
|
||||||
|
if (!user) {
|
||||||
|
await clearPendingAdminMfaCookie();
|
||||||
|
return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = getUserWebAuthnCredentials(user.id);
|
||||||
|
if (credentials.length === 0) {
|
||||||
|
await clearPendingAdminMfaCookie();
|
||||||
|
return NextResponse.json({ error: "Kein Sicherheitsschlüssel hinterlegt." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: webauthnRpId(request),
|
||||||
|
userVerification: "preferred",
|
||||||
|
allowCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credential_id,
|
||||||
|
transports: credential.transports ? JSON.parse(credential.transports) as AuthenticatorTransport[] : undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const challengeId = createWebAuthnChallenge(user.id, "authentication", options.challenge);
|
||||||
|
return NextResponse.json({ options, challengeId });
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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 { db, initDatabase } from "@/lib/db";
|
||||||
|
import { getAuthSecret } from "@/lib/env";
|
||||||
|
import {
|
||||||
|
consumeWebAuthnChallenge,
|
||||||
|
getUserWebAuthnCredentials,
|
||||||
|
webauthnExpectedOrigin,
|
||||||
|
webauthnRpId,
|
||||||
|
} from "@/lib/webauthn";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type VerifyBody = {
|
||||||
|
challengeId?: number;
|
||||||
|
authenticationResponse?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
initDatabase();
|
||||||
|
const userId = await getPendingAdminMfaUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "MFA-Sitzung ist abgelaufen." }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json() as VerifyBody;
|
||||||
|
const challengeId = Number(body.challengeId ?? 0);
|
||||||
|
if (!Number.isSafeInteger(challengeId) || challengeId < 1 || !body.authenticationResponse) {
|
||||||
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedChallenge = consumeWebAuthnChallenge(userId, challengeId, "authentication");
|
||||||
|
if (!expectedChallenge) {
|
||||||
|
return NextResponse.json({ error: "Anfrage ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = getUserWebAuthnCredentials(userId);
|
||||||
|
const credentialId = (body.authenticationResponse as { id?: string })?.id;
|
||||||
|
if (!credentialId) return NextResponse.json({ error: "Ungültige Antwort vom Sicherheitsschlüssel." }, { status: 400 });
|
||||||
|
const matchingCredential = credentials.find((credential) => credential.credential_id === credentialId);
|
||||||
|
if (!matchingCredential) return NextResponse.json({ error: "Sicherheitsschlüssel ist nicht registriert." }, { status: 400 });
|
||||||
|
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response: body.authenticationResponse as Parameters<typeof verifyAuthenticationResponse>[0]["response"],
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: webauthnExpectedOrigin(request),
|
||||||
|
expectedRPID: webauthnRpId(request),
|
||||||
|
credential: {
|
||||||
|
id: matchingCredential.credential_id,
|
||||||
|
publicKey: Buffer.from(matchingCredential.public_key, "base64url"),
|
||||||
|
counter: matchingCredential.counter,
|
||||||
|
transports: matchingCredential.transports
|
||||||
|
? JSON.parse(matchingCredential.transports) as AuthenticatorTransport[]
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.verified) {
|
||||||
|
return NextResponse.json({ error: "YubiKey-Verifikation fehlgeschlagen." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?").run(
|
||||||
|
verification.authenticationInfo.newCounter,
|
||||||
|
matchingCredential.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = db
|
||||||
|
.prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1 AND role = 'admin'")
|
||||||
|
.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 response = NextResponse.json({ ok: true });
|
||||||
|
response.cookies.set(SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: SESSION_TTL_SECONDS,
|
||||||
|
path: "/",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
response.cookies.delete(ADMIN_MFA_COOKIE);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth";
|
||||||
|
import { createWebAuthnChallenge, getUserWebAuthnCredentials, webauthnRpId } from "@/lib/webauthn";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const user = await requireAdminUser();
|
||||||
|
const credentials = getUserWebAuthnCredentials(user.id);
|
||||||
|
if (credentials.length >= 2) {
|
||||||
|
return NextResponse.json({ error: "Maximal zwei YubiKeys sind erlaubt." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpID: webauthnRpId(request),
|
||||||
|
rpName: "Familiengarage",
|
||||||
|
userID: new TextEncoder().encode(String(user.id)),
|
||||||
|
userName: user.name,
|
||||||
|
attestationType: "none",
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "discouraged",
|
||||||
|
requireResidentKey: false,
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
excludeCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credential_id,
|
||||||
|
transports: credential.transports ? JSON.parse(credential.transports) as AuthenticatorTransport[] : undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const challengeId = createWebAuthnChallenge(user.id, "registration", options.challenge);
|
||||||
|
return NextResponse.json({ options, challengeId });
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth";
|
||||||
|
import { db, initDatabase } from "@/lib/db";
|
||||||
|
import { consumeWebAuthnChallenge, getUserWebAuthnCredentials, webauthnExpectedOrigin, webauthnRpId } from "@/lib/webauthn";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type VerifyBody = {
|
||||||
|
challengeId?: number;
|
||||||
|
registrationResponse?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const user = await requireAdminUser();
|
||||||
|
initDatabase();
|
||||||
|
|
||||||
|
const current = getUserWebAuthnCredentials(user.id);
|
||||||
|
if (current.length >= 2) {
|
||||||
|
return NextResponse.json({ error: "Maximal zwei YubiKeys sind erlaubt." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json() as VerifyBody;
|
||||||
|
const challengeId = Number(body.challengeId ?? 0);
|
||||||
|
if (!Number.isSafeInteger(challengeId) || challengeId < 1 || !body.registrationResponse) {
|
||||||
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedChallenge = consumeWebAuthnChallenge(user.id, challengeId, "registration");
|
||||||
|
if (!expectedChallenge) {
|
||||||
|
return NextResponse.json({ error: "Anfrage ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response: body.registrationResponse as Parameters<typeof verifyRegistrationResponse>[0]["response"],
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: webauthnExpectedOrigin(request),
|
||||||
|
expectedRPID: webauthnRpId(request),
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
return NextResponse.json({ error: "YubiKey konnte nicht registriert werden." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationCredential = verification.registrationInfo.credential;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO webauthn_credentials (user_id, credential_id, public_key, counter, transports, label)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
user.id,
|
||||||
|
registrationCredential.id,
|
||||||
|
Buffer.from(registrationCredential.publicKey).toString("base64url"),
|
||||||
|
registrationCredential.counter,
|
||||||
|
JSON.stringify(registrationCredential.transports ?? []),
|
||||||
|
`YubiKey ${current.length + 1}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { db, initDatabase } from "@/lib/db";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type MotorcycleMaintenanceRow = {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
year: number | null;
|
||||||
|
plate: string | null;
|
||||||
|
owner_name: string;
|
||||||
|
tire_pressure_front_bar: number | null;
|
||||||
|
tire_pressure_rear_bar: number | null;
|
||||||
|
chain_tension_min_mm: number | null;
|
||||||
|
chain_tension_max_mm: number | null;
|
||||||
|
oil_type: string | null;
|
||||||
|
oil_capacity_liters: number | null;
|
||||||
|
oil_check_temp: "warm" | "cold" | null;
|
||||||
|
oil_check_run_minutes: number | null;
|
||||||
|
oil_check_wait_min_minutes: number | null;
|
||||||
|
oil_check_wait_max_minutes: number | null;
|
||||||
|
oil_check_method: "dipstick" | "sight_glass" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBar(value: number | null) {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return `${new Intl.NumberFormat("de-DE", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value)} bar`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oilTempLabel(value: MotorcycleMaintenanceRow["oil_check_temp"]) {
|
||||||
|
if (value === "warm") return "Warm";
|
||||||
|
if (value === "cold") return "Kalt";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function oilMethodLabel(value: MotorcycleMaintenanceRow["oil_check_method"]) {
|
||||||
|
if (value === "dipstick") return "Peilstab";
|
||||||
|
if (value === "sight_glass") return "Schauglas";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function oilInstruction(bike: MotorcycleMaintenanceRow) {
|
||||||
|
const tempText =
|
||||||
|
bike.oil_check_temp === "warm"
|
||||||
|
? "bei warmem Motor"
|
||||||
|
: bike.oil_check_temp === "cold"
|
||||||
|
? "bei kaltem Motor"
|
||||||
|
: null;
|
||||||
|
const methodText =
|
||||||
|
bike.oil_check_method === "dipstick"
|
||||||
|
? "mit Peilstab"
|
||||||
|
: bike.oil_check_method === "sight_glass"
|
||||||
|
? "über Schauglas"
|
||||||
|
: null;
|
||||||
|
if (!tempText && !methodText) return "Keine Anleitung hinterlegt.";
|
||||||
|
const runText =
|
||||||
|
bike.oil_check_temp === "warm" && bike.oil_check_run_minutes != null
|
||||||
|
? `Motor mindestens ${bike.oil_check_run_minutes} Min. laufen lassen, `
|
||||||
|
: "";
|
||||||
|
const waitText =
|
||||||
|
bike.oil_check_temp === "warm" &&
|
||||||
|
bike.oil_check_wait_min_minutes != null &&
|
||||||
|
bike.oil_check_wait_max_minutes != null
|
||||||
|
? `danach ${bike.oil_check_wait_min_minutes}-${bike.oil_check_wait_max_minutes} Min. warten, `
|
||||||
|
: "";
|
||||||
|
return `${runText}${waitText}Ölstand ${tempText ?? "im passenden Motorzustand"} messen ${methodText ?? "Methode offen"}.`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plateFromParams(value: string | string[] | undefined) {
|
||||||
|
if (Array.isArray(value)) return String(value[0] ?? "").trim();
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatedPlate(value: string) {
|
||||||
|
const trimmed = value.slice(0, 20).trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (!/^[A-Za-z0-9ÄÖÜäöü \-]+$/.test(trimmed)) {
|
||||||
|
throw new Error("Kennzeichen enthält ungültige Zeichen.");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function KioskPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
initDatabase();
|
||||||
|
const params = await searchParams;
|
||||||
|
let plate = "";
|
||||||
|
let plateError: string | null = null;
|
||||||
|
try {
|
||||||
|
plate = validatedPlate(plateFromParams(params.plate));
|
||||||
|
} catch (error) {
|
||||||
|
plateError = error instanceof Error ? error.message : "Ungültige Eingabe.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = plate && !plateError
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.id, m.nickname, m.brand, m.model, m.year, m.plate, owner.name AS owner_name,
|
||||||
|
m.tire_pressure_front_bar, m.tire_pressure_rear_bar, m.chain_tension_min_mm, m.chain_tension_max_mm,
|
||||||
|
m.oil_type, m.oil_capacity_liters,
|
||||||
|
m.oil_check_temp, m.oil_check_run_minutes, m.oil_check_wait_min_minutes, m.oil_check_wait_max_minutes, m.oil_check_method
|
||||||
|
FROM motorcycles m
|
||||||
|
JOIN users owner ON owner.id = m.created_by
|
||||||
|
WHERE m.plate IS NOT NULL
|
||||||
|
AND TRIM(m.plate) <> ''
|
||||||
|
AND REPLACE(REPLACE(UPPER(m.plate), ' ', ''), '-', '') = REPLACE(REPLACE(UPPER(?), ' ', ''), '-', '')
|
||||||
|
ORDER BY m.id ASC
|
||||||
|
LIMIT 2`,
|
||||||
|
)
|
||||||
|
.all(plate) as MotorcycleMaintenanceRow[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const bike = matches.length === 1 ? matches[0] : null;
|
||||||
|
const notFound = plate.length > 0 && matches.length === 0;
|
||||||
|
const ambiguous = matches.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Öffentlicher Zugriff</p>
|
||||||
|
<h1>Kiosk-Modus</h1>
|
||||||
|
<p className="muted">Kennzeichen eingeben und Wartungsdaten direkt anzeigen.</p>
|
||||||
|
</div>
|
||||||
|
<Link className="button" href="/login">Zur Anmeldung</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="panel form-grid" method="get" action="/kiosk">
|
||||||
|
<label className="span-2">
|
||||||
|
Kennzeichen
|
||||||
|
<input
|
||||||
|
name="plate"
|
||||||
|
defaultValue={plate}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
maxLength={20}
|
||||||
|
placeholder="z. B. HH AB 123"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-actions span-2">
|
||||||
|
<button className="button button-primary" type="submit">Wartungsdaten anzeigen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{plateError && (
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="flash flash-error">{plateError}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notFound && (
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="flash flash-error">Kein Motorrad mit diesem Kennzeichen gefunden.</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ambiguous && (
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="flash flash-error">
|
||||||
|
Dieses Kennzeichen ist nicht eindeutig. Bitte in der App ein eindeutiges Kennzeichen pflegen.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bike && (
|
||||||
|
<>
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="panel">
|
||||||
|
<p className="eyebrow">{bike.brand} · {bike.model}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||||
|
<h2>{bike.nickname}</h2>
|
||||||
|
<p className="muted">Kennzeichen: {bike.plate ?? "—"} · Besitzer: {bike.owner_name}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="detail-grid">
|
||||||
|
<div className="panel">
|
||||||
|
<h3 className="panel-title">Reifen & Kette</h3>
|
||||||
|
<dl className="data-list">
|
||||||
|
<div><dt>Reifendruck vorne</dt><dd>{formatBar(bike.tire_pressure_front_bar)}</dd></div>
|
||||||
|
<div><dt>Reifendruck hinten</dt><dd>{formatBar(bike.tire_pressure_rear_bar)}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Kettenspannung</dt>
|
||||||
|
<dd>
|
||||||
|
{bike.chain_tension_min_mm != null && bike.chain_tension_max_mm != null
|
||||||
|
? `${bike.chain_tension_min_mm}-${bike.chain_tension_max_mm} mm`
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h3 className="panel-title">Ölstand-Kontrolle</h3>
|
||||||
|
<dl className="data-list">
|
||||||
|
<div><dt>Benötigtes Öl</dt><dd>{bike.oil_type ?? "—"}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Ölmenge gesamt</dt>
|
||||||
|
<dd>
|
||||||
|
{bike.oil_capacity_liters != null
|
||||||
|
? `${new Intl.NumberFormat("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(bike.oil_capacity_liters)} l`
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Motorzustand</dt><dd>{oilTempLabel(bike.oil_check_temp)}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Laufzeit vor Messung</dt>
|
||||||
|
<dd>{bike.oil_check_temp === "warm" && bike.oil_check_run_minutes != null ? `${bike.oil_check_run_minutes} Min.` : "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Wartezeitfenster</dt>
|
||||||
|
<dd>
|
||||||
|
{bike.oil_check_temp === "warm" &&
|
||||||
|
bike.oil_check_wait_min_minutes != null &&
|
||||||
|
bike.oil_check_wait_max_minutes != null
|
||||||
|
? `${bike.oil_check_wait_min_minutes}-${bike.oil_check_wait_max_minutes} Min.`
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Methode</dt><dd>{oilMethodLabel(bike.oil_check_method)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p className="notes">{oilInstruction(bike)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AdminMfaLogin } from "@/components/admin-mfa-login";
|
||||||
|
import { getPendingAdminMfaUserId } from "@/lib/auth";
|
||||||
|
import { db, initDatabase } from "@/lib/db";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function AdminYubiKeyLoginPage() {
|
||||||
|
initDatabase();
|
||||||
|
const pendingUserId = await getPendingAdminMfaUserId();
|
||||||
|
if (!pendingUserId) redirect("/login");
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.name, COUNT(c.id) AS key_count
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN webauthn_credentials c ON c.user_id = u.id
|
||||||
|
WHERE u.id = ? AND u.active = 1 AND u.role = 'admin'
|
||||||
|
GROUP BY u.id, u.name`,
|
||||||
|
)
|
||||||
|
.get(pendingUserId) as { name: string; key_count: number } | undefined;
|
||||||
|
if (!row || row.key_count < 1) redirect("/login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="login-card">
|
||||||
|
<div className="brand login-brand">
|
||||||
|
<span className="brand-mark">FG</span>
|
||||||
|
<span>
|
||||||
|
<strong>Familiengarage</strong>
|
||||||
|
<small>Admin-Sicherheitsanmeldung</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Zweiter Faktor</p>
|
||||||
|
<h1>YubiKey bestätigen</h1>
|
||||||
|
<p className="muted">Admin {row.name}: Bitte bestätige die Anmeldung mit einem registrierten YubiKey.</p>
|
||||||
|
</div>
|
||||||
|
<AdminMfaLogin />
|
||||||
|
<p className="muted"><Link href="/login">Zurück zur Passwort-Anmeldung</Link></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { startAuthentication } from "@simplewebauthn/browser";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type LoginOptionsResponse = {
|
||||||
|
options: unknown;
|
||||||
|
challengeId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminMfaLogin() {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function signInWithSecurityKey() {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const optionsRes = await fetch("/api/auth/webauthn/login/options", { method: "POST" });
|
||||||
|
const optionsPayload = await optionsRes.json() as LoginOptionsResponse | { error: string };
|
||||||
|
if (!optionsRes.ok || !("challengeId" in optionsPayload)) {
|
||||||
|
throw new Error("error" in optionsPayload ? optionsPayload.error : "Anmeldung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticationResponse = await startAuthentication({
|
||||||
|
optionsJSON: optionsPayload.options as Parameters<typeof startAuthentication>[0]["optionsJSON"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyRes = await fetch("/api/auth/webauthn/login/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challengeId: optionsPayload.challengeId,
|
||||||
|
authenticationResponse,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const verifyPayload = await verifyRes.json() as { error?: string };
|
||||||
|
if (!verifyRes.ok) throw new Error(verifyPayload.error ?? "Anmeldung fehlgeschlagen.");
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "Anmeldung fehlgeschlagen.");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <div className="flash flash-error">{error}</div>}
|
||||||
|
<button
|
||||||
|
className="button button-primary"
|
||||||
|
type="button"
|
||||||
|
onClick={signInWithSecurityKey}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Warte auf YubiKey …" : "Mit YubiKey anmelden"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { startRegistration } from "@simplewebauthn/browser";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type RegisterOptionsResponse = {
|
||||||
|
options: unknown;
|
||||||
|
challengeId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminSecurityKeys({ disabled }: { disabled: boolean }) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function registerKey() {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const optionsRes = await fetch("/api/auth/webauthn/register/options", { method: "POST" });
|
||||||
|
const optionsPayload = await optionsRes.json() as RegisterOptionsResponse | { error: string };
|
||||||
|
if (!optionsRes.ok || !("challengeId" in optionsPayload)) {
|
||||||
|
throw new Error("error" in optionsPayload ? optionsPayload.error : "Registrierung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
const registrationResponse = await startRegistration({
|
||||||
|
optionsJSON: optionsPayload.options as Parameters<typeof startRegistration>[0]["optionsJSON"],
|
||||||
|
});
|
||||||
|
const verifyRes = await fetch("/api/auth/webauthn/register/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challengeId: optionsPayload.challengeId,
|
||||||
|
registrationResponse,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const verifyPayload = await verifyRes.json() as { error?: string };
|
||||||
|
if (!verifyRes.ok) throw new Error(verifyPayload.error ?? "Registrierung fehlgeschlagen.");
|
||||||
|
window.location.reload();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "Registrierung fehlgeschlagen.");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <div className="flash flash-error">{error}</div>}
|
||||||
|
<button
|
||||||
|
className="button button-primary"
|
||||||
|
type="button"
|
||||||
|
onClick={registerKey}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
>
|
||||||
|
{busy ? "YubiKey wird registriert …" : "YubiKey hinzufügen"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { updateMotorcycleMaintenanceAction } from "@/app/actions/motorcycles";
|
||||||
|
|
||||||
|
type MaintenanceValue = {
|
||||||
|
id: number;
|
||||||
|
tire_pressure_front_bar: number | null;
|
||||||
|
tire_pressure_rear_bar: number | null;
|
||||||
|
chain_tension_min_mm: number | null;
|
||||||
|
chain_tension_max_mm: number | null;
|
||||||
|
oil_type: string | null;
|
||||||
|
oil_capacity_liters: number | null;
|
||||||
|
oil_check_temp: "warm" | "cold" | null;
|
||||||
|
oil_check_run_minutes: number | null;
|
||||||
|
oil_check_wait_min_minutes: number | null;
|
||||||
|
oil_check_wait_max_minutes: number | null;
|
||||||
|
oil_check_method: "dipstick" | "sight_glass" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MaintenanceSpecsForm({ motorcycle }: { motorcycle: MaintenanceValue }) {
|
||||||
|
const [oilCheckTemp, setOilCheckTemp] = useState<"" | "warm" | "cold">(
|
||||||
|
motorcycle.oil_check_temp ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={updateMotorcycleMaintenanceAction} className="panel form-grid">
|
||||||
|
<input type="hidden" name="id" value={motorcycle.id} />
|
||||||
|
<label>
|
||||||
|
Reifendruck vorne (bar)
|
||||||
|
<input
|
||||||
|
name="tire_pressure_front_bar"
|
||||||
|
type="number"
|
||||||
|
min="0.5"
|
||||||
|
max="6"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={motorcycle.tire_pressure_front_bar ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Reifendruck hinten (bar)
|
||||||
|
<input
|
||||||
|
name="tire_pressure_rear_bar"
|
||||||
|
type="number"
|
||||||
|
min="0.5"
|
||||||
|
max="6"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={motorcycle.tire_pressure_rear_bar ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Kettenspannung von (mm)
|
||||||
|
<input
|
||||||
|
name="chain_tension_min_mm"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle.chain_tension_min_mm ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Kettenspannung bis (mm)
|
||||||
|
<input
|
||||||
|
name="chain_tension_max_mm"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle.chain_tension_max_mm ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Benötigtes Öl
|
||||||
|
<input
|
||||||
|
name="oil_type"
|
||||||
|
maxLength={80}
|
||||||
|
placeholder="z. B. 10W-40, JASO MA2"
|
||||||
|
defaultValue={motorcycle.oil_type ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ölmenge gesamt (Liter)
|
||||||
|
<input
|
||||||
|
name="oil_capacity_liters"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="20"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={motorcycle.oil_capacity_liters ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Öl prüfen: Motorzustand
|
||||||
|
<select
|
||||||
|
name="oil_check_temp"
|
||||||
|
value={oilCheckTemp}
|
||||||
|
onChange={(event) => setOilCheckTemp(event.target.value as "" | "warm" | "cold")}
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
<option value="cold">Kalt</option>
|
||||||
|
<option value="warm">Warm</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Öl prüfen: Methode
|
||||||
|
<select name="oil_check_method" defaultValue={motorcycle.oil_check_method ?? ""}>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
<option value="dipstick">Peilstab</option>
|
||||||
|
<option value="sight_glass">Schauglas</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{oilCheckTemp === "warm" && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Motor vorher laufen (Minuten)
|
||||||
|
<input
|
||||||
|
name="oil_check_run_minutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle.oil_check_run_minutes ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wartezeit min (Minuten)
|
||||||
|
<input
|
||||||
|
name="oil_check_wait_min_minutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle.oil_check_wait_min_minutes ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wartezeit max (Minuten)
|
||||||
|
<input
|
||||||
|
name="oil_check_wait_max_minutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle.oil_check_wait_max_minutes ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="form-actions span-2">
|
||||||
|
<button className="button button-primary" type="submit">Wartungswerte speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { db } from "./db";
|
||||||
|
import type { SessionUser } from "./types";
|
||||||
|
|
||||||
|
type AccessFilter = {
|
||||||
|
clause: string;
|
||||||
|
params: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function motorcycleAccessFilter(user: SessionUser, alias = "m"): AccessFilter {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
|
||||||
|
throw new Error("Ungültiger SQL-Alias.");
|
||||||
|
}
|
||||||
|
if (user.role === "admin") {
|
||||||
|
return { clause: "1=1", params: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clause: `(${alias}.created_by = ? OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM motorcycle_shares ms
|
||||||
|
WHERE ms.motorcycle_id = ${alias}.id AND ms.user_id = ?
|
||||||
|
))`,
|
||||||
|
params: [user.id, user.id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessMotorcycle(user: SessionUser, motorcycleId: number): boolean {
|
||||||
|
if (user.role === "admin") return true;
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1
|
||||||
|
FROM motorcycles m
|
||||||
|
WHERE m.id = ?
|
||||||
|
AND (m.created_by = ? OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM motorcycle_shares ms
|
||||||
|
WHERE ms.motorcycle_id = m.id AND ms.user_id = ?
|
||||||
|
))`,
|
||||||
|
)
|
||||||
|
.get(motorcycleId, user.id, user.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureMotorcycleAccess(user: SessionUser, motorcycleId: number) {
|
||||||
|
if (!canAccessMotorcycle(user, motorcycleId)) {
|
||||||
|
throw new Error("Motorrad wurde nicht gefunden oder ist nicht freigegeben.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageShares(user: SessionUser, motorcycleId: number): boolean {
|
||||||
|
if (user.role === "admin") return true;
|
||||||
|
return Boolean(
|
||||||
|
db
|
||||||
|
.prepare("SELECT 1 FROM motorcycles WHERE id = ? AND created_by = ?")
|
||||||
|
.get(motorcycleId, user.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { db, initDatabase } from "./db";
|
||||||
|
|
||||||
|
export type WebAuthnCredentialRow = {
|
||||||
|
id: number;
|
||||||
|
credential_id: string;
|
||||||
|
public_key: string;
|
||||||
|
counter: number;
|
||||||
|
transports: string | null;
|
||||||
|
label: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function webauthnRpId(request: Request) {
|
||||||
|
const configured = process.env.WEBAUTHN_RP_ID?.trim();
|
||||||
|
if (configured) return configured;
|
||||||
|
return new URL(request.url).hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webauthnExpectedOrigin(request: Request) {
|
||||||
|
const configured = process.env.WEBAUTHN_ORIGIN?.trim();
|
||||||
|
if (configured) return configured;
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
if (origin) return origin;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserWebAuthnCredentials(userId: number) {
|
||||||
|
initDatabase();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, credential_id, public_key, counter, transports, label, created_at, last_used_at
|
||||||
|
FROM webauthn_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
)
|
||||||
|
.all(userId) as WebAuthnCredentialRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebAuthnChallenge(userId: number, purpose: "registration" | "authentication", challenge: string) {
|
||||||
|
initDatabase();
|
||||||
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO webauthn_challenges (user_id, challenge, purpose, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(userId, challenge, purpose, expiresAt);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeWebAuthnChallenge(
|
||||||
|
userId: number,
|
||||||
|
challengeId: number,
|
||||||
|
purpose: "registration" | "authentication",
|
||||||
|
) {
|
||||||
|
initDatabase();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, challenge
|
||||||
|
FROM webauthn_challenges
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND purpose = ?
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at >= ?`,
|
||||||
|
)
|
||||||
|
.get(challengeId, userId, purpose, new Date().toISOString()) as { id: number; challenge: string } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
db.prepare("UPDATE webauthn_challenges SET used_at = CURRENT_TIMESTAMP WHERE id = ?").run(row.id);
|
||||||
|
return row.challenge;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user