Upgrade dependencies in package.json and update next, eslint, and related packages

This commit is contained in:
2026-07-27 17:38:38 +02:00
parent 2b85152bb9
commit b14f670157
22 changed files with 12128 additions and 3778 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.next
node_modules
data
.env
npm-debug.log*
hs_err_pid*
+7
View File
@@ -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
+5
View File
@@ -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
+12
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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"]
+20
View File
@@ -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:
+10811 -3775
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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 });
}
+237
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+59
View File
@@ -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>
</>
);
}
+57
View File
@@ -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>
</>
);
}
+155
View File
@@ -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>
);
}
+59
View File
@@ -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),
);
}
+73
View File
@@ -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;
}