feat: implement email verification and password management features

This commit is contained in:
2025-09-04 16:21:51 -03:00
parent 4d5de1ba88
commit fa355da5f6
7 changed files with 864 additions and 17 deletions

View File

@@ -2,7 +2,7 @@ const MIN_PASSWORD_LENGTH = 8;
import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { account, authClient } from "@/lib/auth-client";
@@ -53,7 +53,7 @@ export default function SignInForm({
.string()
.min(
MIN_PASSWORD_LENGTH,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
),
}),
},
@@ -134,7 +134,17 @@ export default function SignInForm({
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<div className="mt-4 space-y-2 text-center">
<div className="flex items-center justify-between">
<Button asChild variant="link" className="px-0">
<Link to="/password" search={{ mode: "recover" }}>
Forgot password?
</Link>
</Button>
<Button asChild variant="link" className="px-0">
<Link to="/verify-email">Verify your email</Link>
</Button>
</div>
<Button
className="text-indigo-600 hover:text-indigo-800"
onClick={onSwitchToSignUp}

View File

@@ -1,6 +1,6 @@
import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { account, authClient } from "@/lib/auth-client";
@@ -39,7 +39,7 @@ export default function SignUpForm({
const me = await account.get();
queryClient.setQueryData(["session", "me"], me);
await queryClient.invalidateQueries({ queryKey: ["session", "me"] });
navigate({ to: "/dashboard" });
navigate({ to: "/verify-email" });
toast.success("Sign up successful");
} catch (error) {
const msg =
@@ -55,7 +55,7 @@ export default function SignUpForm({
.string()
.min(
MIN_PASSWORD_LENGTH,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
),
}),
},
@@ -158,14 +158,19 @@ export default function SignUpForm({
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
className="text-indigo-600 hover:text-indigo-800"
onClick={onSwitchToSignIn}
variant="link"
>
Already have an account? Sign In
</Button>
<div className="mt-4 space-y-2 text-center">
<div className="flex items-center justify-between">
<Button asChild variant="link" className="px-0">
<Link to="/verify-email">Verify your email</Link>
</Button>
<Button
className="text-indigo-600 hover:text-indigo-800"
onClick={onSwitchToSignIn}
variant="link"
>
Already have an account? Sign In
</Button>
</div>
</div>
</div>
);

View File

@@ -38,6 +38,12 @@ export default function UserMenu() {
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session.email}</DropdownMenuItem>
<DropdownMenuItem>
<Link to="/verify-email">Verify Email</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link to="/password">Password</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
await authClient.signOut();

View File

@@ -64,4 +64,61 @@ export const authClient = {
return null;
}
},
// Email verification helpers
verify: {
// Sends a verification email to the currently logged-in user
sendEmail: async (redirectUrl: string) => {
// Redirect URL must be registered in Appwrite console platform settings
await account.createVerification(redirectUrl);
},
// Confirms verification using query params (?userId=...&secret=...)
confirm: async ({ userId, secret }: { userId: string; secret: string }) => {
await account.updateVerification(userId, secret);
},
// Quickly check current user's verification status
status: async (): Promise<boolean> => {
const me = (await account.get()) as Models.User<Models.Preferences>;
return Boolean(me.emailVerification);
},
},
// Password management helpers
password: {
// Change password for the currently logged-in user
change: async ({
oldPassword,
newPassword,
}: {
oldPassword: string;
newPassword: string;
}) => {
await account.updatePassword(newPassword, oldPassword);
},
// Password recovery (reset via email)
recover: {
// Start recovery flow: sends email with link to redirectUrl
request: async ({
email,
redirectUrl,
}: {
email: string;
redirectUrl: string;
}) => {
// Redirect URL must be registered in Appwrite console platform settings
await account.createRecovery(email, redirectUrl);
},
// Complete recovery using the link's query params and new password
confirm: async ({
userId,
secret,
password,
}: {
userId: string;
secret: string;
password: string;
}) => {
// Some SDK versions require providing password twice
await account.updateRecovery(userId, secret, password);
},
},
},
};

View File

@@ -9,16 +9,28 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
import { Route as SpaceRouteImport } from './routes/space'
import { Route as PasswordRouteImport } from './routes/password'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as IndexRouteImport } from './routes/index'
const VerifyEmailRoute = VerifyEmailRouteImport.update({
id: '/verify-email',
path: '/verify-email',
getParentRoute: () => rootRouteImport,
} as any)
const SpaceRoute = SpaceRouteImport.update({
id: '/space',
path: '/space',
getParentRoute: () => rootRouteImport,
} as any)
const PasswordRoute = PasswordRouteImport.update({
id: '/password',
path: '/password',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -39,38 +51,66 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/password': typeof PasswordRoute
'/space': typeof SpaceRoute
'/verify-email': typeof VerifyEmailRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/password': typeof PasswordRoute
'/space': typeof SpaceRoute
'/verify-email': typeof VerifyEmailRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/password': typeof PasswordRoute
'/space': typeof SpaceRoute
'/verify-email': typeof VerifyEmailRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/dashboard' | '/login' | '/space'
fullPaths:
| '/'
| '/dashboard'
| '/login'
| '/password'
| '/space'
| '/verify-email'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/dashboard' | '/login' | '/space'
id: '__root__' | '/' | '/dashboard' | '/login' | '/space'
to: '/' | '/dashboard' | '/login' | '/password' | '/space' | '/verify-email'
id:
| '__root__'
| '/'
| '/dashboard'
| '/login'
| '/password'
| '/space'
| '/verify-email'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
PasswordRoute: typeof PasswordRoute
SpaceRoute: typeof SpaceRoute
VerifyEmailRoute: typeof VerifyEmailRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/verify-email': {
id: '/verify-email'
path: '/verify-email'
fullPath: '/verify-email'
preLoaderRoute: typeof VerifyEmailRouteImport
parentRoute: typeof rootRouteImport
}
'/space': {
id: '/space'
path: '/space'
@@ -78,6 +118,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SpaceRouteImport
parentRoute: typeof rootRouteImport
}
'/password': {
id: '/password'
path: '/password'
fullPath: '/password'
preLoaderRoute: typeof PasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -106,7 +153,9 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
PasswordRoute: PasswordRoute,
SpaceRoute: SpaceRoute,
VerifyEmailRoute: VerifyEmailRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -0,0 +1,483 @@
import { useQueryClient } from "@tanstack/react-query";
import {
createFileRoute,
Link,
useNavigate,
useSearch,
} from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import z from "zod";
import Loader from "@/components/loader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
const MIN_PASSWORD_LENGTH = 8;
const SearchSchema = z.object({
mode: z.enum(["change", "recover", "confirm"]).optional(),
userId: z.string().optional(),
secret: z.string().optional(),
});
export const Route = createFileRoute("/password")({
validateSearch: SearchSchema,
component: PasswordPage,
});
function PasswordPage() {
const navigate = useNavigate();
const query = useSearch({ from: "/password" });
const { data: session, isPending: isSessionPending } =
authClient.useSession();
const mode = useMemo(() => {
if (query.mode === "confirm" || (query.userId && query.secret)) {
return "confirm" as const;
}
if (query.mode === "recover") {
return "recover" as const;
}
return "change" as const;
}, [query.mode, query.userId, query.secret]);
if (isSessionPending) {
return <Loader />;
}
// If in confirm mode but missing params, guide user to recover
if (mode === "confirm" && !(query.userId && query.secret)) {
return (
<Wrapper>
<Title>Password reset</Title>
<p className="text-muted-foreground">
Your reset link appears to be missing details. Start a new password
recovery request.
</p>
<div className="mt-6 flex gap-3">
<Button asChild type="button">
<Link search={{ mode: "recover" }} to="/password">
Start recovery
</Link>
</Button>
<Button asChild type="button" variant="secondary">
<Link to="/">Back to home</Link>
</Button>
</div>
</Wrapper>
);
}
if (mode === "confirm" && query.userId && query.secret) {
return (
<Wrapper>
<Title>Set a new password</Title>
<ResetConfirmForm
onSuccess={() => {
toast.success("Password reset successful. Please sign in.");
navigate({ to: "/login" });
}}
secret={query.secret}
userId={query.userId}
/>
</Wrapper>
);
}
return (
<Wrapper>
<HeaderTabs active={mode} />
{mode === "recover" ? (
<>
<Title>Forgot password</Title>
<p className="text-muted-foreground">
Enter your email to receive a password reset link.
</p>
<div className="mt-6">
<RecoveryRequestForm />
</div>
</>
) : (
<>
<Title>Change password</Title>
{session ? (
<>
<p className="text-muted-foreground">
Update your password. You will stay signed in.
</p>
<div className="mt-6">
<ChangePasswordForm />
</div>
</>
) : (
<>
<p className="text-muted-foreground">
You need to sign in to change your password. If you forgot your
password, use recovery instead.
</p>
<div className="mt-6 flex gap-3">
<Button asChild type="button">
<Link to="/login">Sign in</Link>
</Button>
<Button asChild type="button" variant="secondary">
<Link search={{ mode: "recover" }} to="/password">
Forgot password
</Link>
</Button>
</div>
</>
)}
</>
)}
</Wrapper>
);
}
function HeaderTabs({ active }: { active: "change" | "recover" | "confirm" }) {
if (active === "confirm") {
return null;
}
return (
<div className="mb-6 inline-flex rounded-lg border bg-card p-1">
<TabLink
active={active === "change"}
label="Change password"
search={{ mode: "change" as const }}
to="/password"
/>
<TabLink
active={active === "recover"}
label="Recover password"
search={{ mode: "recover" as const }}
to="/password"
/>
</div>
);
}
function TabLink<TSearch extends Record<string, unknown>>({
to,
search,
active,
label,
}: {
to: string;
search: TSearch;
active: boolean;
label: string;
}) {
return (
<Button asChild type="button" variant={active ? "default" : "ghost"}>
<Link search={search} to={to}>
{label}
</Link>
</Button>
);
}
function ChangePasswordForm() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [submitting, setSubmitting] = useState(false);
const queryClient = useQueryClient();
const schema = z
.object({
currentPassword: z
.string()
.min(
MIN_PASSWORD_LENGTH,
`Must be at least ${MIN_PASSWORD_LENGTH} characters`
),
newPassword: z
.string()
.min(
MIN_PASSWORD_LENGTH,
`Must be at least ${MIN_PASSWORD_LENGTH} characters`
),
confirm: z.string(),
})
.refine((v) => v.newPassword === v.confirm, {
message: "Passwords do not match",
path: ["confirm"],
})
.refine((v) => v.newPassword !== v.currentPassword, {
message: "New password must be different from current password",
path: ["newPassword"],
});
const [errors, setErrors] = useState<Record<string, string>>({});
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: why not
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
const parsed = schema.safeParse({ currentPassword, newPassword, confirm });
if (!parsed.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = issue.path.join(".") || "form";
if (!fieldErrors[key]) {
fieldErrors[key] = issue.message;
}
}
setErrors(fieldErrors);
return;
}
setSubmitting(true);
try {
await authClient.password.change({
oldPassword: currentPassword,
newPassword,
});
toast.success("Password updated");
setCurrentPassword("");
setNewPassword("");
setConfirm("");
// Refresh session cache
await queryClient.invalidateQueries({ queryKey: ["session", "me"] });
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to change password";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
return (
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="currentPassword">Current password</Label>
<Input
id="currentPassword"
name="currentPassword"
onChange={(e) => setCurrentPassword(e.target.value)}
type="password"
value={currentPassword}
/>
{errors.currentPassword ? (
<p className="text-red-500">{errors.currentPassword}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New password</Label>
<Input
id="newPassword"
name="newPassword"
onChange={(e) => setNewPassword(e.target.value)}
type="password"
value={newPassword}
/>
{errors.newPassword ? (
<p className="text-red-500">{errors.newPassword}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm new password</Label>
<Input
id="confirm"
name="confirm"
onChange={(e) => setConfirm(e.target.value)}
type="password"
value={confirm}
/>
{errors.confirm ? (
<p className="text-red-500">{errors.confirm}</p>
) : null}
</div>
<div className="pt-2">
<Button className="w-full" disabled={submitting} type="submit">
{submitting ? "Updating..." : "Update password"}
</Button>
</div>
</form>
);
}
function RecoveryRequestForm() {
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const schema = z.object({
email: z.string().email("Enter a valid email"),
});
const redirectUrl = useMemo(() => {
if (typeof window === "undefined") {
return "";
}
const url = new URL("/password", window.location.origin);
url.searchParams.set("mode", "confirm");
return url.toString();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const parsed = schema.safeParse({ email });
if (!parsed.success) {
const msg = parsed.error.issues[0]?.message || "Invalid email";
setError(msg);
return;
}
setSubmitting(true);
try {
await authClient.password.recover.request({
email,
redirectUrl,
});
toast.success("If an account exists, a recovery link has been sent");
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to start recovery";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
return (
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
autoComplete="email"
id="email"
name="email"
onChange={(e) => setEmail(e.target.value)}
type="email"
value={email}
/>
{error ? <p className="text-red-500">{error}</p> : null}
</div>
<div className="pt-2">
<Button className="w-full" disabled={submitting} type="submit">
{submitting ? "Sending..." : "Send recovery email"}
</Button>
</div>
</form>
);
}
function ResetConfirmForm({
userId,
secret,
onSuccess,
}: {
userId: string;
secret: string;
onSuccess: () => void;
}) {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const schema = z
.object({
password: z
.string()
.min(
MIN_PASSWORD_LENGTH,
`Must be at least ${MIN_PASSWORD_LENGTH} characters`
),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
message: "Passwords do not match",
path: ["confirm"],
});
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: why not
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
const parsed = schema.safeParse({ password, confirm });
if (!parsed.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = issue.path.join(".") || "form";
if (!fieldErrors[key]) {
fieldErrors[key] = issue.message;
}
}
setErrors(fieldErrors);
return;
}
setSubmitting(true);
try {
await authClient.password.recover.confirm({
userId,
secret,
password,
});
onSuccess();
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to reset password";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
return (
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<Input
autoComplete="new-password"
id="password"
name="password"
onChange={(e) => setPassword(e.target.value)}
type="password"
value={password}
/>
{errors.password ? (
<p className="text-red-500">{errors.password}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="confirmReset">Confirm new password</Label>
<Input
autoComplete="new-password"
id="confirmReset"
name="confirmReset"
onChange={(e) => setConfirm(e.target.value)}
type="password"
value={confirm}
/>
{errors.confirm ? (
<p className="text-red-500">{errors.confirm}</p>
) : null}
</div>
<div className="pt-2">
<Button className="w-full" disabled={submitting} type="submit">
{submitting ? "Resetting..." : "Reset password"}
</Button>
</div>
</form>
);
}
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto mt-10 w-full max-w-lg p-6">
<div className="rounded-2xl border bg-card p-8 shadow-sm">{children}</div>
</div>
);
}
function Title({ children }: { children: React.ReactNode }) {
return <h1 className="mb-2 font-bold text-2xl">{children}</h1>;
}

View File

@@ -0,0 +1,237 @@
import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import z from "zod";
import Loader from "@/components/loader";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/verify-email")({
validateSearch: z.object({
userId: z.string().optional(),
secret: z.string().optional(),
}),
component: VerifyEmailPage,
});
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: why not
function VerifyEmailPage() {
const { userId, secret } = Route.useSearch();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: session, isPending: isSessionPending } =
authClient.useSession();
const [isConfirming, setIsConfirming] = useState(false);
const [confirmed, setConfirmed] = useState<boolean | null>(null);
const [confirmError, setConfirmError] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(false);
const [isVerified, setIsVerified] = useState<boolean | null>(null);
// Avoid double-confirming on React strict mode mounts
const hasAttemptedConfirm = useRef(false);
// Build redirect URL for the verification email
const redirectUrl = useMemo(() => {
if (typeof window === "undefined") {
return "";
}
const url = new URL("/verify-email", window.location.origin);
return url.toString();
}, []);
// Auto-confirm if userId and secret are present
useEffect(() => {
const doConfirm = async () => {
if (!(userId && secret)) {
return;
}
if (hasAttemptedConfirm.current) {
return;
}
hasAttemptedConfirm.current = true;
setIsConfirming(true);
setConfirmError(null);
try {
await authClient.verify.confirm({ userId, secret });
setConfirmed(true);
toast.success("Email verified successfully");
// Refresh the session so UI reflects verification state
await queryClient.invalidateQueries({ queryKey: ["session", "me"] });
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to verify email";
setConfirmError(msg);
setConfirmed(false);
toast.error(msg);
} finally {
setIsConfirming(false);
}
};
doConfirm();
}, [secret, userId, queryClient]);
// Check current verification status
useEffect(() => {
const check = async () => {
if (!session) {
setIsVerified(null);
return;
}
setIsCheckingStatus(true);
try {
const ok = await authClient.verify.status();
setIsVerified(ok);
} catch {
setIsVerified(null);
} finally {
setIsCheckingStatus(false);
}
};
check();
}, [session]);
const handleSendVerification = async () => {
if (!session) {
toast.info("Please sign in to send a verification email");
navigate({ to: "/login" });
return;
}
setIsSending(true);
try {
await authClient.verify.sendEmail(redirectUrl);
toast.success("Verification email sent");
} catch (err) {
const msg =
err instanceof Error
? err.message
: "Failed to send verification email";
toast.error(msg);
} finally {
setIsSending(false);
}
};
if (isSessionPending || isCheckingStatus || isConfirming) {
return <Loader />;
}
// Render different states based on URL params and outcomes
if (userId && secret) {
if (confirmed) {
return (
<Wrapper>
<Title>You're verified 🎉</Title>
<p className="text-muted-foreground">
Your email has been successfully verified.
</p>
<div className="mt-6 flex gap-3">
<Button asChild type="button">
<Link to="/dashboard">Go to dashboard</Link>
</Button>
<Button asChild type="button" variant="secondary">
<Link to="/">Back to home</Link>
</Button>
</div>
</Wrapper>
);
}
if (confirmError) {
return (
<Wrapper>
<Title>Verification failed</Title>
<p className="text-muted-foreground">{confirmError}</p>
<div className="mt-6 flex gap-3">
<Button
disabled={isSending}
onClick={handleSendVerification}
type="button"
>
{isSending ? "Sending..." : "Resend verification email"}
</Button>
<Button asChild type="button" variant="secondary">
<Link to="/login">Sign in</Link>
</Button>
</div>
</Wrapper>
);
}
// Fallback while confirming
return <Loader />;
}
// No confirmation params present: allow user to trigger verification
if (!session) {
return (
<Wrapper>
<Title>Verify your email</Title>
<p className="text-muted-foreground">
You need to be signed in to verify your email.
</p>
<div className="mt-6">
<Button asChild type="button">
<Link to="/login">Sign in</Link>
</Button>
</div>
</Wrapper>
);
}
return (
<Wrapper>
<Title>Verify your email</Title>
<p className="text-muted-foreground">
{session.email ? `Signed in as ${session.email}` : "Signed in"}
</p>
{isVerified ? (
<div className="mt-6">
<p className="text-emerald-400">Your email is already verified.</p>
<div className="mt-4">
<Button asChild type="button">
<Link to="/dashboard">Go to dashboard</Link>
</Button>
</div>
</div>
) : (
<div className="mt-6">
<p className="text-muted-foreground">
Click the button below and check your inbox to complete
verification.
</p>
<div className="mt-4 flex gap-3">
<Button
disabled={isSending}
onClick={handleSendVerification}
type="button"
>
{isSending ? "Sending..." : "Send verification email"}
</Button>
<Button asChild type="button" variant="secondary">
<Link to="/">Back to home</Link>
</Button>
</div>
</div>
)}
</Wrapper>
);
}
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto mt-10 w-full max-w-lg p-6">
<div className="rounded-2xl border bg-card p-8 shadow-sm">{children}</div>
</div>
);
}
function Title({ children }: { children: React.ReactNode }) {
return <h1 className="mb-2 font-bold text-2xl">{children}</h1>;
}