Merge pull request #57 from Since-3/feature/employee-onboarding-process

added employee onboarding process and access permission
This commit is contained in:
Rajpreet0 2026-03-09 18:24:46 +01:00 • committed by GitHub
commit 13da24a411
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 913 additions and 56 deletions

View file

@ -1,7 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "api.dicebear.com",
},
{
protocol: "https",
hostname: "dlvlsahpuahlymvemzkl.supabase.co",
},
],
},
};
export default nextConfig;

View file

@ -0,0 +1,7 @@
import EmployeeOnboardingView from "@/src/modules/auth/ui/views/employee-onboaring-view";
const Onboarding = () => {
return <EmployeeOnboardingView />;
};
export default Onboarding;

View file

@ -1,9 +1,9 @@
import { requireManager } from "@/src/lib/auth/getUser";
import { requireManagerOrPermission } from "@/src/lib/auth/getUser";
import CourseCreateView from "@/src/modules/courses/ui/views/course-create-view";
import { getMySportTypes } from "@/src/modules/courses/actions/course-actions";
const CourseCreate = async () => {
await requireManager();
await requireManagerOrPermission("canCreateCourses");
const sportsResult = await getMySportTypes();
const customSports = sportsResult.success ? sportsResult.sports : [];

View file

@ -1,9 +1,9 @@
import { requireManager } from "@/src/lib/auth/getUser";
import { requireManagerOrPermission } from "@/src/lib/auth/getUser";
import CourseEditView from "@/src/modules/courses/ui/views/course-edit-view";
import { getMySportTypes } from "@/src/modules/courses/actions/course-actions";
const CourseEdit = async ({ params }: { params: Promise<{ id: string }> }) => {
await requireManager();
await requireManagerOrPermission("canCreateCourses");
const { id } = await params;
const sportsResult = await getMySportTypes();

View file

@ -1,8 +1,8 @@
import { requireManager } from "@/src/lib/auth/getUser";
import { requireManagerOrPermission } from "@/src/lib/auth/getUser";
import CourseView from "@/src/modules/courses/ui/views/course-view";
const Course = async () => {
await requireManager();
await requireManagerOrPermission("canCreateCourses");
return <CourseView />;
};

View file

@ -1,9 +1,9 @@
import { requireManager } from "@/src/lib/auth/getUser";
import { requireManagerOrPermission } from "@/src/lib/auth/getUser";
import EmployeeCreateView from "@/src/modules/employee/ui/views/employee-create-view";
import { getMyEmployeeRoles } from "@/src/modules/employee/actions/employee-actions";
const EmployeeCreate = async () => {
await requireManager();
await requireManagerOrPermission("canCreateEmployees");
// Lade alle bereits verwendeten custom Rollen
const rolesResult = await getMyEmployeeRoles();

View file

@ -1,5 +0,0 @@
const Onboarding = () => {
return <div>Onboarding Employee</div>;
};
export default Onboarding;

View file

@ -1,8 +1,8 @@
import { requireManager } from "@/src/lib/auth/getUser";
import { requireManagerOrPermission } from "@/src/lib/auth/getUser";
import EmployeeView from "@/src/modules/employee/ui/views/employee-view";
const Employee = async () => {
await requireManager();
await requireManagerOrPermission("canCreateEmployees");
return <EmployeeView />;
};

View file

@ -0,0 +1,44 @@
import { createClient } from "@/src/lib/supabase/server";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
const body = await request.json();
const { email, password, firstName, lastName, tel, gender, qualification } = body;
if (!email || !password || !firstName || !lastName) {
return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
}
const supabase = await createClient();
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
firstName,
lastName,
tel,
gender,
qualification,
role: "employee",
},
},
});
if (error) {
console.error("Supabase Auth Error:", error.message);
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (!data.user) {
return NextResponse.json({ error: "Account konnte nicht erstellt werden" }, { status: 500 });
}
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
console.error("Error in /api/auth/register/employee:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View file

@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { createAdminClient } from "@/src/lib/supabase/admin";
/**
* 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.
*/
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;
if (!file || !path) {
return NextResponse.json({ error: "Datei und Pfad erforderlich" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Nur Bilddateien erlaubt" }, { status: 400 });
}
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: "Datei darf maximal 5MB groß sein" }, { status: 400 });
}
const adminClient = createAdminClient();
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const { data, error } = await adminClient.storage.from("avatars").upload(path, buffer, {
contentType: file.type,
upsert: true,
cacheControl: "3600",
});
if (error) {
console.error("Storage upload error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
const {
data: { publicUrl },
} = adminClient.storage.from("avatars").getPublicUrl(data.path);
return NextResponse.json({ publicUrl }, { status: 201 });
} catch (error) {
console.error("Error in /api/upload/avatar:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View file

@ -0,0 +1,99 @@
"use client";
import { useRef, useState } from "react";
import Image from "next/image";
import { Camera, X } from "lucide-react";
interface ImageUploadComponentProps {
value: File | null;
preview: string | null;
onChange: (file: File | null, preview: string | null) => void;
error?: string;
}
const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadComponentProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const handleFile = (file: File) => {
if (!file.type.startsWith("image/")) return;
const url = URL.createObjectURL(file);
onChange(file, url);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
};
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
onChange(null, null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<div className="flex flex-col items-center gap-3">
<div
onClick={() => !preview && inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`relative w-28 h-28 rounded-full border-2 border-dashed flex items-center justify-center cursor-pointer transition-colors overflow-hidden
${isDragging ? "border-blue bg-blue/5" : "border-gray-300 hover:border-blue hover:bg-gray-50"}
${preview ? "border-solid border-blue cursor-default" : ""}
`}
>
{preview ? (
<>
<Image src={preview} alt="Profilbild Vorschau" fill className="object-cover" />
<button
type="button"
onClick={handleRemove}
className="absolute top-1 right-1 bg-white rounded-full p-0.5 shadow hover:bg-red-50 transition-colors z-10"
>
<X size={14} className="text-red-500" />
</button>
</>
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<Camera size={28} />
<span className="text-xs text-center leading-tight px-2">Foto hochladen</span>
</div>
)}
</div>
{!preview && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-sm text-blue underline cursor-pointer"
>
Bild auswählen
</button>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleInputChange}
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
);
};
export default ImageUploadComponent;

View file

@ -35,7 +35,22 @@ export type ManagerData = {
}>;
};
export type AuthUserData = UserData | ManagerData;
export type EmployeeData = {
id: string;
firstName: string | null;
lastName: string | null;
email: string;
qualification: string | null;
pbSrc: string | null;
createdAt: Date | null;
role: "employee";
permissions: {
canCreateCourses: boolean;
canCreateEmployees: boolean;
};
};
export type AuthUserData = UserData | ManagerData | EmployeeData;
// Note: We don't use in-memory caching because this app runs on Vercel (serverless)
// Serverless functions are stateless - in-memory cache would be:
@ -137,7 +152,43 @@ export const getUserData = cache(async (): Promise<AuthUserData | null> => {
return managerWithRole;
}
console.log("⚠️ [DB] No user or manager found for:", user.email);
// Try Employee table — match by email since Supabase ID is not stored in Employee
const employeeData = await prisma.employee.findUnique({
where: { email: user.email! },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
qualification: true,
pbSrc: true,
permissions: true,
createdAt: true,
},
});
if (employeeData) {
console.log(
"✅ [DB] Employee data fetched:",
`${employeeData.firstName} ${employeeData.lastName}`
);
const rawPermissions = employeeData.permissions as {
canCreateCourses?: boolean;
canCreateEmployees?: boolean;
} | null;
const employeeWithRole: EmployeeData = {
...employeeData,
role: "employee",
permissions: {
canCreateCourses: rawPermissions?.canCreateCourses ?? false,
canCreateEmployees: rawPermissions?.canCreateEmployees ?? false,
},
};
return employeeWithRole;
}
console.log("⚠️ [DB] No user, manager or employee found for:", user.email);
return null;
});
@ -194,3 +245,23 @@ export function isManager(userData: AuthUserData): userData is ManagerData {
export function isUser(userData: AuthUserData): userData is UserData {
return userData.role === "user";
}
// Helper function to check if user is an employee
export function isEmployee(userData: AuthUserData): userData is EmployeeData {
return userData.role === "employee";
}
/**
* Requires that the authenticated user is a Manager OR an Employee with the given permission.
* Redirects to /dashboard if access is denied.
*/
export async function requireManagerOrPermission(permission: keyof EmployeeData["permissions"]) {
const userData = await requireAuthWithData();
if (isManager(userData)) return userData;
if (isEmployee(userData) && userData.permissions[permission]) return userData;
const { redirect } = await import("next/navigation");
redirect("/dashboard");
}

21
src/lib/supabase/admin.ts Normal file
View file

@ -0,0 +1,21 @@
import { createClient } from "@supabase/supabase-js";
/**
* Supabase Admin Client — nutzt den Service Role Key (Bypass Row Level Security).
* Nur serverseitig verwenden, niemals im Client-Code.
*/
export function createAdminClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !serviceRoleKey) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY ist nicht gesetzt");
}
return createClient(url, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}

