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
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 1m28s
This commit is contained in:
parent
48151906d3
commit
4936e41c43
4 changed files with 356 additions and 14 deletions
|
|
@ -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<typeof users.$inferInsert> = {};
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
258
components/settings/SettingsProfileEditCard.tsx
Normal file
258
components/settings/SettingsProfileEditCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-4 py-4 min-h-0 pr-1">
|
||||
<SettingsProfileCard onUpdate={updateProfile} />
|
||||
<SettingsProfileEditCard />
|
||||
<SettingsDeleteAccountCard />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue