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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The FairPlay web frontend, built with Next.js App Router, TypeScript, Tailwind C
## What This Repository Contains

- A marketing homepage at `/`
- Authentication flows at `/login`, `/register`, and `/verify-email`
- Authentication flows at `/login`, `/register`, `/forgot-password`, `/reset-password`, and `/verify-email`
- Discovery and search experiences at `/explore`, `/subscriptions`, and `/search`
- Video playback pages at `/video/[id]`
- Public creator pages at `/channel/[username]`
Expand Down
20 changes: 20 additions & 0 deletions app/(auth)/forgot-password/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Metadata } from "next";

const title = "Forgot Password";
const description = "Request a secure password reset link for your FairPlay account.";

export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/forgot-password",
},
robots: {
index: false,
follow: false,
},
};

export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
return children;
}
108 changes: 108 additions & 0 deletions app/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"use client";

import Link from "next/link";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { MailCheck } from "lucide-react";
import { AuthPageShell } from "@/components/app/auth/auth-page-shell";
import { AuthTextField } from "@/components/app/auth/auth-text-field";
import BackgroundDecoration from "@/components/ui/background-decoration";
import { Button } from "@/components/ui/button";
import { useAuthSubmit } from "@/hooks/use-auth-submit";
import { requestPasswordReset } from "@/lib/auth/api";
import {
forgotPasswordFormSchema,
type ForgotPasswordFormValues,
} from "@/lib/auth/forms";

function ForgotPasswordSuccess({ email }: { email: string }) {
return (
<div className="relative min-h-screen w-full overflow-hidden bg-background">
<BackgroundDecoration />

<main className="relative z-10 flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full max-w-md rounded-2xl border bg-background/80 p-8 shadow-lg backdrop-blur-sm">
<div className="flex flex-col items-center gap-4 text-center">
<MailCheck className="size-10 text-primary" />
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-foreground">Check your inbox</h1>
<p className="text-sm text-muted-foreground">
If an eligible account exists for{" "}
<span className="font-medium text-foreground">{email}</span>, we sent a one-time
password reset link.
</p>
</div>
</div>

<div className="mt-6 flex flex-col gap-3">
<Button asChild className="w-full">
<Link href="/login">Back to login</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/forgot-password">Send another link</Link>
</Button>
</div>
</div>
</main>
</div>
);
}

export default function ForgotPasswordPage() {
const params = useSearchParams();
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null);

const submitForgotPassword = useCallback(async (values: ForgotPasswordFormValues) => {
await requestPasswordReset({ email: values.email });
setSubmittedEmail(values.email);
}, []);

const { onSubmit, error, isSubmitting, clearError } = useAuthSubmit<ForgotPasswordFormValues>(
submitForgotPassword,
"Unable to send a reset link right now. Please try again.",
);

const form = useForm<ForgotPasswordFormValues>({
resolver: zodResolver(forgotPasswordFormSchema),
shouldFocusError: true,
defaultValues: {
email: params.get("email") ?? "",
},
});

if (submittedEmail) {
return <ForgotPasswordSuccess email={submittedEmail} />;
}

return (
<AuthPageShell
title="Forgot Password"
switchHref="/login"
switchLabel="Login"
formId="forgot-password-form"
onSubmit={form.handleSubmit(onSubmit)}
submitLabel="Send reset link"
submitPendingLabel="Sending..."
isSubmitting={isSubmitting}
error={error}
errorId="forgot-password-form-error"
>
<p className="text-sm text-muted-foreground">
Enter the email linked to your account and we&apos;ll send you a secure one-time reset
link.
</p>
<AuthTextField
id="email"
label="Email"
type="email"
placeholder="[email protected]"
autoComplete="email"
autoFocus
registration={form.register("email", { onChange: clearError })}
error={form.formState.errors.email?.message}
/>
</AuthPageShell>
);
}
23 changes: 22 additions & 1 deletion app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import Link from "next/link";
import { useCallback } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useRouter, useSearchParams } from "next/navigation";
Expand Down Expand Up @@ -46,7 +47,9 @@ export default function LoginPage() {
defaultValue: "",
});
const isUnverifiedError = error?.includes("Please verify your email address before logging in.");
const emailForResend = identifierValue.includes("@") ? identifierValue : undefined;
const normalizedIdentifier = identifierValue.trim();
const emailForResend = normalizedIdentifier.includes("@") ? normalizedIdentifier : undefined;
const resetSuccess = params.get("reset") === "success";

return (
<AuthPageShell
Expand Down Expand Up @@ -79,6 +82,24 @@ export default function LoginPage() {
registration={form.register("password", { onChange: clearError })}
error={form.formState.errors.password?.message}
/>
<div className="flex justify-end">
<Link
href={
emailForResend
? `/forgot-password?email=${encodeURIComponent(emailForResend)}`
: "/forgot-password"
}
className="text-sm text-muted-foreground underline underline-offset-2 hover:text-primary"
>
Forgot your password?
</Link>
</div>

{resetSuccess && (
<p className="text-sm text-muted-foreground">
Your password was reset. You can now sign in with your new password.
</p>
)}

{isUnverifiedError && (
<p className="text-sm text-destructive">
Expand Down
20 changes: 20 additions & 0 deletions app/(auth)/reset-password/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Metadata } from "next";

const title = "Reset Password";
const description = "Choose a new password for your FairPlay account.";

export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/reset-password",
},
robots: {
index: false,
follow: false,
},
};

export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) {
return children;
}
183 changes: 183 additions & 0 deletions app/(auth)/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"use client";