View file

@ -0,0 +1,293 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { ZodError, z } from "zod";
import AuthSidebarComponent from "../components/AuthSidebarComponent";
import InputComponent from "@/src/components/layout/InputComponent";
import PhoneInputComponent from "@/src/components/layout/PhoneInputComponent";
import DropdownComponent from "@/src/components/layout/DropdownComponent";
import ImageUploadComponent from "@/src/components/layout/ImageUploadComponent";
import { Button } from "@/src/components/ui/button";
import { Spinner } from "@/src/components/ui/spinner";
import {
getEmployeeByOnboardingToken,
completeEmployeeOnboarding,
} from "@/src/modules/employee/actions/employee-actions";
import { generateFilePath } from "@/src/lib/supabase/storage";
import { toast } from "sonner";
const step1Schema = z
.object({
firstName: z.string().min(2, "Vorname ist zu kurz"),
lastName: z.string().min(2, "Nachname ist zu kurz"),
tel: z.string().min(5, "Telefonnummer ist erforderlich"),
password: z
.string()
.min(8)
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[.@$!%*?&])[A-Za-z\d.@$!%*?&]+$/,
"Passwort muss mindestens 8 Zeichen, Groß-/Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten"
),
passwordConfirm: z.string(),
})
.refine((data) => data.password === data.passwordConfirm, {
message: "Passwörter stimmen nicht überein",
path: ["passwordConfirm"],
});
const step2Schema = z.object({
gender: z
.enum(["Men", "Woman", "Various"])
.or(z.literal(""))
.refine((val) => val !== "", { message: "Geschlecht ist erforderlich" }),
qualification: z.string().min(1, "Qualifikation ist erforderlich"),
});
const EmployeeOnboardingView = () => {
const params = useParams();
const router = useRouter();
const token = params?.onboardingToken as string;
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [tokenLoading, setTokenLoading] = useState(true);
const [tokenError, setTokenError] = useState<string | null>(null);
const [employeeEmail, setEmployeeEmail] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [tel, setTel] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [gender, setGender] = useState("");
const [qualification, setQualification] = useState("");
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
useEffect(() => {
const checkToken = async () => {
if (!token) {
setTokenError("Kein Onboarding-Token gefunden");
setTokenLoading(false);
return;
}
const result = await getEmployeeByOnboardingToken(token);
if (!result.success) {
setTokenError(result.error || "Ungültiger Link");
} else {
setEmployeeEmail(result.employee!.email);
}
setTokenLoading(false);
};
checkToken();
}, [token]);
const handleNext = (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
try {
step1Schema.parse({ firstName, lastName, tel, password, passwordConfirm });
setStep(2);
} catch (err) {
if (err instanceof ZodError) {
const fieldErrors: Record<string, string> = {};
err.issues.forEach((issue) => {
const path = issue.path[0];
if (typeof path === "string") fieldErrors[path] = issue.message;
});
setErrors(fieldErrors);
}
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
try {
step2Schema.parse({ gender, qualification });
} catch (err) {
if (err instanceof ZodError) {
const fieldErrors: Record<string, string> = {};
err.issues.forEach((issue) => {
const path = issue.path[0];
if (typeof path === "string") fieldErrors[path] = issue.message;
});
setErrors(fieldErrors);
return;
}
}
setLoading(true);
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);
const res = await fetch("/api/upload/avatar", { method: "POST", body: formData });
const json = await res.json();
if (!res.ok) throw new Error(json.error);
pbSrc = json.publicUrl;
} catch {
toast.error("Fehler beim Hochladen des Profilbilds");
setLoading(false);
return;
}
}
const result = await completeEmployeeOnboarding(token, {
firstName,
lastName,
tel,
password,
gender,
qualification,
pbSrc,
});
setLoading(false);
if (result.success) {
toast.success("Account erfolgreich erstellt! Du kannst dich jetzt einloggen.");
router.push("/login");
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
}
};
if (tokenLoading) {
return (
<div className="flex min-h-screen justify-center items-center">
<Spinner />
</div>
);
}
if (tokenError) {
return (
<div className="flex min-h-screen justify-center items-center flex-col gap-4 px-4">
<h1 className="text-2xl font-bold text-red-500">{tokenError}</h1>
<p className="text-gray-500 text-center">
Bitte wende dich an deinen Manager für einen neuen Onboarding-Link.
</p>
</div>
);
}
return (
<div className="flex min-h-screen">
<div className="hidden lg:block lg:w-[40%]">
<div className="fixed left-0 top-0 h-screen w-[40%] p-2 z-10">
<AuthSidebarComponent
title="Werde Teil unseres Teams"
footer="info@since3.de"
isSteps
step={step}
totalSteps={2}
onStepChange={setStep}
/>
</div>
</div>
<div className="w-full lg:w-[60%] lg:ml-auto flex justify-center items-center min-h-screen px-4">
<div className="w-full max-w-lg flex flex-col gap-4 z-20">
<h1 className="text-2xl font-extrabold text-blue mb-2 mt-8">Herzlich Willkommen!</h1>
<p className="text-gray-500 mb-6">{employeeEmail}</p>
{step === 1 && (
<form onSubmit={handleNext} className="space-y-5">
<div className="flex gap-4">
<InputComponent
isLabel
label="Vorname"
type="text"
id="onboarding-firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
error={errors.firstName}
/>
<InputComponent
isLabel
label="Nachname"
type="text"
id="onboarding-lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
error={errors.lastName}
/>
</div>
<PhoneInputComponent
isLabel
label="Telefonnummer"
id="onboarding-tel"
value={tel}
onChange={(val) => setTel(val)}
error={errors.tel}
/>
<InputComponent
isLabel
label="Passwort"
type="password"
id="onboarding-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
error={errors.password}
/>
<InputComponent
isLabel
label="Passwort bestätigen"
type="password"
id="onboarding-passwordConfirm"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
error={errors.passwordConfirm}
/>
<Button className="w-full mt-6" type="submit">
Weiter
</Button>
</form>
)}
{step === 2 && (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="flex flex-col items-center">
<p className="text-sm text-gray-500 mb-3">Profilbild (optional)</p>
<ImageUploadComponent
value={avatarFile}
preview={avatarPreview}
onChange={(file, preview) => {
setAvatarFile(file);
setAvatarPreview(preview);
}}
/>
</div>
<DropdownComponent
label="Geschlecht"
selected={gender}
onSelect={(value: string) => setGender(value)}
error={errors.gender}
/>
<InputComponent
isLabel
label="Qualifikation"
type="text"
id="onboarding-qualification"
value={qualification}
onChange={(e) => setQualification(e.target.value)}
error={errors.qualification}
/>
<Button type="submit" disabled={loading} className="w-full mt-6">
{loading ? <Spinner /> : "Account erstellen"}
</Button>
</form>
)}
</div>
</div>
</div>
);
};
export default EmployeeOnboardingView;

