diff --git a/prisma/migrations/20260711090000_multi_user/migration.sql b/prisma/migrations/20260711090000_multi_user/migration.sql new file mode 100644 index 0000000..7556416 --- /dev/null +++ b/prisma/migrations/20260711090000_multi_user/migration.sql @@ -0,0 +1,25 @@ +-- Multi-User-Umbau mit Fresh-Start: bestehende Testdaten werden verworfen +-- (abgestimmt am 11.07.2026). Household haengt neu an einem User-Konto. + +-- Bestehende Daten loeschen (Cascade raeumt Persons, Plans, Phases, ... mit ab) +DELETE FROM "Household"; +DROP TABLE "AppCredential"; + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- AlterTable (Tabelle ist leer, daher NOT NULL ohne Default moeglich) +ALTER TABLE "Household" ADD COLUMN "userId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "Household" ADD CONSTRAINT "Household_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf77b58..09c492b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,12 +10,16 @@ datasource db { provider = "postgresql" } -// Zugriffsschutz der Webapplikation: kein vorkonfiguriertes Passwort -- der -// Benutzer legt es beim allerersten Login selbst fest (genau eine Zeile). -model AppCredential { +// Benutzerkonto: offene Registrierung mit Benutzername + Passwort (bcrypt-Hash). +// Jeder Benutzer hat seinen eigenen Haushalt samt Plaenen -- Daten sind strikt +// pro Konto isoliert. +model User { id String @id @default(cuid()) + username String @unique passwordHash String createdAt DateTime @default(now()) + + households Household[] } enum HouseholdType { @@ -54,9 +58,11 @@ enum PositionType { REAL_ESTATE } -// Ein Haushalt (1 oder 2 Personen) - Wurzel-Objekt +// Ein Haushalt (1 oder 2 Personen) - Wurzel-Objekt, gehoert genau einem Benutzer model Household { id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) householdType HouseholdType inflationRateDefault Float createdAt DateTime @default(now()) diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..8baaa3f --- /dev/null +++ b/src/app/api/auth/change-password/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCurrentUserId } from "@/lib/session"; +import { changeUserPassword } from "@/lib/users"; + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(6, "Das neue Passwort muss mindestens 6 Zeichen lang sein."), +}); + +export async function POST(request: NextRequest) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + + const body = await request.json(); + const parsed = changePasswordSchema.safeParse(body); + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe."; + return NextResponse.json({ error: message }, { status: 400 }); + } + + try { + await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." }, + { status: 400 } + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index af12778..405c1f8 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,24 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth"; -import { getAppCredential, verifyAppPassword } from "@/lib/credentials"; +import { verifyUserCredentials } from "@/lib/users"; + +const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); export async function POST(request: NextRequest) { - const { password } = await request.json(); - - const credential = await getAppCredential(); - if (!credential) { - return NextResponse.json( - { error: "Es ist noch kein Passwort gesetzt. Bitte zuerst ein Passwort festlegen." }, - { status: 409 } - ); + const body = await request.json(); + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Bitte Benutzername und Passwort angeben." }, { status: 400 }); } - if (typeof password !== "string" || password.length === 0 || !(await verifyAppPassword(password))) { - return NextResponse.json({ error: "Falsches Passwort." }, { status: 401 }); + const user = await verifyUserCredentials(parsed.data.username, parsed.data.password); + if (!user) { + return NextResponse.json({ error: "Benutzername oder Passwort falsch." }, { status: 401 }); } - const token = await createSessionToken(); - const response = NextResponse.json({ ok: true }); + const token = await createSessionToken(user.id); + const response = NextResponse.json({ ok: true, username: user.username }); response.cookies.set(SESSION_COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..ceb8dd3 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getCurrentUserId } from "@/lib/session"; + +export async function GET() { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, createdAt: true }, + }); + if (!user) { + return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 }); + } + return NextResponse.json({ user }); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..762cdee --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth"; +import { registerUser, validateUsername } from "@/lib/users"; + +const registerSchema = z.object({ + username: z.string().min(1), + password: z.string().min(6, "Das Passwort muss mindestens 6 Zeichen lang sein."), +}); + +export async function POST(request: NextRequest) { + const body = await request.json(); + const parsed = registerSchema.safeParse(body); + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe."; + return NextResponse.json({ error: message }, { status: 400 }); + } + + const usernameError = validateUsername(parsed.data.username); + if (usernameError) { + return NextResponse.json({ error: usernameError }, { status: 400 }); + } + + let user; + try { + user = await registerUser(parsed.data.username, parsed.data.password); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Registrierung fehlgeschlagen." }, + { status: 409 } + ); + } + + const token = await createSessionToken(user.id); + const response = NextResponse.json({ ok: true, username: user.username }, { status: 201 }); + response.cookies.set(SESSION_COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, + }); + return response; +} diff --git a/src/app/api/auth/setup/route.ts b/src/app/api/auth/setup/route.ts deleted file mode 100644 index acfbd74..0000000 --- a/src/app/api/auth/setup/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth"; -import { getAppCredential, setAppPassword } from "@/lib/credentials"; - -const setupSchema = z.object({ - password: z.string().min(4, "Das Passwort muss mindestens 4 Zeichen lang sein."), -}); - -// Legt das Login-Passwort einmalig fest. Nur solange noch keine AppCredential-Zeile -// existiert (d. h. beim allerersten Login) erreichbar -- danach ausschliesslich -// ueber /api/auth/login. -export async function POST(request: NextRequest) { - const existing = await getAppCredential(); - if (existing) { - return NextResponse.json({ error: "Es ist bereits ein Passwort gesetzt." }, { status: 409 }); - } - - const body = await request.json(); - const parsed = setupSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } - - await setAppPassword(parsed.data.password); - - const token = await createSessionToken(); - const response = NextResponse.json({ ok: true }); - response.cookies.set(SESSION_COOKIE_NAME, token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 30, - }); - return response; -} diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts deleted file mode 100644 index 8085313..0000000 --- a/src/app/api/auth/status/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from "next/server"; -import { getAppCredential } from "@/lib/credentials"; - -export async function GET() { - const credential = await getAppCredential(); - return NextResponse.json({ passwordSet: credential != null }); -} diff --git a/src/app/api/household/route.ts b/src/app/api/household/route.ts index f15b17d..1260003 100644 --- a/src/app/api/household/route.ts +++ b/src/app/api/household/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; import { getHouseholdOrNull, toHouseholdInput } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; const personSchema = z.object({ role: z.enum(["PERSON_A", "PERSON_B"]), @@ -26,12 +27,21 @@ function validatePersonsForType(data: z.infer) { } export async function GET() { - const household = await getHouseholdOrNull(); + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + const household = await getHouseholdOrNull(userId); return NextResponse.json({ household: household ? toHouseholdInput(household) : null }); } export async function POST(request: NextRequest) { - const existing = await getHouseholdOrNull(); + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + + const existing = await getHouseholdOrNull(userId); if (existing) { return NextResponse.json( { error: "Es existiert bereits ein Haushalt. Bitte PATCH verwenden, um ihn zu bearbeiten." }, @@ -51,6 +61,7 @@ export async function POST(request: NextRequest) { const household = await prisma.household.create({ data: { + userId, householdType: parsed.data.householdType, inflationRateDefault: parsed.data.inflationRateDefault, persons: { create: parsed.data.persons }, @@ -62,7 +73,12 @@ export async function POST(request: NextRequest) { } export async function PATCH(request: NextRequest) { - const existing = await getHouseholdOrNull(); + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + + const existing = await getHouseholdOrNull(userId); if (!existing) { return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 404 }); } diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 7de0c35..0bd317e 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { phaseInclude } from "@/lib/queries"; +import { phaseInclude, getOwnedPhase } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; const incomeEntrySchema = z.object({ personId: z.string().nullable().optional(), @@ -61,6 +62,10 @@ export async function PUT( request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { phaseId } = await params; const body = await request.json(); const parsed = updatePhaseSchema.safeParse(body); @@ -69,7 +74,7 @@ export async function PUT( } const data = parsed.data; - const existing = await prisma.phase.findUnique({ where: { id: phaseId } }); + const existing = await getOwnedPhase(phaseId, userId); if (!existing) { return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); } @@ -111,8 +116,12 @@ export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { phaseId } = await params; - const phase = await prisma.phase.findUnique({ where: { id: phaseId } }); + const phase = await getOwnedPhase(phaseId, userId); if (!phase) { return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); } diff --git a/src/app/api/phases/[phaseId]/transition/route.ts b/src/app/api/phases/[phaseId]/transition/route.ts index bb4b9d0..e185ebc 100644 --- a/src/app/api/phases/[phaseId]/transition/route.ts +++ b/src/app/api/phases/[phaseId]/transition/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { prisma } from "@/lib/db"; import { computeMortgageYearly, computeSecurityYearlyValues } from "@/lib/calculations"; import { floorToThousand } from "@/lib/format"; +import { getCurrentUserId } from "@/lib/session"; const transitionItemSchema = z.object({ positionType: z.enum(["SECURITY", "REAL_ESTATE"]), @@ -25,9 +26,13 @@ export async function GET( _request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { phaseId } = await params; - const phase = await prisma.phase.findUnique({ - where: { id: phaseId }, + const phase = await prisma.phase.findFirst({ + where: { id: phaseId, plan: { household: { userId } } }, include: { securities: true, realEstates: true }, }); if (!phase) { @@ -61,6 +66,10 @@ export async function PUT( request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { phaseId } = await params; const body = await request.json(); const parsed = putTransitionSchema.safeParse(body); @@ -68,8 +77,8 @@ export async function PUT( return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const phase = await prisma.phase.findUnique({ - where: { id: phaseId }, + const phase = await prisma.phase.findFirst({ + where: { id: phaseId, plan: { household: { userId } } }, include: { securities: true, realEstates: true }, }); if (!phase) { diff --git a/src/app/api/plans/[planId]/export/route.ts b/src/app/api/plans/[planId]/export/route.ts index 71fb674..9f0c0e6 100644 --- a/src/app/api/plans/[planId]/export/route.ts +++ b/src/app/api/plans/[planId]/export/route.ts @@ -1,19 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; -import { planInclude, toHouseholdInput, toPlanInput, getHouseholdOrNull } from "@/lib/queries"; +import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; import { computePlan, planToCsv } from "@/lib/calculations"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { planId } = await params; - const household = await getHouseholdOrNull(); + const household = await getHouseholdOrNull(userId); if (!household) { return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); } - const plan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude }); + const plan = await getOwnedPlan(planId, userId); if (!plan) { return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); } diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/plans/[planId]/phases/route.ts index 4bd0b4e..b18e33b 100644 --- a/src/app/api/plans/[planId]/phases/route.ts +++ b/src/app/api/plans/[planId]/phases/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; import { phaseInclude } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; const createPhaseSchema = z.object({ name: z.string().min(1).max(120), @@ -16,6 +17,10 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { planId } = await params; const body = await request.json(); const parsed = createPhaseSchema.safeParse(body); @@ -23,7 +28,7 @@ export async function POST( return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const plan = await prisma.plan.findUnique({ where: { id: planId } }); + const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } }); if (!plan) { return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); } diff --git a/src/app/api/plans/[planId]/route.ts b/src/app/api/plans/[planId]/route.ts index ae90c64..56f23e2 100644 --- a/src/app/api/plans/[planId]/route.ts +++ b/src/app/api/plans/[planId]/route.ts @@ -1,19 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; -import { planInclude, toHouseholdInput, toPlanInput, getHouseholdOrNull } from "@/lib/queries"; +import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; import { computePlan } from "@/lib/calculations"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { planId } = await params; - const household = await getHouseholdOrNull(); + const household = await getHouseholdOrNull(userId); if (!household) { return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); } - const plan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude }); + const plan = await getOwnedPlan(planId, userId); if (!plan) { return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); } @@ -29,7 +34,15 @@ export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { planId } = await params; - await prisma.plan.delete({ where: { id: planId } }); + const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } }); + if (!plan) { + return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + } + await prisma.plan.delete({ where: { id: plan.id } }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts index 1ed5505..e774556 100644 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ b/src/app/api/plans/[planId]/scenario/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { phaseInclude, planInclude } from "@/lib/queries"; +import { phaseInclude, getOwnedPlan } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; const scenarioSchema = z.object({ name: z.string().min(1).max(120), @@ -15,6 +16,10 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } const { planId } = await params; const body = await request.json(); const parsed = scenarioSchema.safeParse(body); @@ -22,7 +27,7 @@ export async function POST( return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const sourcePlan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude }); + const sourcePlan = await getOwnedPlan(planId, userId); if (!sourcePlan) { return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 }); } diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index 0e59711..2ce6034 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -2,13 +2,18 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; import { getHouseholdOrNull } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; const createPlanSchema = z.object({ name: z.string().min(1).max(120), }); export async function GET() { - const household = await getHouseholdOrNull(); + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + const household = await getHouseholdOrNull(userId); if (!household) { return NextResponse.json({ plans: [] }); } @@ -31,7 +36,11 @@ export async function GET() { } export async function POST(request: NextRequest) { - const household = await getHouseholdOrNull(); + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + const household = await getHouseholdOrNull(userId); if (!household) { return NextResponse.json( { error: "Bitte zuerst das Grundprofil (Onboarding) anlegen." }, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 86a0300..7df3c25 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,39 +1,36 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Lock, PiggyBank } from "lucide-react"; +import { Lock, PiggyBank, User } from "lucide-react"; + +type Mode = "login" | "register"; function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); - const [passwordSet, setPasswordSet] = useState(undefined); + const [mode, setMode] = useState("login"); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - useEffect(() => { - fetch("/api/auth/status") - .then((r) => r.json()) - .then((data) => setPasswordSet(Boolean(data.passwordSet))); - }, []); - async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); - if (!passwordSet && password !== passwordConfirm) { + if (mode === "register" && password !== passwordConfirm) { setError("Die Passwoerter stimmen nicht ueberein."); return; } setLoading(true); try { - const response = await fetch(passwordSet ? "/api/auth/login" : "/api/auth/setup", { + const response = await fetch(mode === "login" ? "/api/auth/login" : "/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), + body: JSON.stringify({ username, password }), }); if (!response.ok) { const body = await response.json().catch(() => ({})); @@ -48,62 +45,94 @@ function LoginForm() { } } - if (passwordSet === undefined) { - return ( -
-

