From df2fb49e34e825afcf28e35dc6a1105b77ce6edb Mon Sep 17 00:00:00 2001 From: Talha Asif Date: Tue, 23 Jun 2026 10:57:42 +0500 Subject: [PATCH] feat(profile): implement inline username and email editing (issue #143) --- backend/src/routes/account.js | 107 +++++++++++++++ src/lib/__tests__/api.test.ts | 76 ++++++++++- src/lib/api.ts | 45 +++++++ src/pages/Profile.tsx | 238 ++++++++++++++++++++++++++++++++-- 4 files changed, 451 insertions(+), 15 deletions(-) diff --git a/backend/src/routes/account.js b/backend/src/routes/account.js index de288b8..c962da2 100644 --- a/backend/src/routes/account.js +++ b/backend/src/routes/account.js @@ -71,4 +71,111 @@ router.delete('/', requireAuth, async (req, res) => { } }); +/** + * PATCH /api/account + * + * Update the authenticated user's username and/or email address. + * + * Request body: + * { username?: string, email?: string } + * + * Response: + * 200 { ok: true, message: "Profile updated successfully", profile: { ... } } + * 400 Validation errors + * 409 Unique constraint violations (username taken or email in use) + * 500 Internal error + */ +router.patch('/', requireAuth, async (req, res) => { + const userId = req.user.id; + const { username, email } = req.body; + + // Validate request + const errors = []; + if (username !== undefined) { + const trimmedUsername = String(username).trim(); + if (trimmedUsername.length < 3 || trimmedUsername.length > 30) { + errors.push({ field: 'username', message: 'Username must be between 3 and 30 characters' }); + } + if (!/^[a-zA-Z0-9_-]+$/.test(trimmedUsername)) { + errors.push({ field: 'username', message: 'Username can only contain alphanumeric characters, underscores, and hyphens' }); + } + } + + if (email !== undefined) { + const trimmedEmail = String(email).trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) { + errors.push({ field: 'email', message: 'Please provide a valid email address' }); + } + } + + if (errors.length > 0) { + return res.status(400).json({ error: 'Validation failed', details: errors }); + } + + try { + let profileUpdate = {}; + + if (username !== undefined) { + profileUpdate.username = username.trim(); + } + + // 1. If updating email + if (email !== undefined) { + const targetEmail = email.trim(); + const { error: authError } = await supabase.auth.admin.updateUserById(userId, { + email: targetEmail + }); + + if (authError) { + console.error('[account] auth email update failed:', authError.message); + if (authError.message.includes('already exists') || authError.message.includes('unique') || authError.status === 422) { + return res.status(409).json({ error: 'Email address is already in use by another account' }); + } + return res.status(400).json({ error: authError.message }); + } + } + + // 2. If updating username + if (Object.keys(profileUpdate).length > 0) { + const { data, error: profileError } = await supabase + .from('profiles') + .update(profileUpdate) + .eq('id', userId) + .select() + .single(); + + if (profileError) { + console.error('[account] profile update failed:', profileError.message); + if (profileError.code === '23505' || profileError.message.includes('unique')) { + return res.status(409).json({ error: 'Username is already taken' }); + } + return res.status(400).json({ error: profileError.message }); + } + } + + // 3. Fetch updated profile + const { data: updatedProfile, error: getError } = await supabase + .from('profiles') + .select('*') + .eq('id', userId) + .single(); + + if (getError) throw getError; + + return res.status(200).json({ + ok: true, + message: 'Profile updated successfully', + profile: updatedProfile + }); + + } catch (error) { + console.error('[account] update unexpected error:', error); + return res.status(500).json({ + error: 'Failed to update profile', + details: error.message + }); + } +}); + export default router; + diff --git a/src/lib/__tests__/api.test.ts b/src/lib/__tests__/api.test.ts index 25b4ecd..72e1be3 100644 --- a/src/lib/__tests__/api.test.ts +++ b/src/lib/__tests__/api.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { verifyBatch, registerBatch, verifyBatchById } from "../api"; +import { verifyBatch, registerBatch, verifyBatchById, updateProfile } from "../api"; // --------------------------------------------------------------------------- // vi.mock() is hoisted to the top of the file by Vitest's transform, so any @@ -397,3 +397,77 @@ describe("verifyBatchById", () => { ); }); }); + +// --------------------------------------------------------------------------- +// updateProfile — Issue #143 tests +// --------------------------------------------------------------------------- +describe("updateProfile", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + // Authenticated session by default + mockGetSession.mockResolvedValue({ + data: { session: { access_token: "test-jwt-token" } }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("should make PATCH request to /api/account with correct body and headers", async () => { + const mockResponse = { + ok: true, + message: "Profile updated successfully", + profile: { username: "new_username" }, + }; + + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(mockResponse), + } as any); + + const result = await updateProfile({ username: "new_username" }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/account"), + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: "Bearer test-jwt-token", + }, + body: JSON.stringify({ username: "new_username" }), + }, + ); + expect(result).toEqual(mockResponse); + }); + + it("should throw error if session is not active", async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + await expect(updateProfile({ username: "new_username" })).rejects.toThrow( + "No active session", + ); + }); + + it("should throw server error when response not ok", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 409, + json: vi.fn().mockResolvedValue({ + error: "Username is already taken", + }), + } as any); + + await expect(updateProfile({ username: "taken_username" })).rejects.toThrow( + "Username is already taken", + ); + }); +}); + diff --git a/src/lib/api.ts b/src/lib/api.ts index dcb9366..91c2c35 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -848,3 +848,48 @@ export const deleteAccount = async (): Promise<{ ok: boolean; message: string }> throw new Error('deleteAccount failed: network error'); } }; + +/** + * Updates the authenticated user's profile details (username/email) via the backend. + */ +export const updateProfile = async ( + data: { username?: string; email?: string } +): Promise<{ ok: boolean; message: string; profile: any }> => { + const headers = await buildAuthHeaders(); + headers["Content-Type"] = "application/json"; + + if (!headers["Authorization"]) { + throw new Error("No active session"); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let payload: any = null; + + try { + const response = await fetch(`${API_BASE_URL}/api/account`, { + method: "PATCH", + headers, + body: JSON.stringify(data), + }); + + try { + payload = await response.json(); + } catch { + // ignore + } + + if (!response.ok) { + throw new Error( + payload?.error || + payload?.message || + `updateProfile failed: HTTP ${response.status}` + ); + } + + return payload; + } catch (err) { + if (err instanceof Error) throw err; + throw new Error("updateProfile failed: network error"); + } +}; + diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index cc46724..672b6c9 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -13,6 +13,8 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Input } from "@/components/ui/input"; +import { updateProfile } from "@/lib/api"; import { Wallet, Mail, @@ -20,12 +22,16 @@ import { AlertCircle, CheckCircle, Clock, + Pencil, + Save, + X, } from "lucide-react"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; import { Helmet } from "react-helmet-async"; import { DeleteProfileModal } from "@/components/DeleteProfileModal"; + interface UserProfile { username: string | null; full_name: string | null; @@ -44,6 +50,103 @@ export default function Profile() { const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [editUsername, setEditUsername] = useState(""); + const [editEmail, setEditEmail] = useState(""); + const [updating, setUpdating] = useState(false); + const [validationErrors, setValidationErrors] = useState<{ username?: string; email?: string }>({}); + + const validateFields = (usernameVal: string, emailVal: string) => { + const errors: { username?: string; email?: string } = {}; + + // Validate username + const trimmedUsername = usernameVal.trim(); + if (!trimmedUsername) { + errors.username = "Username is required"; + } else if (trimmedUsername.length < 3 || trimmedUsername.length > 30) { + errors.username = "Username must be between 3 and 30 characters"; + } else if (!/^[a-zA-Z0-9_-]+$/.test(trimmedUsername)) { + errors.username = "Username can only contain alphanumeric characters, underscores, and hyphens"; + } + + // Validate email + const trimmedEmail = emailVal.trim(); + if (!trimmedEmail) { + errors.email = "Email is required"; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) { + errors.email = "Please enter a valid email address"; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleStartEdit = () => { + setEditUsername(profile?.username || ""); + setEditEmail(user?.email || ""); + setValidationErrors({}); + setError(null); + setSuccess(null); + setIsEditing(true); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setValidationErrors({}); + }; + + const handleSaveChanges = async () => { + setError(null); + setSuccess(null); + + const isValid = validateFields(editUsername, editEmail); + if (!isValid) return; + + if (!supabase) { + setError("Supabase client is not initialized"); + return; + } + + setUpdating(true); + + try { + // Determine what changed + const payload: { username?: string; email?: string } = {}; + if (editUsername.trim() !== (profile?.username || "")) { + payload.username = editUsername.trim(); + } + if (editEmail.trim() !== (user?.email || "")) { + payload.email = editEmail.trim(); + } + + // If nothing changed, just exit edit mode + if (Object.keys(payload).length === 0) { + setIsEditing(false); + return; + } + + const res = await updateProfile(payload); + if (res.ok) { + // Refresh local session so user.email updates in supabase client cache + if (payload.email) { + const { error: refreshError } = await supabase.auth.refreshSession(); + if (refreshError) { + console.warn("Failed to refresh session:", refreshError.message); + } + } + await loadProfile(); + setIsEditing(false); + setSuccess("Profile updated successfully"); + } + } catch (err: any) { + console.error("Profile update error:", err); + setError(err.message || "Failed to update profile"); + } finally { + setUpdating(false); + } + }; + + useEffect(() => { loadProfile(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -55,6 +158,12 @@ export default function Profile() { return; } + if (!supabase) { + setError("Supabase client is not initialized"); + setLoading(false); + return; + } + try { const { data, error } = await supabase .from("profiles") @@ -124,11 +233,24 @@ export default function Profile() {
- - User Profile - - Manage your account and linked Hedera wallet - + +
+ User Profile + + Manage your account and linked Hedera wallet + +
+ {!isEditing && ( + + )}
@@ -154,12 +276,39 @@ export default function Profile() { -
- - - {user?.email || "Anonymous"} - -
+ {isEditing ? ( +
+
+ + { + setEditEmail(e.target.value); + validateFields(editUsername, e.target.value); + }} + disabled={updating} + className={`pl-9 bg-background border ${ + validationErrors.email + ? "border-red-500 focus-visible:ring-red-500" + : "border-slate-300 dark:border-slate-800 focus-visible:ring-primary" + }`} + /> +
+ {validationErrors.email && ( +

+ {validationErrors.email} +

+ )} +
+ ) : ( +
+ + + {user?.email || "Anonymous"} + +
+ )}
{/* Username */} @@ -167,11 +316,72 @@ export default function Profile() { -

- {profile?.username || "Not set"} -

+ {isEditing ? ( +
+ { + setEditUsername(e.target.value); + validateFields(e.target.value, editEmail); + }} + disabled={updating} + className={`bg-background border ${ + validationErrors.username + ? "border-red-500 focus-visible:ring-red-500" + : "border-slate-300 dark:border-slate-800 focus-visible:ring-primary" + }`} + /> + {validationErrors.username ? ( +

+ {validationErrors.username} +

+ ) : ( +

+ Alphanumeric, underscores, and hyphens, 3-30 characters. +

+ )} +
+ ) : ( +

+ {profile?.username || "Not set"} +

+ )} + {/* Edit Actions */} + {isEditing && ( +
+ + +
+ )} + + {/* Authentication Method */}