mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { FingerprintIcon, GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type SocialAuthProps = {
|
||||
requestSignUp?: boolean;
|
||||
};
|
||||
|
||||
type SocialSignInOptions = {
|
||||
provider: string;
|
||||
callbackURL: string;
|
||||
requestSignUp?: true;
|
||||
};
|
||||
|
||||
function getSocialSignInOptions(provider: string, requestSignUp: boolean): SocialSignInOptions {
|
||||
const options: SocialSignInOptions = { provider, callbackURL: "/dashboard" };
|
||||
if (requestSignUp) options.requestSignUp = true;
|
||||
return options;
|
||||
}
|
||||
|
||||
export function SocialAuth({ requestSignUp = false }: SocialAuthProps) {
|
||||
const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<hr className="flex-1" />
|
||||
<span className="font-medium text-xs tracking-wide">
|
||||
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
|
||||
or continue with
|
||||
</Trans>
|
||||
</span>
|
||||
<hr className="flex-1" />
|
||||
</div>
|
||||
|
||||
{isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} requestSignUp={requestSignUp} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialAuthSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SocialAuthButtonsProps = {
|
||||
providers: RouterOutput["auth"]["providers"]["list"];
|
||||
requestSignUp: boolean;
|
||||
};
|
||||
|
||||
function SocialAuthButtons({ providers, requestSignUp }: SocialAuthButtonsProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.social(getSocialSignInOptions(provider, requestSignUp));
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when social sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.oauth2({
|
||||
providerId: "custom",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when custom OAuth sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: false });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when passkey sign-in fails without an error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOAuthLogin}
|
||||
className={cn("hidden", "custom" in providers && "inline-flex")}
|
||||
>
|
||||
<VaultIcon />
|
||||
{providers.custom}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePasskeyLogin}
|
||||
className={cn("hidden", "passkey" in providers && "inline-flex")}
|
||||
>
|
||||
<FingerprintIcon />
|
||||
<Trans comment="Label for passkey sign-in button">Passkey</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
|
||||
"google" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
<Trans comment="Brand name label for Google social sign-in button">Google</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
|
||||
"github" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
<Trans comment="Brand name label for GitHub social sign-in button">GitHub</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("linkedin")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#0A66C2] text-white hover:bg-[#0A66C2]/80",
|
||||
"linkedin" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<LinkedinLogoIcon />
|
||||
<Trans comment="Brand name label for LinkedIn social sign-in button">LinkedIn</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
export const Route = createFileRoute("/auth/forgot-password")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email(),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { email: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Sending password reset email...`);
|
||||
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email: value.email,
|
||||
redirectTo: "/auth/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when requesting password reset email fails without backend message",
|
||||
message: "Failed to send password reset email. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
});
|
||||
|
||||
if (submitted) return <PostForgotPasswordScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Remember your password?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from forgot-password page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on forgot-password form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on forgot-password form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on forgot-password form">Send Password Reset Email</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostForgotPasswordScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to reset your password.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="mailto:">
|
||||
<Trans comment="Button label to open the user's default email app">Open Email Client</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/auth/")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
throw redirect({ to: "/auth/login", replace: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "./-components/social-auth";
|
||||
|
||||
export const Route = createFileRoute("/auth/login")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
return { session: null };
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
identifier: z.string().trim().toLowerCase(),
|
||||
password: z.string().trim().min(6).max(64),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { flags } = Route.useRouteContext();
|
||||
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { data: providers = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { identifier: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
try {
|
||||
const isEmail = value.identifier.includes("@");
|
||||
|
||||
const result = isEmail
|
||||
? await authClient.signIn.email({ email: value.identifier, password: value.password })
|
||||
: await authClient.signIn.username({ username: value.identifier, password: value.password });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when sign-in fails and no server error message is available",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresTwoFactor =
|
||||
result.data &&
|
||||
typeof result.data === "object" &&
|
||||
"twoFactorRedirect" in result.data &&
|
||||
result.data.twoFactorRedirect;
|
||||
|
||||
if (requiresTwoFactor) {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: "/auth/verify-2fa", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
} catch {
|
||||
toast.error(t`Failed to sign in. Please try again.`, { id: toastId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans comment="Title on the login page">Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
{!flags.disableSignups && (
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/register">
|
||||
<Trans comment="Call-to-action link from login page to account registration page">
|
||||
Create one now
|
||||
</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!flags.disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="identifier">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for login identifier input that accepts email or username">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
autoComplete="section-login username webauthn"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder for login identifier field",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
<FormDescription>
|
||||
<Trans>You can also use your username to login.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on login form">Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto p-0 text-xs leading-none"
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans comment="Link label to password reset page from login form">Forgot Password?</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-login current-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides the password in login form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals the password in login form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on login form">Sign in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/auth/oauth")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (session?.user) {
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const state = url.searchParams.get("state");
|
||||
const scope = url.searchParams.get("scope");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||
|
||||
if (!client) {
|
||||
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = hashCode(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 600_000); // 10 min
|
||||
|
||||
await db.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier: hashedCode,
|
||||
value: JSON.stringify({
|
||||
type: "authorization_code",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
},
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
authTime: new Date(session.session.createdAt).getTime(),
|
||||
}),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: callbackUrl.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
// Not logged in — redirect to the real login page with a callback
|
||||
const loginUrl = new URL("/auth/login", url.origin);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!["exp", "sig"].includes(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
loginUrl.searchParams.set("callbackURL", `/auth/oauth?${oauthParams.toString()}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: loginUrl.toString() },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "./-components/social-auth";
|
||||
|
||||
export const Route = createFileRoute("/auth/register")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
if (context.flags.disableSignups) throw redirect({ to: "/auth/login", replace: true });
|
||||
return { session: null };
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3).max(64),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9._-]+$/, {
|
||||
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
|
||||
}),
|
||||
email: z.email().toLowerCase(),
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const { flags } = Route.useRouteContext();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { name: "", username: "", email: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing up...`);
|
||||
|
||||
const { error } = await authClient.signUp.email({
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
username: value.username,
|
||||
displayUsername: value.username,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when account registration fails without a server error message",
|
||||
message: "Failed to create your account. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
});
|
||||
|
||||
if (submitted) return <PostSignupScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Create a new account</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from registration page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!flags.disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for full name input on registration form">Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on registration form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="username">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for username input on registration form">Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register username"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on registration form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on registration form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="section-register email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on registration form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on registration form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-register new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in registration form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in registration form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on registration form">Sign up</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth requestSignUp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSignupScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to verify your account.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
<Trans>This step is optional, but recommended.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Verifying your email is required when resetting your password.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<Trans comment="Button label to continue to dashboard after successful registration">Continue</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, redirect, SearchParamError, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const searchSchema = z.object({ token: z.string().min(1) });
|
||||
|
||||
export const Route = createFileRoute("/auth/reset-password")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof SearchParamError) {
|
||||
throw redirect({ to: "/auth/login" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const { token } = Route.useSearch();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Resetting your password...`);
|
||||
|
||||
const { error } = await authClient.resetPassword({ token, newPassword: value.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when resetting password fails and no backend message is available",
|
||||
message: "Failed to reset your password. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your password has been reset successfully. You can now sign in with your new password.`, {
|
||||
id: toastId,
|
||||
});
|
||||
|
||||
void navigate({ to: "/auth/login" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Reset your password</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Please enter a new password for your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for new password input on reset-password form">New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in reset-password form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in reset-password form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on reset-password form">Reset Password</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { EyeIcon, EyeSlashIcon, LockOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect, SearchParamError, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\/[^/]+\/[^/]+$/),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/auth/resume-password")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
onError: (error) => {
|
||||
if (error instanceof SearchParamError) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const { redirect } = Route.useSearch();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
|
||||
|
||||
const [username, slug] = useMemo(() => {
|
||||
const [username, slug] = redirect.split("/").slice(1) as [string, string];
|
||||
if (!username || !slug) throw navigate({ to: "/" });
|
||||
return [username, slug];
|
||||
}, [redirect, navigate]);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
const toastId = toast.loading(t`Verifying password...`);
|
||||
|
||||
verifyPassword(
|
||||
{ username, slug, password: value.password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: redirect, replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "INVALID_PASSWORD") {
|
||||
toast.dismiss(toastId);
|
||||
formApi.setFieldMeta("password", (meta) => ({
|
||||
...meta,
|
||||
isTouched: true,
|
||||
errors: [{ message: t`The password you entered is incorrect` }],
|
||||
errorMap: {
|
||||
...meta.errorMap,
|
||||
onSubmit: { message: t`The password you entered is incorrect` },
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resume password verification fails unexpectedly",
|
||||
message: "Failed to verify the password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>The resume you are trying to access is password protected</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground leading-relaxed">
|
||||
<Trans>Please enter the password shared with you by the owner of the resume to continue.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on protected resume access form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password on protected resume screen",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password on protected resume screen",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<LockOpenIcon />
|
||||
<Trans comment="Primary action button label to unlock a password-protected resume">Unlock</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
|
||||
export const Route = createFileRoute("/auth")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh w-dvw max-w-sm flex-col justify-center space-y-6 px-4 xs:px-0">
|
||||
<BrandIcon className="mb-4 size-20 self-center" />
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
export const Route = createFileRoute("/auth/verify-2fa-backup")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().trim(),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying backup code...`);
|
||||
const formattedCode = `${value.code.slice(0, 5)}-${value.code.slice(5)}`;
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
|
||||
message: "Failed to verify your backup code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Verify with a Backup Code</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter one of your saved backup codes to access your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
maxLength={10}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/verify-2fa">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit backup code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
export const Route = createFileRoute("/auth/verify-2fa")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().length(6, "Code must be 6 digits"),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code: value.code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a two-factor authentication code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter the verification code from your authenticator app</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="number"
|
||||
maxLength={6}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto justify-self-center p-0 text-sm"
|
||||
render={
|
||||
<Link to="/auth/verify-2fa-backup">
|
||||
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
|
||||
Lost access to your authenticator?
|
||||
</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user