View file

@ -1,6 +1,7 @@
"use client";
import { HelpCircle, Settings, Plus } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useState } from "react";
import type { AuthUserData } from "@/src/lib/auth/getUser";
import {
@ -25,6 +26,26 @@ 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 pbSrc = "pbSrc" in userData ? userData.pbSrc : null;
const src = pbSrc || getDiceBearUrl(displayName);
return (
<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 />
</div>
);
};
interface DashboardFooterProps {
displayName: string;
userData: AuthUserData;
@ -91,10 +112,10 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
</div>
)}
{/* DisplayName */}
{/* DisplayName + Avatar */}
<div className="w-full flex items-center gap-4 p-2 mt-2">
<div className="w-10 h-10 bg-black rounded-full" />
<p>{displayName}</p>
<AvatarImage userData={userData} displayName={displayName} />
<p className="truncate text-sm font-medium">{displayName}</p>
</div>
</div>
);

View file

@ -26,14 +26,22 @@ import { usePathname } from "next/navigation";
import Logo from "@/src/public/logo-dark.png";
import { cn } from "@/src/lib/utils";
import DashboardFooter from "./DashboardFooter";
import type { AuthUserData } from "@/src/lib/auth/getUser";
import type { AuthUserData, EmployeeData } from "@/src/lib/auth/getUser";
const allMenuItems = [
type MenuItem = {
icon: React.ComponentType<{ className?: string }>;
label: string;
href: string;
roles?: string[];
permission?: keyof EmployeeData["permissions"];
};
const allMenuItems: MenuItem[] = [
{
icon: LayoutDashboard,
label: "Dashboard",
href: "/dashboard",
roles: ["manager", "user"],
roles: ["manager", "user", "employee"],
},
{
icon: Dumbbell,
@ -47,12 +55,26 @@ const allMenuItems = [
href: "/courses",
roles: ["manager"],
},
{
icon: Dumbbell,
label: "Kurse",
href: "/courses",
roles: ["employee"],
permission: "canCreateCourses",
},
{
icon: IdCardLanyard,
label: "Mitarbeiter",
href: "/employee",
roles: ["manager"],
},
{
icon: IdCardLanyard,
label: "Mitarbeiter",
href: "/employee",
roles: ["employee"],
permission: "canCreateEmployees",
},
{
icon: User,
label: "Kunden",
@ -88,8 +110,14 @@ const DashboardSidebar = ({ userData, displayName }: DashboardSidebarProps) => {
const pathname = usePathname();
const userRole = userData.role;
// Filter menu items based on user role
const menu = allMenuItems.filter((item) => item.roles.includes(userRole));
const menu = allMenuItems.filter((item) => {
if (!item.roles?.includes(userRole)) return false;
if (userRole === "employee" && item.permission) {
const employeeData = userData as EmployeeData;
return employeeData.permissions[item.permission] === true;
}
return true;
});
return (
<Sidebar>
@ -104,7 +132,7 @@ const DashboardSidebar = ({ userData, displayName }: DashboardSidebarProps) => {
<SidebarGroupContent>
<SidebarMenu className="mt-10">
{menu.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuItem key={item.href + item.label}>
<SidebarMenuButton
asChild
className={cn(

View file

@ -1,11 +1,13 @@
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import { requireAuthWithData, isManager, isEmployee } from "@/src/lib/auth/getUser";
import DashboardSidebar from "./DashboardSidebar";
const DashboardSidebarWrapper = async () => {
const userData = await requireAuthWithData();
const displayName = isManager(userData)
? `${userData.firstName} ${userData.lastName}`
: userData.name || userData.email;
: isEmployee(userData)
? `${userData.firstName ?? ""} ${userData.lastName ?? ""}`.trim() || userData.email
: userData.name || userData.email;
return <DashboardSidebar userData={userData} displayName={displayName} />;
};

View file

@ -1,13 +1,14 @@
import { LogoutButton } from "@/src/components/layout/LogoutButton";
import { isManager, requireAuthWithData, isUser } from "@/src/lib/auth/getUser";
import { isManager, isEmployee, requireAuthWithData, isUser } from "@/src/lib/auth/getUser";
const DashboardView = async () => {
const userData = await requireAuthWithData();
// Get display name based on user type
const displayName = isManager(userData)
? `${userData.firstName} ${userData.lastName}`
: userData.name || userData.email;
: isEmployee(userData)
? `${userData.firstName ?? ""} ${userData.lastName ?? ""}`.trim() || userData.email
: userData.name || userData.email;
return (
<div className="space-y-6">
@ -19,6 +20,11 @@ const DashboardView = async () => {
Manager
</span>
)}
{isEmployee(userData) && (
<span className="inline-block mt-2 px-3 py-1 bg-green-100 text-green-800 text-sm font-medium rounded-full">
Mitarbeiter
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -35,6 +41,18 @@ const DashboardView = async () => {
{userData.businesses.length === 1 ? "Business" : "Businesses"}
</p>
</div>
) : isEmployee(userData) ? (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-2">Meine Berechtigungen</h2>
<ul className="text-gray-600 text-sm space-y-1 mt-2">
{userData.permissions.canCreateCourses && <li>• Kurse erstellen</li>}
{userData.permissions.canCreateEmployees && <li>• Mitarbeiter anlegen</li>}
{!userData.permissions.canCreateCourses &&
!userData.permissions.canCreateEmployees && (
<li className="text-gray-400">Keine erweiterten Berechtigungen</li>
)}
</ul>
</div>
) : (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-2">Trainings</h2>
@ -135,6 +153,30 @@ const DashboardView = async () => {
</div>
)}
{isEmployee(userData) && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Mitarbeiter-Daten</h2>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Vorname</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.firstName || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Nachname</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.lastName || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">E-Mail</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Qualifikation</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.qualification || "—"}</dd>
</div>
</dl>
</div>
)}
<LogoutButton />
</div>
);

View file

@ -9,7 +9,8 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager } from "@/src/lib/auth/getUser";
import { getUserData, isManager, isEmployee } from "@/src/lib/auth/getUser";
import { createAdminClient } from "@/src/lib/supabase/admin";
import { employeeSchema, type EmployeeFormData } from "../schemas/employee-schema";
import { revalidatePath } from "next/cache";
import { randomBytes } from "crypto";
@ -50,11 +51,13 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
};
}
// Schritt 3: Überprüfen, ob Benutzer ein Manager ist (nur Manager dürfen Mitarbeiter erstellen)
if (!isManager(userData)) {
// Schritt 3: Manager oder Employee mit Berechtigung
const canManageEmployees =
isManager(userData) || (isEmployee(userData) && userData.permissions.canCreateEmployees);
if (!canManageEmployees) {
return {
success: false,
error: "Nur Manager können Mitarbeiter erstellen",
error: "Keine Berechtigung zum Erstellen von Mitarbeitern",
};
}
@ -94,7 +97,14 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
onboardingTokenExpiry.setDate(onboardingTokenExpiry.getDate() + 7);
}
// Schritt 7: Mitarbeiter in der Datenbank erstellen
// Schritt 7: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
let managerId = userData.id;
if (isEmployee(userData)) {
const selfRecord = await prisma.employee.findUnique({ where: { email: userData.email } });
managerId = selfRecord?.createdBy ?? userData.id;
}
// Schritt 8: Mitarbeiter in der Datenbank erstellen
const employee = await prisma.employee.create({
data: {
email: validatedData.email,
@ -105,7 +115,7 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
isOnboarded: false,
onboardingToken,
onboardingTokenExpiry,
createdBy: userData.id, // Manager-ID als Ersteller
createdBy: managerId,
},
});
@ -167,8 +177,11 @@ export async function getMyEmployees() {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
if (!userData || !isManager(userData)) {
// Schritt 2: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.canCreateEmployees))
) {
return {
success: false,
error: "Unauthorized",
@ -177,12 +190,20 @@ export async function getMyEmployees() {
}
// Schritt 3: Mitarbeiter aus der Datenbank abrufen
let whereClause: Record<string, unknown>;
if (isManager(userData)) {
// Manager sieht alle Mitarbeiter unter seiner ID
whereClause = { createdBy: userData.id };
} else {
// Employee: eigene Manager-ID ermitteln, dann alle unter diesem Manager anzeigen (außer sich selbst)
const selfRecord = await prisma.employee.findUnique({ where: { email: userData.email } });
const managerId = selfRecord?.createdBy;
whereClause = { createdBy: managerId, NOT: { email: userData.email } };
}
const employees = await prisma.employee.findMany({
where: {
createdBy: userData.id, // Filter nach Manager ID
},
where: whereClause,
orderBy: {
createdAt: "desc", // Neueste zuerst
createdAt: "desc",
},
});
@ -214,8 +235,11 @@ export async function getEmployeeById(employeeId: string) {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
if (!userData || !isManager(userData)) {
// Schritt 2: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.canCreateEmployees))
) {
return {
success: false,
error: "Nur Manager können Mitarbeiter bearbeiten",
@ -235,8 +259,16 @@ export async function getEmployeeById(employeeId: string) {
};
}
// Schritt 4b: Employee darf eigenen Account nicht bearbeiten
if (isEmployee(userData) && employee.email === userData.email) {
return {
success: false,
error: "Sie können Ihren eigenen Account nicht bearbeiten",
};
}
// Schritt 5: Überprüfen, ob der Manager der Besitzer des Mitarbeiters ist
if (employee.createdBy !== userData.id) {
if (isManager(userData) && employee.createdBy !== userData.id) {
return {
success: false,
error: "Sie können nur Ihre eigenen Mitarbeiter bearbeiten",
@ -285,8 +317,10 @@ export async function updateEmployee(
};
}
// Schritt 3: Überprüfen, ob Benutzer ein Manager ist
if (!isManager(userData)) {
// Schritt 3: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
const canManageEmployees =
isManager(userData) || (isEmployee(userData) && userData.permissions.canCreateEmployees);
if (!canManageEmployees) {
return {
success: false,
error: "Nur Manager können Mitarbeiter bearbeiten",
@ -306,8 +340,16 @@ export async function updateEmployee(
};
}
// Schritt 5b: Employee darf eigenen Account nicht bearbeiten
if (isEmployee(userData) && existingEmployee.email === userData.email) {
return {
success: false,
error: "Sie können Ihren eigenen Account nicht bearbeiten",
};
}
// Schritt 6: Überprüfen, ob der Manager der Besitzer des Mitarbeiters ist
if (existingEmployee.createdBy !== userData.id) {
if (isManager(userData) && existingEmployee.createdBy !== userData.id) {
return {
success: false,
error: "Sie können nur Ihre eigenen Mitarbeiter bearbeiten",
@ -471,8 +513,11 @@ export async function deleteEmployee(employeeId: string) {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
if (!userData || !isManager(userData)) {
// Schritt 2: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.canCreateEmployees))
) {
return {
success: false,
error: "Nur Manager können Mitarbeiter löschen",
@ -492,8 +537,16 @@ export async function deleteEmployee(employeeId: string) {
};
}
// Schritt 4b: Employee darf eigenen Account nicht löschen
if (isEmployee(userData) && employee.email === userData.email) {
return {
success: false,
error: "Sie können Ihren eigenen Account nicht löschen",
};
}
// Schritt 5: Überprüfen, ob der Manager der Besitzer des Mitarbeiters ist
if (employee.createdBy !== userData.id) {
if (isManager(userData) && employee.createdBy !== userData.id) {
return {
success: false,
error: "Sie können nur Ihre eigenen Mitarbeiter löschen",
@ -505,7 +558,22 @@ export async function deleteEmployee(employeeId: string) {
where: { id: employeeId },
});
// Schritt 7: Next.js Cache invalidieren
// Schritt 7: Supabase Auth User löschen (erzwingt automatisches Ausloggen)
if (employee.isOnboarded) {
try {
const adminClient = createAdminClient();
const { data: authUsers } = await adminClient.auth.admin.listUsers();
const authUser = authUsers?.users.find((u) => u.email === employee.email);
if (authUser) {
await adminClient.auth.admin.deleteUser(authUser.id);
}
} catch (err) {
// Auth-Löschung schlägt fehl wenn SERVICE_ROLE_KEY fehlt — DB ist bereits gelöscht
console.error("Supabase Auth User konnte nicht gelöscht werden:", err);
}
}
// Schritt 8: Next.js Cache invalidieren
revalidatePath("/employee");
return {
@ -520,3 +588,106 @@ export async function deleteEmployee(employeeId: string) {
};
}
}
/**
* Ruft einen Mitarbeiter anhand des Onboarding-Tokens ab (öffentlich, kein Login nötig)
*/
export async function getEmployeeByOnboardingToken(token: string) {
try {
const employee = await prisma.employee.findUnique({
where: { onboardingToken: token },
});
if (!employee) {
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" };
}
return { success: true, employee };
} catch (error) {
console.error("Error fetching employee by token:", error);
return { success: false, error: "Fehler beim Laden der Mitarbeiterdaten" };
}
}
/**
* Schließt das Onboarding eines Mitarbeiters ab und erstellt einen Supabase-Account
*/
export async function completeEmployeeOnboarding(
token: string,
data: {
firstName: string;
lastName: string;
tel: string;
password: string;
gender: string;
qualification: string;
pbSrc?: string;
}
) {
try {
const employee = await prisma.employee.findUnique({
where: { onboardingToken: token },
});
if (!employee) {
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({
email: employee.email,
password: data.password,
firstName: data.firstName,
lastName: data.lastName,
tel: data.tel,
gender: data.gender,
qualification: data.qualification,
}),
});
const result = await res.json();
if (!res.ok) {
return { success: false, error: result.error || "Registrierung fehlgeschlagen" };
}
// Mitarbeiter in DB als onboarded markieren
await prisma.employee.update({
where: { onboardingToken: token },
data: {
firstName: data.firstName,
lastName: data.lastName,
qualification: data.qualification,
pbSrc: data.pbSrc ?? null,
isOnboarded: true,
onboardingToken: null,
onboardingTokenExpiry: null,
},
});
return { success: true };
} catch (error) {
console.error("Error completing onboarding:", error);
return { success: false, error: "Ein Fehler ist aufgetreten" };
}
}

View file

@ -32,9 +32,9 @@ export const employeeSchema = z
// Administrationsrechte
permissions: z.object({
// Berechtigung zum Anlegen von Kursen
// Berechtigung zum Anzeigen/Verwalten von Kursen
canCreateCourses: z.boolean(),
// Berechtigung zum Anlegen von Mitarbeitern
// Berechtigung zum Anzeigen/Verwalten von Mitarbeitern
canCreateEmployees: z.boolean(),
}),
})