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
Deploy App / deploy (push) Successful in 1m47s
This commit is contained in:
+6
-5
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 } } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
Reference in New Issue
Block a user