mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 18:04:45 +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,192 @@
|
||||
import type { DialogProps } from "../store";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon, PasswordIcon } from "@phosphor-icons/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { useDialogStore } from "../store";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(6).max(64),
|
||||
newPassword: z.string().min(6).max(64),
|
||||
})
|
||||
.refine((data) => data.newPassword !== data.currentPassword, {
|
||||
message: "New password cannot be the same as the current password.",
|
||||
path: ["newPassword"],
|
||||
});
|
||||
|
||||
export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
|
||||
const queryClient = useQueryClient();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const [showCurrentPassword, toggleShowCurrentPassword] = useToggle(false);
|
||||
const [showNewPassword, toggleShowNewPassword] = useToggle(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: formSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Updating your password...`);
|
||||
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword: value.currentPassword,
|
||||
newPassword: value.newPassword,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when changing account password fails",
|
||||
message: "Failed to update your password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your password has been updated successfully.`, { id: toastId });
|
||||
void queryClient.invalidateQueries({ queryKey: ["auth", "accounts"] });
|
||||
closeDialog();
|
||||
},
|
||||
});
|
||||
|
||||
useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PasswordIcon />
|
||||
<Trans>Update your password</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Enter your current password and a new password to update your account.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="currentPassword">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Current Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowCurrentPassword}>
|
||||
<span className="sr-only">
|
||||
{showCurrentPassword
|
||||
? t({
|
||||
comment: "Accessible label for toggle button that hides the visible current password",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for toggle button that reveals the masked current password",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showCurrentPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="newPassword">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showNewPassword ? "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" type="button" onClick={toggleShowNewPassword}>
|
||||
<span className="sr-only">
|
||||
{showNewPassword
|
||||
? t({
|
||||
comment: "Accessible label for toggle button that hides the visible new password",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for toggle button that reveals the masked new password",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showNewPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans comment="Primary action button to submit changed password">Update Password</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { DialogProps } from "../store";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon, LockOpenIcon } from "@phosphor-icons/react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { useDialogStore } from "../store";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">) {
|
||||
const router = useRouter();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Disabling two-factor authentication...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.disable({ password: value.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when disabling two-factor authentication fails",
|
||||
message: "Failed to disable two-factor authentication. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Two-factor authentication has been disabled successfully.`, { id: toastId });
|
||||
void router.invalidate();
|
||||
closeDialog();
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<LockOpenIcon />
|
||||
<Trans>Disable Two-Factor Authentication</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Enter your password to disable two-factor authentication. Your account will be less secure without 2FA
|
||||
enabled.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
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>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
<span className="sr-only">
|
||||
{showPassword
|
||||
? t({
|
||||
comment:
|
||||
"Accessible label for toggle button that hides the visible password in two-factor disable dialog",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment:
|
||||
"Accessible label for toggle button that reveals the masked password in two-factor disable dialog",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trans comment="Destructive action button to turn off two-factor authentication">Disable 2FA</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { DialogProps } from "../store";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowDownIcon, CopyIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { useDialogStore } from "../store";
|
||||
|
||||
const enableFormSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
const verifyFormSchema = z.object({
|
||||
code: z.string().length(6, "Code must be 6 digits"),
|
||||
});
|
||||
|
||||
export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) {
|
||||
const router = useRouter();
|
||||
|
||||
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
const [step, setStep] = useState<"enable" | "verify" | "backup">("enable");
|
||||
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const enableForm = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: enableFormSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Enabling two-factor authentication...`);
|
||||
|
||||
const { data, error } = await authClient.twoFactor.enable({
|
||||
password: value.password,
|
||||
issuer: "Reactive Resume",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when enabling two-factor authentication fails",
|
||||
message: "Failed to enable two-factor authentication. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.totpURI && data.backupCodes) {
|
||||
setTotpUri(data.totpURI);
|
||||
setBackupCodes(data.backupCodes);
|
||||
setStep("verify");
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
toast.error(t`Failed to setup two-factor authentication.`, { id: toastId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verifyForm = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: verifyFormSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyTotp({ code: value.code });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when verifying two-factor setup code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
setStep("backup");
|
||||
},
|
||||
});
|
||||
|
||||
const enableIsDirty = useStore(enableForm.store, (s) => s.isDirty);
|
||||
const enableIsSubmitting = useStore(enableForm.store, (s) => s.isSubmitting);
|
||||
const verifyIsDirty = useStore(verifyForm.store, (s) => s.isDirty);
|
||||
const verifyIsSubmitting = useStore(verifyForm.store, (s) => s.isSubmitting);
|
||||
|
||||
const { requestClose } = useFormBlocker(enableForm, {
|
||||
shouldBlock: () => {
|
||||
if (step === "enable") return enableIsDirty && !enableIsSubmitting;
|
||||
if (step === "verify") return verifyIsDirty && !verifyIsSubmitting;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const onConfirmBackup = () => {
|
||||
toast.success(t`Two-factor authentication has been setup successfully.`);
|
||||
void router.invalidate();
|
||||
closeDialog();
|
||||
onReset();
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
enableForm.reset();
|
||||
verifyForm.reset();
|
||||
setStep("enable");
|
||||
setTotpUri(null);
|
||||
setBackupCodes(null);
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
if (!totpUri) return;
|
||||
const secret = extractSecretFromTotpUri(totpUri);
|
||||
if (!secret) return;
|
||||
await navigator.clipboard.writeText(secret);
|
||||
toast.success(t`Secret copied to clipboard.`);
|
||||
};
|
||||
|
||||
const handleCopyBackupCodes = async () => {
|
||||
if (!backupCodes) return;
|
||||
await navigator.clipboard.writeText(backupCodes.join("\n"));
|
||||
toast.success(t`Backup codes copied to clipboard.`);
|
||||
};
|
||||
|
||||
const handleDownloadBackupCodes = () => {
|
||||
if (!backupCodes) return;
|
||||
const content = backupCodes.join("\n");
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "reactive-resume_backup-codes.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{match(step)
|
||||
.with("enable", () => <Trans>Enable Two-Factor Authentication</Trans>)
|
||||
.with("verify", () => <Trans>Setup Authenticator App</Trans>)
|
||||
.with("backup", () => <Trans>Copy Backup Codes</Trans>)
|
||||
.exhaustive()}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{match(step)
|
||||
.with("enable", () => (
|
||||
<Trans>
|
||||
Enter your password to confirm setting up two-factor authentication. When enabled, you'll need to enter
|
||||
a code from your authenticator app every time you log in.
|
||||
</Trans>
|
||||
))
|
||||
.with("verify", () => (
|
||||
<Trans>
|
||||
Scan the QR code below with your preferred authenticator app. You can also copy the secret below and
|
||||
paste it into your app.
|
||||
</Trans>
|
||||
))
|
||||
.with("backup", () => <Trans>Copy and store these backup codes in case you lose your device.</Trans>)
|
||||
.exhaustive()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with("enable", () => (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void enableForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<enableForm.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
<span className="sr-only">
|
||||
{showPassword
|
||||
? t({
|
||||
comment:
|
||||
"Accessible label for toggle button that hides the visible password in two-factor setup",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment:
|
||||
"Accessible label for toggle button that reveals the masked password in two-factor setup",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</enableForm.Field>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
))
|
||||
.with("verify", () => {
|
||||
const secret = totpUri ? extractSecretFromTotpUri(totpUri) : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{totpUri && secret && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly value={secret} className="font-mono text-sm" />
|
||||
<Button size="icon" variant="ghost" type="button" onClick={handleCopySecret}>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TwoFactorQRCode totpUri={totpUri} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<p>
|
||||
<Trans>Then, enter the 6 digit code that the app provides to continue.</Trans>
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void verifyForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<verifyForm.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem 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>
|
||||
)}
|
||||
</verifyForm.Field>
|
||||
|
||||
<DialogFooter className="gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={requestClose}>
|
||||
<Trans comment="Secondary action button to close two-factor setup dialog">Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans comment="Primary action button to proceed to next step in two-factor setup">Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.with("backup", () => (
|
||||
<div className="space-y-4">
|
||||
{backupCodes && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code} className="rounded-md border border-border p-2 text-center font-mono text-sm">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={handleDownloadBackupCodes} className="flex-1">
|
||||
<ArrowDownIcon className="me-2 size-4" />
|
||||
<Trans comment="Action button to download two-factor backup codes as a text file">Download</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleCopyBackupCodes} className="flex-1">
|
||||
<CopyIcon className="me-2 size-4" />
|
||||
<Trans comment="Action button to copy two-factor backup codes to clipboard">Copy</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onConfirmBackup}>
|
||||
<Trans comment="Final action button after saving backup codes">Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function extractSecretFromTotpUri(totpUri: string): string | null {
|
||||
try {
|
||||
const url = new URL(totpUri);
|
||||
return url.searchParams.get("secret");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function TwoFactorQRCode({ totpUri }: { totpUri: string }) {
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={totpUri}
|
||||
size={256}
|
||||
marginSize={2}
|
||||
className="rounded-md"
|
||||
title={t({
|
||||
comment: "Accessible title for QR code image shown during two-factor setup",
|
||||
message: "Two-Factor Authentication QR Code",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user