diff --git a/frontend/hooks/use-login.ts b/frontend/hooks/use-login.ts index 99d91ab0..fd023109 100644 --- a/frontend/hooks/use-login.ts +++ b/frontend/hooks/use-login.ts @@ -3,41 +3,57 @@ import { useState } from "react"; import { useAuthStore } from "@/lib/store/authStore"; import { toast } from "sonner"; +// Shape of the data submitted by the login form type LoginBody = { email: string; password: string; rememberMe: boolean; }; +// Custom hook that encapsulates the login flow: calling the auth store, +// showing toast feedback, tracking loading state, and redirecting afterwards export function useLogin() { + // Router for client-side navigation after a successful login const router = useRouter(); + // Reads the URL query string (e.g. /login?redirect=/settings) const searchParams = useSearchParams(); + // Select only the `login` action from the store, so this component + // doesn't re-render when unrelated auth state changes const login = useAuthStore((s) => s.login); + // True while the login request is in flight; useful for disabling the submit button const [loading, setLoading] = useState(false); const handleLogin = async (payload: LoginBody) => { setLoading(true); try { + // Only email and password are sent; `rememberMe` is not passed to the store await login({ email: payload.email, password: payload.password }); toast.success("Login successful"); + // If the user was sent here from a protected page, return them there const redirect = searchParams.get("redirect"); if (redirect) { router.push(redirect); } else { + // Otherwise go to the default landing page router.push("/dashboard"); } } catch (err) { + // Show the error's message if there is one, otherwise a generic fallback const msg = err instanceof Error ? err.message : "Something went wrong. Please try again."; toast.error(msg); } finally { + // Always clear the loading state, whether login succeeded or failed setLoading(false); } }; // ...compat.extends("next/core-web-vitals", "next/typescript"), + // (Stray comment: this looks like a leftover ESLint config line and has nothing to do with this hook) + + // Expose the handler (renamed to `login`) and the loading flag to the component return { login: handleLogin, loading }; -} +} \ No newline at end of file diff --git a/frontend/lib/hooks/useAuthRedirect.ts b/frontend/lib/hooks/useAuthRedirect.ts index 1085aba3..54d5f5fb 100644 --- a/frontend/lib/hooks/useAuthRedirect.ts +++ b/frontend/lib/hooks/useAuthRedirect.ts @@ -4,21 +4,36 @@ import { useEffect, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuthState } from '../store/authStore'; +// Options a page passes in to describe its access rules interface AuthRedirectOptions { + // If true, only authenticated users may view the page requireAuth?: boolean; + // If set, the user must also have this role requiredRole?: "admin" | "user"; + // Where unauthenticated users are sent (defaults to the login page) redirectTo?: string; + // If set, already-authenticated users are sent here (e.g. away from /login) redirectIfAuthenticated?: string; } +// What the hook gives back to the calling component interface AuthRedirectReturn { + // True while the auth state is still being resolved (e.g. checking a stored token) isLoading: boolean; isAuthenticated: boolean; + // Typed as `any`, so callers get no type safety on the user object user: any; + // Whether the current user is allowed to see the page; components can use this + // to avoid rendering protected content while a redirect is in progress canAccess: boolean; } +// Hook that protects a page: it computes whether the user may access it and +// redirects them when they may not. Usage: +// useAuthRedirect({ requireAuth: true, requiredRole: 'admin' }) +// useAuthRedirect({ redirectIfAuthenticated: '/dashboard' }) // on the login page export const useAuthRedirect = (options: AuthRedirectOptions = {}): AuthRedirectReturn => { + // Pull out the options and apply defaults const { requireAuth = false, requiredRole, @@ -26,11 +41,15 @@ export const useAuthRedirect = (options: AuthRedirectOptions = {}): AuthRedirect redirectIfAuthenticated } = options; + // Router for client-side navigation const router = useRouter(); + // Reads the URL query string (used for the ?redirect= parameter) const searchParams = useSearchParams(); + // Current auth state from the store const { user, isAuthenticated, isLoading } = useAuthState(); // Compute access permissions + // Memoized so it's only recalculated when the relevant inputs change const canAccess = useMemo(() => { // If page doesn't require auth, anyone can access if (!requireAuth) return true; @@ -44,15 +63,21 @@ export const useAuthRedirect = (options: AuthRedirectOptions = {}): AuthRedirect return true; }, [requireAuth, isAuthenticated, requiredRole, user?.role]); + // Side effect: perform the actual redirects. Runs whenever auth state or options change useEffect(() => { // Don't redirect while loading + // Otherwise a logged-in user would be briefly bounced to the login page + // before their stored session has been checked if (isLoading) return; // Current path for redirect after login + // Uses window.location so the path is read at redirect time, outside React state const currentPath = window.location.pathname; // Redirect authenticated users away from certain pages (like login) if (isAuthenticated && redirectIfAuthenticated) { + // Honor ?redirect= if present (e.g. returning to the page they came from), + // otherwise use the configured default const redirectPath = searchParams.get('redirect') || redirectIfAuthenticated; router.push(redirectPath); return; @@ -60,17 +85,20 @@ export const useAuthRedirect = (options: AuthRedirectOptions = {}): AuthRedirect // Redirect unauthenticated users to login if (requireAuth && !isAuthenticated) { + // Pass the current path along so the login flow can send them back after signing in const loginUrl = `${redirectTo}?redirect=${encodeURIComponent(currentPath)}`; router.push(loginUrl); return; } // Redirect users without required role to dashboard + // Authenticated but not authorized, so send them somewhere safe instead of the login page if (isAuthenticated && requiredRole && user?.role !== requiredRole) { router.push('/dashboard'); return; } }, [ + // Effect re-runs if any of these change isLoading, isAuthenticated, requireAuth, @@ -82,10 +110,11 @@ export const useAuthRedirect = (options: AuthRedirectOptions = {}): AuthRedirect searchParams ]); + // Expose the state so components can show loaders or hide protected content return { isLoading, isAuthenticated, user, canAccess }; -}; +}; \ No newline at end of file diff --git a/frontend/lib/schemas/auth.test.ts b/frontend/lib/schemas/auth.test.ts index f6bbb6de..f08f9317 100644 --- a/frontend/lib/schemas/auth.test.ts +++ b/frontend/lib/schemas/auth.test.ts @@ -8,8 +8,11 @@ import { strongPasswordSchema, } from "./auth"; +// Tests for the strong-password rule. It is meant to mirror the backend's +// @StrongPassword validator, so the client and server accept the same passwords describe("strongPasswordSchema (FE-05)", () => { it("matches the backend @StrongPassword rule", () => { + // Has uppercase, lowercase, a number, a special character, and 8+ characters expect(strongPasswordSchema.safeParse("Sup3r$ecret!").success).toBe(true); }); @@ -23,27 +26,38 @@ describe("strongPasswordSchema (FE-05)", () => { "Sup3rSecret", // no special character ]; + // Check every entry except index 3 (the valid one): slice(0, 3) takes the + // first three, and slice(4) takes the rest. Each of these must fail validation for (const password of rejected.slice(0, 3).concat(rejected.slice(4))) { expect(strongPasswordSchema.safeParse(password).success).toBe(false); } + // The entry at index 3 is actually a valid password, so it is asserted + // separately as a success expect(strongPasswordSchema.safeParse(rejected[3]).success).toBe(true); }); it("states the minimum length it enforces", () => { + // The exported constant should match the backend's minimum expect(PASSWORD_MIN_LENGTH).toBe(8); + // A too-short password should fail... const result = strongPasswordSchema.safeParse("Ab1!"); expect(result.success).toBe(false); if (!result.success) { + // ...and the first error message should tell the user the minimum length expect(result.error.issues[0]?.message).toContain("8 characters"); } }); }); +// Tests for the email length limit inside the personal info form schema describe("personalInfoSchema email bound", () => { it("accepts an address up to the backend limit", () => { + // Pad the local part so the whole address is exactly EMAIL_MAX_LENGTH + // characters long (the boundary case) const localPart = "a".repeat(EMAIL_MAX_LENGTH - "@example.com".length); const email = `${localPart}@example.com`; + // Sanity check that the test data really sits on the boundary expect(email.length).toBe(EMAIL_MAX_LENGTH); expect( @@ -56,6 +70,8 @@ describe("personalInfoSchema email bound", () => { }); it("rejects an address longer than the backend allows", () => { + // The local part alone is EMAIL_MAX_LENGTH long, so the full address + // is well over the limit const email = `${"a".repeat(EMAIL_MAX_LENGTH)}@example.com`; expect( @@ -68,7 +84,10 @@ describe("personalInfoSchema email bound", () => { }); }); +// Tests for the account setup step (user type, organization, password, terms) describe("accountSetupSchema", () => { + // A known-good payload reused by the tests below; each test overrides + // only the field it cares about. `as const` keeps "member" as a literal type const valid = { userType: "member" as const, organizationName: "Oraculum", @@ -84,11 +103,14 @@ describe("accountSetupSchema", () => { it("reports a password mismatch on the confirmation field", () => { const result = accountSetupSchema.safeParse({ ...valid, + // A strong password, but different from `password` confirmPassword: "Different1!", }); expect(result.success).toBe(false); if (!result.success) { + // The error should be attached to confirmPassword so the form can show + // it under that input rather than as a general error expect(result.error.issues[0]?.path).toEqual(["confirmPassword"]); } }); @@ -100,13 +122,16 @@ describe("accountSetupSchema", () => { }); }); +// Tests for the forgot-password form describe("forgotPasswordSchema", () => { it("uses the same email bound as the backend", () => { + // A normal, valid email passes expect(forgotPasswordSchema.safeParse({ email: "ada@example.com" }).success).toBe( true ); + // A malformed email fails expect(forgotPasswordSchema.safeParse({ email: "not-an-email" }).success).toBe( false ); }); -}); +}); \ No newline at end of file