Files
2026-07-13 13:32:57 +08:00

302 lines
7.4 KiB
TypeScript

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",
};
}