mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: implement email verification and password management features
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
483
apps/web/src/routes/password.tsx
Normal file
483
apps/web/src/routes/password.tsx
Normal 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>;
|
||||
}
|
||||
237
apps/web/src/routes/verify-email.tsx
Normal file
237
apps/web/src/routes/verify-email.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user