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:
1 parent
e0214ea0f8
commit
325eaa9a25
7 files changed
+455
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user