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 { 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);
|
||||||
|
|
|
||||||
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
|
* @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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue