stoat-for-desktop/components/modal/modals/MFAFlow.tsx

277 lines
7.4 KiB
TypeScript

import { createFormControl, createFormGroup } from "solid-forms";
import { BiRegularArchive, BiSolidKey, BiSolidKeyboard } from "solid-icons/bi";
import {
For,
Match,
Switch,
createEffect,
createSignal,
onMount,
} from "solid-js";
import { Trans, useLingui } from "@lingui-solid/solid/macro";
import type { API } from "stoat.js";
import {
CategoryButton,
CircularProgress,
Column,
Dialog,
DialogProps,
Form2,
Text,
} from "@revolt/ui";
import { useModals } from "..";
import { Modals } from "../types";
/**
* Modal to create an MFA ticket
*/
export function MFAFlowModal(
props: DialogProps & Modals & { type: "mfa_flow" },
) {
const { t } = useLingui();
const { showError } = useModals();
// Keep track of available methods
const [methods, setMethods] = createSignal<API.MFAMethod[] | undefined>(
// eslint-disable-next-line solid/reactivity
props.state === "unknown" ? props.available_methods : undefined,
);
// Current state of the modal
const [selectedMethod, setSelected] = createSignal<API.MFAMethod>();
const group = createFormGroup({
password: createFormControl(""),
totp_code: createFormControl(""),
recovery_code: createFormControl(""),
});
// Fetch available methods if they have not been provided.
onMount(() => {
if (!methods() && props.state === "known") {
setMethods(props.mfa.availableMethods);
}
});
// Always select first available method if only one available.
createEffect(() => {
const list = methods();
if (list) {
setSelected(list.find((entry) => entry !== "Recovery"));
}
});
/**
* Callback to generate a new ticket or send response back up the chain
*/
async function onSubmit() {
try {
const method = selectedMethod();
if (!method) return;
let mfa_response: API.MFAResponse;
switch (method) {
case "Password":
mfa_response = { password: group.controls.password.value };
break;
case "Totp":
mfa_response = { totp_code: group.controls.totp_code.value };
break;
case "Recovery":
mfa_response = { recovery_code: group.controls.recovery_code.value };
break;
default:
return;
}
if (props.state === "known") {
const ticket = await props.mfa.createTicket(mfa_response);
props.callback(ticket);
} else {
props.callback(mfa_response);
}
props.onClose();
} catch (error) {
showError(error);
}
}
function onCancel() {
props.callback();
props.onClose();
}
function onBack() {
if (methods()!.length === 1) {
onCancel();
} else {
setSelected(undefined);
// Clear form values
group.controls.password.setValue("");
group.controls.totp_code.setValue("");
group.controls.recovery_code.setValue("");
}
}
function canSubmit() {
return (
Form2.canSubmit(group) &&
(group.controls.password.value ||
group.controls.totp_code.value ||
group.controls.recovery_code.value)
);
}
const getActions = () => {
if (selectedMethod()) {
return [
{
text: (
<Switch fallback={<Trans>Back</Trans>}>
<Match when={methods()!.length === 1}>
<Trans>Cancel</Trans>
</Match>
</Switch>
),
onClick: () => {
onBack();
return false;
},
},
{
text: <Trans>Confirm</Trans>,
onClick: () => {
onSubmit();
return false;
},
isDisabled: !canSubmit(),
},
];
}
return [
{
text: <Trans>Cancel</Trans>,
onClick: () => {
onCancel();
return false;
},
},
];
};
const submit = Form2.useSubmitHandler(group, onSubmit);
return (
<Dialog
show={props.show}
onClose={() =>
props.state === "unknown" || selectedMethod()
? undefined
: props.onClose()
}
title={<Trans>Confirm action</Trans>}
actions={getActions()}
isDisabled={group.isPending}
>
<form onSubmit={submit}>
<Column>
<Text>
<Switch
fallback={
<Trans>
Please select a method to authenticate your request.
</Trans>
}
>
<Match when={selectedMethod()}>
<Trans>
Please confirm this action using the selected method.
</Trans>
</Match>
</Switch>
</Text>
<Switch fallback={<CircularProgress />}>
<Match when={selectedMethod()}>
<Switch>
<Match when={selectedMethod() === "Password"}>
<Form2.TextField
name="password"
control={group.controls.password}
type="password"
label={t`Password`}
/>
</Match>
<Match when={selectedMethod() === "Totp"}>
<Form2.TextField
name="totp_code"
control={group.controls.totp_code}
type="text"
label={t`Authenticator App`}
/>
</Match>
<Match when={selectedMethod() === "Recovery"}>
<Form2.TextField
name="recovery_code"
control={group.controls.recovery_code}
type="text"
label={t`Recovery Code`}
/>
</Match>
</Switch>
</Match>
<Match when={methods()}>
<For each={methods()}>
{(method) => (
<CategoryButton
action="chevron"
icon={
<Switch>
<Match when={method === "Password"}>
<BiSolidKeyboard size={24} />
</Match>
<Match when={method === "Totp"}>
<BiSolidKey size={24} />
</Match>
<Match when={method === "Recovery"}>
<BiRegularArchive size={24} />
</Match>
</Switch>
}
onClick={() => {
setSelected(method);
// Clear form values when switching methods
group.controls.password.setValue("");
group.controls.totp_code.setValue("");
group.controls.recovery_code.setValue("");
}}
>
<Switch>
<Match when={method === "Password"}>
<Trans>Password</Trans>
</Match>
<Match when={method === "Totp"}>
<Trans>Authenticator App</Trans>
</Match>
<Match when={method === "Recovery"}>
<Trans>Recovery Code</Trans>
</Match>
</Switch>
</CategoryButton>
)}
</For>
</Match>
</Switch>
</Column>
</form>
</Dialog>
);
}