From c635684b017e01bfb55f556058063c38fa4ae0e9 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 24 Mar 2026 11:19:32 +0100 Subject: [PATCH] addressed a few issues --- src/app/api/auth/register/employee/route.ts | 66 ++++++++++++++++++- src/app/api/upload/avatar/route.ts | 33 ++++++++-- src/lib/auth/getUser.ts | 7 +- .../auth/ui/views/employee-onboaring-view.tsx | 4 +- .../ui/components/DashboardFooter.tsx | 14 ++-- .../employee/actions/employee-actions.ts | 26 ++++---- 6 files changed, 117 insertions(+), 33 deletions(-) diff --git a/src/app/api/auth/register/employee/route.ts b/src/app/api/auth/register/employee/route.ts index defa86f..f1836f6 100644 --- a/src/app/api/auth/register/employee/route.ts +++ b/src/app/api/auth/register/employee/route.ts @@ -1,15 +1,75 @@ import { createClient } from "@/src/lib/supabase/server"; import { NextResponse } from "next/server"; +import { prisma } from "@/src/lib/prisma"; + +// Simple in-memory rate limiter: max 5 attempts per IP per 15 minutes +const rateLimitMap = new Map(); +const RATE_LIMIT_MAX = 5; +const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(ip); + + if (!entry || now > entry.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return false; + } + + if (entry.count >= RATE_LIMIT_MAX) return true; + + entry.count++; + return false; +} + +const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d]).{8,}$/; export async function POST(request: Request) { try { - const body = await request.json(); - const { email, password, firstName, lastName, tel, gender, qualification } = body; + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; - if (!email || !password || !firstName || !lastName) { + if (isRateLimited(ip)) { + return NextResponse.json( + { error: "Zu viele Anfragen. Bitte warte 15 Minuten." }, + { status: 429 } + ); + } + + const body = await request.json(); + const { token, email, password, firstName, lastName, tel, gender, qualification } = body; + + if (!token || !email || !password || !firstName || !lastName) { return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); } + // Token serverseitig validieren + const employee = await prisma.employee.findUnique({ + where: { onboardingToken: token }, + select: { email: true, isOnboarded: true, onboardingTokenExpiry: true }, + }); + + if (!employee || employee.isOnboarded || employee.email !== email) { + return NextResponse.json({ error: "Ungültiger Token" }, { status: 401 }); + } + + if (employee.onboardingTokenExpiry && employee.onboardingTokenExpiry < new Date()) { + return NextResponse.json({ error: "Token abgelaufen" }, { status: 401 }); + } + + // Passwort-Stärke serverseitig prüfen + if (!PASSWORD_REGEX.test(password)) { + return NextResponse.json( + { + error: + "Passwort muss mindestens 8 Zeichen, Groß-/Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten", + }, + { status: 400 } + ); + } + const supabase = await createClient(); const { data, error } = await supabase.auth.signUp({ diff --git a/src/app/api/upload/avatar/route.ts b/src/app/api/upload/avatar/route.ts index 94d7f38..b52cb3f 100644 --- a/src/app/api/upload/avatar/route.ts +++ b/src/app/api/upload/avatar/route.ts @@ -1,20 +1,35 @@ import { NextResponse } from "next/server"; import { createAdminClient } from "@/src/lib/supabase/admin"; +import { prisma } from "@/src/lib/prisma"; /** * POST /api/upload/avatar * Öffentliche Route für den Bild-Upload während des Onboardings. * Nutzt den Service Role Key um RLS zu bypassen. - * Erwartet: multipart/form-data mit "file" und "path" Feldern. + * Erwartet: multipart/form-data mit "file" und "token" Feldern. */ export async function POST(request: Request) { try { const formData = await request.formData(); const file = formData.get("file") as File | null; - const path = formData.get("path") as string | null; + const token = formData.get("token") as string | null; - if (!file || !path) { - return NextResponse.json({ error: "Datei und Pfad erforderlich" }, { status: 400 }); + if (!file || !token) { + return NextResponse.json({ error: "Datei und Token erforderlich" }, { status: 400 }); + } + + // Token serverseitig validieren + const employee = await prisma.employee.findUnique({ + where: { onboardingToken: token }, + select: { id: true, isOnboarded: true, onboardingTokenExpiry: true }, + }); + + if (!employee || employee.isOnboarded) { + return NextResponse.json({ error: "Ungültiger Token" }, { status: 401 }); + } + + if (employee.onboardingTokenExpiry && employee.onboardingTokenExpiry < new Date()) { + return NextResponse.json({ error: "Token abgelaufen" }, { status: 401 }); } if (!file.type.startsWith("image/")) { @@ -25,11 +40,19 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Datei darf maximal 5MB groß sein" }, { status: 400 }); } + // Pfad serverseitig generieren — kein Client-Input + const extension = + file.name + .split(".") + .pop() + ?.replace(/[^a-zA-Z0-9]/g, "") || "jpg"; + const safePath = `employees/${employee.id}/${Date.now()}.${extension}`; + const adminClient = createAdminClient(); const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const { data, error } = await adminClient.storage.from("avatars").upload(path, buffer, { + const { data, error } = await adminClient.storage.from("avatars").upload(safePath, buffer, { contentType: file.type, upsert: true, cacheControl: "3600", diff --git a/src/lib/auth/getUser.ts b/src/lib/auth/getUser.ts index 351278d..5ec4ee5 100644 --- a/src/lib/auth/getUser.ts +++ b/src/lib/auth/getUser.ts @@ -153,8 +153,13 @@ export const getUserData = cache(async (): Promise => { } // Try Employee table — match by email since Supabase ID is not stored in Employee + if (!user.email) { + console.log("⚠️ [DB] User has no email, cannot lookup employee"); + return null; + } + const employeeData = await prisma.employee.findUnique({ - where: { email: user.email! }, + where: { email: user.email }, select: { id: true, firstName: true, diff --git a/src/modules/auth/ui/views/employee-onboaring-view.tsx b/src/modules/auth/ui/views/employee-onboaring-view.tsx index de407f6..c7f9182 100644 --- a/src/modules/auth/ui/views/employee-onboaring-view.tsx +++ b/src/modules/auth/ui/views/employee-onboaring-view.tsx @@ -14,7 +14,6 @@ import { getEmployeeByOnboardingToken, completeEmployeeOnboarding, } from "@/src/modules/employee/actions/employee-actions"; -import { generateFilePath } from "@/src/lib/supabase/storage"; import { toast } from "sonner"; const step1Schema = z @@ -124,10 +123,9 @@ const EmployeeOnboardingView = () => { let pbSrc: string | undefined; if (avatarFile) { try { - const filePath = generateFilePath(token, avatarFile.name, "employees"); const formData = new FormData(); formData.append("file", avatarFile); - formData.append("path", filePath); + formData.append("token", token); const res = await fetch("/api/upload/avatar", { method: "POST", body: formData }); const json = await res.json(); if (!res.ok) throw new Error(json.error); diff --git a/src/modules/dashboard/ui/components/DashboardFooter.tsx b/src/modules/dashboard/ui/components/DashboardFooter.tsx index 9492d57..2116ec6 100644 --- a/src/modules/dashboard/ui/components/DashboardFooter.tsx +++ b/src/modules/dashboard/ui/components/DashboardFooter.tsx @@ -29,19 +29,13 @@ const footerMenu = [ const getDiceBearUrl = (seed: string) => `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}`; -const AvatarImage = ({ - userData, - displayName, -}: { - userData: AuthUserData; - displayName: string; -}) => { +const AvatarImage = ({ userData }: { userData: AuthUserData }) => { const pbSrc = "pbSrc" in userData ? userData.pbSrc : null; - const src = pbSrc || getDiceBearUrl(displayName); + const src = pbSrc || getDiceBearUrl(userData.id); return (
- {displayName} +
); }; @@ -114,7 +108,7 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => { {/* DisplayName + Avatar */}
- +

{displayName}

diff --git a/src/modules/employee/actions/employee-actions.ts b/src/modules/employee/actions/employee-actions.ts index d89e5ce..42d0c6b 100644 --- a/src/modules/employee/actions/employee-actions.ts +++ b/src/modules/employee/actions/employee-actions.ts @@ -642,20 +642,13 @@ export async function completeEmployeeOnboarding( return { success: false, error: "Ungültiger Onboarding-Link" }; } - if (employee.isOnboarded) { - return { success: false, error: "Onboarding wurde bereits abgeschlossen" }; - } - - if (employee.onboardingTokenExpiry && employee.onboardingTokenExpiry < new Date()) { - return { success: false, error: "Dieser Onboarding-Link ist abgelaufen" }; - } - // Supabase Account über API Route erstellen const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; const res = await fetch(`${baseUrl}/api/auth/register/employee`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + token, email: employee.email, password: data.password, firstName: data.firstName, @@ -672,9 +665,13 @@ export async function completeEmployeeOnboarding( return { success: false, error: result.error || "Registrierung fehlgeschlagen" }; } - // Mitarbeiter in DB als onboarded markieren - await prisma.employee.update({ - where: { onboardingToken: token }, + // Mitarbeiter atomisch als onboarded markieren — verhindert TOCTOU race condition + const updated = await prisma.employee.updateMany({ + where: { + onboardingToken: token, + isOnboarded: false, + OR: [{ onboardingTokenExpiry: null }, { onboardingTokenExpiry: { gte: new Date() } }], + }, data: { firstName: data.firstName, lastName: data.lastName, @@ -686,6 +683,13 @@ export async function completeEmployeeOnboarding( }, }); + if (updated.count === 0) { + return { + success: false, + error: "Onboarding wurde bereits abgeschlossen oder der Link ist abgelaufen", + }; + } + // Manager-Bestätigungs-E-Mail senden if (employee.createdBy) { const manager = await prisma.manager.findUnique({