chore: refactor account UI

This commit is contained in:
Paul Makles
2022-06-12 15:07:30 +01:00
parent db653b3981
commit 98bdea0b65
7 changed files with 145 additions and 207 deletions

View File

@@ -162,5 +162,8 @@
"repository": "https://github.com/revoltchat/revite.git", "repository": "https://github.com/revoltchat/revite.git",
"author": "Paul <paulmakles@gmail.com>", "author": "Paul <paulmakles@gmail.com>",
"license": "MIT", "license": "MIT",
"packageManager": "yarn@3.2.0" "packageManager": "yarn@3.2.0",
"resolutions": {
"@revoltchat/ui": "portal:../components"
}
} }

View File

@@ -4,12 +4,7 @@ import { ContextMenuTrigger } from "preact-context-menu";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { import { Preloader, UIProvider } from "@revoltchat/ui";
LinkProvider,
Preloader,
TextProvider,
TrigProvider,
} from "@revoltchat/ui";
import { hydrateState } from "../mobx/State"; import { hydrateState } from "../mobx/State";
@@ -20,6 +15,13 @@ 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";
const uiContext = {
Link,
Text: Text as any,
Trigger: ContextMenuTrigger,
emitAction: () => {},
};
/** /**
* This component provides all of the application's context layers. * This component provides all of the application's context layers.
* @param param0 Provided children * @param param0 Provided children
@@ -35,21 +37,17 @@ export default function Context({ children }: { children: Children }) {
return ( return (
<Router basename={import.meta.env.BASE_URL}> <Router basename={import.meta.env.BASE_URL}>
<LinkProvider value={Link}> <UIProvider value={uiContext}>
<TextProvider value={Text as any}> <Locale>
<TrigProvider value={ContextMenuTrigger}> <Intermediate>
<Locale> <Client>
<Intermediate> {children}
<Client> <SyncManager />
{children} </Client>
<SyncManager /> </Intermediate>
</Client> <ModalRenderer />
</Intermediate> </Locale>
<ModalRenderer /> </UIProvider>
</Locale>
</TrigProvider>
</TextProvider>
</LinkProvider>
<Theme /> <Theme />
</Router> </Router>
); );

View File

@@ -3,7 +3,12 @@ import { Key, Keyboard } from "@styled-icons/boxicons-solid";
import { API } from "revolt.js"; import { API } from "revolt.js";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useCallback, useEffect, useState } from "preact/hooks"; import {
useCallback,
useEffect,
useLayoutEffect,
useState,
} from "preact/hooks";
import { import {
Category, Category,
@@ -15,8 +20,6 @@ import {
import { noopTrue } from "../../../lib/js"; import { noopTrue } from "../../../lib/js";
import { useApplicationState } from "../../../mobx/State";
import { ModalProps } from "../types"; import { ModalProps } from "../types";
const ICONS: Record<API.MFAMethod, React.FC<any>> = { const ICONS: Record<API.MFAMethod, React.FC<any>> = {
@@ -34,12 +37,13 @@ function ResponseEntry({
value?: API.MFAResponse; value?: API.MFAResponse;
onChange: (v: API.MFAResponse) => void; onChange: (v: API.MFAResponse) => void;
}) { }) {
if (type === "Password") { return (
return ( <>
<> <Category compact>
<Category compact> <Text id={`login.${type.toLowerCase()}`} />
<Text id={`login.${type.toLowerCase()}`} /> </Category>
</Category>
{type === "Password" && (
<InputBox <InputBox
type="password" type="password"
value={(value as { password: string })?.password} value={(value as { password: string })?.password}
@@ -47,56 +51,51 @@ function ResponseEntry({
onChange({ password: e.currentTarget.value }) onChange({ password: e.currentTarget.value })
} }
/> />
</> )}
); </>
} else { );
return null;
}
} }
/** /**
* MFA ticket creation flow * MFA ticket creation flow
*/ */
export default function MFAFlow({ export default function MFAFlow({ onClose, ...props }: ModalProps<"mfa_flow">) {
callback,
onClose,
...props
}: ModalProps<"mfa_flow">) {
const state = useApplicationState();
const [methods, setMethods] = useState<API.MFAMethod[] | undefined>( const [methods, setMethods] = useState<API.MFAMethod[] | undefined>(
props.state === "unknown" ? props.available_methods : undefined, props.state === "unknown" ? props.available_methods : undefined,
); );
// Current state of the modal
const [selectedMethod, setSelected] = useState<API.MFAMethod>(); const [selectedMethod, setSelected] = useState<API.MFAMethod>();
const [response, setResponse] = useState<API.MFAResponse>(); const [response, setResponse] = useState<API.MFAResponse>();
// Fetch available methods if they have not been provided.
useEffect(() => { useEffect(() => {
if (!methods && props.state === "known") { if (!methods && props.state === "known") {
props.client.api.get("/auth/mfa/methods").then(setMethods); props.client.api.get("/auth/mfa/methods").then(setMethods);
} }
}, []); }, []);
// Always select first available method if only one available.
useLayoutEffect(() => {
if (methods && methods.length === 1) {
setSelected(methods[0]);
}
}, [methods]);
// Callback to generate a new ticket or send response back up the chain.
const generateTicket = useCallback(async () => { const generateTicket = useCallback(async () => {
if (response) { if (response) {
let ticket;
if (props.state === "known") { if (props.state === "known") {
ticket = await props.client.api.put( const ticket = await props.client.api.put(
"/auth/mfa/ticket", "/auth/mfa/ticket",
response, response,
); );
props.callback(ticket);
} else { } else {
ticket = await state.config props.callback(response);
.createClient()
.api.put("/auth/mfa/ticket", response, {
headers: {
"X-MFA-Ticket": props.ticket.token,
},
});
} }
callback(ticket);
return true; return true;
} }
@@ -122,8 +121,12 @@ export default function MFAFlow({
}, },
{ {
palette: "plain", palette: "plain",
children: "Back", children:
onClick: () => setSelected(undefined), methods!.length === 1 ? "Cancel" : "Back",
onClick: () =>
methods!.length === 1
? true
: void setSelected(undefined),
}, },
] ]
: [ : [

View File

@@ -5,16 +5,16 @@ export type Modal = {
} & ( } & (
| ({ | ({
type: "mfa_flow"; type: "mfa_flow";
callback: (ticket: API.MFATicket) => void;
} & ( } & (
| { | {
state: "known"; state: "known";
client: Client; client: Client;
callback: (ticket: API.MFATicket) => void;
} }
| { | {
state: "unknown"; state: "unknown";
available_methods: API.MFAMethod[]; available_methods: API.MFAMethod[];
ticket: API.MFATicket & { validated: false }; callback: (response: API.MFAResponse) => void;
} }
)) ))
| { | {

View File

@@ -4,6 +4,7 @@ import { API } from "revolt.js";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { modalController } from "../../../context/modals";
import { Form } from "./Form"; import { Form } from "./Form";
@@ -43,14 +44,34 @@ export function FormLogin() {
// This should be replaced in the future. // This should be replaced in the future.
const client = state.config.createClient(); const client = state.config.createClient();
await client.fetchConfiguration(); await client.fetchConfiguration();
const session = await client.api.post("/auth/session/login", {
let session = await client.api.post("/auth/session/login", {
...data, ...data,
friendly_name, friendly_name,
}); });
if (session.result !== "Success") { if (session.result === "MFA") {
alert("unsupported!"); const { allowed_methods } = session;
return; let mfa_response: API.MFAResponse = await new Promise(
(callback) =>
modalController.push({
type: "mfa_flow",
state: "unknown",
available_methods: allowed_methods,
callback,
}),
);
session = await client.api.post("/auth/session/login", {
mfa_response,
mfa_ticket: session.ticket,
friendly_name,
});
if (session.result === "MFA") {
// unreachable code
return;
}
} }
const s = session; const s = session;

View File

@@ -1,22 +1,19 @@
import { At, Key, Block } from "@styled-icons/boxicons-regular"; import { At, Key, Block } from "@styled-icons/boxicons-regular";
import { import { Envelope, Lock, Trash, Pencil } from "@styled-icons/boxicons-solid";
Envelope,
HelpCircle,
Lock,
Trash,
Pencil,
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { Link } from "react-router-dom";
import { API } from "revolt.js";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { Button, CategoryButton, LineDivider, Tip } from "@revoltchat/ui"; import {
AccountDetail,
import { stopPropagation } from "../../../lib/stopPropagation"; CategoryButton,
Column,
HiddenValue,
Tip,
} from "@revoltchat/ui";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { modalController } from "../../../context/modals"; import { modalController } from "../../../context/modals";
@@ -27,26 +24,14 @@ import {
useClient, useClient,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../context/revoltjs/RevoltClient";
import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon";
export const Account = observer(() => { export const Account = observer(() => {
const { openScreen, writeClipboard } = useIntermediate(); const { openScreen } = useIntermediate();
const logOut = useContext(LogOutContext); const logOut = useContext(LogOutContext);
const status = useContext(StatusContext); const status = useContext(StatusContext);
const client = useClient(); const client = useClient();
const [email, setEmail] = useState("..."); const [email, setEmail] = useState("...");
const [revealEmail, setRevealEmail] = useState(false);
const [profile, setProfile] = useState<undefined | API.UserProfile>(
undefined,
);
const history = useHistory();
function switchPage(to: string) {
history.replace(`/settings/${to}`);
}
useEffect(() => { useEffect(() => {
if (email === "..." && status === ClientStatus.ONLINE) { if (email === "..." && status === ClientStatus.ONLINE) {
@@ -54,124 +39,48 @@ export const Account = observer(() => {
.get("/auth/account/") .get("/auth/account/")
.then((account) => setEmail(account.email)); .then((account) => setEmail(account.email));
} }
}, [client, email, status]);
if (profile === undefined && status === ClientStatus.ONLINE) {
client
.user!.fetchProfile()
.then((profile) => setProfile(profile ?? {}));
}
}, [client, email, profile, status]);
return ( return (
<div className={styles.user}> <div className={styles.user}>
<div className={styles.banner}> <Column group>
<div className={styles.container}> <AccountDetail user={client.user!} />
<UserIcon </Column>
className={styles.avatar}
target={client.user!} {(
size={72} [
onClick={() => switchPage("profile")} ["username", client.user!.username, At],
/> ["email", email, Envelope],
<div className={styles.userDetail}> ["password", "•••••••••", Key],
<div className={styles.userContainer}> ] as const
<UserIcon ).map(([field, value, Icon]) => (
className={styles.tinyavatar} <CategoryButton
target={client.user!} key={field}
size={25} icon={<Icon size={24} />}
onClick={() => switchPage("profile")} description={
/> field === "email" ? (
<div className={styles.username}> <HiddenValue
@{client.user!.username} value={value}
</div> placeholder={"•••••••••••@••••••.•••"}
</div> />
<div className={styles.userid}> ) : (
<Tooltip value
content={ )
<Text id="app.settings.pages.account.unique_id" /> }
}> account
<HelpCircle size={16} /> action={<Pencil size={20} />}
</Tooltip> onClick={() =>
<Tooltip content={<Text id="app.special.copy" />}> openScreen({
<a id: "modify_account",
onClick={() => field,
writeClipboard(client.user!._id) })
}> }>
{client.user!._id} <Text id={`login.${field}`} />
</a> </CategoryButton>
</Tooltip> ))}
</div>
</div>
</div>
<Button
onClick={() => switchPage("profile")}
palette="secondary">
<Text id="app.settings.pages.profile.edit_profile" />
</Button>
</div>
<div>
{(
[
[
"username",
client.user!.username,
<At key="at" size={24} />,
],
["email", email, <Envelope key="envelope" size={24} />],
["password", "•••••••••", <Key key="key" size={24} />],
] as const
).map(([field, value, icon]) => (
<CategoryButton
key={field}
icon={icon}
description={
field === "email" ? (
revealEmail ? (
<>
{value}{" "}
<a
style={{ fontSize: "13px" }}
onClick={(ev) =>
stopPropagation(
ev,
setRevealEmail(false),
)
}>
<Text id="app.special.modals.actions.hide" />
</a>
</>
) : (
<>
@.{" "}
<a
style={{ fontSize: "13px" }}
onClick={(ev) =>
stopPropagation(
ev,
setRevealEmail(true),
)
}>
<Text id="app.special.modals.actions.reveal" />
</a>
</>
)
) : (
value
)
}
account
action={<Pencil size={20} />}
onClick={() =>
openScreen({
id: "modify_account",
field,
})
}>
<Text id={`login.${field}`} />
</CategoryButton>
))}
</div>
<hr /> <hr />
<h3> <h3>
<Text id="app.settings.pages.account.2fa.title" /> <Text id="app.settings.pages.account.2fa.title" />
</h3> </h3>
@@ -197,10 +106,13 @@ export const Account = observer(() => {
action="chevron"> action="chevron">
View my backup codes View my backup codes
</CategoryButton>*/} </CategoryButton>*/}
<hr /> <hr />
<h3> <h3>
<Text id="app.settings.pages.account.manage.title" /> <Text id="app.settings.pages.account.manage.title" />
</h3> </h3>
<h5> <h5>
<Text id="app.settings.pages.account.manage.description" /> <Text id="app.settings.pages.account.manage.description" />
</h5> </h5>
@@ -227,6 +139,7 @@ export const Account = observer(() => {
}> }>
<Text id="app.settings.pages.account.manage.disable" /> <Text id="app.settings.pages.account.manage.disable" />
</CategoryButton> </CategoryButton>
<CategoryButton <CategoryButton
icon={<Trash size={24} color="var(--error)" />} icon={<Trash size={24} color="var(--error)" />}
description={ description={
@@ -250,13 +163,14 @@ export const Account = observer(() => {
}> }>
<Text id="app.settings.pages.account.manage.delete" /> <Text id="app.settings.pages.account.manage.delete" />
</CategoryButton> </CategoryButton>
<Tip> <Tip>
<span> <span>
<Text id="app.settings.tips.account.a" /> <Text id="app.settings.tips.account.a" />
</span>{" "} </span>{" "}
<a onClick={() => switchPage("profile")}> <Link to="/settings/profile" replace>
<Text id="app.settings.tips.account.b" /> <Text id="app.settings.tips.account.b" />
</a> </Link>
</Tip> </Tip>
</div> </div>
); );

View File

@@ -2220,9 +2220,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@revoltchat/ui@npm:1.0.39": "@revoltchat/ui@portal:../components::locator=client%40workspace%3A.":
version: 1.0.39 version: 0.0.0-use.local
resolution: "@revoltchat/ui@npm:1.0.39" resolution: "@revoltchat/ui@portal:../components::locator=client%40workspace%3A."
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,9 +2235,8 @@ __metadata:
react-device-detect: "*" react-device-detect: "*"
react-virtuoso: "*" react-virtuoso: "*"
revolt.js: "*" revolt.js: "*"
checksum: 0376ef1e6c90a139da613a0b76d498327c7bad63941d02eb27b9d5b8208f09c01fb45330fc4e0643554a298beee416814dd41fd9992750378491450c6f773ee0
languageName: node languageName: node
linkType: hard linkType: soft
"@rollup/plugin-babel@npm:^5.2.0": "@rollup/plugin-babel@npm:^5.2.0":
version: 5.3.0 version: 5.3.0