187 lines
5.1 KiB
TypeScript
187 lines
5.1 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|