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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Base URL of the MyFans NestJS backend
# Development default: http://localhost:3000
# Must be prefixed with NEXT_PUBLIC_ so it is inlined into the browser bundle.
NEXT_PUBLIC_API_URL=http://localhost:3000
27 changes: 12 additions & 15 deletions src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import { api } from "@/lib/api/client";
import { login, extractApiErrorMessage } from "@/lib/api/auth";
import { ApiError } from "@/lib/api/errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Expand All @@ -16,31 +17,29 @@ import {
FormMessage,
} from "@/components/ui/form";
import { loginSchema, type LoginFormValues } from "@/lib/validations/auth";
import type { AuthResponse } from "@/types/api";

export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect") || "/dashboard";
const [serverError, setServerError] = useState<string | null>(null);

// useForm wires up RHF with our Zod schema as the validator.
// zodResolver translates Zod errors into the format RHF expects.
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});

// This function only runs when ALL fields pass Zod validation.
async function onSubmit(values: LoginFormValues) {
setServerError(null);
try {
const response = await api.post<AuthResponse>("/auth/login", values);
api.setToken(response.access_token);
api.setCurrentUser(response.user);
await login(values);
router.replace(redirectTo);
} catch {
setServerError("Login failed. Check your credentials and try again.");
} catch (error) {
if (error instanceof ApiError && error.statusCode === 401) {
setServerError("Invalid email or password.");
} else {
setServerError(extractApiErrorMessage(error));
}
}
}

Expand All @@ -49,21 +48,17 @@ export function LoginForm() {
<div className="w-full max-w-md space-y-4 rounded-lg border p-6">
<h1 className="text-2xl font-semibold">Log in</h1>

{/* Form spreads the RHF context so all FormField children can access it */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">

<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
{/* FormControl merges id + aria-invalid onto the Input */}
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
{/* Renders the Zod error message, or nothing when valid */}
<FormMessage />
</FormItem>
)}
Expand All @@ -84,7 +79,9 @@ export function LoginForm() {
/>

{serverError ? (
<p className="text-sm text-destructive">{serverError}</p>
<p role="alert" className="text-sm text-destructive">
{serverError}
</p>
) : null}

<Button
Expand Down
24 changes: 12 additions & 12 deletions src/app/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { signup, extractApiErrorMessage } from "@/lib/api/auth";
import { ApiError } from "@/lib/api/errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Expand All @@ -16,7 +17,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { signupSchema, type SignupFormValues } from "@/lib/validations/auth";
import type { AuthResponse } from "@/types/api";

export function SignupForm() {
const router = useRouter();
Expand All @@ -30,14 +30,14 @@ export function SignupForm() {
async function onSubmit(values: SignupFormValues) {
setServerError(null);
try {
const response = await api.post<AuthResponse>("/auth/signup", {
email: values.email,
password: values.password,
});
api.setToken(response.access_token);
await signup({ email: values.email, password: values.password });
router.replace("/dashboard");
} catch {
setServerError("Sign up failed. This email may already be in use.");
} catch (error) {
if (error instanceof ApiError && error.statusCode === 409) {
setServerError("An account with this email already exists.");
} else {
setServerError(extractApiErrorMessage(error));
}
}
}

Expand All @@ -48,7 +48,6 @@ export function SignupForm() {

<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">

<FormField
control={form.control}
name="email"
Expand Down Expand Up @@ -86,14 +85,15 @@ export function SignupForm() {
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
{/* This message comes from the .refine() cross-field check */}
<FormMessage />
</FormItem>
)}
/>

{serverError ? (
<p className="text-sm text-destructive">{serverError}</p>
<p role="alert" className="text-sm text-destructive">
{serverError}
</p>
) : null}

<Button
Expand Down
46 changes: 46 additions & 0 deletions src/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { api } from "./client";
import { ApiError, NetworkError } from "./errors";
import type { AuthResponse } from "@/types/api";
import type { LoginFormValues, SignupFormValues } from "@/lib/validations/auth";

export async function login(credentials: LoginFormValues): Promise<AuthResponse> {
const response = await api.post<AuthResponse>("/auth/login", credentials);
api.setToken(response.access_token);
api.setCurrentUser(response.user);
return response;
}

export async function signup(
values: Pick<SignupFormValues, "email" | "password">
): Promise<AuthResponse> {
const response = await api.post<AuthResponse>("/auth/signup", values);
api.setToken(response.access_token);
api.setCurrentUser(response.user);
return response;
}

export function logout(): void {
api.clearToken();
api.clearCurrentUser();
}

/**
* Extracts a human-readable message from an API or network error.
* NestJS validation errors arrive as message arrays; credential/conflict
* errors arrive as a single string.
*/
export function extractApiErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
const data = error.data as { message?: string | string[] } | null;
if (data?.message) {
return Array.isArray(data.message)
? data.message.join(". ")
: data.message;
}
return error.message;
}
if (error instanceof NetworkError) {
return "Could not reach the server. Check your connection and try again.";
}
return "An unexpected error occurred. Please try again.";
}
Loading