From 4936e41c437781aa94c7333a96c5c5dbb7bb8473 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Mon, 28 Sep 2026 11:54:36 +0200 Subject: [PATCH] feat(settings): add profile edit card with zod validation and save restrictions --- app/api/users/profile/route.ts | 66 ++++- .../settings/SettingsProfileEditCard.tsx | 258 ++++++++++++++++++ components/settings/SettingsView.tsx | 4 +- lib/schemas/auth.schema.ts | 42 ++- 4 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 components/settings/SettingsProfileEditCard.tsx diff --git a/app/api/users/profile/route.ts b/app/api/users/profile/route.ts index 76c6562..270caf7 100644 --- a/app/api/users/profile/route.ts +++ b/app/api/users/profile/route.ts @@ -7,6 +7,7 @@ import { auth } from "@/auth"; import { db } from "@/db"; import { users, UserStatus } from "@/db/schema"; import { MEMBER_COLOR_OPTIONS } from "@/lib/constants/member.styles"; +import { hashPassword } from "@/lib/password"; import { eq } from "drizzle-orm"; import { NextResponse } from "next/server"; @@ -22,11 +23,31 @@ export async function PATCH(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { status, color } = await req.json(); + const existingUser = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + }); - const updateData: { status?: UserStatus; color?: string } = {}; + if (!existingUser) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } - // Validate status if provided + const body = await req.json(); + const { status, color, username, email, password } = body; + + // Protection against changes to the guest account + const guestEmail = process.env.GUEST_EMAIL; + const isGuest = guestEmail && existingUser.email === guestEmail; + + if (isGuest && (username || email || password)) { + return NextResponse.json( + { error: "Guest account details cannot be modified" }, + { status: 403 }, + ); + } + + const updateData: Partial = {}; + + // Validate status if provided (auch Gäste dürfen ihren Status/Farbe ändern) if (status !== undefined) { if (!VALID_STATUSES.includes(status)) { return NextResponse.json( @@ -48,6 +69,41 @@ export async function PATCH(req: Request) { updateData.color = color; } + // Validate and set username + if (username !== undefined) { + const trimmedUsername = username.trim(); + if (!trimmedUsername) { + return NextResponse.json( + { error: "Username cannot be empty" }, + { status: 400 }, + ); + } + updateData.username = trimmedUsername; + } + + // Validate and set email + if (email !== undefined) { + const trimmedEmail = email.trim().toLowerCase(); + if (!trimmedEmail.includes("@")) { + return NextResponse.json( + { error: "Invalid email format" }, + { status: 400 }, + ); + } + updateData.email = trimmedEmail; + } + + // Hash and set password + if (password !== undefined && password !== "") { + if (password.length < 6) { + return NextResponse.json( + { error: "Password must be at least 6 characters" }, + { status: 400 }, + ); + } + updateData.password = await hashPassword(password); + } + // Ensure at least one field is provided for update if (Object.keys(updateData).length === 0) { return NextResponse.json( @@ -63,10 +119,6 @@ export async function PATCH(req: Request) { .where(eq(users.id, session.user.id)) .returning(); - if (!updatedUser) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - return NextResponse.json(updatedUser); } catch (error) { console.error("API User Settings PATCH error:", error); diff --git a/components/settings/SettingsProfileEditCard.tsx b/components/settings/SettingsProfileEditCard.tsx new file mode 100644 index 0000000..297014f --- /dev/null +++ b/components/settings/SettingsProfileEditCard.tsx @@ -0,0 +1,258 @@ +/** + * @file components/settings/SettingsProfileEditCard.tsx + * @description Card component for changing user details like email, username, and password with guest protection. + */ + +"use client"; + +import { useState } from "react"; +import { useUser } from "@/lib/context/UserContext"; +import { + User, + Mail, + Lock, + AlertTriangle, + ChevronDown, + Save, +} from "lucide-react"; +import { ActionButton } from "@/components/ui/ActionButton"; +import { profileUpdateSchema } from "@/lib/schemas/auth.schema"; + +/** Props for the SettingsProfileEditCard component. */ +interface SettingsProfileEditCardProps { + defaultOpen?: boolean; +} + +/** Renders a collapsible card allowing users to update username, email, and password. */ +export function SettingsProfileEditCard({ + defaultOpen = false, +}: SettingsProfileEditCardProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const { currentUser, updateCurrentUser } = useUser(); + + const [username, setUsername] = useState(currentUser?.username || ""); + const [email, setEmail] = useState(currentUser?.email || ""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + if (!currentUser) return null; + + const guestEmail = process.env.NEXT_PUBLIC_GUEST_EMAIL; + const isGuest = Boolean( + guestEmail && currentUser.email?.toLowerCase() === guestEmail.toLowerCase(), + ); + + // Simple check for changes & password match + const hasChanges = + username !== currentUser.username || + email !== currentUser.email || + password.length > 0; + + const isPasswordMatch = !password || password === confirmPassword; + const isSaveDisabled = !hasChanges || !isPasswordMatch || isLoading; + + /** Validates form inputs and sends account detail updates to the server. */ + const handleSubmit = async (e: React.SubmitEvent) => { + e.preventDefault(); + if (isGuest || isSaveDisabled) return; + + setError(null); + setSuccess(null); + + // Zod Validation + const validationResult = profileUpdateSchema.safeParse({ + username, + email, + password, + confirmPassword, + }); + + if (!validationResult.success) { + const firstError = validationResult.error.issues[0]?.message; + setError(firstError || "Validation failed."); + return; + } + + try { + setIsLoading(true); + + const payload: Record = {}; + if (username !== currentUser.username) payload.username = username; + if (email !== currentUser.email) payload.email = email; + if (password) payload.password = password; + + const res = await fetch("/api/users/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to update profile."); + } + + updateCurrentUser({ + ...(payload.username && { username: payload.username }), + ...(payload.email && { email: payload.email }), + }); + + setPassword(""); + setConfirmPassword(""); + setSuccess("Profile updated successfully!"); + } catch (err) { + setError( + err instanceof Error ? err.message : "An unexpected error occurred.", + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Header / Toggle Button */} + + + {/* Collapsible Body */} + {isOpen && ( +
+ {isGuest ? ( +
+ + Guest accounts cannot modify their account details. +
+ ) : ( +
+ {error &&

{error}

} + {success &&

{success}

} + + {/* Username Input */} +
+ +
+ + setUsername(e.target.value)} + disabled={isLoading} + className="w-full bg-background border border-surface/80 rounded-xl pl-10 pr-3.5 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent transition-all disabled:opacity-50" + /> +
+
+ + {/* Email Input */} +
+ +
+ + setEmail(e.target.value)} + disabled={isLoading} + className="w-full bg-background border border-surface/80 rounded-xl pl-10 pr-3.5 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent transition-all disabled:opacity-50" + /> +
+
+ + {/* Password Inputs */} +
+
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + className="w-full bg-background border border-surface/80 rounded-xl pl-10 pr-3.5 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent transition-all disabled:opacity-50" + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isLoading || !password} + className="w-full bg-background border border-surface/80 rounded-xl pl-10 pr-3.5 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent transition-all disabled:opacity-50" + /> +
+
+
+ +
+ + Save Changes + +
+
+ )} +
+ )} +
+ ); +} diff --git a/components/settings/SettingsView.tsx b/components/settings/SettingsView.tsx index 1945fda..e88980b 100644 --- a/components/settings/SettingsView.tsx +++ b/components/settings/SettingsView.tsx @@ -1,6 +1,6 @@ /** * @file components/settings/SettingsView.tsx - * @description Main container for user settings view displaying header and profile card. + * @description Settings view layout component managing user profile updates, editing forms, and account deletion options. */ "use client"; @@ -8,6 +8,7 @@ import { useUser } from "@/lib/context/UserContext"; import { SettingsProfileCard } from "./SettingsProfileCard"; import { SettingsDeleteAccountCard } from "./SettingsDeleteAccountCard"; +import { SettingsProfileEditCard } from "./SettingsProfileEditCard"; /** Renders the settings view layout for managing user profile settings. */ export function SettingsView() { @@ -28,6 +29,7 @@ export function SettingsView() { return (
+
); diff --git a/lib/schemas/auth.schema.ts b/lib/schemas/auth.schema.ts index 67f7c45..28f1a4e 100644 --- a/lib/schemas/auth.schema.ts +++ b/lib/schemas/auth.schema.ts @@ -5,9 +5,7 @@ import { z } from "zod"; -/** - * Zod validation schema for user login credentials. - */ +/** Zod validation schema for user login credentials. */ export const loginSchema = z.object({ email: z .string() @@ -19,9 +17,7 @@ export const loginSchema = z.object({ .max(72, "Password is too long"), }); -/** - * Zod validation schema for new user registration payloads, including password confirmation matching. - */ +/** Zod validation schema for user registration payloads with password confirmation. */ export const registerSchema = z .object({ username: z @@ -42,3 +38,37 @@ export const registerSchema = z message: "Passwords do not match", path: ["confirmPassword"], }); + +/** Zod validation schema for profile updates with optional password change matching. */ +export const profileUpdateSchema = z + .object({ + username: z + .string() + .min(1, "Username is required") + .max(50, "Username is too long") + .optional(), + email: z + .string() + .max(255, "Email is too long") + .pipe(z.email("Please provide a valid email address")) + .optional(), + password: z + .string() + .min(8, "Password must be at least 8 characters long") + .max(72, "Password is too long (max 72 characters)") + .or(z.literal("")) + .optional(), + confirmPassword: z.string().optional(), + }) + .refine( + (data) => { + if (data.password && data.password.length > 0) { + return data.password === data.confirmPassword; + } + return true; + }, + { + message: "Passwords do not match", + path: ["confirmPassword"], + }, + );