Major rework: multi-user accounts (register/login, per-user data isolation), new layout with sidebar/dashboard/profile menu, matrix phase view with collapsible category columns, life timeline with ages per phase, live budget capping, collapsible transitions, mobile support
Deploy App / deploy (push) Successful in 1m47s

This commit is contained in:
2026-07-11 17:31:18 +02:00
parent 2e65bb3a2d
commit 685ff46c27
31 changed files with 1479 additions and 721 deletions
+6 -5
View File
@@ -11,20 +11,21 @@ function getSecretKey() {
return new TextEncoder().encode(secret);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({ auth: true })
export async function createSessionToken(userId: string): Promise<string> {
return new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(SESSION_DURATION)
.sign(getSecretKey());
}
export async function verifySessionToken(token: string): Promise<boolean> {
// Liefert die User-ID aus einem gueltigen Session-Token, sonst null.
export async function verifySessionToken(token: string): Promise<string | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey());
return payload.auth === true;
return typeof payload.userId === "string" ? payload.userId : null;
} catch {
return false;
return null;
}
}
+23 -2
View File
@@ -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);
}
-28
View File
@@ -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<boolean> {
const credential = await getAppCredential();
if (!credential) return false;
return bcrypt.compare(password, credential.passwordHash);
}
+18 -2
View File
@@ -96,6 +96,22 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
};
}
export async function getHouseholdOrNull(): Promise<HouseholdWithPersons | null> {
return prisma.household.findFirst({ include: { persons: true } });
// Liefert den Haushalt des eingeloggten Benutzers (pro Konto genau einer).
export async function getHouseholdOrNull(userId: string): Promise<HouseholdWithPersons | null> {
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 } } },
});
}
+12
View File
@@ -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<string | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
return verifySessionToken(token);
}
+43
View File
@@ -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 } });
}