added personal settings for all users

This commit is contained in:
Rajpreet0 2026-06-11 19:51:33 +02:00
parent ddd7a41384
commit b882094601
8 changed files with 708 additions and 8 deletions

4
.gitignore vendored
View file

@ -41,4 +41,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
/src/generated/prisma
/graphify-out/*

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/src/lib/supabase/server";
import { syncEmailAfterConfirmation } from "@/src/modules/settings/actions/personal-settings-actions";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const code = searchParams.get("code");
const next = searchParams.get("next") ?? "/settings/personal";
const isEmailChange = searchParams.get("email_confirmed") === "true";
if (!code) {
return NextResponse.redirect(new URL(next, request.url));
}
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(new URL(next, request.url));
}
if (isEmailChange) {
await syncEmailAfterConfirmation();
}
// Hard redirect so the browser re-fetches the page fully,
// bypassing any Next.js client-side cache.
const url = new URL(next, request.url);
url.searchParams.set("t", Date.now().toString());
return NextResponse.redirect(url);
}

3
src/app/auth/layout.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View file

@ -8,6 +8,7 @@ export type UserData = {
id: string;
name: string | null;
email: string;
tel: string | null;
birthday: Date | null;
plz: string | null;
city: string | null;
@ -83,6 +84,7 @@ export const getUserData = cache(async (): Promise<AuthUserData | null> => {
id: true,
name: true,
email: true,
tel: true,
birthday: true,
plz: true,
city: true,

View file

@ -0,0 +1,236 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { createClient } from "@/src/lib/supabase/server";
import { createAdminClient } from "@/src/lib/supabase/admin";
import { requireAuthWithData, isManager, isEmployee, isUser } from "@/src/lib/auth/getUser";
import { revalidateTag } from "next/cache";
type ActionResult = { success: boolean; error?: string };
function revalidateUserCache(email: string) {
(revalidateTag as (tag: string, type: "max") => void)(`user-data-${email}`, "max");
}
export async function updateProfileInfo(data: {
firstName?: string;
lastName?: string;
name?: string;
tel?: string;
gender?: string;
}): Promise<ActionResult> {
try {
const userData = await requireAuthWithData();
if (isManager(userData)) {
await prisma.manager.update({
where: { id: userData.id },
data: {
firstName: data.firstName ?? userData.firstName,
lastName: data.lastName ?? userData.lastName,
tel: data.tel ?? userData.tel,
},
});
} else if (isEmployee(userData)) {
await prisma.employee.update({
where: { id: userData.id },
data: {
firstName: data.firstName ?? userData.firstName,
lastName: data.lastName ?? userData.lastName,
},
});
} else if (isUser(userData)) {
await prisma.user.update({
where: { id: userData.id },
data: {
name: data.name ?? userData.name,
tel: data.tel,
gender: data.gender,
},
});
}
revalidateUserCache(userData.email);
return { success: true };
} catch {
return { success: false, error: "Fehler beim Aktualisieren der Profildaten." };
}
}
export async function updateAddress(data: {
street: string;
plz: string;
city: string;
}): Promise<ActionResult> {
try {
const userData = await requireAuthWithData();
if (!isUser(userData)) {
return { success: false, error: "Adresse kann nur von regulären Nutzern geändert werden." };
}
await prisma.user.update({
where: { id: userData.id },
data: {
street: data.street,
plz: data.plz,
city: data.city,
},
});
revalidateUserCache(userData.email);
return { success: true };
} catch {
return { success: false, error: "Fehler beim Aktualisieren der Adresse." };
}
}
export async function updateEmail(newEmail: string): Promise<ActionResult> {
try {
await requireAuthWithData();
if (!newEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
return { success: false, error: "Ungültige E-Mail-Adresse." };
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const supabase = await createClient();
const { error: authError } = await supabase.auth.updateUser(
{ email: newEmail },
{ emailRedirectTo: `${baseUrl}/auth/callback?next=/settings/personal&email_confirmed=true` }
);
if (authError) {
return { success: false, error: authError.message };
}
// Prisma is updated in /auth/callback after the user confirms the link.
return { success: true };
} catch {
return { success: false, error: "Fehler beim Aktualisieren der E-Mail." };
}
}
export async function syncEmailAfterConfirmation(): Promise<ActionResult> {
try {
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user?.email) {
return { success: false, error: "Nicht authentifiziert." };
}
const { id, email: newEmail } = user;
const prismaUser = await prisma.user.findUnique({ where: { id }, select: { id: true } });
if (prismaUser) {
await prisma.user.update({ where: { id }, data: { email: newEmail } });
} else {
const manager = await prisma.manager.findUnique({ where: { id }, select: { id: true } });
if (manager) {
await prisma.manager.update({ where: { id }, data: { email: newEmail } });
} else {
await prisma.employee.updateMany({ where: { id }, data: { email: newEmail } });
}
}
revalidateUserCache(newEmail);
return { success: true };
} catch {
return { success: false, error: "Fehler beim Synchronisieren der E-Mail." };
}
}
export async function updatePassword(
currentPassword: string,
newPassword: string
): Promise<ActionResult> {
try {
if (newPassword.length < 8) {
return { success: false, error: "Das neue Passwort muss mindestens 8 Zeichen lang sein." };
}
const userData = await requireAuthWithData();
const supabase = await createClient();
// Re-authenticate to verify current password
const { error: signInError } = await supabase.auth.signInWithPassword({
email: userData.email,
password: currentPassword,
});
if (signInError) {
return { success: false, error: "Das aktuelle Passwort ist falsch." };
}
const { error } = await supabase.auth.updateUser({ password: newPassword });
if (error) {
return { success: false, error: error.message };
}
return { success: true };
} catch {
return { success: false, error: "Fehler beim Aktualisieren des Passworts." };
}
}
export async function updateAvatar(formData: FormData): Promise<ActionResult & { url?: string }> {
try {
const userData = await requireAuthWithData();
const file = formData.get("file") as File | null;
if (!file) {
return { success: false, error: "Keine Datei übergeben." };
}
if (!file.type.startsWith("image/")) {
return { success: false, error: "Nur Bilddateien erlaubt." };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: "Datei darf maximal 5 MB groß sein." };
}
const extension =
file.name
.split(".")
.pop()
?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
const folder = isManager(userData) ? "managers" : isEmployee(userData) ? "employees" : "users";
const safePath = `${folder}/${userData.id}/${Date.now()}.${extension}`;
const adminClient = createAdminClient();
const buffer = Buffer.from(await file.arrayBuffer());
const { data, error } = await adminClient.storage.from("avatars").upload(safePath, buffer, {
contentType: file.type,
upsert: true,
cacheControl: "3600",
});
if (error) {
return { success: false, error: error.message };
}
const {
data: { publicUrl },
} = adminClient.storage.from("avatars").getPublicUrl(data.path);
if (isManager(userData)) {
await prisma.manager.update({ where: { id: userData.id }, data: { pbSrc: publicUrl } });
} else if (isEmployee(userData)) {
await prisma.employee.update({ where: { id: userData.id }, data: { pbSrc: publicUrl } });
} else if (isUser(userData)) {
await prisma.user.update({ where: { id: userData.id }, data: { pbSrc: publicUrl } });
}
revalidateUserCache(userData.email);
return { success: true, url: publicUrl };
} catch {
return { success: false, error: "Fehler beim Hochladen des Profilbilds." };
}
}

View file

@ -0,0 +1,361 @@
"use client";
import { useState, useTransition } from "react";
import ImageUploadComponent from "@/src/components/layout/ImageUploadComponent";
import InputComponent from "@/src/components/layout/InputComponent";
import DropdownComponent from "@/src/components/layout/DropdownComponent";
import AddressAutocompleteComponent, {
ParsedAddress,
} from "@/src/components/layout/AddressAutocompleteComponent";
import { Button } from "@/src/components/ui/button";
import {
updateProfileInfo,
updateAddress,
updateEmail,
updatePassword,
updateAvatar,
} from "../../actions/personal-settings-actions";
import { toast } from "sonner";
export type PersonalSettingsProps = {
role: "user" | "manager" | "employee";
id: string;
email: string;
pbSrc: string | null;
firstName?: string | null;
lastName?: string | null;
tel?: string | null;
name?: string | null;
gender?: string | null;
street?: string | null;
plz?: string | null;
city?: string | null;
};
// ── Shared card shell ────────────────────────────────────────────────────────
function Card({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900">{title}</h2>
{subtitle && <p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>}
</div>
{children}
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
const PersonalSettingsForm = ({
role,
email: initialEmail,
pbSrc,
firstName: initialFirstName,
lastName: initialLastName,
tel: initialTel,
name: initialName,
gender: initialGender,
street: initialStreet,
plz: initialPlz,
city: initialCity,
}: PersonalSettingsProps) => {
const [pending, startTransition] = useTransition();
// Avatar
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(pbSrc);
// Profile info
const [firstName, setFirstName] = useState(initialFirstName ?? "");
const [lastName, setLastName] = useState(initialLastName ?? "");
const [name, setName] = useState(initialName ?? "");
const [tel, setTel] = useState(initialTel ?? "");
const [gender, setGender] = useState(initialGender ?? "");
// Address
const [street, setStreet] = useState(initialStreet ?? "");
const [plz, setPlz] = useState(initialPlz ?? "");
const [city, setCity] = useState(initialCity ?? "");
// Email
const [email, setEmail] = useState(initialEmail);
// Password
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// ── Handlers ─────────────────────────────────────────────────────────────
const handleAvatarSave = () => {
if (!avatarFile) return;
startTransition(async () => {
const fd = new FormData();
fd.append("file", avatarFile);
const result = await updateAvatar(fd);
if (result.success) {
toast.success("Profilbild erfolgreich gespeichert.");
setAvatarFile(null);
} else {
toast.error(result.error ?? "Fehler beim Hochladen.");
}
});
};
const handleProfileSave = () => {
startTransition(async () => {
const result = await updateProfileInfo({ firstName, lastName, name, tel, gender });
if (result.success) {
toast.success("Profildaten erfolgreich gespeichert.");
} else {
toast.error(result.error ?? "Fehler beim Speichern.");
}
});
};
const handleAddressSave = () => {
startTransition(async () => {
const result = await updateAddress({ street, plz, city });
if (result.success) {
toast.success("Adresse erfolgreich gespeichert.");
} else {
toast.error(result.error ?? "Fehler beim Speichern.");
}
});
};
const handleEmailSave = () => {
startTransition(async () => {
const result = await updateEmail(email);
if (result.success) {
toast.success("Bestätigungslink wurde an deine neue E-Mail-Adresse gesendet.");
} else {
toast.error(result.error ?? "Fehler beim Speichern.");
}
});
};
const handlePasswordSave = () => {
if (newPassword !== confirmPassword) {
toast.error("Die Passwörter stimmen nicht überein.");
return;
}
startTransition(async () => {
const result = await updatePassword(currentPassword, newPassword);
if (result.success) {
toast.success("Passwort erfolgreich geändert.");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
toast.error(result.error ?? "Fehler beim Speichern.");
}
});
};
return (
<div className="space-y-6">
{/* ── Hero: Profilbild ─────────────────────────────────────────────
Full-width card with centred avatar, accent gradient like dashboard */}
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<div className="flex flex-col items-center gap-4">
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase">
Profilbild
</p>
<ImageUploadComponent
value={avatarFile}
preview={avatarPreview}
onChange={(file, preview) => {
setAvatarFile(file);
setAvatarPreview(preview);
}}
/>
<Button
onClick={handleAvatarSave}
disabled={!avatarFile || pending}
size="sm"
variant="outline"
className="bg-white/60 border-white/60 backdrop-blur-sm"
>
Profilbild speichern
</Button>
</div>
</div>
{/* ── 2-column grid on lg+, single column on mobile ─────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* ── Persönliche Daten ───────────────────────────────────────── */}
<Card title="Persönliche Daten" subtitle="Vor- und Nachname, Telefon, Geschlecht">
<div className="space-y-4">
{role === "user" ? (
<InputComponent
label="Name"
type="text"
id="name"
isLabel
placeholder="Vollständiger Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
) : (
<div className="grid grid-cols-2 gap-3">
<InputComponent
label="Vorname"
type="text"
id="firstName"
isLabel
placeholder="Vorname"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<InputComponent
label="Nachname"
type="text"
id="lastName"
isLabel
placeholder="Nachname"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
)}
{(role === "user" || role === "manager") && (
<InputComponent
label="Telefonnummer"
type="tel"
id="tel"
isLabel
placeholder="+49 000 000000"
value={tel}
onChange={(e) => setTel(e.target.value)}
/>
)}
{role === "user" && (
<DropdownComponent label="Geschlecht" selected={gender} onSelect={setGender} />
)}
</div>
<div className="pt-1">
<Button onClick={handleProfileSave} disabled={pending} size="sm" className="w-fit">
Speichern
</Button>
</div>
</Card>
{/* ── E-Mail ──────────────────────────────────────────────────── */}
<Card title="E-Mail-Adresse" subtitle="Änderungen erfordern eine Bestätigung per E-Mail">
<InputComponent
label="E-Mail"
type="email"
id="email"
isLabel
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<div className="pt-1">
<Button
onClick={handleEmailSave}
disabled={pending || email === initialEmail}
size="sm"
className="w-fit"
>
Speichern
</Button>
</div>
</Card>
{/* ── Adresse (nur reguläre Nutzer) ────────────────────────────
Spans both columns on large screens so the 3-field layout
has room to breathe. */}
{role === "user" && (
<div className="lg:col-span-2">
<Card title="Adresse" subtitle="Straße, PLZ und Wohnort">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-3">
<AddressAutocompleteComponent
streetValue={street}
plzValue={plz}
placeValue={city}
onStreetChange={setStreet}
onPlzChange={setPlz}
onPlaceChange={setCity}
onSelect={(parsed: ParsedAddress) => {
setStreet(parsed.street);
setPlz(parsed.plz);
setCity(parsed.place);
}}
idPrefix="settings-address"
/>
</div>
</div>
<div className="pt-1">
<Button onClick={handleAddressSave} disabled={pending} size="sm" className="w-fit">
Speichern
</Button>
</div>
</Card>
</div>
)}
{/* ── Passwort ────────────────────────────────────────────────── */}
<div className="lg:col-span-2">
<Card title="Passwort ändern" subtitle="Mindestens 8 Zeichen">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<InputComponent
label="Aktuelles Passwort"
type="password"
id="currentPassword"
isLabel
placeholder="••••••••"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<InputComponent
label="Neues Passwort"
type="password"
id="newPassword"
isLabel
placeholder="••••••••"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<InputComponent
label="Passwort bestätigen"
type="password"
id="confirmPassword"
isLabel
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
<div className="pt-1">
<Button
onClick={handlePasswordSave}
disabled={pending || !currentPassword || !newPassword || !confirmPassword}
size="sm"
className="w-fit"
>
Passwort ändern
</Button>
</div>
</Card>
</div>
</div>
</div>
);
};
export default PersonalSettingsForm;