Laedt…

-
- ); - } + const inputClass = + "w-full rounded-lg border border-zinc-300 bg-white py-2 pl-9 pr-3 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; return (
-
-

- - {passwordSet ? "FPT — Anmelden" : "FPT — Passwort festlegen"} -

- {!passwordSet && ( +
+
+
+ +
+

+ Financial Planning Tool +

- Es ist noch kein Passwort eingerichtet. Legen Sie hier Ihr persoenliches Passwort fest, - um den Zugriff auf Ihre Finanzplanung zu schuetzen. + Persoenliche Finanzplanung ueber Lebensphasen

- )} -
- - setPassword(e.target.value)} - className="w-full rounded-lg border border-zinc-300 bg-white py-2 pl-9 pr-3 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" - />
- {!passwordSet && ( + + +
+ {(["login", "register"] as Mode[]).map((m) => ( + + ))} +
+ +
+ + setUsername(e.target.value)} + className={inputClass} + /> +
setPasswordConfirm(e.target.value)} - className="w-full rounded-lg border border-zinc-300 bg-white py-2 pl-9 pr-3 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" + autoComplete={mode === "login" ? "current-password" : "new-password"} + placeholder="Passwort" + value={password} + onChange={(e) => setPassword(e.target.value)} + className={inputClass} />
- )} - {error &&

{error}

} - - + {mode === "register" && ( +
+ + setPasswordConfirm(e.target.value)} + className={inputClass} + /> +
+ )} + {error &&

{error}

} + + +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 51df9f1..46ca9a4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,11 +8,21 @@ import type { HouseholdInput } from "@/lib/types"; export default function Home() { const [household, setHousehold] = useState(undefined); + const [username, setUsername] = useState(""); useEffect(() => { - api.get<{ household: HouseholdInput | null }>("/api/household").then((data) => { - setHousehold(data.household); - }); + Promise.all([ + api.get<{ household: HouseholdInput | null }>("/api/household"), + api.get<{ user: { username: string } }>("/api/auth/me"), + ]) + .then(([householdData, meData]) => { + setUsername(meData.user.username); + setHousehold(householdData.household); + }) + .catch(() => { + // Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um. + window.location.href = "/login"; + }); }, []); if (household === undefined) { @@ -27,5 +37,5 @@ export default function Home() { return ; } - return ; + return ; } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 229565a..f724ae4 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,11 +1,20 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { LogOut, PiggyBank, Plus, Settings, X } from "lucide-react"; -import { PhaseCard } from "@/components/PhaseCard"; +import { + FolderKanban, + LayoutDashboard, + Menu, + PiggyBank, + Plus, + Trash2, + X, +} from "lucide-react"; +import { PhaseCard, formatAges } from "@/components/PhaseCard"; import { TransitionPanel } from "@/components/TransitionPanel"; import { Dashboard } from "@/components/Dashboard"; import { HouseholdSettings } from "@/components/HouseholdSettings"; +import { ProfileMenu } from "@/components/ProfileMenu"; import { api } from "@/lib/api-client"; import type { HouseholdInput, PlanInput } from "@/lib/types"; import type { PlanComputed } from "@/lib/calculations"; @@ -18,13 +27,20 @@ interface PlanListItem { phases: { id: string; name: string; sequenceNumber: number }[]; } -export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInput }) { +export function AppShell({ + initialHousehold, + username, +}: { + initialHousehold: HouseholdInput; + username: string; +}) { const [household, setHousehold] = useState(initialHousehold); const [showSettings, setShowSettings] = useState(false); const [plans, setPlans] = useState([]); const [selectedPlanId, setSelectedPlanId] = useState(null); const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null); const [loading, setLoading] = useState(true); + const [sidebarOpen, setSidebarOpen] = useState(false); const [showNewPlan, setShowNewPlan] = useState(false); const [showScenario, setShowScenario] = useState(false); @@ -33,11 +49,8 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu setPlans(data.plans); if (preferId) { setSelectedPlanId(preferId); - } else if (!selectedPlanId && data.plans.length > 0) { - setSelectedPlanId(data.plans[0].id); } return data.plans; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const loadDetail = useCallback(async (planId: string) => { @@ -51,17 +64,16 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu }, []); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount, kein synchrones setState - loadPlans(); + // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount + loadPlans().finally(() => setLoading(false)); }, [loadPlans]); useEffect(() => { if (selectedPlanId) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf, kein synchrones setState + // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf loadDetail(selectedPlanId); } else { setDetail(null); - setLoading(false); } }, [selectedPlanId, loadDetail]); @@ -70,10 +82,9 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu } async function handleAddPhase() { - if (!selectedPlanId) return; - const lastPhase = detail?.plan.phases[detail.plan.phases.length - 1]; + if (!selectedPlanId || !detail) return; await api.post(`/api/plans/${selectedPlanId}/phases`, { - name: lastPhase ? `Neue Phase ${detail!.plan.phases.length + 1}` : "Erste Lebensphase", + name: detail.plan.phases.length === 0 ? "Erste Lebensphase" : `Neue Phase ${detail.plan.phases.length + 1}`, durationYears: 10, incomeMode: "HOUSEHOLD", }); @@ -83,206 +94,391 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu async function handleDeletePlan(id: string) { if (!confirm("Diesen Plan wirklich loeschen?")) return; await api.delete(`/api/plans/${id}`); - const remaining = await loadPlans(); + await loadPlans(); if (selectedPlanId === id) { - setSelectedPlanId(remaining[0]?.id ?? null); + setSelectedPlanId(null); } } - return ( -
-
-

- - Financial Planning Tool -

-
- - -
-
+ const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null; - {showSettings && ( - setShowSettings(false)} - /> - )} - - {/* Tab-Leiste */} -
- {plans.map((p) => ( -
- - {selectedPlanId === p.id && ( - - )} -
- ))} -
- - {showNewPlan && ( - { - const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name }); - setShowNewPlan(false); - await loadPlans(plan.id); - }} - onClose={() => setShowNewPlan(false)} - /> - )} + const sidebar = ( +
+
+
+
- {detail && detail.plan.phases.length > 0 && ( -
- - {showScenario && ( - { - const { planId } = await api.post<{ planId: string }>( - `/api/plans/${selectedPlanId}/scenario`, - { name, branchFromPhaseId } - ); - setShowScenario(false); - await loadPlans(planId); - }} - onClose={() => setShowScenario(false)} - /> - )} -
- )} + FPT
- {loading &&

Laedt…

} - - {!loading && plans.length === 0 && ( -

- Noch kein Plan vorhanden. Erstellen Sie oben Ihren ersten Plan. -

- )} - - {!loading && detail && ( - <> -
- {detail.plan.phases.map((phase, i) => { - const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!; - const nextPhase = detail.plan.phases[i + 1]; - return ( -
- - {nextPhase && ( - - )} -
- ); - })} -
- - - - {detail.plan.phases.length > 0 && ( - - )} - - )} -
- ); -} - -function NewPlanPopover({ onCreate, onClose }: { onCreate: (name: string) => void; onClose: () => void }) { - const [name, setName] = useState("Basisplan"); - return ( -
- setName(e.target.value)} - placeholder="Name des Plans" - /> -
+
+ {plans.length === 0 && ( +

Noch keine Plaene.

+ )} + {plans.map((p) => ( + + ))} + +
+ ); + + return ( +
+ {/* Sidebar Desktop */} + + + {/* Sidebar Mobile (Overlay) */} + {sidebarOpen && ( +
+
setSidebarOpen(false)} /> + +
+ )} + + {/* Hauptbereich */} +
+
+ +

