aboutsummaryrefslogtreecommitdiff
path: root/src/web/components/login
diff options
context:
space:
mode:
authorNicolas James <Eele1Ephe7uZahRie@tutanota.com>2025-02-13 18:04:18 +1100
committerNicolas James <Eele1Ephe7uZahRie@tutanota.com>2025-02-13 18:04:18 +1100
commit93dfe2be64e8658839bcfe5356adf35f8cde7075 (patch)
treec60b1e20d569b74dbde85123e1b2bf3590c66244 /src/web/components/login
initial commit
Diffstat (limited to 'src/web/components/login')
-rw-r--r--src/web/components/login/Login.css118
-rw-r--r--src/web/components/login/Login.jsx218
2 files changed, 336 insertions, 0 deletions
diff --git a/src/web/components/login/Login.css b/src/web/components/login/Login.css
new file mode 100644
index 0000000..1be8f84
--- /dev/null
+++ b/src/web/components/login/Login.css
@@ -0,0 +1,118 @@
+.screenCenter {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: var(--login-z-index);
+
+ position: fixed;
+ inset: 0;
+ visibility: hidden;
+}
+
+.darken {
+ z-index: calc(var(--login-z-index) - 1);
+}
+.login {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ flex: 0 1 100%;
+
+ position: relative;
+ padding: 4em 2em 2em;
+ margin: 0em 3em;
+ max-width: 25em;
+
+ color: var(--black-98);
+ visibility: visible;
+ border-radius: 2em;
+
+ translate: 100vw;
+ transition: translate var(--transition-time) ease-in-out;
+}
+.loginActive {
+ translate: initial;
+ visibility: initial;
+}
+
+.formContainer {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+}
+
+.login .formContainer h1 {
+ font-size: 2em;
+ font-weight: bolder;
+ text-align: center;
+ color: white;
+ margin-bottom: 2rem;
+}
+.login .formContainer h2 {
+ font-size: 1.5em;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+}
+.login .formContainer p {
+ text-align: center;
+ line-height: 1.25em;
+}
+
+.promptContainer {
+ min-height: 5em;
+ display: flex;
+ justify-content: space-between;
+}
+
+.login .formContainer button {
+ height: 2.25rem;
+ font-size: 1.25em;
+ margin-bottom: 1.25rem;
+ width: 100%;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ border-radius: 2.5rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.loading {
+ font-size: 1.65rem;
+ filter: invert(95%);
+}
+
+.formContainer a {
+ display: block;
+ text-align: center;
+ text-decoration: underline;
+ cursor: pointer;
+ min-height: 3em;
+ color: inherit;
+}
+
+.promptContainer input {
+ margin: 0 0.25em 0 0;
+}
+
+.cardError {
+ display: none;
+ position: absolute;
+ inset: 0 0 auto 0;
+ min-height: 2em;
+ margin: 1em;
+
+ justify-content: center;
+ align-items: center;
+
+ color: white;
+ border-radius: 2em;
+ font-weight: normal;
+ font-style: italic;
+ text-transform: uppercase;
+}
+.cardErrorActive {
+ display: flex;
+} \ No newline at end of file
diff --git a/src/web/components/login/Login.jsx b/src/web/components/login/Login.jsx
new file mode 100644
index 0000000..7944aa3
--- /dev/null
+++ b/src/web/components/login/Login.jsx
@@ -0,0 +1,218 @@
+import React, {Fragment, useState, useContext} from "react";
+import {Link} from "react-router-dom";
+
+import Card from "components/card/Card.jsx";
+import Button from "components/button/Button.jsx";
+import Darken from "components/darken/Darken.jsx";
+import Loading from "components/loading/Loading.jsx";
+import Input from "components/input/Input.jsx";
+import {UserContext} from "contexts/UserContext.jsx";
+import {maybeAuthFromCookie} from "helpers/Auth.jsx";
+import {getFieldError, correctFieldAnimate} from "helpers/Input.jsx";
+
+import styles from "./Login.css";
+
+const validateEmail = (email) => getFieldError(email?.target?.value, [3, 254]);
+const validatePassword = (password) => getFieldError(password?.target?.value, [8, 64]);
+
+function RequestButton({email, password, apiPath, promise, setPromise, setEmail,
+ setPassword, setLoginActive, setError, children}) {
+
+ const userState = useContext(UserContext);
+
+ const onClick = () => {
+ if (promise != null) {
+ return;
+ }
+ setError(null);
+
+ // We don't want to send a worthless request, we should animate the bad values.
+ const emailErrors = validateEmail(email);
+ const passwordErrors = validatePassword(password);
+ if (emailErrors != null || passwordErrors != null) {
+ correctFieldAnimate(email, setEmail, emailErrors);
+ correctFieldAnimate(password, setPassword, passwordErrors);
+ return;
+ }
+
+ const init = {
+ method: "POST",
+ headers: {
+ 'Content-Type': "application/json"
+ },
+ body: JSON.stringify({
+ "email": email.target.value,
+ "password": password.target.value
+ }),
+ referrer: "same-origin",
+ };
+ setPromise(fetch(apiPath, init)
+ .then((response) => {
+ if (response.ok) {
+ if (!maybeAuthFromCookie(userState)) {
+ throw new Error("rejected authentication token");
+ }
+
+ setLoginActive(false);
+ return;
+ }
+
+ return response.json()
+ .then((json) => {
+ if ("error" in json) {
+ throw new Error(json.error);
+ }
+ throw new Error("unexpected data received");
+ })
+ })
+ .catch((error) => {
+ console.log(error);
+ setError(error.message);
+ })
+ .finally(() => {
+ setPromise(null);
+ })
+ );
+ };
+
+ // I'm not worried about this object becoming unmounted mid promise because
+ // our website design removes this possibility.
+ return (
+ <Button className={(promise != null ? styles.buttonLoading : null)}
+ onClick={onClick}>
+
+ {children}
+ </Button>
+ );
+}
+
+function CardError({active, text}) {
+ return (
+ <div className={styles.cardError + (active ? " " + styles.cardErrorActive : "")}>
+ <p>
+ {text}
+ </p>
+ </div>
+ );
+}
+
+function FormContents(props) {
+ const {setLoginActive,
+ isRegister,
+ setIsRegister,
+ title,
+ prompt,
+ linkContents,
+ apiPath} = props;
+
+ const [error, setError] = useState(null);
+ // Email and password .value's are null here to represent the state where the user
+ // has not typed in anything yet. This is to only validate after at least some
+ // input has been received.
+ const [email, setEmail] = useState(null);
+ const [password, setPassword] = useState(null);
+ const [promise, setPromise] = useState(null);
+
+ return (
+ <div className={styles.formContainer}>
+ <CardError active={error != null} text={error} />
+
+ <h1>{title}</h1>
+
+ <Input type="text"
+ title="Email"
+ field={email}
+ setField={setEmail}
+ validateField={validateEmail} />
+
+ <Input type="text"
+ title="Password"
+ field={password}
+ setField={setPassword}
+ validateField={validatePassword} />
+
+ <div className={styles.promptContainer}>
+ {prompt}
+ </div>
+
+ <RequestButton email={email}
+ password={password}
+ promise={promise}
+ setEmail={setEmail}
+ setPassword={setPassword}
+ setLoginActive={setLoginActive}
+ setPromise={setPromise}
+ setError={setError}
+ apiPath={apiPath}>
+
+ {promise == null ? title : <Loading className={styles.loading} />}
+ </RequestButton>
+
+ <a onClick={() => {
+ if (promise != null) { // disallow mid transaction change
+ return;
+ }
+ setIsRegister(!isRegister);
+ setError(null);
+ setEmail({...email, value: null, animating: false});
+ setPassword({...password, value: null, animating: false});}}>
+
+ {linkContents}
+ </a>
+ </div>
+ );
+};
+
+function RegisterPrompt() {
+ return (
+ <p>
+ By registering, you agree to both our privacy policy and to
+ waive all rights of basic privacy and security.
+ </p>
+ );
+}
+
+function LoginPrompt(props) {
+ const {setLoginActive} = props;
+
+ return (
+ <Fragment>
+ <div>
+ <input type="checkbox" id="save_login" /><label htmlFor="save_login">Save Login</label>
+ </div>
+ <Link to="settings/recover" onClick={() => setLoginActive(false)}>
+ Can't log in?
+ </Link>
+ </Fragment>
+ );
+}
+
+function Prompt(props) {
+ const {setLoginActive} = props;
+ const [isRegister, setIsRegister] = useState(true);
+ return (
+ <FormContents setLoginActive={setLoginActive}
+ isRegister={isRegister}
+ setIsRegister={setIsRegister}
+ title={isRegister ? "Sign Up" : "Login"}
+ prompt={isRegister ? <RegisterPrompt /> : <LoginPrompt setLoginActive={setLoginActive} />}
+ linkContents={isRegister ? "Already have an account?" : "Don't have an account?"}
+ apiPath={isRegister ? "/api/signup" : "/api/login"} />
+ );
+}
+
+// A popup which asks for login/register info.
+export default function Login(props) {
+ const {active, setActive} = props;
+
+ return (
+ <Fragment>
+ <Darken active={active} onClick={() => setActive(false)} className={styles.darken} />
+ <div className={styles.screenCenter}>
+ <Card className={styles.login + (active ? " " + styles.loginActive : "")}>
+ <Prompt setLoginActive={setActive} />
+ </Card>
+ </div>
+ </Fragment>
+ );
+} \ No newline at end of file