From 4bd98ef06f9242f2557ab09a2825a99c99277f9c Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Thu, 2 Apr 2026 09:06:24 -0600 Subject: [PATCH] Improve auth flows --- web/app/appointments/page.js | 5 +- web/app/globals.css | 38 +++++++ web/app/login/page.js | 24 ++++- web/app/profile/page.js | 194 +++++++++++++++++++++++++++++++++-- web/app/register/page.js | 24 ++++- web/context/AuthContext.js | 54 ++++++---- 6 files changed, 300 insertions(+), 39 deletions(-) diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index bec8bd78..fac5a86f 100644 --- a/web/app/appointments/page.js +++ b/web/app/appointments/page.js @@ -226,10 +226,11 @@ function AppointmentsPage() { useEffect(() => { if (!authLoading && !user) { - router.push("/login"); + const target = preselectedPetId ? `/appointments?petId=${encodeURIComponent(preselectedPetId)}` : "/appointments"; + router.push(`/login?next=${encodeURIComponent(target)}`); } - }, [authLoading, user, router]); + }, [authLoading, user, router, preselectedPetId]); useEffect(() => { if (!token) { diff --git a/web/app/globals.css b/web/app/globals.css index da8ce03b..8571bfeb 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1076,6 +1076,13 @@ body { align-items: center; justify-content: center; margin-bottom: 0.25rem; + overflow: hidden; +} + +.profile-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; } .profile-name { @@ -1105,6 +1112,37 @@ body { padding-top: 1rem; } +.profile-update-form { + width: 100%; + display: grid; + gap: 0.9rem; +} + +.profile-update-title { + margin: 0.25rem 0 0; + font-size: 1.1rem; + color: #222; +} + +.profile-avatar-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; +} + +.profile-avatar-upload-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.65rem 1rem; + border-radius: 8px; + background: #fff3e0; + color: #a65c00; + font-weight: 600; + cursor: pointer; +} + .profile-field-row { display: flex; justify-content: space-between; diff --git a/web/app/login/page.js b/web/app/login/page.js index dcc78d02..2e7d1fe1 100644 --- a/web/app/login/page.js +++ b/web/app/login/page.js @@ -1,13 +1,25 @@ "use client"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; -export default function LoginPage() { +function resolveNextPath(candidate) { + if (!candidate || !candidate.startsWith("/")) { + return "/"; + } + if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) { + return "/"; + } + return candidate; +} + +function LoginPage() { const {login} = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -21,7 +33,7 @@ export default function LoginPage() { try { await login(username, password); - router.push("/"); + router.push(resolveNextPath(searchParams.get("next"))); } catch (err) { @@ -68,9 +80,13 @@ export default function LoginPage() {

Don't have an account?{" "} - Register here + Register here

); } + +export default dynamic(() => Promise.resolve(LoginPage), { + ssr: false, +}); diff --git a/web/app/profile/page.js b/web/app/profile/page.js index 6b28aa35..d0b78c5e 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -7,7 +7,7 @@ import { useAuth } from "@/context/AuthContext"; const API_BASE = ""; export default function ProfilePage() { - const {user, token, loading, logout} = useAuth(); + const {user, token, loading, logout, refreshUser} = useAuth(); const router = useRouter(); const [pets, setPets] = useState([]); @@ -19,14 +19,27 @@ export default function ProfilePage() { const [breed, setBreed] = useState(""); const [submitting, setSubmitting] = useState(false); const [petError, setPetError] = useState(null); + const [profileForm, setProfileForm] = useState({ fullName: "", email: "", phone: "" }); + const [profileSubmitting, setProfileSubmitting] = useState(false); + const [profileError, setProfileError] = useState(null); + const [profileSuccess, setProfileSuccess] = useState(null); + const [avatarSubmitting, setAvatarSubmitting] = useState(false); useEffect(() => { if (!loading && !user) { - router.replace("/login"); + router.replace(`/login?next=${encodeURIComponent("/profile")}`); } }, [user, loading, router]); + useEffect(() => { + setProfileForm({ + fullName: user?.fullName || "", + email: user?.email || "", + phone: user?.phone || "", + }); + }, [user]); + const loadPets = useCallback(() => { if (!token) return; setLoadingPets(true); @@ -50,6 +63,105 @@ export default function ProfilePage() { router.push("/"); } + async function handleProfileSubmit(e) { + e.preventDefault(); + setProfileSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(profileForm), + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || `Request failed (${res.status})`); + } + + await refreshUser(); + setProfileSuccess("Profile updated successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setProfileSubmitting(false); + } + } + + async function handleAvatarUpload(file) { + if (!file) { + return; + } + + const formData = new FormData(); + formData.append("avatar", file); + setAvatarSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || "Failed to upload avatar"); + } + + await refreshUser(); + setProfileSuccess(data?.message || "Avatar updated successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setAvatarSubmitting(false); + } + } + + async function handleAvatarDelete() { + setAvatarSubmitting(true); + setProfileError(null); + setProfileSuccess(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.message || "Failed to delete avatar"); + } + + await refreshUser(); + setProfileSuccess(data?.message || "Avatar removed successfully."); + } + + catch (err) { + setProfileError(err.message); + } + + finally { + setAvatarSubmitting(false); + } + } + function openAddForm() { setEditingPet(null); setPetName(""); @@ -170,7 +282,11 @@ export default function ProfilePage() {
- {(user.fullName || user.username).charAt(0).toUpperCase()} + {user.avatarUrl ? ( + {user.fullName + ) : ( + (user.fullName || user.username).charAt(0).toUpperCase() + )}

{user.fullName || user.username}

@@ -185,7 +301,65 @@ export default function ProfilePage() { ))} - + )} +
+ + + + @@ -194,7 +368,7 @@ export default function ProfilePage() {

My Pets

- +
{showForm && ( @@ -285,11 +459,11 @@ export default function ProfilePage() { {pet.breed && {pet.breed}}
- - -
- - ))} + + + + + ))} )} diff --git a/web/app/register/page.js b/web/app/register/page.js index eb9de480..17f49672 100644 --- a/web/app/register/page.js +++ b/web/app/register/page.js @@ -1,13 +1,25 @@ "use client"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; -export default function RegisterPage() { +function resolveNextPath(candidate) { + if (!candidate || !candidate.startsWith("/")) { + return "/"; + } + if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) { + return "/"; + } + return candidate; +} + +function RegisterPage() { const {register} = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const [form, setForm] = useState({ fullName: "", @@ -41,7 +53,7 @@ export default function RegisterPage() { phone: form.phone, password: form.password, }); - router.push("/"); + router.push(resolveNextPath(searchParams.get("next"))); } catch (err) { @@ -144,9 +156,13 @@ export default function RegisterPage() {

Already have an account?{" "} - Log in here + Log in here

); } + +export default dynamic(() => Promise.resolve(RegisterPage), { + ssr: false, +}); diff --git a/web/context/AuthContext.js b/web/context/AuthContext.js index 5a9b0d08..e861f624 100644 --- a/web/context/AuthContext.js +++ b/web/context/AuthContext.js @@ -19,6 +19,28 @@ export function AuthProvider({ children }) { const [token, setToken] = useState(null); const [loading, setLoading] = useState(true); + const refreshUser = useCallback(async (providedToken) => { + const activeToken = providedToken ?? token; + if (!activeToken) { + setUser(null); + return null; + } + + const userInfo = await fetchCurrentUser(activeToken); + if (!userInfo) { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + return null; + } + + if (!token) { + setToken(activeToken); + } + setUser(userInfo); + return userInfo; + }, [token]); + useEffect(() => { const stored = localStorage.getItem(TOKEN_KEY); if (!stored) { @@ -26,17 +48,14 @@ export function AuthProvider({ children }) { return; } - fetchCurrentUser(stored) - .then((data) => { - if (data) { - setToken(stored); - setUser(data); - } - - else { - localStorage.removeItem(TOKEN_KEY); - } - }).catch(() => localStorage.removeItem(TOKEN_KEY)).finally(() => setLoading(false));}, []); + refreshUser(stored) + .catch(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + }) + .finally(() => setLoading(false)); + }, [refreshUser]); const login = useCallback(async (username, password) => { const res = await fetch("/api/v1/auth/login", { @@ -55,12 +74,10 @@ export function AuthProvider({ children }) { localStorage.setItem(TOKEN_KEY, jwt); setToken(jwt); - const userInfo = await fetchCurrentUser(jwt); - - setUser(userInfo); + const userInfo = await refreshUser(jwt); return userInfo; - }, []); + }, [refreshUser]); const register = useCallback(async ({ username, password, email, fullName, phone }) => { const res = await fetch("/api/v1/auth/register", { @@ -79,11 +96,10 @@ export function AuthProvider({ children }) { localStorage.setItem(TOKEN_KEY, jwt); setToken(jwt); - const userInfo = await fetchCurrentUser(jwt); - setUser(userInfo); + const userInfo = await refreshUser(jwt); return userInfo; - }, []); + }, [refreshUser]); const logout = useCallback(() => { localStorage.removeItem(TOKEN_KEY); @@ -91,7 +107,7 @@ export function AuthProvider({ children }) { setUser(null);}, []); return ( - + {children} );