+ {activePlan ? activePlan.name : "Uebersicht"} +

+ setShowSettings(true)} /> +
+ +
+ {showSettings && ( +
+ setShowSettings(false)} + /> +
+ )} + + {loading &&

Laedt…

} + + {!loading && selectedPlanId === null && ( + setShowNewPlan(true)} + onDelete={handleDeletePlan} + /> + )} + + {!loading && detail && selectedPlanId && ( +
+ {/* Plan-Kopf mit Aktionen */} +
+ + {detail.plan.phases.length > 0 && ( + + )} + +
+ + {/* Phasen mit Lebenslinie */} +
+ {detail.plan.phases.map((phase, i) => { + const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!; + const nextPhase = detail.plan.phases[i + 1]; + const startAges = computedPhase.ages.map((a) => a.startAge).join("·"); + return ( +
+ {/* Lebenslinie */} +
+
+ {startAges} +
+
+
+
+ + {nextPhase && ( + + )} +
+
+ ); + })} + {detail.computed.phases.length > 0 && ( +
+
+ {detail.computed.phases[detail.computed.phases.length - 1].ages + .map((a) => a.endAge) + .join("·")} +
+
+ )} +
+ + {detail.plan.phases.length === 0 && ( +

+ Dieser Plan hat noch keine Phasen. Fuegen Sie oben die erste Lebensphase hinzu. +

+ )} + + {detail.plan.phases.length > 0 && ( + + )} +
+ )} +
+
+ + {/* Dialoge */} + {showNewPlan && ( + { + const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name }); + setShowNewPlan(false); + await loadPlans(plan.id); + }} + onClose={() => setShowNewPlan(false)} + /> + )} + {showScenario && detail && selectedPlanId && ( + { + const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, { + name, + branchFromPhaseId, + }); + setShowScenario(false); + await loadPlans(planId); + }} + onClose={() => setShowScenario(false)} + /> + )} +
+ ); +} + +// Startansicht: Begruessung + Plan-Kacheln. +function DashboardHome({ + username, + plans, + onSelect, + onCreate, + onDelete, +}: { + username: string; + plans: PlanListItem[]; + onSelect: (id: string) => void; + onCreate: () => void; + onDelete: (id: string) => void; +}) { + return ( +
+
+

