feat(settings): add profile edit card with zod validation and save restrictions
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 1m28s

This commit is contained in:
Chneemann 2026-09-28 11:54:36 +02:00
parent 48151906d3
commit 4936e41c43
No known key found for this signature in database
4 changed files with 356 additions and 14 deletions

View file

@ -7,6 +7,7 @@ import { auth } from "@/auth";
import { db } from "@/db"; import { db } from "@/db";
import { users, UserStatus } from "@/db/schema"; import { users, UserStatus } from "@/db/schema";
import { MEMBER_COLOR_OPTIONS } from "@/lib/constants/member.styles"; import { MEMBER_COLOR_OPTIONS } from "@/lib/constants/member.styles";
import { hashPassword } from "@/lib/password";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
@ -22,11 +23,31 @@ export async function PATCH(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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<typeof users.$inferInsert> = {};
// Validate status if provided (auch Gäste dürfen ihren Status/Farbe ändern)
if (status !== undefined) { if (status !== undefined) {
if (!VALID_STATUSES.includes(status)) { if (!VALID_STATUSES.includes(status)) {
return NextResponse.json( return NextResponse.json(
@ -48,6 +69,41 @@ export async function PATCH(req: Request) {
updateData.color = color; 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 // Ensure at least one field is provided for update
if (Object.keys(updateData).length === 0) { if (Object.keys(updateData).length === 0) {
return NextResponse.json( return NextResponse.json(
@ -63,10 +119,6 @@ export async function PATCH(req: Request) {
.where(eq(users.id, session.user.id)) .where(eq(users.id, session.user.id))
.returning(); .returning();
if (!updatedUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json(updatedUser); return NextResponse.json(updatedUser);
} catch (error) { } catch (error) {
console.error("API User Settings PATCH error:", error); console.error("API User Settings PATCH error:", error);

View file

@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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<string, string> = {};
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 (
<div className="bg-surface border border-muted/20 rounded-xl overflow-hidden transition-all shrink-0">
{/* Header / Toggle Button */}
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="w-full p-6 flex items-center justify-between hover:bg-white/5 transition-colors cursor-pointer text-left"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent/10 text-accent">
<User className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Account Details</h2>
<p className="text-sm text-muted">
Change your username, email address, or password.
</p>
</div>
</div>
<ChevronDown
className={`w-5 h-5 text-muted transition-transform duration-200 ${
isOpen ? "rotate-180" : "rotate-0"
}`}
/>
</button>
{/* Collapsible Body */}
{isOpen && (
<div className="p-6 pt-0 space-y-4 border-t border-muted/10 mt-2">
{isGuest ? (
<div className="flex items-center gap-2 p-3 mt-4 rounded-lg bg-amber-500/10 text-amber-400 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>Guest accounts cannot modify their account details.</span>
</div>
) : (
<form onSubmit={handleSubmit} className="pt-4 space-y-4">
{error && <p className="text-xs text-red-400">{error}</p>}
{success && <p className="text-xs text-emerald-400">{success}</p>}
{/* Username Input */}
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">
Username
</label>
<div className="relative flex items-center">
<User className="w-4 h-4 absolute left-3.5 text-muted" />
<input
id="username"
name="username"
type="text"
required
value={username}
maxLength={50}
onChange={(e) => 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"
/>
</div>
</div>
{/* Email Input */}
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">
Email
</label>
<div className="relative flex items-center">
<Mail className="w-4 h-4 absolute left-3.5 text-muted" />
<input
id="email"
name="email"
type="email"
required
value={email}
maxLength={255}
onChange={(e) => 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"
/>
</div>
</div>
{/* Password Inputs */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">
New Password (optional)
</label>
<div className="relative flex items-center">
<Lock className="w-4 h-4 absolute left-3.5 text-muted" />
<input
id="password"
name="password"
type="password"
placeholder="•••••••••••••"
value={password}
maxLength={72}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">
Confirm Password
</label>
<div className="relative flex items-center">
<Lock className="w-4 h-4 absolute left-3.5 text-muted" />
<input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="•••••••••••••"
value={confirmPassword}
maxLength={72}
onChange={(e) => 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"
/>
</div>
</div>
</div>
<div className="pt-2">
<ActionButton
type="submit"
variant="primary"
isLoading={isLoading}
disabled={isSaveDisabled}
icon={Save}
>
Save Changes
</ActionButton>
</div>
</form>
)}
</div>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
/** /**
* @file components/settings/SettingsView.tsx * @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"; "use client";
@ -8,6 +8,7 @@
import { useUser } from "@/lib/context/UserContext"; import { useUser } from "@/lib/context/UserContext";
import { SettingsProfileCard } from "./SettingsProfileCard"; import { SettingsProfileCard } from "./SettingsProfileCard";
import { SettingsDeleteAccountCard } from "./SettingsDeleteAccountCard"; import { SettingsDeleteAccountCard } from "./SettingsDeleteAccountCard";
import { SettingsProfileEditCard } from "./SettingsProfileEditCard";
/** Renders the settings view layout for managing user profile settings. */ /** Renders the settings view layout for managing user profile settings. */
export function SettingsView() { export function SettingsView() {
@ -28,6 +29,7 @@ export function SettingsView() {
return ( return (
<div className="flex-1 overflow-y-auto flex flex-col gap-4 py-4 min-h-0 pr-1"> <div className="flex-1 overflow-y-auto flex flex-col gap-4 py-4 min-h-0 pr-1">
<SettingsProfileCard onUpdate={updateProfile} /> <SettingsProfileCard onUpdate={updateProfile} />
<SettingsProfileEditCard />
<SettingsDeleteAccountCard /> <SettingsDeleteAccountCard />
</div> </div>
); );

View file

@ -5,9 +5,7 @@
import { z } from "zod"; import { z } from "zod";
/** /** Zod validation schema for user login credentials. */
* Zod validation schema for user login credentials.
*/
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z email: z
.string() .string()
@ -19,9 +17,7 @@ export const loginSchema = z.object({
.max(72, "Password is too long"), .max(72, "Password is too long"),
}); });
/** /** Zod validation schema for user registration payloads with password confirmation. */
* Zod validation schema for new user registration payloads, including password confirmation matching.
*/
export const registerSchema = z export const registerSchema = z
.object({ .object({
username: z username: z
@ -42,3 +38,37 @@ export const registerSchema = z
message: "Passwords do not match", message: "Passwords do not match",
path: ["confirmPassword"], 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"],
},
);