-
!
- {error}
+
+ {!otpStepChecked ? (
+
+ ) : (
+
+
+
+ HH
+
+
Hellth Hub
+
+ Health-Tracking fuer dein Home-Lab
+
- )}
-
-
-
setEmail(e.target.value)}
- placeholder="admin@example.com"
- className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
- />
+
+
+
+ Geschuetzt durch 2FA, Passkeys und verschluesselte Verbindungen.
+
-
-
-
- setPassword(e.target.value)}
- placeholder="************"
- className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
- />
-
-
-
-
-
-
+ )}
- )}
);
}
diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts
index 9edff1c..c4b7818 100644
--- a/apps/admin/next-env.d.ts
+++ b/apps/admin/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/api/src/trpc/health.test.ts b/packages/api/src/trpc/health.test.ts
index 441117a..eee4fef 100644
--- a/packages/api/src/trpc/health.test.ts
+++ b/packages/api/src/trpc/health.test.ts
@@ -589,3 +589,85 @@ test("health.statsSummary uses database logs for weekly and projection data", as
assert.equal(summary.projection?.trendKgPerWeek, -1.1);
});
+test("health.upsertWeeklyCaloriePlan speichert Tageskalorien pro Nutzer und Datum", async () => {
+ const { db, calls } = createHealthDbMock();
+ const caller = appRouter.createCaller(createContext({ db }) as never);
+
+ const result = await caller.health.upsertWeeklyCaloriePlan({
+ date: "2026-05-30",
+ plannedCalories: 1800,
+ note: "Refeed-Tag",
+ });
+
+ assert.deepEqual(result, { ok: true });
+ const values = calls.find((call) => call.op === "insert.values")?.payload as {
+ userId?: string;
+ date?: string;
+ plannedCalories?: number;
+ note?: string | null;
+ };
+ assert.equal(values.userId, "user-health");
+ assert.equal(values.date, "2026-05-30");
+ assert.equal(values.plannedCalories, 1800);
+ assert.equal(values.note, "Refeed-Tag");
+
+ const conflict = calls.find((call) => call.op === "insert.values.result.onConflictDoUpdate")?.payload as {
+ set?: { plannedCalories?: number; note?: string | null };
+ };
+ assert.equal(conflict.set?.plannedCalories, 1800);
+ assert.equal(conflict.set?.note, "Refeed-Tag");
+});
+
+test("health.upsertWeeklyCaloriePlan lehnt unrealistische Kalorienwerte ab", async () => {
+ const caller = appRouter.createCaller(createContext() as never);
+
+ await assert.rejects(
+ () => caller.health.upsertWeeklyCaloriePlan({ date: "2026-05-30", plannedCalories: 500 }),
+ (error: unknown) => {
+ assert.equal((error as { code?: string }).code, "BAD_REQUEST");
+ return true;
+ },
+ );
+ await assert.rejects(
+ () => caller.health.upsertWeeklyCaloriePlan({ date: "2026-05-30", plannedCalories: 9000 }),
+ (error: unknown) => {
+ assert.equal((error as { code?: string }).code, "BAD_REQUEST");
+ return true;
+ },
+ );
+});
+
+test("health.addActivityLog schreibt Hellfireclub-Punkte mit Quelle und Idempotenz", async () => {
+ const { db, calls } = createHealthDbMock();
+ const caller = appRouter.createCaller(createContext({ db }) as never);
+
+ await caller.health.addActivityLog({
+ date: "2026-05-26",
+ name: "30 Minuten Joggen",
+ minutes: 30,
+ points: 45,
+ calories: 280,
+ source: "fitbit",
+ externalId: "fitbit-activity-99",
+ });
+
+ const values = calls.find((call) => call.op === "insert.values")?.payload as {
+ userId?: string;
+ name?: string;
+ points?: number;
+ calories?: number;
+ source?: string;
+ externalId?: string | null;
+ };
+ assert.equal(values.userId, "user-health");
+ assert.equal(values.name, "30 Minuten Joggen");
+ assert.equal(values.points, 45);
+ assert.equal(values.calories, 280);
+ assert.equal(values.source, "fitbit");
+ assert.equal(values.externalId, "fitbit-activity-99");
+ assert.ok(
+ calls.some((call) => call.op === "insert.values.result.onConflictDoNothing"),
+ "Wiederholtes Importieren der gleichen externalId darf nicht doppelt zaehlen",
+ );
+});
+
diff --git a/packages/api/src/trpc/security.test.ts b/packages/api/src/trpc/security.test.ts
new file mode 100644
index 0000000..0791f0a
--- /dev/null
+++ b/packages/api/src/trpc/security.test.ts
@@ -0,0 +1,229 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { appRouter } from "./index";
+import { twoFactorLossReasons } from "../lib/security-audit";
+
+type Call = { op: string; payload?: unknown };
+
+type DbUserRow = {
+ email?: string;
+ role?: string;
+ twoFactorEnabled?: boolean | null;
+ twoFactorRecoveryRequired?: boolean | null;
+} | null;
+
+type TwoFactorRow = { enabled?: boolean | null } | null;
+
+function chain(calls: Call[], op: string, payload?: unknown): any {
+ calls.push({ op, payload });
+ return {
+ set(value: unknown) {
+ calls.push({ op: `${op}.set`, payload: value });
+ return chain(calls, `${op}.set.result`);
+ },
+ where(value?: unknown) {
+ calls.push({ op: `${op}.where`, payload: value });
+ return Promise.resolve([]);
+ },
+ };
+}
+
+function createSecurityDbMock(options: { dbUser?: DbUserRow; twoFactorRow?: TwoFactorRow } = {}) {
+ const calls: Call[] = [];
+ const db = {
+ query: {
+ user: { findFirst: async () => options.dbUser ?? null },
+ twoFactor: { findFirst: async () => options.twoFactorRow ?? null },
+ },
+ update(table: unknown) {
+ return chain(calls, "update", table);
+ },
+ };
+ return { db, calls };
+}
+
+function createContext(overrides: Record
= {}) {
+ const user = { id: "user-1", email: "user@example.org", role: "user" };
+ return {
+ db: {},
+ headers: new Headers(),
+ session: { user },
+ user,
+ userRole: "user",
+ isPlatformAdmin: false,
+ ...overrides,
+ };
+}
+
+function createUnauthedContext() {
+ return {
+ db: {},
+ headers: new Headers(),
+ session: null,
+ user: null,
+ userRole: "user",
+ isPlatformAdmin: false,
+ };
+}
+
+test("twoFactorLossReasons meldet nur Probleme fuer privilegierte Accounts", () => {
+ assert.deepEqual(
+ twoFactorLossReasons({
+ isPrivileged: false,
+ userTwoFactorEnabled: false,
+ twoFactorRowExists: false,
+ twoFactorRowEnabled: false,
+ recoveryRequired: true,
+ }),
+ [],
+ "Normale Nutzer sind nicht zu 2FA verpflichtet",
+ );
+
+ const reasons = twoFactorLossReasons({
+ isPrivileged: true,
+ userTwoFactorEnabled: false,
+ twoFactorRowExists: false,
+ twoFactorRowEnabled: false,
+ recoveryRequired: true,
+ });
+ assert.ok(reasons.includes("user.twoFactorEnabled=false"));
+ assert.ok(reasons.includes("twoFactor row missing"));
+ assert.ok(reasons.includes("twoFactorRecoveryRequired=true"));
+
+ assert.deepEqual(
+ twoFactorLossReasons({
+ isPrivileged: true,
+ userTwoFactorEnabled: true,
+ twoFactorRowExists: true,
+ twoFactorRowEnabled: true,
+ recoveryRequired: false,
+ }),
+ [],
+ "Sauberer Admin-Account hat keine Recovery-Reasons",
+ );
+});
+
+test("twoFactorLossReasons unterscheidet fehlende Row von deaktivierter Row", () => {
+ const missing = twoFactorLossReasons({
+ isPrivileged: true,
+ userTwoFactorEnabled: true,
+ twoFactorRowExists: false,
+ twoFactorRowEnabled: false,
+ recoveryRequired: false,
+ });
+ assert.deepEqual(missing, ["twoFactor row missing"]);
+
+ const disabled = twoFactorLossReasons({
+ isPrivileged: true,
+ userTwoFactorEnabled: true,
+ twoFactorRowExists: true,
+ twoFactorRowEnabled: false,
+ recoveryRequired: false,
+ });
+ assert.deepEqual(disabled, ["twoFactor.enabled=false"]);
+});
+
+test("security.status erfordert Authentifizierung", async () => {
+ const caller = appRouter.createCaller(createUnauthedContext() as never);
+ await assert.rejects(
+ () => caller.security.status(),
+ (error: unknown) => {
+ assert.equal((error as { code?: string }).code, "UNAUTHORIZED");
+ return true;
+ },
+ );
+});
+
+test("security.status meldet sauberen Admin mit aktivem 2FA", async () => {
+ const { db, calls } = createSecurityDbMock({
+ dbUser: {
+ email: "admin@example.org",
+ role: "admin",
+ twoFactorEnabled: true,
+ twoFactorRecoveryRequired: false,
+ },
+ twoFactorRow: { enabled: true },
+ });
+ const caller = appRouter.createCaller(createContext({ db }) as never);
+
+ const status = await caller.security.status();
+
+ assert.deepEqual(status, {
+ isAdminAccount: true,
+ twoFactorEnabled: true,
+ hasTwoFactorSecret: true,
+ twoFactorRecoveryRequired: false,
+ requiresTwoFactorSetup: false,
+ });
+ assert.equal(calls.length, 0, "Keine Recovery-Updates noetig");
+});
+
+test("security.status markiert Admin ohne 2FA-Row als setup-pflichtig", async () => {
+ const { db } = createSecurityDbMock({
+ dbUser: {
+ email: "admin@example.org",
+ role: "platform_admin",
+ twoFactorEnabled: false,
+ twoFactorRecoveryRequired: false,
+ },
+ twoFactorRow: null,
+ });
+ const caller = appRouter.createCaller(createContext({ db }) as never);
+
+ const status = await caller.security.status();
+
+ assert.equal(status.isAdminAccount, true);
+ assert.equal(status.twoFactorEnabled, false);
+ assert.equal(status.hasTwoFactorSecret, false);
+ assert.equal(status.requiresTwoFactorSetup, true);
+});
+
+test("security.status loescht Recovery-Flag automatisch wenn 2FA wieder aktiv", async () => {
+ const { db, calls } = createSecurityDbMock({
+ dbUser: {
+ email: "admin@example.org",
+ role: "admin",
+ twoFactorEnabled: true,
+ twoFactorRecoveryRequired: true,
+ },
+ twoFactorRow: { enabled: true },
+ });
+ const caller = appRouter.createCaller(createContext({ db }) as never);
+
+ const status = await caller.security.status();
+
+ assert.equal(status.twoFactorRecoveryRequired, false, "Flag wird im Response zurueckgesetzt");
+ assert.equal(status.requiresTwoFactorSetup, false);
+ const updateSet = calls.find((call) => call.op === "update.set")?.payload as {
+ twoFactorRecoveryRequired?: boolean;
+ twoFactorRecoveryStartedAt?: Date | null;
+ twoFactorRecoveryStartedBy?: string | null;
+ };
+ assert.equal(updateSet.twoFactorRecoveryRequired, false);
+ assert.equal(updateSet.twoFactorRecoveryStartedAt, null);
+ assert.equal(updateSet.twoFactorRecoveryStartedBy, null);
+});
+
+test("security.status haelt requiresTwoFactorSetup aktiv solange Recovery noch laeuft mit deaktivierter Row", async () => {
+ const { db, calls } = createSecurityDbMock({
+ dbUser: {
+ email: "admin@example.org",
+ role: "admin",
+ twoFactorEnabled: true,
+ twoFactorRecoveryRequired: true,
+ },
+ twoFactorRow: { enabled: false },
+ });
+ const caller = appRouter.createCaller(createContext({ db }) as never);
+
+ const status = await caller.security.status();
+
+ assert.equal(status.twoFactorEnabled, false, "Row.enabled=false -> 2FA gilt als aus");
+ assert.equal(status.twoFactorRecoveryRequired, true);
+ assert.equal(status.requiresTwoFactorSetup, true);
+ assert.equal(
+ calls.some((call) => call.op === "update.set"),
+ false,
+ "Kein Auto-Clear solange 2FA effektiv aus ist",
+ );
+});