+ Willkommen, {username} +

+

+ Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen. +

+
+ +
+ {plans.map((p) => ( +
onSelect(p.id)} + > +
+
+ +
+
+
{p.name}
+
+ {p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"} + {p.parentPlanId ? " · Szenario" : ""} +
+
+ +
+
+ ))} + +
); } -function ScenarioPopover({ +function PlanDialog({ + title, + defaultName, + onCreate, + onClose, +}: { + title: string; + defaultName: string; + onCreate: (name: string) => void; + onClose: () => void; +}) { + const [name, setName] = useState(defaultName); + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900" + > +

{title}

+ setName(e.target.value)} + placeholder="Name des Plans" + /> +
+ + +
+
+
+ ); +} + +function ScenarioDialog({ phases, onCreate, onClose, @@ -294,36 +490,47 @@ function ScenarioPopover({ const [name, setName] = useState("Neues Szenario"); const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? ""); return ( -
- setName(e.target.value)} - placeholder="Name des Szenarios" - /> - - -
- + +
); diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 823ba05..4f46211 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -54,14 +54,17 @@ export function NumberField({ // formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, rundet // beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts) // und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten. +// Optionales `max` kappt Eingaben live auf das verfuegbare Budget (z. B. Sparquote). export function MoneyInput({ value, onChange, className, + max, }: { value: number; onChange: (value: number) => void; className?: string; + max?: number; }) { const [focused, setFocused] = useState(false); const [text, setText] = useState(() => String(Math.floor(value || 0))); @@ -72,8 +75,14 @@ export function MoneyInput({ const holdTimeout = useRef | null>(null); const holdInterval = useRef | null>(null); + function clamp(v: number): number { + let result = Math.max(0, v); + if (max != null) result = Math.min(result, Math.max(0, floorToThousand(max))); + return result; + } + function step(delta: number) { - onChange(floorToThousand(valueRef.current) + delta); + onChange(clamp(floorToThousand(valueRef.current) + delta)); } function stopHold() { @@ -107,12 +116,14 @@ export function MoneyInput({ value={focused ? text : formatChf(value)} onFocus={() => { setFocused(true); - setText(String(Math.floor(value || 0))); + // Default-0 sofort leeren, damit man direkt lostippen kann. + const current = Math.floor(value || 0); + setText(current === 0 ? "" : String(current)); }} onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))} onBlur={() => { setFocused(false); - onChange(floorToThousand(parseChfInput(text))); + onChange(clamp(floorToThousand(parseChfInput(text)))); }} />
@@ -164,16 +175,18 @@ export function MoneyField({ help, value, onChange, + max, }: { label: string; help?: string; value: number; onChange: (value: number) => void; + max?: number; }) { return (
- +
); } diff --git a/src/components/PhaseCard.tsx b/src/components/PhaseCard.tsx index 7b2c4fc..225da42 100644 --- a/src/components/PhaseCard.tsx +++ b/src/components/PhaseCard.tsx @@ -2,23 +2,38 @@ import { useState } from "react"; import { LineChart, Line, ResponsiveContainer } from "recharts"; -import { AlertTriangle, ChevronDown, ChevronRight, Trash2 } from "lucide-react"; +import { AlertTriangle, ChevronDown, ChevronRight, Trash2, Users } from "lucide-react"; import { PhaseForm } from "@/components/PhaseForm"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import type { HouseholdInput, PhaseInput } from "@/lib/types"; import type { PhaseComputed } from "@/lib/calculations"; +// Formatiert die Altersspannen der Personen einer Phase, z. B. "35–45" (Single) +// oder "A 35–45 · B 33–43" (Paar). +export function formatAges(computed: PhaseComputed): string { + if (computed.ages.length === 0) return ""; + if (computed.ages.length === 1) { + const a = computed.ages[0]; + return `${a.startAge}–${a.endAge}`; + } + return computed.ages + .map((a) => `${a.role === "PERSON_A" ? "A" : "B"} ${a.startAge}–${a.endAge}`) + .join(" · "); +} + export function PhaseCard({ household, phase, computed, + isFirst, isLast, onChanged, }: { household: HouseholdInput; phase: PhaseInput; computed: PhaseComputed; + isFirst: boolean; isLast: boolean; onChanged: () => void; }) { @@ -48,15 +63,19 @@ export function PhaseCard({ {expanded && ( { onChanged(); }} diff --git a/src/components/PhaseForm.tsx b/src/components/PhaseForm.tsx index 5e92194..0c44a16 100644 --- a/src/components/PhaseForm.tsx +++ b/src/components/PhaseForm.tsx @@ -4,11 +4,12 @@ import { useState } from "react"; import { AlertTriangle, CheckCircle2, + ChevronDown, + ChevronRight, Gift, Home, Plus, PiggyBank, - ShoppingCart, TrendingUp, Wallet, X, @@ -47,11 +48,12 @@ function personLabel(household: HouseholdInput, personId: string | null) { interface Props { household: HouseholdInput; phase: PhaseInput; + isFirstPhase: boolean; onSaved: () => void; onCancel: () => void; } -export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { +export function PhaseForm({ household, phase, isFirstPhase, onSaved, onCancel }: Props) { const [name, setName] = useState(phase.name); const [durationYears, setDurationYears] = useState(phase.durationYears); const [inflationRate, setInflationRate] = useState(phase.inflationRate); @@ -71,11 +73,10 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0); const savingsQuota = totalIncome - totalExpense; // Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der - // Wertschriften gegen dieselbe verfuegbare Sparquote. + // Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf). const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0) + realEstates.reduce((s, re) => s + re.amortization, 0); - const overAllocated = allocated > savingsQuota; const savingsRemaining = savingsQuota - allocated > 0.5; const allocatedStartCapital = securities.reduce( @@ -84,6 +85,19 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { ); const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5; + // Live-Kappung: pro Feld das noch verfuegbare Budget (eigener Anteil zaehlt nicht + // gegen sich selbst, damit man einen bestehenden Wert wieder erhoehen/senken kann). + function maxContributionFor(current: number): number { + return Math.max(0, savingsQuota - (allocated - current)); + } + function maxStartValueFor(sec: SecurityInput): number | undefined { + // In der ersten Phase wird der Ist-Bestand frei erfasst -- kein Limit. + if (isFirstPhase) return undefined; + const ownExtra = Math.max(0, sec.startValue - sec.carriedBaseValue); + const remaining = Math.max(0, phase.incomingCapital - (allocatedStartCapital - ownExtra)); + return sec.carriedBaseValue + remaining; + } + async function handleSave() { const missingPurchasePrice = realEstates.find((re) => !re.purchasePrice || re.purchasePrice <= 0); if (missingPurchasePrice) { @@ -120,10 +134,10 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { } return ( -
- {/* Basis */} -
-
+
+ {/* Basis-Kopfzeile */} +
+
- {/* Einkommen */} -
}> - {incomeEntries.map((entry, i) => ( -
- {incomeMode === "PER_PERSON" ? ( - - setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, personId: v } : e))) - } - options={household.persons.map((p) => ({ - value: p.id, - label: p.role === "PERSON_A" ? "Person A" : "Person B", - }))} - /> - ) : ( - - setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, label: v || null } : e))) - } - /> - )} - - setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e))) + {/* Matrix: Kategorien als Spalten */} +
+ {/* Einkommen & Ausgaben */} + } + summary={`${formatChf(totalIncome)} / ${formatChf(totalExpense)}`} + > +
+
Einkommen
+ {incomeEntries.map((entry, i) => ( + setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))}> + {incomeMode === "PER_PERSON" ? ( + + setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, personId: v } : e))) + } + options={household.persons.map((p) => ({ + value: p.id, + label: p.role === "PERSON_A" ? "Person A" : "Person B", + }))} + /> + ) : ( + + setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, label: v || null } : e))) + } + /> + )} + + setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e))) + } + /> + + ))} + + setIncomeEntries((prev) => [ + ...prev, + { id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 }, + ]) } /> - setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))} /> -
- ))} - - setIncomeEntries((prev) => [ - ...prev, - { id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 }, - ]) - } - /> -
- {/* Ausgaben */} -
}> - {expenseEntries.map((entry, i) => ( -
- - setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e))) - } +
Ausgaben
+ {expenseEntries.map((entry, i) => ( + setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))}> + + setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e))) + } + /> + + ))} + setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])} /> - setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))} />
- ))} - setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])} - /> -
- Verfuegbare Sparquote (CHF/Jahr): {formatChf(savingsQuota)} - {" "}(Details und Verteilung siehe Wertschriften weiter unten) -
-
+ - {/* Wertschriften */} -
}> - {securities.map((s, i) => ( -
- setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} - /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))} - /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))} - /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))} - /> -
-
+ {/* Wertschriften */} + } + summary={`${securities.length}`} + > +
+ {securities.map((s, i) => ( + setSecurities((prev) => prev.filter((_, idx) => idx !== i))}> + setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} + /> + setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))} + /> + setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))} + /> + setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))} + /> -
- setSecurities((prev) => prev.filter((_, idx) => idx !== i))} /> -
+ + ))} + + setSecurities((prev) => [ + ...prev, + { + id: tempId(), + name: "", + startValue: 0, + expectedReturn: 0, + annualContribution: 0, + ownerTag: "HOUSEHOLD", + saleTaxRate: 0, + carriedBaseValue: 0, + }, + ]) + } + />
- ))} - - setSecurities((prev) => [ - ...prev, - { - id: tempId(), - name: "", - startValue: 0, - expectedReturn: 0, - annualContribution: 0, - ownerTag: "HOUSEHOLD", - saleTaxRate: 0, - carriedBaseValue: 0, - }, - ]) - } - /> -
-
+ + + {/* Immobilien */} + } summary={`${realEstates.length}`}> +
+ {realEstates.map((re, i) => ( + setRealEstates((prev) => prev.filter((_, idx) => idx !== i))}> + setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} + /> + setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))} + /> + setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))} + /> + setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))} + /> + + ))} + + setRealEstates((prev) => [ + ...prev, + { id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 }, + ]) + } + /> +
+
+ + {/* Sondereinnahmen / -ausgaben */} + } + summary={`${oneTimeEvents.length}`} + > +
+ {oneTimeEvents.map((ev, i) => ( + setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))}> + + setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x))) + } + options={[ + { value: "INCOME", label: "Einnahme" }, + { value: "EXPENSE", label: "Ausgabe" }, + ]} + /> + setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))} + /> + + setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x))) + } + /> + + ))} + + setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }]) + } + /> +
+
+ + {/* Pensionierung */} + } + summary={`${retirementInfos.length}`} + > +
+ {retirementInfos.map((r, i) => ( + setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))}> + setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))} + options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))} + /> + setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))} + /> + + setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x))) + } + /> + + setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x))) + } + /> + + setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x))) + } + /> + + ))} + + setRetirementInfos((prev) => [ + ...prev, + { + id: tempId(), + personId: household.persons[0]?.id ?? "", + ahvAmount: 0, + pkPensionAmount: 0, + lumpSumAmount: 0, + lumpSumTaxRate: 8, + }, + ]) + } + /> +
+
+
+ + {/* Budget-Status */} +
+ {!isFirstPhase && ( +
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): {formatChf(phase.incomingCapital)} CHF {" "}— zugewiesen: {formatChf(allocatedStartCapital)}
-
- - Verfuegbare Sparquote (CHF/Jahr): {formatChf(savingsQuota)} - {" "}— zugewiesen: {formatChf(allocated)} -
- {overAllocated && ( -
- - Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote. -
- )} + )} +
+ + Verfuegbare Sparquote (CHF/Jahr): {formatChf(savingsQuota)} + {" "}— zugewiesen: {formatChf(allocated)}
-
- - {/* Immobilien */} -
}> - {realEstates.map((re, i) => ( -
- setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} - /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))} - /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))} - /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))} - /> -
- setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} /> -
-
- ))} - - setRealEstates((prev) => [ - ...prev, - { id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 }, - ]) - } - /> -
- - {/* Sondereinnahmen/-ausgaben */} -
}> - {oneTimeEvents.map((ev, i) => ( -
- setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x)))} - options={[ - { value: "INCOME", label: "Einnahme" }, - { value: "EXPENSE", label: "Ausgabe" }, - ]} - /> - setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))} - /> - setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x)))} - /> - setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))} /> -
- ))} - setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])} - /> -
- - {/* Pensionierung */} -
}> - {retirementInfos.map((r, i) => ( -
- setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))} - options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))} - /> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))} - /> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))} - /> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x)))} - /> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x)))} - /> -
- setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))} /> -
-
- ))} - - setRetirementInfos((prev) => [ - ...prev, - { - id: tempId(), - personId: household.persons[0]?.id ?? "", - ahvAmount: 0, - pkPensionAmount: 0, - lumpSumAmount: 0, - lumpSumTaxRate: 8, - }, - ]) - } - /> -
+
{error && (

@@ -478,26 +519,59 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { ); } -function Section({ +// Eine einklappbare Kategorien-Spalte der Matrix (Phase x Kategorie). +function CollapsibleColumn({ title, icon, + summary, children, }: { title: string; icon: React.ReactNode; + summary?: string; children: React.ReactNode; }) { + const [open, setOpen] = useState(true); return ( -

-

+
+

- {children} + {title} + {summary != null && ( + + {summary} + + )} + + {open ? : } + + + {open &&
{children}
}
); } +// Kompakte Karte fuer einen einzelnen Eintrag (Felder vertikal gestapelt). +function EntryCard({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) { + return ( +
+ + {children} +
+ ); +} + function AddButton({ label, onClick }: { label: string; onClick: () => void }) { return ( - ); -} diff --git a/src/components/ProfileMenu.tsx b/src/components/ProfileMenu.tsx new file mode 100644 index 0000000..116f499 --- /dev/null +++ b/src/components/ProfileMenu.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { KeyRound, LogOut, Settings, UserCircle2 } from "lucide-react"; +import { api } from "@/lib/api-client"; + +export function ProfileMenu({ + username, + onOpenHouseholdSettings, +}: { + username: string; + onOpenHouseholdSettings: () => void; +}) { + const [open, setOpen] = useState(false); + const [showPasswordDialog, setShowPasswordDialog] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ + + {open && ( +
+
+
+ + {username} +
+
+ } + label="Grundprofil bearbeiten" + onClick={() => { + setOpen(false); + onOpenHouseholdSettings(); + }} + /> + } + label="Passwort aendern" + onClick={() => { + setOpen(false); + setShowPasswordDialog(true); + }} + /> + } + label="Abmelden" + onClick={async () => { + await api.post("/api/auth/logout"); + window.location.href = "/login"; + }} + /> +
+ )} + + {showPasswordDialog && setShowPasswordDialog(false)} />} +
+ ); +} + +function MenuItem({ + icon, + label, + onClick, +}: { + icon: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ChangePasswordDialog({ onClose }: { onClose: () => void }) { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordConfirm, setNewPasswordConfirm] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [done, setDone] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (newPassword !== newPasswordConfirm) { + setError("Die neuen Passwoerter stimmen nicht ueberein."); + return; + } + setSaving(true); + try { + await api.post("/api/auth/change-password", { currentPassword, newPassword }); + setDone(true); + setTimeout(onClose, 1200); + } catch (err) { + setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen."); + } finally { + setSaving(false); + } + } + + const inputClass = + "w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900" + > +

Passwort aendern

+ setCurrentPassword(e.target.value)} + className={inputClass} + /> + setNewPassword(e.target.value)} + className={inputClass} + /> + setNewPasswordConfirm(e.target.value)} + className={inputClass} + /> + {error &&

{error}

} + {done &&

Passwort geaendert.

} +
+ + +
+
+
+ ); +} diff --git a/src/components/TransitionPanel.tsx b/src/components/TransitionPanel.tsx index 3361a27..f09c75e 100644 --- a/src/components/TransitionPanel.tsx +++ b/src/components/TransitionPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { ArrowDown, CheckCircle2 } from "lucide-react"; +import { ArrowDown, CheckCircle2, ChevronDown, ChevronRight } from "lucide-react"; import { MoneyInput } from "@/components/FormField"; import { api } from "@/lib/api-client"; import { floorToThousand, formatChf } from "@/lib/format"; @@ -36,6 +36,7 @@ export function TransitionPanel({ }) { const [items, setItems] = useState([]); const [loaded, setLoaded] = useState(false); + const [expanded, setExpanded] = useState(false); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); @@ -167,11 +168,23 @@ export function TransitionPanel({ } return ( -
-
+
+
+ Uebergang → {nextPhaseName} + + Startkapital aus Verkaeufen: {formatChf(totalAvailableCapital)} CHF + + + {expanded && ( +
@@ -260,6 +273,8 @@ export function TransitionPanel({ )} + + )} ); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8b2563c..1e2f0d8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,20 +11,21 @@ function getSecretKey() { return new TextEncoder().encode(secret); } -export async function createSessionToken(): Promise { - return new SignJWT({ auth: true }) +export async function createSessionToken(userId: string): Promise { + return new SignJWT({ userId }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(SESSION_DURATION) .sign(getSecretKey()); } -export async function verifySessionToken(token: string): Promise { +// Liefert die User-ID aus einem gueltigen Session-Token, sonst null. +export async function verifySessionToken(token: string): Promise { try { const { payload } = await jwtVerify(token, getSecretKey()); - return payload.auth === true; + return typeof payload.userId === "string" ? payload.userId : null; } catch { - return false; + return null; } } diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 75e8785..a6e7e2d 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -59,6 +59,16 @@ export interface PhaseComputed { endWealthReal: number; yearlyNominal: number[]; // Laenge durationYears, Werte am Ende von Jahr 1..durationYears yearlyReal: number[]; + // Alter der Personen zu Beginn und am Ende dieser Phase (Grundprofil-Alter + + // kumulierte Dauer der Vorphasen). + ages: PersonAgeRange[]; +} + +export interface PersonAgeRange { + personId: string; + role: string; // PERSON_A | PERSON_B + startAge: number; + endAge: number; } export interface PlanComputed { @@ -136,8 +146,16 @@ function computeRetirement( function computePhase( phase: PhaseInput, household: HouseholdInput, - cumulativeInflationStart: number + cumulativeInflationStart: number, + yearsBeforePhase: number ): PhaseComputed { + const ages: PersonAgeRange[] = household.persons.map((p) => ({ + personId: p.id, + role: p.role, + startAge: p.age + yearsBeforePhase, + endAge: p.age + yearsBeforePhase + phase.durationYears, + })); + const incomeFromEntries = phase.incomeEntries.reduce((sum, e) => sum + e.amount, 0); const expenseTotal = phase.expenseEntries.reduce((sum, e) => sum + e.amount, 0); const retirement = computeRetirement(household, phase); @@ -241,6 +259,7 @@ function computePhase( endWealthReal: endWealthNominal / cumulativeInflationEnd, yearlyNominal, yearlyReal, + ages, }; } @@ -248,10 +267,12 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); let cumulativeInflation = 1; + let yearsBefore = 0; const phases: PhaseComputed[] = []; for (const phase of orderedPhases) { - const computed = computePhase(phase, household, cumulativeInflation); + const computed = computePhase(phase, household, cumulativeInflation, yearsBefore); cumulativeInflation = computed.cumulativeInflationEnd; + yearsBefore += phase.durationYears; phases.push(computed); } diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts deleted file mode 100644 index 2c2471f..0000000 --- a/src/lib/credentials.ts +++ /dev/null @@ -1,28 +0,0 @@ -import bcrypt from "bcryptjs"; -import { prisma } from "@/lib/db"; - -// Nur von API-Routes (Node.js-Runtime) verwendet -- niemals von middleware.ts -// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist. - -// Es gibt genau eine AppCredential-Zeile. Solange keine existiert, ist die App -// "unconfigured" und der naechste Login-Versuch legt das Passwort fest. -export async function getAppCredential() { - return prisma.appCredential.findFirst(); -} - -export async function setAppPassword(password: string) { - const existing = await getAppCredential(); - if (existing) { - // Sollte durch die UI (Passwort-Setup nur beim ersten Login sichtbar) nicht - // vorkommen, wird aber sicherheitshalber serverseitig verhindert. - throw new Error("Es ist bereits ein Passwort gesetzt."); - } - const passwordHash = await bcrypt.hash(password, 12); - return prisma.appCredential.create({ data: { passwordHash } }); -} - -export async function verifyAppPassword(password: string): Promise { - const credential = await getAppCredential(); - if (!credential) return false; - return bcrypt.compare(password, credential.passwordHash); -} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 262bd65..73fbee4 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -96,6 +96,22 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput { }; } -export async function getHouseholdOrNull(): Promise { - return prisma.household.findFirst({ include: { persons: true } }); +// Liefert den Haushalt des eingeloggten Benutzers (pro Konto genau einer). +export async function getHouseholdOrNull(userId: string): Promise { + return prisma.household.findFirst({ where: { userId }, include: { persons: true } }); +} + +// Laedt einen Plan inkl. aller Phasen, aber nur wenn er dem Benutzer gehoert. +export async function getOwnedPlan(planId: string, userId: string) { + return prisma.plan.findFirst({ + where: { id: planId, household: { userId } }, + include: planInclude, + }); +} + +// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert. +export async function getOwnedPhase(phaseId: string, userId: string) { + return prisma.phase.findFirst({ + where: { id: phaseId, plan: { household: { userId } } }, + }); } diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..a0e93f6 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,12 @@ +import { cookies } from "next/headers"; +import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth"; + +// Liest die User-ID des eingeloggten Benutzers aus dem Session-Cookie. +// Fuer API-Routen (Node.js-Runtime); die Middleware schuetzt die Routen bereits, +// dies ist die zweite Verteidigungslinie und liefert die ID fuer Ownership-Checks. +export async function getCurrentUserId(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + if (!token) return null; + return verifySessionToken(token); +} diff --git a/src/lib/users.ts b/src/lib/users.ts new file mode 100644 index 0000000..2a84c51 --- /dev/null +++ b/src/lib/users.ts @@ -0,0 +1,43 @@ +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; + +// Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts +// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist. + +const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/; + +export function validateUsername(username: string): string | null { + if (!USERNAME_PATTERN.test(username)) { + return "Benutzername: 3-32 Zeichen, nur Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich."; + } + return null; +} + +export async function registerUser(username: string, password: string) { + const existing = await prisma.user.findUnique({ where: { username } }); + if (existing) { + throw new Error("Dieser Benutzername ist bereits vergeben."); + } + const passwordHash = await bcrypt.hash(password, 12); + return prisma.user.create({ data: { username, passwordHash } }); +} + +export async function verifyUserCredentials(username: string, password: string) { + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) return null; + const valid = await bcrypt.compare(password, user.passwordHash); + return valid ? user : null; +} + +export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string) { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new Error("Benutzer nicht gefunden."); + } + const valid = await bcrypt.compare(currentPassword, user.passwordHash); + if (!valid) { + throw new Error("Das aktuelle Passwort ist falsch."); + } + const passwordHash = await bcrypt.hash(newPassword, 12); + return prisma.user.update({ where: { id: userId }, data: { passwordHash } }); +} diff --git a/src/middleware.ts b/src/middleware.ts index 2cf7486..c2f2bfa 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth"; -const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/setup", "/api/auth/status"]; +const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/register"]; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; @@ -15,9 +15,9 @@ export async function middleware(request: NextRequest) { } const token = request.cookies.get(SESSION_COOKIE_NAME)?.value; - const isAuthenticated = token ? await verifySessionToken(token) : false; + const userId = token ? await verifySessionToken(token) : null; - if (!isAuthenticated) { + if (!userId) { if (pathname.startsWith("/api")) { return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); }