import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { MailCheck, ShieldAlert } from "lucide-react";
import { AuthPageShell } from "@/components/app/auth/auth-page-shell";
import { AuthPasswordField } from "@/components/app/auth/auth-password-field";
import { PasswordCriteria } from "@/components/app/auth/password-criteria";
import BackgroundDecoration from "@/components/ui/background-decoration";
import { Button } from "@/components/ui/button";
import { useAuthSubmit } from "@/hooks/use-auth-submit";
import { resetPassword as resetPasswordRequest } from "@/lib/auth/api";
import {
resetPasswordFormSchema,
type ResetPasswordFormValues,
} from "@/lib/auth/forms";
import { useAuth } from "@/context/auth-context";

function AuthStatusCard({
icon,
title,
description,
primaryHref,
primaryLabel,
secondaryHref,
secondaryLabel,
}: {
icon: React.ReactNode;
title: string;
description: string;
primaryHref: string;
primaryLabel: string;
secondaryHref?: string;
secondaryLabel?: string;
}) {
return (
<div className="relative min-h-screen w-full overflow-hidden bg-background">
<BackgroundDecoration />

<main className="relative z-10 flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full max-w-md rounded-2xl border bg-background/80 p-8 shadow-lg backdrop-blur-sm">
<div className="flex flex-col items-center gap-4 text-center">
{icon}
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-foreground">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>

<div className="mt-6 flex flex-col gap-3">
<Button asChild className="w-full">
<Link href={primaryHref}>{primaryLabel}</Link>
</Button>
{secondaryHref && secondaryLabel ? (
<Button asChild variant="outline" className="w-full">
<Link href={secondaryHref}>{secondaryLabel}</Link>
</Button>
) : null}
</div>
</div>
</main>
</div>
);
}

export default function ResetPasswordPage() {
const params = useSearchParams();
const router = useRouter();
const { refetchUser } = useAuth();
const token = useMemo(() => params.get("token")?.trim() ?? "", [params]);
const [status, setStatus] = useState<"form" | "success">("form");

const submitResetPassword = useCallback(
async (values: ResetPasswordFormValues) => {
if (!token) {
throw new Error("The password reset link is invalid or incomplete.");
}

await resetPasswordRequest({
token,
password: values.password,
});

await refetchUser();
setStatus("success");
},
[refetchUser, token],
);

const { onSubmit, error, isSubmitting, clearError } = useAuthSubmit<ResetPasswordFormValues>(
submitResetPassword,
"Unable to reset your password right now. Please try again.",
);

const form = useForm<ResetPasswordFormValues>({
resolver: zodResolver(resetPasswordFormSchema),
shouldFocusError: true,
defaultValues: {
password: "",
confirmPassword: "",
},
});

const passwordValue = useWatch({ control: form.control, name: "password", defaultValue: "" });
const isInvalidLink =
!token ||
error === "Invalid or expired password reset link." ||
error === "The password reset link is invalid or incomplete.";

useEffect(() => {
if (status !== "success") return undefined;

const timeout = setTimeout(() => {
router.replace("/login?reset=success");
}, 2500);

return () => clearTimeout(timeout);
}, [router, status]);

if (status === "success") {
return (
<AuthStatusCard
icon={<MailCheck className="size-10 text-primary" />}
title="Password updated"
description="Your password has been changed and any active sessions were signed out. Redirecting you to login now..."
primaryHref="/login?reset=success"
primaryLabel="Go to login"
/>
);
}

if (isInvalidLink) {
return (
<AuthStatusCard
icon={<ShieldAlert className="size-10 text-destructive" />}
title="Reset link invalid"
description="This password reset link is missing, expired, or has already been used. Request a fresh link to continue."
primaryHref="/forgot-password"
primaryLabel="Request a new link"
secondaryHref="/login"
secondaryLabel="Back to login"
/>
);
}

return (
<AuthPageShell
title="Reset Password"
switchHref="/login"
switchLabel="Login"
formId="reset-password-form"
onSubmit={form.handleSubmit(onSubmit)}
submitLabel="Update password"
submitPendingLabel="Updating..."
isSubmitting={isSubmitting}
error={error}
errorId="reset-password-form-error"
>
<AuthPasswordField
id="password"
label="New password"
placeholder="New password"
autoComplete="new-password"
autoFocus
registration={form.register("password", { onChange: clearError })}
error={form.formState.errors.password?.message}
helper={<PasswordCriteria password={passwordValue} />}
helperId="reset-password-criteria"
/>
<AuthPasswordField
id="confirmPassword"
label="Confirm new password"
placeholder="Confirm new password"
autoComplete="new-password"
registration={form.register("confirmPassword", { onChange: clearError })}
error={form.formState.errors.confirmPassword?.message}
/>
</AuthPageShell>
);
}
Loading
Loading