feat(01-02): auth layout and Clerk-rendered auth pages

- (auth)/layout.tsx: centered 448px container, items-center, bg-background
- sign-up/[[...sign-up]]/page.tsx: Clerk <SignUp> with orange-500 appearance, redirects to /onboarding/pet
- log-in/[[...sign-in]]/page.tsx: Clerk <SignIn> with orange-500 appearance, redirects to /pets
- forgot-password/page.tsx: two-step Clerk 7 reset flow (create identifier + resetPasswordEmailCode.sendCode)
- reset-password/page.tsx: Clerk 7 resetPasswordEmailCode.verifyCode + submitPassword + finalize
- verify-email/page.tsx: static server component + ResendEmailButton client island
- Deviation: Clerk 7 SignInFutureResource API used (no isLoaded/setActive/attemptFirstFactor)
This commit is contained in:
admin committed 2026-06-06 08:52:23 +02:00
1 parent e0214ea0f8
commit 325eaa9a25
7 files changed
+455

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useSignIn } from "@clerk/nextjs";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Forgot password page — /forgot-password
*
* Initiates Clerk's built-in password reset flow using the Clerk 7 API:
* 1. signIn.create({ identifier: email }) — sets the identifier context
* 2. signIn.resetPasswordEmailCode.sendCode() — sends the email code
*
* On success, redirects to /reset-password where the user enters the code + new password.
*
* UI-SPEC: Auth Screens — Forgot Password
* Heading: "Reset your password"
* CTA: "Send reset link"
* Confirmation: "Check your email — we sent a reset link to {email}"
*
* Deviation note: Clerk 7 removed the old useSignIn().isLoaded / signIn.create({ strategy:
* 'reset_password_email_code' }) approach. The new SignInFutureResource separates identifier
* setting (create) from strategy invocation (resetPasswordEmailCode.sendCode).
*/
export default function ForgotPasswordPage() {
// Clerk 7: useSignIn returns { errors, fetchStatus, signIn: SignInFutureResource }
const { signIn, fetchStatus } = useSignIn();
const router = useRouter();
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const isPending = fetchStatus === "fetching";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccessMessage(null);
try {
// Step 1: set the identifier context
const createResult = await signIn.create({ identifier: email });
if (createResult.error) {
setError(createResult.error.message ?? "Something went wrong.");
return;
}
// Step 2: trigger the password reset email
const sendResult = await signIn.resetPasswordEmailCode.sendCode();
if (sendResult.error) {
setError(sendResult.error.message ?? "Could not send reset email.");
return;
}
setSuccessMessage(
`Check your email — we sent a reset link to ${email}`
);
// Give the user a moment to read the message, then redirect
setTimeout(() => router.push("/reset-password"), 1500);
} catch (err: unknown) {
const clerkError = err as { errors?: Array<{ message: string }> };
setError(
clerkError.errors?.[0]?.message ??
"Something went wrong. Please try again."
);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-xl font-semibold">
Reset your password
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="you@example.com"
/>
</div>
{error && (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
)}
{successMessage && (
<p className="text-xs text-muted-foreground" role="status">
{successMessage}
</p>
)}
<Button
type="submit"
className="w-full bg-orange-500 hover:bg-orange-600 text-white"
disabled={isPending}
>
{isPending ? "Sending…" : "Send reset link"}
</Button>
</form>
</CardContent>
</Card>
);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Auth layout — centers all auth pages in a 448px card.
*
* No sidebar or navigation — auth pages are standalone experiences.
* Applied to all routes under /(auth)/.
*/
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-full mx-auto max-w-[448px] px-4">{children}</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { SignIn } from "@clerk/nextjs";
/**
* Log-in page — /log-in
*
* Clerk renders the full sign-in form (email, password, OAuth options).
* Appearance is overridden to match the OnlyPets orange-500 design system.
* After successful login, owner is redirected to /pets.
*
* UI-SPEC: Auth Screens — Log In
* CTA copy: "Log in" (Clerk default)
*/
export default function LogInPage() {
return (
<SignIn
path="/log-in"
signUpUrl="/sign-up"
forceRedirectUrl="/pets"
appearance={{
layout: {
logoPlacement: "none",
},
variables: {
colorPrimary: "#F97316",
colorBackground: "#FAFAFA",
colorText: "#09090B",
},
elements: {
formButtonPrimary:
"bg-orange-500 hover:bg-orange-600 text-white text-sm",
},
}}
/>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { useSignIn } from "@clerk/nextjs";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Reset password page — /reset-password
*
* Completes Clerk's password reset flow using the Clerk 7 SignInFutureResource API:
* 1. signIn.resetPasswordEmailCode.verifyCode({ code }) — verify the emailed code
* 2. signIn.resetPasswordEmailCode.submitPassword({ password }) — set new password
* 3. signIn.finalize() — create the session and redirect
*
* The reset code comes from the Clerk-generated email link URL params.
*
* UI-SPEC: Auth Screens — Reset Password
* Heading: "Set a new password"
* CTA: "Reset password"
* Error states:
* - "Password must be at least 8 characters."
* - "Passwords don't match."
*
* Deviation note: Clerk 7 uses the new SignInFutureResource API. The old
* useSignIn().attemptFirstFactor() is replaced by resetPasswordEmailCode.verifyCode()
* + resetPasswordEmailCode.submitPassword() + finalize().
*/
export default function ResetPasswordPage() {
// Clerk 7: useSignIn returns { errors, fetchStatus, signIn: SignInFutureResource }
const { signIn, fetchStatus } = useSignIn();
const router = useRouter();
const searchParams = useSearchParams();
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const isPending = fetchStatus === "fetching";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Client-side validation per UI-SPEC Error States
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirmPassword) {
setError("Passwords don't match.");
return;
}
try {
// Clerk appends the code to the URL when the user clicks the email reset link
const code =
searchParams.get("__clerk_ticket") ??
searchParams.get("code") ??
"";
// Step 1: verify the reset code from the email
const verifyResult = await signIn.resetPasswordEmailCode.verifyCode({ code });
if (verifyResult.error) {
setError(
verifyResult.error.message ??
"Invalid or expired reset code. Please request a new reset link."
);
return;
}
// Step 2: submit the new password
const submitResult = await signIn.resetPasswordEmailCode.submitPassword({
password,
});
if (submitResult.error) {
setError(
submitResult.error.message ??
"Could not set new password. Please try again."
);
return;
}
// Step 3: finalize the session
const finalizeResult = await signIn.finalize();
if (finalizeResult.error) {
setError(
finalizeResult.error.message ??
"Password reset failed. Please log in with your new password."
);
return;
}
router.push("/pets");
} catch (err: unknown) {
const clerkError = err as { errors?: Array<{ message: string }> };
setError(
clerkError.errors?.[0]?.message ??
"Something went wrong. Please try again."
);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-xl font-semibold">
Set a new password
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
)}
<Button
type="submit"
className="w-full bg-orange-500 hover:bg-orange-600 text-white"
disabled={isPending}
>
{isPending ? "Resetting…" : "Reset password"}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,35 @@
import { SignUp } from "@clerk/nextjs";
/**
* Sign-up page — /sign-up
*
* Clerk renders the full sign-up form (email, password, email verification).
* Appearance is overridden to match the OnlyPets orange-500 design system.
* After successful sign-up + verification, owner is redirected to /onboarding/pet.
*
* UI-SPEC: Auth Screens — Sign Up
* CTA copy: "Sign Up" (controlled by Clerk's built-in copy)
*/
export default function SignUpPage() {
return (
<SignUp
path="/sign-up"
signInUrl="/log-in"
forceRedirectUrl="/onboarding/pet"
appearance={{
layout: {
logoPlacement: "none",
},
variables: {
colorPrimary: "#F97316",
colorBackground: "#FAFAFA",
colorText: "#09090B",
},
elements: {
formButtonPrimary:
"bg-orange-500 hover:bg-orange-600 text-white text-sm",
},
}}
/>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { useSignUp } from "@clerk/nextjs";
import { Button } from "@/components/ui/button";
/**
* Client component island — resend email verification link.
*
* Uses the Clerk 7 SignUpFutureResource API:
* signUp.verifications.sendEmailCode() — resends the verification email
*
* Isolated so the parent verify-email page can remain a server component.
*
* Deviation note: Clerk 7 replaced the old signUp.prepareEmailAddressVerification()
* with signUp.verifications.sendEmailCode() on the SignUpFutureResource.
*/
export function ResendEmailButton() {
// Clerk 7: useSignUp returns { errors, fetchStatus, signUp: SignUpFutureResource }
const { signUp, fetchStatus } = useSignUp();
const [sent, setSent] = useState(false);
const [error, setError] = useState(false);
const isPending = fetchStatus === "fetching";
const handleResend = async () => {
setError(false);
try {
const result = await signUp.verifications.sendEmailCode();
if (result.error) {
setError(true);
} else {
setSent(true);
}
} catch {
setError(true);
}
};
return (
<div className="flex flex-col items-center gap-2">
<Button
variant="ghost"
className="text-sm text-muted-foreground hover:text-foreground"
onClick={handleResend}
disabled={isPending || sent}
>
{isPending ? "Sending…" : sent ? "Email sent!" : "Resend email"}
</Button>
{error && (
<p className="text-xs text-destructive">
Could not resend. Please try again.
</p>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ResendEmailButton } from "./ResendEmailButton";
/**
* Verify email page — /verify-email
*
* Static server component shown after sign-up while email verification is pending.
* The "Resend email" button is a client component island (ResendEmailButton).
*
* UI-SPEC: Auth Screens — Email Verification Pending
* Heading: "Verify your email"
* Body: "We sent a verification link to {email}. Check your inbox to continue."
* Link: "Resend email"
*/
export default function VerifyEmailPage() {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl font-semibold">
Verify your email
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-sm text-muted-foreground">
We sent a verification link to your email address. Check your inbox to
continue.
</p>
<ResendEmailButton />
</CardContent>
</Card>
);
}