Port Login UI

This commit is contained in:
Paul 2021-06-18 20:21:54 +01:00
parent aa81ebb298
commit 68a35751b3
18 changed files with 749 additions and 3 deletions

View file

@ -25,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@fontsource/open-sans": "^4.4.5", "@fontsource/open-sans": "^4.4.5",
"@hcaptcha/react-hcaptcha": "^0.3.6",
"@preact/preset-vite": "^2.0.0", "@preact/preset-vite": "^2.0.0",
"@styled-icons/bootstrap": "^10.34.0", "@styled-icons/bootstrap": "^10.34.0",
"@styled-icons/feather": "^10.34.0", "@styled-icons/feather": "^10.34.0",
@ -36,6 +37,7 @@
"@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/eslint-plugin": "^4.27.0",
"@typescript-eslint/parser": "^4.27.0", "@typescript-eslint/parser": "^4.27.0",
"dayjs": "^1.10.5", "dayjs": "^1.10.5",
"detect-browser": "^5.2.0",
"eslint": "^7.28.0", "eslint": "^7.28.0",
"eslint-config-preact": "^1.1.4", "eslint-config-preact": "^1.1.4",
"localforage": "^1.9.0", "localforage": "^1.9.0",
@ -43,6 +45,7 @@
"prettier": "^2.3.1", "prettier": "^2.3.1",
"react-device-detect": "^1.17.0", "react-device-detect": "^1.17.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-hook-form": "6.3.0",
"react-overlapping-panels": "1.1.2-patch.0", "react-overlapping-panels": "1.1.2-patch.0",
"react-redux": "^7.2.4", "react-redux": "^7.2.4",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",

View file

@ -2,13 +2,15 @@ import { CheckAuth } from "./context/revoltjs/CheckAuth";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import Context from "./context"; import Context from "./context";
import { Login } from "./pages/login/Login";
export function App() { export function App() {
return ( return (
<Context> <Context>
<Switch> <Switch>
<Route path="/login"> <Route path="/login">
<CheckAuth> <CheckAuth>
<h1>login</h1> <Login />
</CheckAuth> </CheckAuth>
</Route> </Route>
<Route path="/"> <Route path="/">

View file

@ -1,6 +1,7 @@
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { createGlobalStyle } from "styled-components"; import { createGlobalStyle } from "styled-components";
import { Children } from "../types/Preact"; import { Children } from "../types/Preact";
import { createContext } from "preact";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
export type Variables = export type Variables =
@ -111,6 +112,8 @@ const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
} }
`; `;
export const ThemeContext = createContext<Theme>({} as any);
interface Props { interface Props {
children: Children; children: Children;
} }
@ -119,7 +122,7 @@ export default function Theme(props: Props) {
const theme = PRESETS.dark; const theme = PRESETS.dark;
return ( return (
<> <ThemeContext.Provider value={theme}>
<Helmet> <Helmet>
<meta <meta
name="theme-color" name="theme-color"
@ -132,6 +135,6 @@ export default function Theme(props: Props) {
</Helmet> </Helmet>
<GlobalTheme theme={theme} /> <GlobalTheme theme={theme} />
{props.children} {props.children}
</> </ThemeContext.Provider>
); );
} }

View file

@ -0,0 +1,18 @@
export function takeError(
error: any
): string {
const type = error?.response?.data?.type;
let id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && (!!error.isAxiosError && !error.response)) {
return "NetworkError";
}
console.error(error);
return "UnknownError";
}
return id;
}

View file

@ -0,0 +1,68 @@
import Overline from '../../components/ui/Overline';
import InputBox from '../../components/ui/InputBox';
import { Text, Localizer } from 'preact-i18n';
interface Props {
type: "email" | "username" | "password" | "invite" | "current_password";
showOverline?: boolean;
register: Function;
error?: string;
name?: string;
}
export default function FormField({
type,
register,
showOverline,
error,
name
}: Props) {
return (
<>
{showOverline && (
<Overline error={error}>
<Text id={`login.${type}`} />
</Overline>
)}
<Localizer>
<InputBox
placeholder={(<Text id={`login.enter.${type}`} />) as any}
name={
type === "current_password" ? "password" : name ?? type
}
type={
type === "invite" || type === "username"
? "text"
: type === "current_password"
? "password"
: type
}
ref={register(
type === "password" || type === "current_password"
? {
validate: (value: string) =>
value.length === 0
? "RequiredField"
: value.length < 8
? "TooShort"
: value.length > 1024
? "TooLong"
: undefined
}
: type === "email"
? {
required: "RequiredField",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "InvalidEmail"
}
}
: type === "username"
? { required: "RequiredField" }
: { required: "RequiredField" }
)}
/>
</Localizer>
</>
);
}

View file

