chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
|
||||
interface MfaDisableDialogProps {
|
||||
isOpen: boolean;
|
||||
isSubmitting: boolean;
|
||||
error?: string;
|
||||
onDisable: (totpCode?: string, recoveryCode?: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MfaDisableDialog({
|
||||
isOpen,
|
||||
isSubmitting,
|
||||
error,
|
||||
onDisable,
|
||||
onCancel,
|
||||
}: MfaDisableDialogProps) {
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [recoveryCode, setRecoveryCode] = useState("");
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onDisable(useRecoveryCode ? undefined : totpCode, useRecoveryCode ? recoveryCode : undefined);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTotpCode("");
|
||||
setRecoveryCode("");
|
||||
setUseRecoveryCode(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleSwitchToRecoveryCode = () => {
|
||||
setUseRecoveryCode(true);
|
||||
setTotpCode("");
|
||||
};
|
||||
|
||||
const handleSwitchToTotpCode = () => {
|
||||
setUseRecoveryCode(false);
|
||||
setRecoveryCode("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleCancel}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable multi-factor authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form method="post" onSubmit={handleSubmit}>
|
||||
{useRecoveryCode ? (
|
||||
<div className="pt-3">
|
||||
<Paragraph className="mb-6 text-center">
|
||||
Enter one of your recovery codes to disable MFA.
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputGroup>
|
||||
<Input
|
||||
type="password"
|
||||
name="recoveryCode"
|
||||
spellCheck={false}
|
||||
placeholder="Enter recovery code"
|
||||
variant="large"
|
||||
required
|
||||
autoFocus
|
||||
value={recoveryCode}
|
||||
onChange={(e) => setRecoveryCode(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSwitchToTotpCode}
|
||||
variant="minimal/small"
|
||||
className="my-4"
|
||||
>
|
||||
Use an authenticator app
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-3">
|
||||
<Paragraph variant="base" className="mb-6 text-center">
|
||||
Enter the code from your authenticator app to disable MFA.
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(value) => setTotpCode(value)}
|
||||
variant="large"
|
||||
>
|
||||
<InputOTPGroup variant="large" fullWidth>
|
||||
<InputOTPSlot index={0} autoFocus variant="large" fullWidth />
|
||||
<InputOTPSlot index={1} variant="large" fullWidth />
|
||||
<InputOTPSlot index={2} variant="large" fullWidth />
|
||||
<InputOTPSlot index={3} variant="large" fullWidth />
|
||||
<InputOTPSlot index={4} variant="large" fullWidth />
|
||||
<InputOTPSlot index={5} variant="large" fullWidth />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</Fieldset>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSwitchToRecoveryCode}
|
||||
variant="minimal/small"
|
||||
className="my-4"
|
||||
>
|
||||
Use a recovery code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary/medium" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary/medium" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Spinner className="mr-2 size-5" color="white" /> : null}
|
||||
{isSubmitting ? (
|
||||
<span className="text-text-bright">Disabling…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Disable MFA</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { CopyButton } from "~/components/primitives/CopyButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
interface MfaSetupDialogProps {
|
||||
isOpen: boolean;
|
||||
setupData?: {
|
||||
secret: string;
|
||||
otpAuthUrl: string;
|
||||
};
|
||||
recoveryCodes?: string[];
|
||||
error?: string;
|
||||
isSubmitting: boolean;
|
||||
onValidate: (code: string) => void;
|
||||
onCancel: () => void;
|
||||
onSaveRecoveryCodes: () => void;
|
||||
}
|
||||
|
||||
export function MfaSetupDialog({
|
||||
isOpen,
|
||||
setupData,
|
||||
recoveryCodes,
|
||||
error,
|
||||
isSubmitting,
|
||||
onValidate,
|
||||
onCancel,
|
||||
onSaveRecoveryCodes,
|
||||
}: MfaSetupDialogProps) {
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onValidate(totpCode);
|
||||
setTotpCode("");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTotpCode("");
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleRecoverySubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSaveRecoveryCodes();
|
||||
};
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (!recoveryCodes) return;
|
||||
|
||||
const content = recoveryCodes.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 = "trigger-dev-recovery-codes.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Show recovery codes if they exist
|
||||
if (recoveryCodes && recoveryCodes.length > 0) {
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recovery codes</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form method="post" onSubmit={handleRecoverySubmit}>
|
||||
<div className="flex flex-col gap-2 pb-0 pt-3">
|
||||
<Paragraph spacing>
|
||||
Copy and store these recovery codes carefully in case you lose your device.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex flex-col rounded border border-grid-dimmed bg-background-bright">
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-4 px-3 py-6">
|
||||
{recoveryCodes.map((code, index) => (
|
||||
<span key={index} className="text-center font-mono text-xs text-text-bright">
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t border-grid-bright px-1.5 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/medium"
|
||||
onClick={downloadRecoveryCodes}
|
||||
LeadingIcon={DownloadIcon}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={recoveryCodes.join("\n")}
|
||||
buttonVariant="minimal"
|
||||
showTooltip={false}
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="justify-end border-t-0">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
shortcut={{ key: "Enter" }}
|
||||
hideShortcutKey
|
||||
autoFocus
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Show QR setup if no recovery codes yet
|
||||
if (!setupData) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable authenticator app</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form method="post" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<Paragraph>
|
||||
Scan the QR code below with your preferred authenticator app then enter the 6 digit
|
||||
code that the app generates. Alternatively, you can copy the secret below and paste it
|
||||
into your app.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 rounded border border-grid-dimmed bg-background-bright py-4">
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<QRCodeSVG value={setupData.otpAuthUrl} size={300} marginSize={3} />
|
||||
</div>
|
||||
<CopyableText value={setupData.secret} className="font-mono text-sm tracking-wide" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(value) => setTotpCode(value)}
|
||||
variant="large"
|
||||
name="totpCode"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup variant="large">
|
||||
<InputOTPSlot index={0} variant="large" autoFocus />
|
||||
<InputOTPSlot index={1} variant="large" />
|
||||
<InputOTPSlot index={2} variant="large" />
|
||||
<InputOTPSlot index={3} variant="large" />
|
||||
<InputOTPSlot index={4} variant="large" />
|
||||
<InputOTPSlot index={5} variant="large" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex justify-center">{error && <FormError>{error}</FormError>}</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary/medium" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={totpCode.length !== 6 || isSubmitting}
|
||||
shortcut={{ key: "Enter" }}
|
||||
hideShortcutKey
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
|
||||
interface MfaToggleProps {
|
||||
isEnabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function MfaToggle({ isEnabled, onToggle }: MfaToggleProps) {
|
||||
return (
|
||||
<Form method="post" className="w-full">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<InputGroup className="flex-1">
|
||||
<Label htmlFor="mfa">Multi-factor authentication</Label>
|
||||
<Paragraph variant="small">
|
||||
Require a one-time code from your authenticator app (TOTP).
|
||||
</Paragraph>
|
||||
</InputGroup>
|
||||
<div className="flex flex-none items-center">
|
||||
<Switch
|
||||
id="mfa"
|
||||
variant="medium"
|
||||
labelPosition="right"
|
||||
className="w-fit pr-3"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
redirectWithSuccessMessage,
|
||||
redirectWithErrorMessage,
|
||||
typedJsonWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { useMfaSetup } from "./useMfaSetup";
|
||||
import { MfaToggle } from "./MfaToggle";
|
||||
import { MfaSetupDialog } from "./MfaSetupDialog";
|
||||
import { MfaDisableDialog } from "./MfaDisableDialog";
|
||||
|
||||
const formSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("enable-mfa"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("disable-mfa"),
|
||||
totpCode: z.string().optional(),
|
||||
recoveryCode: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("saved-recovery-codes"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("cancel-totp"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("validate-totp"),
|
||||
totpCode: z.string().length(6, "TOTP code must be 6 digits"),
|
||||
}),
|
||||
]);
|
||||
|
||||
function validateForm(formData: FormData) {
|
||||
const formEntries = Object.fromEntries(formData.entries());
|
||||
|
||||
const result = formSchema.safeParse(formEntries);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false as const,
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true as const,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = validateForm(formData);
|
||||
|
||||
if (!submission.valid) {
|
||||
return typedjson({
|
||||
action: "invalid-form" as const,
|
||||
errors: submission.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const mfaSetupService = new MultiFactorAuthenticationService();
|
||||
|
||||
switch (submission.data.action) {
|
||||
case "enable-mfa": {
|
||||
const result = await mfaSetupService.enableTotp(userId);
|
||||
|
||||
return typedjson({
|
||||
action: "enable-mfa" as const,
|
||||
secret: result.secret,
|
||||
otpAuthUrl: result.otpAuthUrl,
|
||||
});
|
||||
}
|
||||
case "disable-mfa": {
|
||||
const result = await mfaSetupService.disableTotp(userId, {
|
||||
totpCode: submission.data.totpCode,
|
||||
recoveryCode: submission.data.recoveryCode,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return typedJsonWithSuccessMessage(
|
||||
{
|
||||
action: "disable-mfa" as const,
|
||||
success: true as const,
|
||||
},
|
||||
request,
|
||||
"Successfully disabled MFA"
|
||||
);
|
||||
} else {
|
||||
return typedjson({
|
||||
action: "disable-mfa" as const,
|
||||
success: false as const,
|
||||
error: "Invalid code provided. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
case "validate-totp": {
|
||||
const result = await mfaSetupService.validateTotpSetup(userId, submission.data.totpCode);
|
||||
|
||||
if (result.success) {
|
||||
return typedjson({
|
||||
action: "validate-totp" as const,
|
||||
success: true as const,
|
||||
recoveryCodes: result.recoveryCodes,
|
||||
});
|
||||
} else {
|
||||
return typedjson({
|
||||
action: "validate-totp" as const,
|
||||
success: false as const,
|
||||
error: "Invalid code provided. Please try again.",
|
||||
otpAuthUrl: result.otpAuthUrl,
|
||||
secret: result.secret,
|
||||
});
|
||||
}
|
||||
}
|
||||
case "cancel-totp": {
|
||||
return typedjson({
|
||||
action: "cancel-totp" as const,
|
||||
success: true as const,
|
||||
});
|
||||
}
|
||||
case "saved-recovery-codes": {
|
||||
return redirectWithSuccessMessage("/account/security", request, "Successfully enabled MFA");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return redirectWithErrorMessage("/account/security", request, error.message);
|
||||
}
|
||||
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function MfaSetup({ isEnabled }: { isEnabled: boolean }) {
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
isQrDialogOpen,
|
||||
isRecoveryDialogOpen: _isRecoveryDialogOpen,
|
||||
isDisableDialogOpen,
|
||||
} = useMfaSetup(isEnabled);
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
if (enabled && !state.isEnabled) {
|
||||
actions.enableMfa();
|
||||
} else if (!enabled && state.isEnabled) {
|
||||
actions.openDisableDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MfaToggle isEnabled={state.isEnabled} onToggle={handleToggle} />
|
||||
|
||||
<MfaSetupDialog
|
||||
isOpen={isQrDialogOpen}
|
||||
setupData={state.setupData}
|
||||
recoveryCodes={state.recoveryCodes}
|
||||
error={state.error}
|
||||
isSubmitting={state.isSubmitting}
|
||||
onValidate={actions.validateTotp}
|
||||
onCancel={actions.cancelSetup}
|
||||
onSaveRecoveryCodes={actions.saveRecoveryCodes}
|
||||
/>
|
||||
|
||||
<MfaDisableDialog
|
||||
isOpen={isDisableDialogOpen}
|
||||
isSubmitting={state.isSubmitting}
|
||||
error={state.error}
|
||||
onDisable={actions.disableMfa}
|
||||
onCancel={actions.cancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useReducer, useEffect } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import type { action } from "./route";
|
||||
|
||||
export type MfaPhase = "idle" | "enabling" | "validating" | "showing-recovery" | "disabling";
|
||||
|
||||
export interface MfaState {
|
||||
phase: MfaPhase;
|
||||
isEnabled: boolean;
|
||||
setupData?: {
|
||||
secret: string;
|
||||
otpAuthUrl: string;
|
||||
};
|
||||
recoveryCodes?: string[];
|
||||
error?: string;
|
||||
isSubmitting: boolean;
|
||||
disableMethod: "totp" | "recovery";
|
||||
}
|
||||
|
||||
export type MfaAction =
|
||||
| { type: "ENABLE_MFA" }
|
||||
| { type: "SETUP_DATA_RECEIVED"; setupData: { secret: string; otpAuthUrl: string } }
|
||||
| { type: "CANCEL_SETUP" }
|
||||
| { type: "VALIDATE_TOTP"; code: string }
|
||||
| { type: "VALIDATION_SUCCESS"; recoveryCodes: string[] }
|
||||
| { type: "VALIDATION_FAILED"; error: string; setupData: { secret: string; otpAuthUrl: string } }
|
||||
| { type: "RECOVERY_CODES_SAVED" }
|
||||
| { type: "OPEN_DISABLE_DIALOG" }
|
||||
| { type: "DISABLE_MFA" }
|
||||
| { type: "DISABLE_SUCCESS" }
|
||||
| { type: "DISABLE_FAILED"; error: string }
|
||||
| { type: "CANCEL_DISABLE" }
|
||||
| { type: "SET_DISABLE_METHOD"; method: "totp" | "recovery" }
|
||||
| { type: "SET_ERROR"; error: string }
|
||||
| { type: "CLEAR_ERROR" }
|
||||
| { type: "SET_SUBMITTING"; isSubmitting: boolean };
|
||||
|
||||
function mfaReducer(state: MfaState, action: MfaAction): MfaState {
|
||||
switch (action.type) {
|
||||
case "ENABLE_MFA":
|
||||
return {
|
||||
...state,
|
||||
phase: "enabling",
|
||||
isSubmitting: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case "SETUP_DATA_RECEIVED":
|
||||
return {
|
||||
...state,
|
||||
phase: "enabling",
|
||||
setupData: action.setupData,
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "CANCEL_SETUP":
|
||||
return {
|
||||
...state,
|
||||
phase: "idle",
|
||||
setupData: undefined,
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "VALIDATE_TOTP":
|
||||
return {
|
||||
...state,
|
||||
phase: "validating",
|
||||
isSubmitting: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case "VALIDATION_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
phase: "showing-recovery",
|
||||
recoveryCodes: action.recoveryCodes,
|
||||
isSubmitting: false,
|
||||
isEnabled: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case "VALIDATION_FAILED":
|
||||
return {
|
||||
...state,
|
||||
phase: "enabling",
|
||||
setupData: action.setupData,
|
||||
error: action.error,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "RECOVERY_CODES_SAVED":
|
||||
return {
|
||||
...state,
|
||||
phase: "idle",
|
||||
setupData: undefined,
|
||||
recoveryCodes: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "OPEN_DISABLE_DIALOG":
|
||||
return {
|
||||
...state,
|
||||
phase: "disabling",
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "DISABLE_MFA":
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case "DISABLE_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
phase: "idle",
|
||||
isEnabled: false,
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "DISABLE_FAILED":
|
||||
return {
|
||||
...state,
|
||||
error: action.error,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "CANCEL_DISABLE":
|
||||
return {
|
||||
...state,
|
||||
phase: "idle",
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case "SET_DISABLE_METHOD":
|
||||
return {
|
||||
...state,
|
||||
disableMethod: action.method,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case "SET_ERROR":
|
||||
return {
|
||||
...state,
|
||||
error: action.error,
|
||||
};
|
||||
|
||||
case "CLEAR_ERROR":
|
||||
return {
|
||||
...state,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case "SET_SUBMITTING":
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: action.isSubmitting,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useMfaSetup(initialIsEnabled: boolean) {
|
||||
const fetcher = useTypedFetcher<typeof action>();
|
||||
|
||||
const [state, dispatch] = useReducer(mfaReducer, {
|
||||
phase: "idle",
|
||||
isEnabled: initialIsEnabled,
|
||||
isSubmitting: false,
|
||||
disableMethod: "totp",
|
||||
});
|
||||
|
||||
// Handle fetcher responses
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
const { data } = fetcher;
|
||||
|
||||
switch (data.action) {
|
||||
case "enable-mfa":
|
||||
dispatch({
|
||||
type: "SETUP_DATA_RECEIVED",
|
||||
setupData: { secret: data.secret, otpAuthUrl: data.otpAuthUrl },
|
||||
});
|
||||
break;
|
||||
|
||||
case "validate-totp":
|
||||
if (data.success) {
|
||||
dispatch({
|
||||
type: "VALIDATION_SUCCESS",
|
||||
recoveryCodes: data.recoveryCodes || [],
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "VALIDATION_FAILED",
|
||||
error: data.error || "Invalid code",
|
||||
setupData: { secret: data.secret!, otpAuthUrl: data.otpAuthUrl! },
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "disable-mfa":
|
||||
if (data.success) {
|
||||
dispatch({ type: "DISABLE_SUCCESS" });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "DISABLE_FAILED",
|
||||
error: data.error || "Failed to disable MFA",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "cancel-totp":
|
||||
dispatch({ type: "CANCEL_SETUP" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
// Handle submitting state
|
||||
useEffect(() => {
|
||||
dispatch({ type: "SET_SUBMITTING", isSubmitting: fetcher.state === "submitting" });
|
||||
}, [fetcher.state]);
|
||||
|
||||
const actions = {
|
||||
enableMfa: () => {
|
||||
dispatch({ type: "ENABLE_MFA" });
|
||||
fetcher.submit(
|
||||
{ action: "enable-mfa" },
|
||||
{ method: "POST", action: "/resources/account/mfa/setup" }
|
||||
);
|
||||
},
|
||||
|
||||
cancelSetup: () => {
|
||||
dispatch({ type: "CANCEL_SETUP" });
|
||||
fetcher.submit(
|
||||
{ action: "cancel-totp" },
|
||||
{ method: "POST", action: "/resources/account/mfa/setup" }
|
||||
);
|
||||
},
|
||||
|
||||
validateTotp: (code: string) => {
|
||||
dispatch({ type: "VALIDATE_TOTP", code });
|
||||
fetcher.submit(
|
||||
{ action: "validate-totp", totpCode: code },
|
||||
{ method: "POST", action: "/resources/account/mfa/setup" }
|
||||
);
|
||||
},
|
||||
|
||||
saveRecoveryCodes: () => {
|
||||
dispatch({ type: "RECOVERY_CODES_SAVED" });
|
||||
fetcher.submit(
|
||||
{ action: "saved-recovery-codes" },
|
||||
{ method: "POST", action: "/resources/account/mfa/setup" }
|
||||
);
|
||||
},
|
||||
|
||||
openDisableDialog: () => {
|
||||
dispatch({ type: "OPEN_DISABLE_DIALOG" });
|
||||
},
|
||||
|
||||
disableMfa: (totpCode?: string, recoveryCode?: string) => {
|
||||
dispatch({ type: "DISABLE_MFA" });
|
||||
const formData: Record<string, string> = { action: "disable-mfa" };
|
||||
if (totpCode) formData.totpCode = totpCode;
|
||||
if (recoveryCode) formData.recoveryCode = recoveryCode;
|
||||
|
||||
fetcher.submit(formData, { method: "POST", action: "/resources/account/mfa/setup" });
|
||||
},
|
||||
|
||||
cancelDisable: () => {
|
||||
dispatch({ type: "CANCEL_DISABLE" });
|
||||
},
|
||||
|
||||
setDisableMethod: (method: "totp" | "recovery") => {
|
||||
dispatch({ type: "SET_DISABLE_METHOD", method });
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
dispatch({ type: "CLEAR_ERROR" });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
// Computed properties for easier access
|
||||
isQrDialogOpen:
|
||||
(state.phase === "enabling" && !!state.setupData) ||
|
||||
(state.phase === "showing-recovery" && !!state.recoveryCodes),
|
||||
isRecoveryDialogOpen: false, // Recovery is now handled within the setup dialog
|
||||
isDisableDialogOpen: state.phase === "disabling",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user