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
106 changes: 106 additions & 0 deletions apps/web/src/app/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import Link from "next/link";
import { CheckCircle } from "lucide-react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { api } from "@/lib/api";

const schema = z.object({
email: z.string().email("Enter a valid email address"),
});

type Fields = z.infer<typeof schema>;

export default function ForgotPasswordPage() {
const [sent, setSent] = useState(false);

const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<Fields>({ resolver: zodResolver(schema) });

const onSubmit = async (data: Fields) => {
try {
await api.post("/auth/forgot-password", data);
} catch {
// Always show the safe generic message regardless of outcome —
// do not reveal whether the email is registered.
} finally {
setSent(true);
}
};

if (sent) {
return (
<div className="text-center pt-4">
<div className="flex justify-center mb-5">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--color-success)]/15">
<CheckCircle
className="h-7 w-7 text-[var(--color-success)]"
aria-hidden="true"
/>
</div>
</div>
<h1 className="font-cabinet font-bold text-2xl text-[var(--color-espresso)] mb-2">
Check your email
</h1>
<p className="text-sm text-muted-foreground font-cabinet mb-8 leading-relaxed">
If an account exists for that email, we&apos;ll send reset
instructions shortly.
</p>
<Link
href="/login"
className="text-sm font-cabinet font-medium text-[var(--color-saffron)] hover:text-[var(--color-saffron-dark)] transition-colors"
>
Back to sign in
</Link>
</div>
);
}

return (
<div>
<h1 className="font-cabinet font-bold text-2xl text-[var(--color-espresso)] mb-1">
Forgot your password?
</h1>
<p className="text-sm text-muted-foreground font-cabinet mb-8 leading-relaxed">
Enter your email and we&apos;ll send instructions to reset it.
</p>

<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-5"
>
<Input
label="Email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
error={errors.email?.message}
{...register("email")}
/>

<Button type="submit" fullWidth loading={isSubmitting} size="lg">
Send reset instructions
</Button>
</form>

<p className="mt-6 text-center text-sm font-cabinet text-muted-foreground">
<Link
href="/login"
className="text-[var(--color-saffron)] hover:text-[var(--color-saffron-dark)] font-medium transition-colors"
>
Back to sign in
</Link>
</p>
</div>
);
}
29 changes: 29 additions & 0 deletions apps/web/src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Image from "next/image";
import Link from "next/link";

export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="shrink-0 px-6 py-6">
<Link href="/" aria-label="twizrr home">
<Image
src="/logo/wordmark.svg"
alt="twizrr"
width={100}
height={10}
priority
className="h-auto w-[92px]"
/>
</Link>
</header>

<main className="flex-1 flex flex-col items-center px-4 pb-16">
<div className="w-full max-w-[420px] pt-2 sm:pt-6">{children}</div>
</main>
</div>
);
}
135 changes: 135 additions & 0 deletions apps/web/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use client";

import { Suspense, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { api, type ApiError } from "@/lib/api";

const loginSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(1, "Password is required"),
});

type LoginFields = z.infer<typeof loginSchema>;

function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [showPassword, setShowPassword] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);

const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFields>({ resolver: zodResolver(loginSchema) });

const onSubmit = async (data: LoginFields) => {
setServerError(null);
try {
const res = await api.post<{ redirectTo: string }>("/auth/login", data);
const destination = searchParams.get("redirect") ?? res.redirectTo;
router.push(destination);
} catch (err) {
setServerError(
(err as ApiError).message ?? "Something went wrong. Please try again.",
);
}
};

return (
<div>
<h1 className="font-cabinet font-bold text-2xl text-[var(--color-espresso)] mb-1">
Welcome back
</h1>
<p className="text-sm text-muted-foreground font-cabinet mb-8">
Sign in to your twizrr account.
</p>

<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-5"
>
<Input
label="Email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
error={errors.email?.message}
{...register("email")}
/>

<Input
label="Password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="••••••••"
error={errors.password?.message}
rightIcon={
<button
type="button"
onClick={() => setShowPassword((p) => !p)}
aria-label={showPassword ? "Hide password" : "Show password"}
className="pointer-events-auto cursor-pointer p-1 -m-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
}
{...register("password")}
/>

<div className="flex justify-end -mt-2">
<Link
href="/forgot-password"
className="text-sm font-cabinet text-[var(--color-saffron)] hover:text-[var(--color-saffron-dark)] transition-colors"
>
Forgot password?
</Link>
</div>

{serverError && (
<p
role="alert"
className="text-sm text-[var(--color-error)] font-cabinet rounded-md bg-[var(--color-error)]/10 px-3 py-2"
>
{serverError}
</p>
)}

<Button type="submit" fullWidth loading={isSubmitting} size="lg">
Sign in
</Button>
</form>

<p className="mt-6 text-center text-sm font-cabinet text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="text-[var(--color-saffron)] hover:text-[var(--color-saffron-dark)] font-medium transition-colors"
>
Create one
</Link>
</p>
</div>
);
}

export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
Loading
Loading