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 { useForm } from "@tanstack/react-form";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { account, authClient } from "@/lib/auth-client";
|
import { account, authClient } from "@/lib/auth-client";
|
||||||
@@ -53,7 +53,7 @@ export default function SignInForm({
|
|||||||
.string()
|
.string()
|
||||||
.min(
|
.min(
|
||||||
MIN_PASSWORD_LENGTH,
|
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.Subscribe>
|
||||||
</form>
|
</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
|
<Button
|
||||||
className="text-indigo-600 hover:text-indigo-800"
|
className="text-indigo-600 hover:text-indigo-800"
|
||||||
onClick={onSwitchToSignUp}
|
onClick={onSwitchToSignUp}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { account, authClient } from "@/lib/auth-client";
|
import { account, authClient } from "@/lib/auth-client";
|
||||||
@@ -39,7 +39,7 @@ export default function SignUpForm({
|
|||||||
const me = await account.get();
|
const me = await account.get();
|
||||||
queryClient.setQueryData(["session", "me"], me);
|
queryClient.setQueryData(["session", "me"], me);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["session", "me"] });
|
await queryClient.invalidateQueries({ queryKey: ["session", "me"] });
|
||||||
navigate({ to: "/dashboard" });
|
navigate({ to: "/verify-email" });
|
||||||
toast.success("Sign up successful");
|
toast.success("Sign up successful");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -55,7 +55,7 @@ export default function SignUpForm({
|
|||||||
.string()
|
.string()
|
||||||
.min(
|
.min(
|
||||||
MIN_PASSWORD_LENGTH,
|
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.Subscribe>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 space-y-2 text-center">
|
||||||
<Button
|
<div className="flex items-center justify-between">
|
||||||
className="text-indigo-600 hover:text-indigo-800"
|
<Button asChild variant="link" className="px-0">
|
||||||
onClick={onSwitchToSignIn}
|
<Link to="/verify-email">Verify your email</Link>
|
||||||
variant="link"
|
</Button>
|
||||||
>
|
<Button
|
||||||
Already have an account? Sign In
|
className="text-indigo-600 hover:text-indigo-800"
|
||||||
</Button>
|
onClick={onSwitchToSignIn}
|
||||||
|
variant="link"
|
||||||
|
>
|
||||||
|
Already have an account? Sign In
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export default function UserMenu() {
|
|||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>{session.email}</DropdownMenuItem>
|
<DropdownMenuItem>{session.email}</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link to="/verify-email">Verify Email</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link to="/password">Password</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
|
|||||||
@@ -64,4 +64,61 @@ export const authClient = {
|
|||||||
return null;
|
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.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
||||||
import { Route as SpaceRouteImport } from './routes/space'
|
import { Route as SpaceRouteImport } from './routes/space'
|
||||||
|
import { Route as PasswordRouteImport } from './routes/password'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
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({
|
const SpaceRoute = SpaceRouteImport.update({
|
||||||
id: '/space',
|
id: '/space',
|
||||||
path: '/space',
|
path: '/space',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PasswordRoute = PasswordRouteImport.update({
|
||||||
|
id: '/password',
|
||||||
|
path: '/password',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -39,38 +51,66 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/password': typeof PasswordRoute
|
||||||
'/space': typeof SpaceRoute
|
'/space': typeof SpaceRoute
|
||||||
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/password': typeof PasswordRoute
|
||||||
'/space': typeof SpaceRoute
|
'/space': typeof SpaceRoute
|
||||||
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/password': typeof PasswordRoute
|
||||||
'/space': typeof SpaceRoute
|
'/space': typeof SpaceRoute
|
||||||
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/dashboard' | '/login' | '/space'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/login'
|
||||||
|
| '/password'
|
||||||
|
| '/space'
|
||||||
|
| '/verify-email'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/dashboard' | '/login' | '/space'
|
to: '/' | '/dashboard' | '/login' | '/password' | '/space' | '/verify-email'
|
||||||
id: '__root__' | '/' | '/dashboard' | '/login' | '/space'
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/login'
|
||||||
|
| '/password'
|
||||||
|
| '/space'
|
||||||
|
| '/verify-email'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
DashboardRoute: typeof DashboardRoute
|
DashboardRoute: typeof DashboardRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
|
PasswordRoute: typeof PasswordRoute
|
||||||
SpaceRoute: typeof SpaceRoute
|
SpaceRoute: typeof SpaceRoute
|
||||||
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/verify-email': {
|
||||||
|
id: '/verify-email'
|
||||||
|
path: '/verify-email'
|
||||||
|
fullPath: '/verify-email'
|
||||||
|
preLoaderRoute: typeof VerifyEmailRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/space': {
|
'/space': {
|
||||||
id: '/space'
|
id: '/space'
|
||||||
path: '/space'
|
path: '/space'
|
||||||
@@ -78,6 +118,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SpaceRouteImport
|
preLoaderRoute: typeof SpaceRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/password': {
|
||||||
|
id: '/password'
|
||||||
|
path: '/password'
|
||||||
|
fullPath: '/password'
|
||||||
|
preLoaderRoute: typeof PasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
id: '/login'
|
id: '/login'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
@@ -106,7 +153,9 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
|
PasswordRoute: PasswordRoute,
|
||||||
SpaceRoute: SpaceRoute,
|
SpaceRoute: SpaceRoute,
|
||||||
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._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