feat: new modal renderer + mfa flow modal

This commit is contained in:
Paul Makles 2022-06-10 16:52:12 +01:00
parent 6be0807433
commit e81b8ed472
12 changed files with 311 additions and 26 deletions

View file

@ -13,8 +13,6 @@ The following code is pending a partial or full rewrite:
- `src/context/intermediate`: modal system is being rewritten from scratch
- `src/context/revoltjs`: client state management needs to be rewritten and include support for concurrent clients
- `src/lib`: this needs to be organised
- `src/*.ts(x)`: half of these files should be moved
- `src/*.d.ts`: should be in dedicated types folder
## Stack

View file

@ -90,7 +90,7 @@
"@hcaptcha/react-hcaptcha": "^0.3.6",
"@insertish/vite-plugin-babel-macros": "^1.0.5",
"@preact/preset-vite": "^2.0.0",
"@revoltchat/ui": "1.0.36",
"@revoltchat/ui": "1.0.39",
"@rollup/plugin-replace": "^2.4.2",
"@styled-icons/boxicons-logos": "^10.38.0",
"@styled-icons/boxicons-regular": "^10.38.0",

View file

@ -16,6 +16,7 @@ import { hydrateState } from "../mobx/State";
import Locale from "./Locale";
import Theme from "./Theme";
import Intermediate from "./intermediate/Intermediate";
import ModalRenderer from "./modals/ModalRenderer";
import Client from "./revoltjs/RevoltClient";
import SyncManager from "./revoltjs/SyncManager";
@ -44,6 +45,7 @@ export default function Context({ children }: { children: Children }) {
<SyncManager />
</Client>
</Intermediate>
<ModalRenderer />
</Locale>
</TrigProvider>
</TextProvider>

View file

@ -0,0 +1,7 @@
import { observer } from "mobx-react-lite";
import { modalController } from ".";
export default observer(() => {
return modalController.render();
});

View file

@ -0,0 +1,164 @@
import { Archive } from "@styled-icons/boxicons-regular";
import { Key, Keyboard } from "@styled-icons/boxicons-solid";
import { API } from "revolt.js";
import { Text } from "preact-i18n";
import { useCallback, useEffect, useState } from "preact/hooks";
import {
Category,
CategoryButton,
InputBox,
Modal,
Preloader,
} from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { useApplicationState } from "../../../mobx/State";
import { ModalProps } from "../types";
const ICONS: Record<API.MFAMethod, React.FC<any>> = {
Password: Keyboard,
Totp: Key,
Recovery: Archive,
};
function ResponseEntry({
type,
value,
onChange,
}: {
type: API.MFAMethod;
value?: API.MFAResponse;
onChange: (v: API.MFAResponse) => void;
}) {
if (type === "Password") {
return (
<>
<Category compact>
<Text id={`login.${type.toLowerCase()}`} />
</Category>
<InputBox
type="password"
value={(value as { password: string })?.password}
onChange={(e) =>
onChange({ password: e.currentTarget.value })
}
/>
</>
);
} else {
return null;
}
}
/**
* MFA ticket creation flow
*/
export default function MFAFlow({
callback,
onClose,
...props
}: ModalProps<"mfa_flow">) {
const state = useApplicationState();
const [methods, setMethods] = useState<API.MFAMethod[] | undefined>(
props.state === "unknown" ? props.available_methods : undefined,
);
const [selectedMethod, setSelected] = useState<API.MFAMethod>();
const [response, setResponse] = useState<API.MFAResponse>();
useEffect(() => {
if (!methods && props.state === "known") {
props.client.api.get("/auth/mfa/methods").then(setMethods);
}
}, []);
const generateTicket = useCallback(async () => {
if (response) {
let ticket;
if (props.state === "known") {
ticket = await props.client.api.put(
"/auth/mfa/ticket",
response,
);
} else {
ticket = await state.config
.createClient()
.api.put("/auth/mfa/ticket", response, {
headers: {
"X-MFA-Ticket": props.ticket.token,
},
});
}
callback(ticket);
return true;
}
return false;
}, [response]);
return (
<Modal
title="Confirm action."
description={
selectedMethod
? "Please confirm using selected method."
: "Please select a method to authenticate your request."
}
actions={
selectedMethod
? [
{
palette: "primary",
children: "Confirm",
onClick: generateTicket,
confirmation: true,
},
{
palette: "plain",
children: "Back",
onClick: () => setSelected(undefined),
},
]
: [
{
palette: "plain",
children: "Cancel",
onClick: noopTrue,
},
]
}
onClose={onClose}>
{methods ? (
selectedMethod ? (
<ResponseEntry
type={selectedMethod}
value={response}
onChange={setResponse}
/>
) : (
methods.map((method) => {
const Icon = ICONS[method];
return (
<CategoryButton
key={method}
action="chevron"
icon={<Icon size={24} />}
onClick={() => setSelected(method)}>
{method}
</CategoryButton>
);
})
)
) : (
<Preloader type="ring" />
)}
</Modal>
);
}

View file

@ -0,0 +1,7 @@
import { Modal } from "@revoltchat/ui";
import { ModalProps } from "../types";
export default function Test({ onClose }: ModalProps<"test">) {
return <Modal title="I am a sub modal!" onClose={onClose} />;
}

View file

@ -0,0 +1,63 @@
import { action, computed, makeAutoObservable } from "mobx";
import { ulid } from "ulid";
import MFAFlow from "./components/MFAFlow";
import Test from "./components/Test";
import { Modal } from "./types";
type Components = Record<string, React.FC<any>>;
/**
* Handles layering and displaying modals to the user.
*/
class ModalController<T extends Modal> {
stack: T[] = [];
components: Components;
constructor(components: Components) {
this.components = components;
makeAutoObservable(this);
this.pop = this.pop.bind(this);
}
/**
* Display a new modal on the stack
* @param modal Modal data
*/
@action push(modal: T) {
this.stack = [
...this.stack,
{
...modal,
key: ulid(),
},
];
}
/**
* Remove the top modal from the stack
*/
@action pop() {
this.stack = this.stack.slice(0, this.stack.length - 1);
}
/**
* Render modals
*/
@computed render() {
return (
<>
{this.stack.map((modal) => {
const Component = this.components[modal.type];
return <Component {...modal} onClose={this.pop} />;
})}
</>
);
}
}
export const modalController = new ModalController<Modal>({
mfa_flow: MFAFlow,
test: Test,
});

View file

@ -0,0 +1,27 @@
import { API, Client } from "revolt.js";
export type Modal = {
key?: string;
} & (
| ({
type: "mfa_flow";
callback: (ticket: API.MFATicket) => void;
} & (
| {
state: "known";
client: Client;
}
| {
state: "unknown";
available_methods: API.MFAMethod[];
ticket: API.MFATicket & { validated: false };
}
))
| {
type: "test";
}
);
export type ModalProps<T extends Modal["type"]> = Modal & { type: T } & {
onClose: () => void;
};

View file

@ -42,10 +42,13 @@ export default observer(({ children }: Props) => {
const [status, setStatus] = useState(ClientStatus.LOADING);
const [loaded, setLoaded] = useState(false);
const logout = useCallback((avoidReq?: boolean) => {
setLoaded(false);
client.logout(avoidReq);
}, []);
const logout = useCallback(
(avoidReq?: boolean) => {
setLoaded(false);
client.logout(avoidReq);
},
[client],
);
useEffect(() => {
if (navigator.onLine) {

View file

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-empty-function */
export const noop = () => {};
export const noopAsync = async () => {};
export const noopTrue = () => true;
/* eslint-enable @typescript-eslint/no-empty-function */

View file

@ -19,6 +19,7 @@ import { Button, CategoryButton, LineDivider, Tip } from "@revoltchat/ui";
import { stopPropagation } from "../../../lib/stopPropagation";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { modalController } from "../../../context/modals";
import {
ClientStatus,
LogOutContext,
@ -210,13 +211,19 @@ export const Account = observer(() => {
}
action="chevron"
onClick={() =>
client.api
.post("/auth/account/disable", undefined, {
headers: {
"X-MFA-Ticket": "TICKET",
},
})
.then(() => logOut(true))
modalController.push({
type: "mfa_flow",
state: "known",
client,
callback: ({ token }) =>
client.api
.post("/auth/account/disable", undefined, {
headers: {
"X-MFA-Ticket": token,
},
})
.then(() => logOut(true)),
})
}>
<Text id="app.settings.pages.account.manage.disable" />
</CategoryButton>
@ -227,13 +234,19 @@ export const Account = observer(() => {
}
action="chevron"
onClick={() =>
client.api
.post("/auth/account/delete", undefined, {
headers: {
"X-MFA-Ticket": "TICKET",
},
})
.then(() => logOut(true))
modalController.push({
type: "mfa_flow",
state: "known",
client,
callback: ({ token }) =>
client.api
.post("/auth/account/delete", undefined, {
headers: {
"X-MFA-Ticket": token,
},
})
.then(() => logOut(true)),
})
}>
<Text id="app.settings.pages.account.manage.delete" />
</CategoryButton>

View file

@ -2220,9 +2220,9 @@ __metadata:
languageName: node
linkType: hard
"@revoltchat/ui@npm:1.0.36":
version: 1.0.36
resolution: "@revoltchat/ui@npm:1.0.36"
"@revoltchat/ui@npm:1.0.39":
version: 1.0.39
resolution: "@revoltchat/ui@npm:1.0.39"
dependencies:
"@styled-icons/boxicons-logos": ^10.38.0
"@styled-icons/boxicons-regular": ^10.38.0
@ -2235,7 +2235,7 @@ __metadata:
react-device-detect: "*"
react-virtuoso: "*"
revolt.js: "*"
checksum: 97eee93df28f2ca826c7cb1493e3c0efe0ab83d3ef8ea3d3ec013ff3b527f2692193ef50c8e44d144f96d49457c4d290a4dc708a38ab527f3a4290e0d05b41b5
checksum: 0376ef1e6c90a139da613a0b76d498327c7bad63941d02eb27b9d5b8208f09c01fb45330fc4e0643554a298beee416814dd41fd9992750378491450c6f773ee0
languageName: node
linkType: hard
@ -3521,7 +3521,7 @@ __metadata:
"@hcaptcha/react-hcaptcha": ^0.3.6
"@insertish/vite-plugin-babel-macros": ^1.0.5
"@preact/preset-vite": ^2.0.0
"@revoltchat/ui": 1.0.36
"@revoltchat/ui": 1.0.39
"@rollup/plugin-replace": ^2.4.2
"@styled-icons/boxicons-logos": ^10.38.0
"@styled-icons/boxicons-regular": ^10.38.0