@ -0,0 +1,123 @@
.login {
display: flex;
flex-direction: row;
svg {
margin: auto;
}
> div {
flex: 1;
}
.content {
display: flex;
flex-direction: column;
justify-content: space-between;
.attribution {
color: var(--tertiary-background);
font-size: 12px;
line-height: 12px;
margin: 8px;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.modal {
display: flex;
flex-direction: row;
justify-content: center;
}
}
.bg {
background-size: cover !important;
}
}
.form {
display: flex;
flex-direction: column;
font-size: 14px;
img {
width: 260px;
margin: auto;
}
a {
margin-top: 4px;
}
form {
margin: 1em 0;
display: flex;
flex-direction: column;
button {
margin-top: 24px;
}
}
.create {
text-align: center;
color: var(--tertiary-foreground);
a {
margin: 0 4px;
}
}
}
.success {
display: flex;
align-items: center;
flex-direction: column;
.note {
color: var(--tertiary-foreground);
}
.mailProvider {
padding: 24px 0;
}
* {
margin: 0;
}
h1 {
font-weight: 400;
}
h2 {
font-weight: 300;
}
}
.footer {
margin-top: 12px;
text-align: center;
color: var(--tertiary6);
a {
color: var(--tertiary-background) !important;
cursor: pointer;
margin: 0 2px;
&:hover {
color: var(--tertiary-foreground) !important;
}
}
}
@media only screen and (max-width: 768px) {
.bg {
display: none;
}
}

70
src/pages/login/Login.tsx Normal file
View file

@ -0,0 +1,70 @@
import { Text } from "preact-i18n";
import { Helmet } from "react-helmet";
import styles from "./Login.module.scss";
import { useContext } from "preact/hooks";
import { APP_VERSION } from "../../version";
import { LIBRARY_VERSION } from "revolt.js";
import { Route, Switch } from "react-router-dom";
import { ThemeContext } from "../../context/Theme";
import { RevoltClient } from "../../context/revoltjs/RevoltClient";
import background from "./background.jpg";
import { FormLogin } from "./forms/FormLogin";
import { FormCreate } from "./forms/FormCreate";
import { FormResend } from "./forms/FormResend";
import { FormReset, FormSendReset } from "./forms/FormReset";
export const Login = () => {
const theme = useContext(ThemeContext);
return (
<div className={styles.login}>
<Helmet>
<meta name="theme-color" content={theme.background} />
</Helmet>
<div className={styles.content}>
<div className={styles.attribution}>
<span>
API:{" "}
<code>{RevoltClient.configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code>
</span>
<span>
{/*<LocaleSelector />*/}
</span>
</div>
<div className={styles.modal}>
<Switch>
<Route path="/login/create">
<FormCreate />
</Route>
<Route path="/login/resend">
<FormResend />
</Route>
<Route path="/login/reset/:token">
<FormReset />
</Route>
<Route path="/login/reset">
<FormSendReset />
</Route>
<Route path="/">
<FormLogin />
</Route>
</Switch>
</div>
<div className={styles.attribution}>
<span>
<Text id="general.image_by" /> &lrm;@lorenzoherrera
&rlm;· unsplash.com
</span>
</div>
</div>
<div
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

View file

@ -0,0 +1,36 @@
import { Text } from "preact-i18n";
import { useEffect } from "preact/hooks";
import styles from "../Login.module.scss";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import Preloader from "../../../components/ui/Preloader";
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
export interface CaptchaProps {
onSuccess: (token?: string) => void;
onCancel: () => void;
}
export function CaptchaBlock(props: CaptchaProps) {
useEffect(() => {
if (!RevoltClient.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, []);
if (!RevoltClient.configuration?.features.captcha.enabled)
return <Preloader />;
return (
<div>
<HCaptcha
sitekey={RevoltClient.configuration.features.captcha.key}
onVerify={token => props.onSuccess(token)}
/>
<div className={styles.footer}>
<a onClick={props.onCancel}>
<Text id="login.cancel" />
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,236 @@
import { Legal } from "./Legal";
import { Text } from "preact-i18n";
import { Link } from "react-router-dom";
import { useState } from "preact/hooks";
import styles from "../Login.module.scss";
import { useForm } from "react-hook-form";
import { MailProvider } from "./MailProvider";
import { CheckCircle, Mail } from "@styled-icons/feather";
import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
import { takeError } from "../../../context/revoltjs/error";
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import FormField from "../FormField";
import Button from "../../../components/ui/Button";
import Overline from "../../../components/ui/Overline";
import Preloader from "../../../components/ui/Preloader";
interface Props {
page: "create" | "login" | "send_reset" | "reset" | "resend";
callback: (fields: {
email: string;
password: string;
invite: string;
captcha?: string;
}) => Promise<void>;
}
function getInviteCode() {
if (typeof window === 'undefined') return '';
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
return code ?? '';
}
export function Form({ page, callback }: Props) {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | undefined>(undefined);
const [error, setGlobalError] = useState<string | undefined>(undefined);
const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined);
const { handleSubmit, register, errors, setError } = useForm({
defaultValues: {
email: '',
password: '',
invite: getInviteCode()
}
});
async function onSubmit(data: {
email: string;
password: string;
invite: string;
}) {
setGlobalError(undefined);
setLoading(true);
function onError(err: any) {
setLoading(false);
const error = takeError(err);
switch (error) {
case "email_in_use":
return setError("email", { type: "", message: error });
case "unknown_user":
return setError("email", { type: "", message: error });
case "invalid_invite":
return setError("invite", { type: "", message: error });
}
setGlobalError(error);
}
try {
if (
RevoltClient.configuration?.features.captcha.enabled &&
page !== "reset"
) {
setCaptcha({
onSuccess: async captcha => {
setCaptcha(undefined);
try {
await callback({ ...data, captcha });
setSuccess(data.email);
} catch (err) {
onError(err);
}
},
onCancel: () => {
setCaptcha(undefined);
setLoading(false);
}
});
} else {
await callback(data);
setSuccess(data.email);
}
} catch (err) {
onError(err);
}
}
if (typeof success !== "undefined") {
return (
<div className={styles.success}>
{RevoltClient.configuration?.features.email ? (
<>
<Mail size={72} />
<h2>
<Text id="login.check_mail" />
</h2>
<p className={styles.note}>
<Text id="login.email_delay" />
</p>
<MailProvider email={success} />
</>
) : (
<>
<CheckCircle size={72} />
<h1>
<Text id="login.successful_registration" />
</h1>
</>
)}
<span className={styles.footer}>
<Link to="/login">
<a>
<Text id="login.remembered" />
</a>
</Link>
</span>
</div>
);
}
if (captcha) return <CaptchaBlock {...captcha} />;
if (loading) return <Preloader />;
return (
<div className={styles.form}>
<form onSubmit={handleSubmit(onSubmit) as any}>
{page !== "reset" && (
<FormField
type="email"
register={register}
showOverline
error={errors.email?.message}
/>
)}
{(page === "login" ||
page === "create" ||
page === "reset") && (
<FormField
type="password"
register={register}
showOverline
error={errors.password?.message}
/>
)}
{RevoltClient.configuration?.features.invite_only &&
page === "create" && (
<FormField
type="invite"
register={register}
showOverline
error={errors.invite?.message}
/>
)}
{error && (
<Overline type="error" error={error}>
<Text id={`login.error.${page}`} />
</Overline>
)}
<Button>
<Text
id={
page === "create"
? "login.register"
: page === "login"
? "login.title"
: page === "reset"
? "login.set_password"
: page === "resend"
? "login.resend"
: "login.reset"
}
/>
</Button>
</form>
{page === "create" && (
<>
<span className={styles.create}>
<Text id="login.existing" />
<Link to="/login">
<Text id="login.title" />
</Link>
</span>
<span className={styles.create}>
<Text id="login.missing_verification" />
<Link to="/login/resend">
<Text id="login.resend" />
</Link>
</span>
</>
)}
{page === "login" && (
<>
<span className={styles.create}>
<Text id="login.new" />
<Link to="/login/create">
<Text id="login.create" />
</Link>
</span>
<span className={styles.create}>
<Text id="login.forgot" />
<Link to="/login/reset">
<Text id="login.reset" />
</Link>
</span>
</>
)}
{(page === "reset" ||
page === "resend" ||
page === "send_reset") && (
<>
<span className={styles.create}>
<Link to="/login">
<Text id="login.remembered" />
</Link>
</span>
</>
)}
<Legal />
</div>
);
}

View file

@ -0,0 +1,13 @@
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormCreate() {
return (
<Form
page="create"
callback={async data => {
await RevoltClient.register(process.env.API_SERVER as string, data);
}}
/>
);
}

View file

@ -0,0 +1,29 @@
import { Form } from "./Form";
import { useContext } from "preact/hooks";
import { useHistory } from "react-router-dom";
import { deviceDetect } from "react-device-detect";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
export function FormLogin() {
const { operations } = useContext(AppContext);
const history = useHistory();
return (
<Form
page="login"
callback={async data => {
const browser = deviceDetect();
let device_name;
if (browser) {
const { name, os } = browser;
device_name = `${name} on ${os}`;
} else {
device_name = "Unknown Device";
}
await operations.login({ ...data, device_name });
history.push("/");
}}
/>
);
}

View file

@ -0,0 +1,13 @@
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormResend() {
return (
<Form
page="resend"
callback={async data => {
await RevoltClient.req("POST", "/auth/resend", data);
}}
/>
);
}

View file

@ -0,0 +1,32 @@
import { Form } from "./Form";
import { useHistory, useParams } from "react-router-dom";
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
export function FormSendReset() {
return (
<Form
page="send_reset"
callback={async data => {
await RevoltClient.req("POST", "/auth/send_reset", data);
}}
/>
);
}
export function FormReset() {
const { token } = useParams<{ token: string }>();
const history = useHistory();
return (
<Form
page="reset"
callback={async data => {
await RevoltClient.req("POST", "/auth/reset" as any, {
token,
...(data as any)
});
history.push("/login");
}}
/>
);
}

View file

@ -0,0 +1,29 @@
import styles from "../Login.module.scss";
import { Text } from "preact-i18n";
export function Legal() {
return (
<span className={styles.footer}>
<a
href="https://revolt.chat/about"
target="_blank"
>
<Text id="general.about" />
</a>
&middot;
<a
href="https://revolt.chat/terms"
target="_blank"
>
<Text id="general.tos" />
</a>
&middot;
<a
href="https://revolt.chat/privacy"
target="_blank"
>
<Text id="general.privacy" />
</a>
</span>
);
}

View file

@ -0,0 +1,55 @@
import { Text } from "preact-i18n";
import styles from "../Login.module.scss";
import Button from "../../../components/ui/Button";
interface Props {
email?: string;
}
function mapMailProvider(email?: string): [string, string] | undefined {
if (!email) return;
const match = /@(.+)/.exec(email);
if (match === null) return;
const domain = match[1];
switch (domain) {
case "gmail.com":
return ["Gmail", "https://gmail.com"];
case "tuta.io":
return ["Tutanota", "https://mail.tutanota.com"];
case "outlook.com":
return ["Outlook", "https://outlook.live.com"];
case "yahoo.com":
return ["Yahoo", "https://mail.yahoo.com"];
case "wp.pl":
return ["WP Poczta", "https://poczta.wp.pl"];
case "protonmail.com":
case "protonmail.ch":
return ["ProtonMail", "https://mail.protonmail.com"];
case "seznam.cz":
case "email.cz":
case "post.cz":
return ["Seznam", "https://email.seznam.cz"];
default:
return [domain, `https://${domain}`];
}
}
export function MailProvider({ email }: Props) {
const provider = mapMailProvider(email);
if (!provider) return null;
return (
<div className={styles.mailProvider}>
<a href={provider[1]} target="_blank">
<Button>
<Text
id="login.open_mail_provider"
fields={{ provider: provider[0] }}
/>
</Button>
</a>
</div>
);
}

1
src/version.ts Normal file
View file

@ -0,0 +1 @@
export const APP_VERSION = "0.1.9-alpha.7";

View file

@ -955,6 +955,11 @@
dependencies: dependencies:
"@hapi/hoek" "^8.3.0" "@hapi/hoek" "^8.3.0"
"@hcaptcha/react-hcaptcha@^0.3.6":
version "0.3.6"
resolved "https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-0.3.6.tgz#cbbb9abdaea451a4df408bc9d476e8b17f0b63f4"
integrity sha512-DQ5nvGVbbhd2IednxRhCV9wiPcCmclEV7bH98yGynGCXzO5XftO/XC0a1M1kEf9Ee+CLO/u+1HM/uE/PSrC3vQ==
"@insertish/mutable@1.0.6": "@insertish/mutable@1.0.6":
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/@insertish/mutable/-/mutable-1.0.6.tgz#f42eaba8528ff68cc8065d51f9bbbd30a24f34de" resolved "https://registry.yarnpkg.com/@insertish/mutable/-/mutable-1.0.6.tgz#f42eaba8528ff68cc8065d51f9bbbd30a24f34de"
@ -1752,6 +1757,11 @@ define-properties@^1.1.3:
dependencies: dependencies:
object-keys "^1.0.12" object-keys "^1.0.12"
detect-browser@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/detect-browser/-/detect-browser-5.2.0.tgz#c9cd5afa96a6a19fda0bbe9e9be48a6b6e1e9c97"
integrity sha512-tr7XntDAu50BVENgQfajMLzacmSe34D+qZc4zjnniz0ZVuw/TZcLcyxHQjYpJTM36sGEkZZlYLnIM1hH7alTMA==
dir-glob@^3.0.1: dir-glob@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@ -3061,6 +3071,11 @@ react-helmet@^6.1.0:
react-fast-compare "^3.1.1" react-fast-compare "^3.1.1"
react-side-effect "^2.1.0" react-side-effect "^2.1.0"
react-hook-form@6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.3.0.tgz#5c1926d51d4532f44818ef73f96d1a8c11015a76"
integrity sha512-Xz7xxnILftxttc6H+miTSi2eYPehiW3XdsPaqY5dW8HcURFZPrnpxnmaRqz6JtZcbfRM8qjjppP/pOBaUzhn4w==
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"