mirror of
https://github.com/revoltchat/revite.git
synced 2025-02-22 08:11:03 -05:00
feat: new modal renderer + mfa flow modal
This commit is contained in:
parent
6be0807433
commit
e81b8ed472
12 changed files with 311 additions and 26 deletions
|
@ -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/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/context/revoltjs`: client state management needs to be rewritten and include support for concurrent clients
|
||||||
- `src/lib`: this needs to be organised
|
- `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
|
## Stack
|
||||||
|
|
||||||
|
|
|
@ -90,7 +90,7 @@
|
||||||
"@hcaptcha/react-hcaptcha": "^0.3.6",
|
"@hcaptcha/react-hcaptcha": "^0.3.6",
|
||||||
"@insertish/vite-plugin-babel-macros": "^1.0.5",
|
"@insertish/vite-plugin-babel-macros": "^1.0.5",
|
||||||
"@preact/preset-vite": "^2.0.0",
|
"@preact/preset-vite": "^2.0.0",
|
||||||
"@revoltchat/ui": "1.0.36",
|
"@revoltchat/ui": "1.0.39",
|
||||||
"@rollup/plugin-replace": "^2.4.2",
|
"@rollup/plugin-replace": "^2.4.2",
|
||||||
"@styled-icons/boxicons-logos": "^10.38.0",
|
"@styled-icons/boxicons-logos": "^10.38.0",
|
||||||
"@styled-icons/boxicons-regular": "^10.38.0",
|
"@styled-icons/boxicons-regular": "^10.38.0",
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { hydrateState } from "../mobx/State";
|
||||||
import Locale from "./Locale";
|
import Locale from "./Locale";
|
||||||
import Theme from "./Theme";
|
import Theme from "./Theme";
|
||||||
import Intermediate from "./intermediate/Intermediate";
|
import Intermediate from "./intermediate/Intermediate";
|
||||||
|
import ModalRenderer from "./modals/ModalRenderer";
|
||||||
import Client from "./revoltjs/RevoltClient";
|
import Client from "./revoltjs/RevoltClient";
|
||||||
import SyncManager from "./revoltjs/SyncManager";
|
import SyncManager from "./revoltjs/SyncManager";
|
||||||
|
|
||||||
|
@ -44,6 +45,7 @@ export default function Context({ children }: { children: Children }) {
|
||||||
<SyncManager />
|
<SyncManager />
|
||||||
</Client>
|
</Client>
|
||||||
</Intermediate>
|
</Intermediate>
|
||||||
|
<ModalRenderer />
|
||||||
</Locale>
|
</Locale>
|
||||||
</TrigProvider>
|
</TrigProvider>
|
||||||
</TextProvider>
|
</TextProvider>
|
||||||
|
|
7
src/context/modals/ModalRenderer.tsx
Normal file
7
src/context/modals/ModalRenderer.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
import { modalController } from ".";
|
||||||
|
|
||||||
|
export default observer(() => {
|
||||||
|
return modalController.render();
|
||||||
|
});
|
164
src/context/modals/components/MFAFlow.tsx
Normal file
164
src/context/modals/components/MFAFlow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
7
src/context/modals/components/Test.tsx
Normal file
7
src/context/modals/components/Test.tsx
Normal 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} />;
|
||||||
|
}
|
63
src/context/modals/index.tsx
Normal file
63
src/context/modals/index.tsx
Normal 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,
|
||||||
|
});
|
27
src/context/modals/types.ts
Normal file
27
src/context/modals/types.ts
Normal 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;
|
||||||
|
};
|
|
@ -42,10 +42,13 @@ export default observer(({ children }: Props) => {
|
||||||
const [status, setStatus] = useState(ClientStatus.LOADING);
|
const [status, setStatus] = useState(ClientStatus.LOADING);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
const logout = useCallback((avoidReq?: boolean) => {
|
const logout = useCallback(
|
||||||
|
(avoidReq?: boolean) => {
|
||||||
setLoaded(false);
|
setLoaded(false);
|
||||||
client.logout(avoidReq);
|
client.logout(avoidReq);
|
||||||
}, []);
|
},
|
||||||
|
[client],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||||
export const noop = () => {};
|
export const noop = () => {};
|
||||||
export const noopAsync = async () => {};
|
export const noopAsync = async () => {};
|
||||||
|
export const noopTrue = () => true;
|
||||||
/* eslint-enable @typescript-eslint/no-empty-function */
|
/* eslint-enable @typescript-eslint/no-empty-function */
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { Button, CategoryButton, LineDivider, Tip } from "@revoltchat/ui";
|
||||||
import { stopPropagation } from "../../../lib/stopPropagation";
|
import { stopPropagation } from "../../../lib/stopPropagation";
|
||||||
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
import { modalController } from "../../../context/modals";
|
||||||
import {
|
import {
|
||||||
ClientStatus,
|
ClientStatus,
|
||||||
LogOutContext,
|
LogOutContext,
|
||||||
|
@ -210,13 +211,19 @@ export const Account = observer(() => {
|
||||||
}
|
}
|
||||||
action="chevron"
|
action="chevron"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
modalController.push({
|
||||||
|
type: "mfa_flow",
|
||||||
|
state: "known",
|
||||||
|
client,
|
||||||
|
callback: ({ token }) =>
|
||||||
client.api
|
client.api
|
||||||
.post("/auth/account/disable", undefined, {
|
.post("/auth/account/disable", undefined, {
|
||||||
headers: {
|
headers: {
|
||||||
"X-MFA-Ticket": "TICKET",
|
"X-MFA-Ticket": token,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => logOut(true))
|
.then(() => logOut(true)),
|
||||||
|
})
|
||||||
}>
|
}>
|
||||||
<Text id="app.settings.pages.account.manage.disable" />
|
<Text id="app.settings.pages.account.manage.disable" />
|
||||||
</CategoryButton>
|
</CategoryButton>
|
||||||
|
@ -227,13 +234,19 @@ export const Account = observer(() => {
|
||||||
}
|
}
|
||||||
action="chevron"
|
action="chevron"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
modalController.push({
|
||||||
|
type: "mfa_flow",
|
||||||
|
state: "known",
|
||||||
|
client,
|
||||||
|
callback: ({ token }) =>
|
||||||
client.api
|
client.api
|
||||||
.post("/auth/account/delete", undefined, {
|
.post("/auth/account/delete", undefined, {
|
||||||
headers: {
|
headers: {
|
||||||
"X-MFA-Ticket": "TICKET",
|
"X-MFA-Ticket": token,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => logOut(true))
|
.then(() => logOut(true)),
|
||||||
|
})
|
||||||
}>
|
}>
|
||||||
<Text id="app.settings.pages.account.manage.delete" />
|
<Text id="app.settings.pages.account.manage.delete" />
|
||||||
</CategoryButton>
|
</CategoryButton>
|
||||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -2220,9 +2220,9 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@revoltchat/ui@npm:1.0.36":
|
"@revoltchat/ui@npm:1.0.39":
|
||||||
version: 1.0.36
|
version: 1.0.39
|
||||||
resolution: "@revoltchat/ui@npm:1.0.36"
|
resolution: "@revoltchat/ui@npm:1.0.39"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@styled-icons/boxicons-logos": ^10.38.0
|
"@styled-icons/boxicons-logos": ^10.38.0
|
||||||
"@styled-icons/boxicons-regular": ^10.38.0
|
"@styled-icons/boxicons-regular": ^10.38.0
|
||||||
|
@ -2235,7 +2235,7 @@ __metadata:
|
||||||
react-device-detect: "*"
|
react-device-detect: "*"
|
||||||
react-virtuoso: "*"
|
react-virtuoso: "*"
|
||||||
revolt.js: "*"
|
revolt.js: "*"
|
||||||
checksum: 97eee93df28f2ca826c7cb1493e3c0efe0ab83d3ef8ea3d3ec013ff3b527f2692193ef50c8e44d144f96d49457c4d290a4dc708a38ab527f3a4290e0d05b41b5
|
checksum: 0376ef1e6c90a139da613a0b76d498327c7bad63941d02eb27b9d5b8208f09c01fb45330fc4e0643554a298beee416814dd41fd9992750378491450c6f773ee0
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -3521,7 +3521,7 @@ __metadata:
|
||||||
"@hcaptcha/react-hcaptcha": ^0.3.6
|
"@hcaptcha/react-hcaptcha": ^0.3.6
|
||||||
"@insertish/vite-plugin-babel-macros": ^1.0.5
|
"@insertish/vite-plugin-babel-macros": ^1.0.5
|
||||||
"@preact/preset-vite": ^2.0.0
|
"@preact/preset-vite": ^2.0.0
|
||||||
"@revoltchat/ui": 1.0.36
|
"@revoltchat/ui": 1.0.39
|
||||||
"@rollup/plugin-replace": ^2.4.2
|
"@rollup/plugin-replace": ^2.4.2
|
||||||
"@styled-icons/boxicons-logos": ^10.38.0
|
"@styled-icons/boxicons-logos": ^10.38.0
|
||||||
"@styled-icons/boxicons-regular": ^10.38.0
|
"@styled-icons/boxicons-regular": ^10.38.0
|
||||||
|
|
Loading…
Add table
Reference in a new issue