chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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