302 lines
7.4 KiB
TypeScript
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",
|
|
};
|
|
}
|