mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
Merge pull request #70 from Since-3/bug/address-issues
addressed a few issues
This commit is contained in:
commit
df09e04d4b
6 changed files with 117 additions and 33 deletions
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue