chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user