Merge pull request #70 from Since-3/bug/address-issues

addressed a few issues
This commit is contained in:
Rajpreet0 2026-03-24 11:23:22 +01:00 • committed by GitHub
commit df09e04d4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 117 additions and 33 deletions

View file

@ -1,15 +1,75 @@
import { createClient } from "@/src/lib/supabase/server"; import { createClient } from "@/src/lib/supabase/server";
import { NextResponse } from "next/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<string, { count: number; resetAt: number }>();
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) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const ip =
const { email, password, firstName, lastName, tel, gender, qualification } = body; 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 }); 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 supabase = await createClient();
const { data, error } = await supabase.auth.signUp({ const { data, error } = await supabase.auth.signUp({

View file

@ -1,20 +1,35 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createAdminClient } from "@/src/lib/supabase/admin"; import { createAdminClient } from "@/src/lib/supabase/admin";
import { prisma } from "@/src/lib/prisma";
/** /**
* POST /api/upload/avatar * POST /api/upload/avatar
* Öffentliche Route für den Bild-Upload während des Onboardings. * Öffentliche Route für den Bild-Upload während des Onboardings.
* Nutzt den Service Role Key um RLS zu bypassen. * 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) { export async function POST(request: Request) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file") as File | null; 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) { if (!file || !token) {
return NextResponse.json({ error: "Datei und Pfad erforderlich" }, { status: 400 }); 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/")) { 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 }); 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 adminClient = createAdminClient();
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(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, contentType: file.type,
upsert: true, upsert: true,
cacheControl: "3600", cacheControl: "3600",

View file

@ -153,8 +153,13 @@ export const getUserData = cache(async (): Promise<AuthUserData | null> => {
} }
// Try Employee table — match by email since Supabase ID is not stored in Employee // 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({ const employeeData = await prisma.employee.findUnique({
where: { email: user.email! }, where: { email: user.email },
select: { select: {
id: true, id: true,
firstName: true, firstName: true,

View file

@ -14,7 +14,6 @@ import {
getEmployeeByOnboardingToken, getEmployeeByOnboardingToken,
completeEmployeeOnboarding, completeEmployeeOnboarding,
} from "@/src/modules/employee/actions/employee-actions"; } from "@/src/modules/employee/actions/employee-actions";
import { generateFilePath } from "@/src/lib/supabase/storage";
import { toast } from "sonner"; import { toast } from "sonner";
const step1Schema = z const step1Schema = z
@ -124,10 +123,9 @@ const EmployeeOnboardingView = () => {
let pbSrc: string | undefined; let pbSrc: string | undefined;
if (avatarFile) { if (avatarFile) {
try { try {
const filePath = generateFilePath(token, avatarFile.name, "employees");
const formData = new FormData(); const formData = new FormData();
formData.append("file", avatarFile); formData.append("file", avatarFile);
formData.append("path", filePath); formData.append("token", token);
const res = await fetch("/api/upload/avatar", { method: "POST", body: formData }); const res = await fetch("/api/upload/avatar", { method: "POST", body: formData });
const json = await res.json(); const json = await res.json();
if (!res.ok) throw new Error(json.error); if (!res.ok) throw new Error(json.error);

View file

@ -29,19 +29,13 @@ const footerMenu = [
const getDiceBearUrl = (seed: string) => const getDiceBearUrl = (seed: string) =>
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}`; `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}`;
const AvatarImage = ({ const AvatarImage = ({ userData }: { userData: AuthUserData }) => {
userData,
displayName,
}: {
userData: AuthUserData;
displayName: string;
}) => {
const pbSrc = "pbSrc" in userData ? userData.pbSrc : null; const pbSrc = "pbSrc" in userData ? userData.pbSrc : null;
const src = pbSrc || getDiceBearUrl(displayName); const src = pbSrc || getDiceBearUrl(userData.id);
return ( return (
<div className="relative w-10 h-10 rounded-full overflow-hidden shrink-0 bg-gray-100"> <div className="relative w-10 h-10 rounded-full overflow-hidden shrink-0 bg-gray-100">
<Image src={src} alt={displayName} fill className="object-cover" unoptimized /> <Image src={src} alt="" fill className="object-cover" unoptimized />
</div> </div>
); );
}; };
@ -114,7 +108,7 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
{/* DisplayName + Avatar */} {/* DisplayName + Avatar */}
<div className="w-full flex items-center gap-4 p-2 mt-2"> <div className="w-full flex items-center gap-4 p-2 mt-2">
<AvatarImage userData={userData} displayName={displayName} /> <AvatarImage userData={userData} />
<p className="truncate text-sm font-medium">{displayName}</p> <p className="truncate text-sm font-medium">{displayName}</p>
</div> </div>
</div> </div>

View file

@ -642,20 +642,13 @@ export async function completeEmployeeOnboarding(
return { success: false, error: "Ungültiger Onboarding-Link" }; 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 // Supabase Account über API Route erstellen
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const res = await fetch(`${baseUrl}/api/auth/register/employee`, { const res = await fetch(`${baseUrl}/api/auth/register/employee`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
token,
email: employee.email, email: employee.email,
password: data.password, password: data.password,
firstName: data.firstName, firstName: data.firstName,
@ -672,9 +665,13 @@ export async function completeEmployeeOnboarding(
return { success: false, error: result.error || "Registrierung fehlgeschlagen" }; return { success: false, error: result.error || "Registrierung fehlgeschlagen" };
} }
// Mitarbeiter in DB als onboarded markieren // Mitarbeiter atomisch als onboarded markieren — verhindert TOCTOU race condition
await prisma.employee.update({ const updated = await prisma.employee.updateMany({
where: { onboardingToken: token }, where: {
onboardingToken: token,
isOnboarded: false,
OR: [{ onboardingTokenExpiry: null }, { onboardingTokenExpiry: { gte: new Date() } }],
},
data: { data: {
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName, 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 // Manager-Bestätigungs-E-Mail senden
if (employee.createdBy) { if (employee.createdBy) {
const manager = await prisma.manager.findUnique({ const manager = await prisma.manager.findUnique({