View file

@ -1,4 +1,4 @@
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import { requireAuthWithData, isManager, isUser, isEmployee } from "@/src/lib/auth/getUser";
import SettingsMenuItem from "../components/SettingsMenuItem";
import {
Bell,
@ -74,11 +74,23 @@ const settingsMenuTitles: SettingsMenuItem[] = [
const SettingsOverviewView = async () => {
const userData = await requireAuthWithData();
if (!isManager(userData)) {
if (isUser(userData) || isEmployee(userData)) {
return (
<div>
<h1 className="text-2xl font-bold mb-1">Einstellungen</h1>
<p className="text-gray-500 text-sm">Keine weiteren Einstellungen verfügbar.</p>
<div className="space-y-4">
<div>
<h1 className="text-2xl flex gap-2 font-bold">
<Settings /> Einstellungen
</h1>
<p className="text-sm text-gray-500 mt-1">Verwalte deine persönlichen Daten</p>
</div>
<div className="flex flex-col gap-4">
<SettingsMenuItem
name="Persönliche Einstellungen"
description="Profilbild, Name, E-Mail, Passwort und mehr"
icon={User}
href="/settings/personal"
/>
</div>
</div>
);
}

View file

@ -1,5 +1,58 @@
const SettingsPersonalPageView = () => {
return <div></div>;
import { requireAuthWithData, isManager, isEmployee, isUser } from "@/src/lib/auth/getUser";
import PersonalSettingsForm, { PersonalSettingsProps } from "../components/PersonalSettingsForm";
const SettingsPersonalPageView = async () => {
const userData = await requireAuthWithData();
let props: PersonalSettingsProps;
if (isManager(userData)) {
props = {
role: "manager",
id: userData.id,
email: userData.email,
pbSrc: userData.pbSrc,
firstName: userData.firstName,
lastName: userData.lastName,
tel: userData.tel,
};
} else if (isEmployee(userData)) {
props = {
role: "employee",
id: userData.id,
email: userData.email,
pbSrc: userData.pbSrc,
firstName: userData.firstName,
lastName: userData.lastName,
};
} else if (isUser(userData)) {
props = {
role: "user",
id: userData.id,
email: userData.email,
pbSrc: userData.pbSrc,
name: userData.name,
tel: userData.tel,
gender: userData.gender,
street: userData.street,
plz: userData.plz,
city: userData.city,
};
} else {
return null;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Persönliche Einstellungen</h1>
<p className="text-sm text-gray-500 mt-1">
Verwalte deine persönlichen Daten und Zugangsdaten
</p>
</div>
<PersonalSettingsForm {...props} />
</div>
);
};
export default SettingsPersonalPageView;