From e81b8ed47296386ab42594b0d443aa4af20eaa47 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Fri, 10 Jun 2022 16:52:12 +0100 Subject: [PATCH] feat: new modal renderer + mfa flow modal --- README.md | 2 - package.json | 2 +- src/context/index.tsx | 2 + src/context/modals/ModalRenderer.tsx | 7 + src/context/modals/components/MFAFlow.tsx | 164 ++++++++++++++++++++++ src/context/modals/components/Test.tsx | 7 + src/context/modals/index.tsx | 63 +++++++++ src/context/modals/types.ts | 27 ++++ src/context/revoltjs/RevoltClient.tsx | 11 +- src/lib/js.ts | 1 + src/pages/settings/panes/Account.tsx | 41 ++++-- yarn.lock | 10 +- 12 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 src/context/modals/ModalRenderer.tsx create mode 100644 src/context/modals/components/MFAFlow.tsx create mode 100644 src/context/modals/components/Test.tsx create mode 100644 src/context/modals/index.tsx create mode 100644 src/context/modals/types.ts diff --git a/README.md b/README.md index b4f645ef..60a1cdf4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 28279792..733f29e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/context/index.tsx b/src/context/index.tsx index 8d4595cd..89026b31 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -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 }) { + diff --git a/src/context/modals/ModalRenderer.tsx b/src/context/modals/ModalRenderer.tsx new file mode 100644 index 00000000..2ed3d350 --- /dev/null +++ b/src/context/modals/ModalRenderer.tsx @@ -0,0 +1,7 @@ +import { observer } from "mobx-react-lite"; + +import { modalController } from "."; + +export default observer(() => { + return modalController.render(); +}); diff --git a/src/context/modals/components/MFAFlow.tsx b/src/context/modals/components/MFAFlow.tsx new file mode 100644 index 00000000..565c4be6 --- /dev/null +++ b/src/context/modals/components/MFAFlow.tsx @@ -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> = { + 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 ( + <> + + + + + 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( + props.state === "unknown" ? props.available_methods : undefined, + ); + + const [selectedMethod, setSelected] = useState(); + const [response, setResponse] = useState(); + + 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 ( + setSelected(undefined), + }, + ] + : [ + { + palette: "plain", + children: "Cancel", + onClick: noopTrue, + }, + ] + } + onClose={onClose}> + {methods ? ( + selectedMethod ? ( + + ) : ( + methods.map((method) => { + const Icon = ICONS[method]; + return ( + } + onClick={() => setSelected(method)}> + {method} + + ); + }) + ) + ) : ( + + )} + + ); +} diff --git a/src/context/modals/components/Test.tsx b/src/context/modals/components/Test.tsx new file mode 100644 index 00000000..ed59a414 --- /dev/null +++ b/src/context/modals/components/Test.tsx @@ -0,0 +1,7 @@ +import { Modal } from "@revoltchat/ui"; + +import { ModalProps } from "../types"; + +export default function Test({ onClose }: ModalProps<"test">) { + return ; +} diff --git a/src/context/modals/index.tsx b/src/context/modals/index.tsx new file mode 100644 index 00000000..a5843d30 --- /dev/null +++ b/src/context/modals/index.tsx @@ -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>; + +/** + * Handles layering and displaying modals to the user. + */ +class ModalController { + 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 ; + })} + + ); + } +} + +export const modalController = new ModalController({ + mfa_flow: MFAFlow, + test: Test, +}); diff --git a/src/context/modals/types.ts b/src/context/modals/types.ts new file mode 100644 index 00000000..e15a552c --- /dev/null +++ b/src/context/modals/types.ts @@ -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 = Modal & { type: T } & { + onClose: () => void; +}; diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index e166c309..cfb5d4fe 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -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) { diff --git a/src/lib/js.ts b/src/lib/js.ts index 80158291..5a35a5a2 100644 --- a/src/lib/js.ts +++ b/src/lib/js.ts @@ -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 */ diff --git a/src/pages/settings/panes/Account.tsx b/src/pages/settings/panes/Account.tsx index eb67ea01..bc3849a8 100644 --- a/src/pages/settings/panes/Account.tsx +++ b/src/pages/settings/panes/Account.tsx @@ -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)), + }) }> @@ -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)), + }) }> diff --git a/yarn.lock b/yarn.lock index 0c7de6ce..5e07617b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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