Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion frontend/hooks/use-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
31 changes: 30 additions & 1 deletion frontend/lib/hooks/useAuthRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,52 @@ 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,
redirectTo = '/auth/login',
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;
Expand All @@ -44,33 +63,42 @@ 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;
}

// 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,
Expand All @@ -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
};
};
};
27 changes: 26 additions & 1 deletion frontend/lib/schemas/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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",
Expand All @@ -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"]);
}
});
Expand All @@ -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
);
});
});
});
Loading