mirror of
https://github.com/revoltchat/revite.git
synced 2024-12-24 06:32:08 -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/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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
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 [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) {
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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>
|
||||
|
|
10
yarn.lock
10
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
|
||||
|
|
Loading…
Reference in a new issue