From 93dfe2be64e8658839bcfe5356adf35f8cde7075 Mon Sep 17 00:00:00 2001
From: Nicolas James <Eele1Ephe7uZahRie@tutanota.com>
Date: Thu, 13 Feb 2025 18:04:18 +1100
Subject: initial commit

---
 src/web/components/login/Login.jsx | 218 +++++++++++++++++++++++++++++++++++++
 1 file changed, 218 insertions(+)
 create mode 100644 src/web/components/login/Login.jsx

(limited to 'src/web/components/login/Login.jsx')

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
-- 
cgit v1.2.3

