Full UIAA implement (#93), #32, #146, #64, #102
authorAjay Bura <ajbura@gmail.com>
Sat, 6 Nov 2021 09:45:35 +0000 (15:15 +0530)
committerAjay Bura <ajbura@gmail.com>
Sat, 6 Nov 2021 09:45:35 +0000 (15:15 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
13 files changed:
config.json [new file with mode: 0644]
package-lock.json
package.json
src/app/atoms/button/Button.jsx
src/app/atoms/input/Input.jsx
src/app/molecules/sso-buttons/SSOButtons.jsx
src/app/molecules/sso-buttons/SSOButtons.scss
src/app/organisms/settings/Settings.jsx
src/app/templates/auth/Auth.jsx
src/app/templates/auth/Auth.scss
src/client/action/auth.js
src/client/state/cons.js
src/util/matrixUtil.js

diff --git a/config.json b/config.json
new file mode 100644 (file)
index 0000000..f134384
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "defaultHomeserver": 5,
+  "homeserverList": [
+    "boba.best",
+    "converser.eu",
+    "envs.net",
+    "halogen.city",
+    "kde.org",
+    "matrix.org",
+    "mozilla.modular.im",
+    "perthchat.org",
+    "ru-matrix.org"
+  ]
+}
\ No newline at end of file
index 3f71d551ace60abe321701c951c85b36f5d60375..ac3680d28d051783929d55aad4682326d52c7139 100644 (file)
@@ -16,6 +16,7 @@
         "dateformat": "^4.5.1",
         "emojibase-data": "^6.2.0",
         "flux": "^4.0.1",
+        "formik": "^2.2.9",
         "html-react-parser": "^1.2.7",
         "linkify-react": "^3.0.3",
         "matrix-js-sdk": "^12.4.1",
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
       "dev": true
     },
+    "node_modules/deepmerge": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
+      "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/default-gateway": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz",
         "node": ">=0.4.x"
       }
     },
+    "node_modules/formik": {
+      "version": "2.2.9",
+      "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
+      "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://opencollective.com/formik"
+        }
+      ],
+      "dependencies": {
+        "deepmerge": "^2.1.1",
+        "hoist-non-react-statics": "^3.3.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "react-fast-compare": "^2.0.1",
+        "tiny-warning": "^1.0.2",
+        "tslib": "^1.10.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/formik/node_modules/tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
     "node_modules/lodash": {
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
-      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "dev": true
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
     },
     "node_modules/lodash.clonedeep": {
       "version": "4.5.0",
         "react": "17.0.2"
       }
     },
+    "node_modules/react-fast-compare": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
+      "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
+    },
     "node_modules/react-google-recaptcha": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-2.1.0.tgz",
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
       "dev": true
     },
+    "deepmerge": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
+      "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="
+    },
     "default-gateway": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz",
       "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
       "integrity": "sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs="
     },
+    "formik": {
+      "version": "2.2.9",
+      "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
+      "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
+      "requires": {
+        "deepmerge": "^2.1.1",
+        "hoist-non-react-statics": "^3.3.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "react-fast-compare": "^2.0.1",
+        "tiny-warning": "^1.0.2",
+        "tslib": "^1.10.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "1.14.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+          "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+        }
+      }
+    },
     "forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
     "lodash": {
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
-      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "dev": true
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+    },
+    "lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
     },
     "lodash.clonedeep": {
       "version": "4.5.0",
         "scheduler": "^0.20.2"
       }
     },
+    "react-fast-compare": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
+      "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
+    },
     "react-google-recaptcha": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-2.1.0.tgz",
index 1800edf78289d648587ba4cf80681dc771e6031e..5c5a360211623887bd7c15d33204c0a49360472c 100644 (file)
@@ -22,6 +22,7 @@
     "dateformat": "^4.5.1",
     "emojibase-data": "^6.2.0",
     "flux": "^4.0.1",
+    "formik": "^2.2.9",
     "html-react-parser": "^1.2.7",
     "linkify-react": "^3.0.3",
     "matrix-js-sdk": "^12.4.1",
index 7fbf8c5753b4d24c0bd1d5d8098d5a9ca599dfa2..1c1c950c3d4c408aa117246de827f3e331a9c33b 100644 (file)
@@ -6,13 +6,14 @@ import Text from '../text/Text';
 import RawIcon from '../system-icons/RawIcon';
 import { blurOnBubbling } from './script';
 
-function Button({
+const Button = React.forwardRef(({
   id, className, variant, iconSrc,
   type, onClick, children, disabled,
-}) {
+}, ref) => {
   const iconClass = (iconSrc === null) ? '' : `btn-${variant}--icon`;
   return (
     <button
+      ref={ref}
       id={id === '' ? undefined : id}
       className={`${className ? `${className} ` : ''}btn-${variant} ${iconClass} noselect`}
       onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)}
@@ -26,7 +27,7 @@ function Button({
       {typeof children !== 'string' && children }
     </button>
   );
-}
+});
 
 Button.defaultProps = {
   id: '',
index 7b5f0967ca1fe2b0ad270fddc8cf8cc2829e2762..5c1d842281c46673abc4d3ee7aa86e17e65a4e91 100644 (file)
@@ -5,7 +5,7 @@ import './Input.scss';
 import TextareaAutosize from 'react-autosize-textarea';
 
 function Input({
-  id, label, value, placeholder,
+  id, label, name, value, placeholder,
   required, type, onChange, forwardRef,
   resizable, minHeight, onResize, state,
   onKeyDown,
@@ -17,6 +17,7 @@ function Input({
         ? (
           <TextareaAutosize
             style={{ minHeight: `${minHeight}px` }}
+            name={name}
             id={id}
             className={`input input--resizable${state !== 'normal' ? ` input--${state}` : ''}`}
             ref={forwardRef}
@@ -33,6 +34,7 @@ function Input({
           <input
             ref={forwardRef}
             id={id}
+            name={name}
             className={`input ${state !== 'normal' ? ` input--${state}` : ''}`}
             type={type}
             placeholder={placeholder}
@@ -49,6 +51,7 @@ function Input({
 
 Input.defaultProps = {
   id: null,
+  name: '',
   label: '',
   value: '',
   placeholder: '',
@@ -65,6 +68,7 @@ Input.defaultProps = {
 
 Input.propTypes = {
   id: PropTypes.string,
+  name: PropTypes.string,
   label: PropTypes.string,
   value: PropTypes.string,
   placeholder: PropTypes.string,
index 312a16501b5e817503e8feb16ae3cb39a35c2041..15751ae7493ec7cae8577b13edb07fa76087dd55 100644 (file)
-import React, { useEffect, useState } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import './SSOButtons.scss';
 
-import { createTemporaryClient, getLoginFlows, startSsoLogin } from '../../../client/action/auth';
+import { createTemporaryClient, startSsoLogin } from '../../../client/action/auth';
 
-import Text from '../../atoms/text/Text';
-
-function SSOButtons({ homeserver }) {
-  const [identityProviders, setIdentityProviders] = useState([]);
-
-  useEffect(() => {
-    // Reset sso proviers to avoid displaying sso icons if the homeserver is not valid
-    setIdentityProviders([]);
-
-    // If the homeserver passed in is not a fully-qualified domain name, do not update.
-    if (!homeserver.match('^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\\.[a-zA-Z]{2,})+$')) {
-      return;
-    }
-
-    // TODO Check that there is a Matrix server at homename before making requests.
-    // This will prevent the CORS errors that happen when a user changes their homeserver.
-    createTemporaryClient(homeserver).then((client) => {
-      const providers = [];
-      getLoginFlows(client).then((flows) => {
-        if (flows.flows !== undefined) {
-          const ssoFlows = flows.flows.filter((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas');
-          ssoFlows.forEach((flow) => {
-            if (flow.identity_providers !== undefined) {
-              const type = flow.type.substring(8);
-              flow.identity_providers.forEach((idp) => {
-                const imageSrc = client.mxcUrlToHttp(idp.icon);
-                providers.push({
-                  homeserver, id: idp.id, name: idp.name, type, imageSrc,
-                });
-              });
-            }
-          });
-        }
-        setIdentityProviders(providers);
-      }).catch(() => {});
-    }).catch(() => {
-      setIdentityProviders([]);
-    });
-  }, [homeserver]);
-
-  if (identityProviders.length === 0) return <></>;
+import Button from '../../atoms/button/Button';
 
+function SSOButtons({ type, identityProviders, baseUrl }) {
+  const tempClient = createTemporaryClient(baseUrl);
+  function handleClick(id) {
+    startSsoLogin(baseUrl, type, id);
+  }
   return (
     <div className="sso-buttons">
-      <div className="sso-buttons__divider">
-        <Text>OR</Text>
-      </div>
-      <div className="sso-buttons__container">
-        {identityProviders
-          // Sort by alphabetical order
-          .sort((idp, idp2) => {
-            if (typeof idp.imageSrc !== 'string') return -1;
-            return idp.name.toLowerCase() > idp2.name.toLowerCase() ? 1 : -1;
-          })
-          .map((idp) => (
-            <SSOButton
-              key={idp.id}
-              homeserver={idp.homeserver}
-              id={idp.id}
-              name={idp.name}
-              type={idp.type}
-              imageSrc={idp.imageSrc}
-            />
-          ))}
-      </div>
+      {identityProviders
+        .sort((idp, idp2) => {
+          if (typeof idp.icon !== 'string') return -1;
+          return idp.name.toLowerCase() > idp2.name.toLowerCase() ? 1 : -1;
+        })
+        .map((idp) => (
+          idp.icon
+            ? (
+              <button key={idp.id} type="button" className="sso-btn" onClick={() => handleClick(idp.id)}>
+                <img className="sso-btn__img" src={tempClient.mxcUrlToHttp(idp.icon, 36, 36, 'crop')} alt={idp.name} />
+              </button>
+            ) : <Button key={idp.id} className="sso-btn__text-only" onClick={() => handleClick(idp.id)}>{`Login with ${idp.name}`}</Button>
+        ))}
     </div>
   );
 }
 
-function SSOButton({
-  homeserver, id, name, type, imageSrc,
-}) {
-  const isImageAvail = !!imageSrc;
-  function handleClick() {
-    startSsoLogin(homeserver, type, id);
-  }
-  return (
-    <button
-      type="button"
-      className={`sso-btn${!isImageAvail ? ' sso-btn__text-only' : ''}`}
-      onClick={handleClick}
-    >
-      {isImageAvail && <img className="sso-btn__img" src={imageSrc} alt={name} />}
-      {!isImageAvail && <Text>{`Login with ${name}`}</Text>}
-    </button>
-  );
-}
-
 SSOButtons.propTypes = {
-  homeserver: PropTypes.string.isRequired,
-};
-
-SSOButton.propTypes = {
-  homeserver: PropTypes.string.isRequired,
-  id: PropTypes.string.isRequired,
-  name: PropTypes.string.isRequired,
-  type: PropTypes.string.isRequired,
-  imageSrc: PropTypes.string.isRequired,
+  identityProviders: PropTypes.arrayOf(
+    PropTypes.shape({}),
+  ).isRequired,
+  baseUrl: PropTypes.string.isRequired,
+  type: PropTypes.oneOf(['sso', 'cas']).isRequired,
 };
 
 export default SSOButtons;
index 76f9b46bbda92741c2709179a363b3de68e02a31..0650670427fd54d811f30d9232bc4b66f136abdf 100644 (file)
@@ -1,22 +1,7 @@
 .sso-buttons {
-  &__divider {
-    display: flex;
-    align-items: center;
-
-    &::before,
-    &::after {
-      flex: 1;
-      content: '';
-      margin: var(--sp-tight);
-      border-bottom: 1px solid var(--bg-surface-border);
-    }
-  }
-  &__container {
-    margin-bottom: var(--sp-extra-loose);
-    display: flex;
-    justify-content: center;
-    flex-wrap: wrap;  
-  }
+  display: flex;
+  justify-content: center;
+  flex-wrap: wrap;  
 }
 
 .sso-btn {
     width: var(--av-small);
   }
   &__text-only {
+    margin-top: var(--sp-normal);
     flex-basis: 100%;
-    text-align: center;
-
-    margin: var(--sp-tight) 0px;
-    cursor: pointer;
     & .text {
       color: var(--tc-link);
     }
index 10945114ca120c9e267038f8daa28a06afbe9c50..79a9638d9fea5d5f88ceec514143e97514c85270 100644 (file)
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import './Settings.scss';
 
 import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
 import settings from '../../../client/state/settings';
 import { toggleMarkdown } from '../../../client/action/settings';
 
@@ -104,7 +105,7 @@ function AboutSection() {
         <div>
           <Text variant="h2">
             Cinny
-            <span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>v1.4.0</span>
+            <span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>{`v${cons.version}`}</span>
           </Text>
           <Text>Yet another matrix client</Text>
 
index 121f4200e4b4606bbff41be6e889a772ac80a649..b73a1983099496c723388697a4517151f9781056 100644 (file)
@@ -1,11 +1,14 @@
-import React, { useState, useRef } from 'react';
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect, useRef } from 'react';
 import PropTypes from 'prop-types';
 import './Auth.scss';
 import ReCAPTCHA from 'react-google-recaptcha';
+import { Formik } from 'formik';
 
-import { useLocation } from 'react-router-dom';
 import * as auth from '../../../client/action/auth';
 import cons from '../../../client/state/cons';
+import { Debounce, getUrlPrams } from '../../../util/common';
+import { getBaseUrl } from '../../../util/matrixUtil';
 
 import Text from '../../atoms/text/Text';
 import Button from '../../atoms/button/Button';
@@ -13,356 +16,552 @@ import IconButton from '../../atoms/button/IconButton';
 import Input from '../../atoms/input/Input';
 import Spinner from '../../atoms/spinner/Spinner';
 import ScrollView from '../../atoms/scroll/ScrollView';
+import Header, { TitleWrapper } from '../../atoms/header/Header';
+import Avatar from '../../atoms/avatar/Avatar';
+import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
 
-import EyeIC from '../../../../public/res/ic/outlined/eye.svg';
+import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
 import CinnySvg from '../../../../public/res/svg/cinny.svg';
 import SSOButtons from '../../molecules/sso-buttons/SSOButtons';
 
-// This regex validates historical usernames, which don't satisfy today's username requirements.
-// See https://matrix.org/docs/spec/appendices#id13 for more info.
-const LOCALPART_LOGIN_REGEX = /.*/;
 const LOCALPART_SIGNUP_REGEX = /^[a-z0-9_\-.=/]+$/;
-const BAD_LOCALPART_ERROR = 'Username must contain only a-z, 0-9, ., _, =, -, and /.';
+const BAD_LOCALPART_ERROR = 'Username can only contain characters a-z, 0-9, or \'=_-./\'';
 const USER_ID_TOO_LONG_ERROR = 'Your user ID, including the hostname, can\'t be more than 255 characters long.';
 
-const PASSWORD_REGEX = /.+/;
 const PASSWORD_STRENGHT_REGEX = /^(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[^\w\d\s:])([^\s]){8,127}$/;
-const BAD_PASSWORD_ERROR = 'Password must contain at least 1 number, 1 uppercase letter, 1 lowercase letter, 1 non-alphanumeric character. Passwords can range from 8-127 characters with no whitespaces.';
+const BAD_PASSWORD_ERROR = 'Password must contain at least 1 lowercase, 1 uppercase, 1 number, 1 non-alphanumeric character, 8-127 characters with no space.';
 const CONFIRM_PASSWORD_ERROR = 'Passwords don\'t match.';
 
-const EMAIL_REGEX = /([a-z0-9]+[_a-z0-9.-][a-z0-9]+)@([a-z0-9-]+(?:.[a-z0-9-]+).[a-z]{2,4})/;
+const EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
 const BAD_EMAIL_ERROR = 'Invalid email address';
 
 function isValidInput(value, regex) {
   if (typeof regex === 'string') return regex === value;
   return regex.test(value);
 }
-function renderErrorMessage(error) {
-  const $error = document.getElementById('auth_error');
-  $error.textContent = error;
-  $error.style.display = 'block';
-}
-function showBadInputError($input, error, stopAutoFocus) {
-  renderErrorMessage(error);
-  if (!stopAutoFocus) $input.focus();
-  const myInput = $input;
-  myInput.style.border = '1px solid var(--bg-danger)';
-  myInput.style.boxShadow = 'none';
-  document.getElementById('auth_submit-btn').disabled = true;
-}
-
-function validateOnChange(targetInput, regex, error, stopAutoFocus) {
-  if (!isValidInput(targetInput.value, regex) && targetInput.value) {
-    showBadInputError(targetInput, error, stopAutoFocus);
-    return false;
-  }
-  document.getElementById('auth_error').style.display = 'none';
-  targetInput.style.removeProperty('border');
-  targetInput.style.removeProperty('box-shadow');
-  document.getElementById('auth_submit-btn').disabled = false;
-  return true;
-}
-
-/**
- * Normalizes a username into a standard format.
- *
- * Removes leading and trailing whitespaces and leading "@" symbols.
- * @param {string} rawUsername A raw-input username, which may include invalid characters.
- * @returns {string}
- */
 function normalizeUsername(rawUsername) {
   const noLeadingAt = rawUsername.indexOf('@') === 0 ? rawUsername.substr(1) : rawUsername;
   return noLeadingAt.trim();
 }
 
-function Auth() {
-  const [type, setType] = useState('login');
-  const [process, changeProcess] = useState(null);
-  const [homeserver, changeHomeserver] = useState('matrix.org');
-
-  const usernameRef = useRef(null);
-  const homeserverRef = useRef(null);
-  const passwordRef = useRef(null);
-  const confirmPasswordRef = useRef(null);
-  const emailRef = useRef(null);
-
-  const { search } = useLocation();
-  const searchParams = new URLSearchParams(search);
-  if (searchParams.has('loginToken')) {
-    const loginToken = searchParams.get('loginToken');
-    if (loginToken !== undefined) {
-      if (localStorage.getItem(cons.secretKey.BASE_URL) !== undefined) {
-        const baseUrl = localStorage.getItem(cons.secretKey.BASE_URL);
-        auth.loginWithToken(baseUrl, loginToken)
-          .then(() => {
-            const { href } = window.location;
-            window.location.replace(href.slice(0, href.indexOf('?')));
-          })
-          .catch((error) => {
-            changeProcess(null);
-            if (!error.contains('CORS request rejected')) {
-              renderErrorMessage(error);
-            }
-          });
-      }
+let searchingHs = null;
+function Homeserver({ onChange }) {
+  const [hs, setHs] = useState(null);
+  const [debounce] = useState(new Debounce());
+  const [process, setProcess] = useState({ isLoading: true, message: 'Loading homeserver list...' });
+  const hsRef = useRef();
+
+  const setupHsConfig = async (servername) => {
+    setProcess({ isLoading: true, message: 'Looking for homeserver...' });
+    let baseUrl = null;
+    try {
+      baseUrl = await getBaseUrl(servername);
+    } catch (e) {
+      baseUrl = e.message;
     }
-  }
-
-  function register(recaptchaValue, terms, verified) {
-    auth.register(
-      usernameRef.current.value,
-      homeserverRef.current.value,
-      passwordRef.current.value,
-      emailRef.current.value,
-      recaptchaValue,
-      terms,
-      verified,
-    ).then((res) => {
-      document.getElementById('auth_submit-btn').disabled = false;
-      if (res.type === 'recaptcha') {
-        changeProcess({ type: res.type, sitekey: res.public_key });
-        return;
-      }
-      if (res.type === 'terms') {
-        changeProcess({ type: res.type, en: res.en });
-      }
-      if (res.type === 'email') {
-        changeProcess({ type: res.type });
-      }
-      if (res.type === 'done') {
-        window.location.replace('/');
-      }
-    }).catch((error) => {
-      changeProcess(null);
-      renderErrorMessage(error);
-      document.getElementById('auth_submit-btn').disabled = false;
-    });
-    if (terms) {
-      changeProcess({ type: 'loading', message: 'Sending email verification link...' });
-    } else changeProcess({ type: 'loading', message: 'Registration in progress...' });
-  }
-
-  function handleLogin(e) {
-    e.preventDefault();
-    document.getElementById('auth_submit-btn').disabled = true;
-    document.getElementById('auth_error').style.display = 'none';
-
-    /** @type {string} */
-    const rawUsername = usernameRef.current.value;
-    /** @type {string} */
-    const normalizedUsername = normalizeUsername(rawUsername);
-
-    auth.login(normalizedUsername, homeserverRef.current.value, passwordRef.current.value)
-      .then(() => {
-        document.getElementById('auth_submit-btn').disabled = false;
-        window.location.replace('/');
-      })
-      .catch((error) => {
-        changeProcess(null);
-        renderErrorMessage(error);
-        document.getElementById('auth_submit-btn').disabled = false;
+    if (searchingHs !== servername) return;
+    setProcess({ isLoading: true, message: `Connecting to ${baseUrl}...` });
+    const tempClient = auth.createTemporaryClient(baseUrl);
+
+    Promise.allSettled([tempClient.loginFlows(), tempClient.register()])
+      .then((values) => {
+        const loginFlow = values[0].status === 'fulfilled' ? values[0]?.value : undefined;
+        const registerFlow = values[1].status === 'rejected' ? values[1]?.reason?.data : undefined;
+        if (loginFlow === undefined || registerFlow === undefined) throw new Error();
+
+        if (searchingHs !== servername) return;
+        onChange({ baseUrl, login: loginFlow, register: registerFlow });
+        setProcess({ isLoading: false });
+      }).catch(() => {
+        if (searchingHs !== servername) return;
+        onChange(null);
+        setProcess({ isLoading: false, error: 'Unable to connect. Please check your input.' });
       });
-    changeProcess({ type: 'loading', message: 'Login in progress...' });
-  }
+  };
 
-  function handleRegister(e) {
-    e.preventDefault();
-    document.getElementById('auth_submit-btn').disabled = true;
-    document.getElementById('auth_error').style.display = 'none';
+  useEffect(() => {
+    onChange(null);
+    if (hs === null || hs?.selected.trim() === '') return;
+    searchingHs = hs.selected;
+    setupHsConfig(hs.selected);
+  }, [hs]);
 
-    if (!isValidInput(usernameRef.current.value, LOCALPART_SIGNUP_REGEX)) {
-      showBadInputError(usernameRef.current, BAD_LOCALPART_ERROR);
-      return;
-    }
-    if (!isValidInput(passwordRef.current.value, PASSWORD_STRENGHT_REGEX)) {
-      showBadInputError(passwordRef.current, BAD_PASSWORD_ERROR);
-      return;
-    }
-    if (passwordRef.current.value !== confirmPasswordRef.current.value) {
-      showBadInputError(confirmPasswordRef.current, CONFIRM_PASSWORD_ERROR);
-      return;
-    }
-    if (!isValidInput(emailRef.current.value, EMAIL_REGEX)) {
-      showBadInputError(emailRef.current, BAD_EMAIL_ERROR);
-      return;
-    }
-    if (`@${usernameRef.current.value}:${homeserverRef.current.value}`.length > 255) {
-      showBadInputError(usernameRef.current, USER_ID_TOO_LONG_ERROR);
-      return;
+  useEffect(async () => {
+    const configFileUrl = `${window.location.href}/config.json`;
+    try {
+      const result = await (await fetch(configFileUrl, { method: 'GET' })).json();
+      const selectedHs = result?.defaultHomeserver;
+      const hsList = result?.homeserverList;
+      if (!hsList?.length > 0 || selectedHs < 0 || selectedHs >= hsList?.length) {
+        throw new Error();
+      }
+      setHs({ selected: hsList[selectedHs], list: hsList });
+    } catch {
+      setHs({ selected: 'matrix.org', list: ['matrix.org'] });
     }
-    register();
-  }
+  }, []);
+
+  const handleHsInput = (e) => {
+    const { value } = e.target;
+    setProcess({ isLoading: false });
+    debounce._(async () => {
+      setHs({ selected: value, list: hs.list });
+    }, 700)();
+  };
 
-  const handleAuth = (type === 'login') ? handleLogin : handleRegister;
   return (
     <>
-      {process?.type === 'loading' && <LoadingScreen message={process.message} />}
-      {process?.type === 'recaptcha' && <Recaptcha message="Please check the box below to proceed." sitekey={process.sitekey} onChange={(v) => { if (typeof v === 'string') register(v); }} />}
-      {process?.type === 'terms' && <Terms url={process.en.url} onSubmit={register} />}
-      {process?.type === 'email' && (
-        <ProcessWrapper>
-          <div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
-            <Text variant="h2">Verify email</Text>
-            <div style={{ margin: 'var(--sp-normal) 0' }}>
-              <Text variant="b1">
-                Please check your email
-                {' '}
-                <b>{`(${emailRef.current.value})`}</b>
-                {' '}
-                and validate before continuing further.
-              </Text>
-            </div>
-            <Button variant="primary" onClick={() => register(undefined, undefined, true)}>Continue</Button>
-          </div>
-        </ProcessWrapper>
-      )}
-      <StaticWrapper>
-        <div className="auth-form__wrapper flex-v--center">
-          <form onSubmit={handleAuth} className="auth-form">
-            <Text variant="h2">{ type === 'login' ? 'Login' : 'Register' }</Text>
-            <div className="username__wrapper">
-              <Input
-                forwardRef={usernameRef}
-                onChange={(e) => (type === 'login'
-                  ? validateOnChange(e.target, LOCALPART_LOGIN_REGEX, BAD_LOCALPART_ERROR)
-                  : validateOnChange(e.target, LOCALPART_SIGNUP_REGEX, BAD_LOCALPART_ERROR))}
-                id="auth_username"
-                label="Username"
-                required
-              />
-              <Input
-                forwardRef={homeserverRef}
-                onChange={(e) => changeHomeserver(e.target.value)}
-                id="auth_homeserver"
-                placeholder="Homeserver"
-                value="matrix.org"
-                required
-              />
-            </div>
-            <div className="password__wrapper">
-              <Input
-                forwardRef={passwordRef}
-                onChange={(e) => {
-                  const isValidPass = validateOnChange(e.target, ((type === 'login') ? PASSWORD_REGEX : PASSWORD_STRENGHT_REGEX), BAD_PASSWORD_ERROR);
-                  if (type === 'register' && isValidPass) {
-                    validateOnChange(
-                      confirmPasswordRef.current, passwordRef.current.value,
-                      CONFIRM_PASSWORD_ERROR, true,
-                    );
-                  }
-                }}
-                id="auth_password"
-                type="password"
-                label="Password"
-                required
-              />
-              <IconButton
-                onClick={() => {
-                  if (passwordRef.current.type === 'password') {
-                    passwordRef.current.type = 'text';
-                  } else passwordRef.current.type = 'password';
-                }}
-                size="extra-small"
-                src={EyeIC}
-              />
-            </div>
-            {type === 'register' && (
-              <>
-                <div className="password__wrapper">
-                  <Input
-                    forwardRef={confirmPasswordRef}
-                    onChange={(e) => {
-                      validateOnChange(e.target, passwordRef.current.value, CONFIRM_PASSWORD_ERROR);
-                    }}
-                    id="auth_confirmPassword"
-                    type="password"
-                    label="Confirm password"
-                    required
-                  />
-                  <IconButton
+      <div className="homeserver-form">
+        <Input name="homeserver" onChange={handleHsInput} value={hs?.selected} forwardRef={hsRef} label="Homeserver" />
+        <ContextMenu
+          placement="right"
+          content={(hideMenu) => (
+            <>
+              <MenuHeader>Homeserver list</MenuHeader>
+              {
+                hs?.list.map((hsName) => (
+                  <MenuItem
+                    key={hsName}
                     onClick={() => {
-                      if (confirmPasswordRef.current.type === 'password') {
-                        confirmPasswordRef.current.type = 'text';
-                      } else confirmPasswordRef.current.type = 'password';
+                      hideMenu();
+                      hsRef.current.value = hsName;
+                      setHs({ selected: hsName, list: hs.list });
                     }}
-                    size="extra-small"
-                    src={EyeIC}
-                  />
-                </div>
-                <Input
-                  forwardRef={emailRef}
-                  onChange={(e) => validateOnChange(e.target, EMAIL_REGEX, BAD_EMAIL_ERROR)}
-                  id="auth_email"
-                  type="email"
-                  label="Email"
-                  required
-                />
-              </>
+                  >
+                    {hsName}
+                  </MenuItem>
+                ))
+              }
+            </>
+          )}
+          render={(toggleMenu) => <IconButton onClick={toggleMenu} src={ChevronBottomIC} />}
+        />
+      </div>
+      {process.error !== undefined && <Text className="homeserver-form__error" variant="b3">{process.error}</Text>}
+      {process.isLoading && (
+        <div className="homeserver-form__status flex--center">
+          <Spinner size="small" />
+          <Text variant="b2">{process.message}</Text>
+        </div>
+      )}
+    </>
+  );
+}
+Homeserver.propTypes = {
+  onChange: PropTypes.func.isRequired,
+};
+
+function Login({ loginFlow, baseUrl }) {
+  const [typeIndex, setTypeIndex] = useState(0);
+  const loginTypes = ['Username', 'Email'];
+  const isPassword = loginFlow?.filter((flow) => flow.type === 'm.login.password')[0];
+  const ssoProviders = loginFlow?.filter((flow) => flow.type.match(/^m.login.(sso|cas)$/))[0];
+
+  const initialValues = {
+    username: '', password: '', email: '', other: '',
+  };
+
+  const validator = (values) => {
+    const errors = {};
+    if (typeIndex === 0 && values.username.length > 0 && values.username.indexOf(':') > -1) {
+      errors.username = 'Username must contain local-part only';
+    }
+    if (typeIndex === 1 && values.email.length > 0 && !isValidInput(values.email, EMAIL_REGEX)) {
+      errors.email = BAD_EMAIL_ERROR;
+    }
+    return errors;
+  };
+  const submitter = (values, actions) => auth.login(
+    baseUrl,
+    typeIndex === 0 ? normalizeUsername(values.username) : undefined,
+    typeIndex === 1 ? values.email : undefined,
+    values.password,
+  ).then(() => {
+    actions.setSubmitting(true);
+    window.location.reload();
+  }).catch((error) => {
+    let msg = error.message;
+    if (msg === 'Unknown message') msg = 'Please check your credentials';
+    actions.setErrors({
+      password: msg === 'Invalid password' ? msg : undefined,
+      other: msg !== 'Invalid password' ? msg : undefined,
+    });
+    actions.setSubmitting(false);
+  });
+
+  return (
+    <>
+      <div className="auth-form__heading">
+        <Text variant="h2">Login</Text>
+        {isPassword && (
+          <ContextMenu
+            placement="right"
+            content={(hideMenu) => (
+              loginTypes.map((type, index) => (
+                <MenuItem
+                  key={type}
+                  onClick={() => {
+                    hideMenu();
+                    setTypeIndex(index);
+                  }}
+                >
+                  {type}
+                </MenuItem>
+              ))
             )}
-            <div className="submit-btn__wrapper flex--end">
-              <Text id="auth_error" className="error-message" variant="b3">Error</Text>
-              <Button
-                id="auth_submit-btn"
-                variant="primary"
-                type="submit"
-              >
-                {type === 'login' ? 'Login' : 'Register' }
+            render={(toggleMenu) => (
+              <Button onClick={toggleMenu} iconSrc={ChevronBottomIC}>
+                {loginTypes[typeIndex]}
               </Button>
-            </div>
-            {type === 'login' && (
-              <SSOButtons homeserver={homeserver} />
             )}
-          </form>
-        </div>
+          />
+        )}
+      </div>
+      {isPassword && (
+        <Formik
+          initialValues={initialValues}
+          onSubmit={submitter}
+          validate={validator}
+        >
+          {({
+            values, errors, handleChange, handleSubmit, isSubmitting,
+          }) => (
+            <>
+              {isSubmitting && <LoadingScreen message="Login in progress..." />}
+              <form className="auth-form" onSubmit={handleSubmit}>
+                {typeIndex === 0 && <Input values={values.username} name="username" onChange={handleChange} label="Username" type="username" required />}
+                {errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
+                {typeIndex === 1 && <Input values={values.email} name="email" onChange={handleChange} label="Email" type="email" required />}
+                {errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}
+                <Input values={values.password} name="password" onChange={handleChange} label="Password" type="password" required />
+                {errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
+                {errors.other && <Text className="auth-form__error" variant="b3">{errors.other}</Text>}
+                <div className="auth-form__btns">
+                  <Button variant="primary" type="submit" disabled={isSubmitting}>Login</Button>
+                </div>
+              </form>
+            </>
+          )}
+        </Formik>
+      )}
+      {ssoProviders && isPassword && <Text className="sso__divider">OR</Text>}
+      {ssoProviders && (
+        <SSOButtons
+          type={ssoProviders.type.match(/^m.login.(sso|cas)$/)[1]}
+          identityProviders={ssoProviders.identity_providers}
+          baseUrl={baseUrl}
+        />
+      )}
+    </>
+  );
+}
+Login.propTypes = {
+  loginFlow: PropTypes.arrayOf(
+    PropTypes.shape({}),
+  ).isRequired,
+  baseUrl: PropTypes.string.isRequired,
+};
 
-        <div style={{ flexDirection: 'column' }} className="flex--center">
-          <Text variant="b2">
-            {`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`}
-            <button
-              type="button"
-              style={{ color: 'var(--tc-link)', cursor: 'pointer', margin: '0 var(--sp-ultra-tight)' }}
-              onClick={() => {
-                if (type === 'login') setType('register');
-                else setType('login');
-              }}
-            >
-              { type === 'login' ? ' Register' : ' Login' }
-            </button>
-          </Text>
-          <span style={{ marginTop: 'var(--sp-extra-tight)' }}>
-            <Text variant="b3">v1.4.0</Text>
-          </span>
-        </div>
-      </StaticWrapper>
+let sid;
+let clientSecret;
+function Register({ registerInfo, loginFlow, baseUrl }) {
+  const [process, setProcess] = useState({});
+  const formRef = useRef();
+
+  const ssoProviders = loginFlow?.filter((flow) => flow.type.match(/^m.login.(sso|cas)$/))[0];
+  const isDisabled = registerInfo.errcode !== undefined;
+  const { flows, params, session } = registerInfo;
+
+  let isEmail = false;
+  let isEmailRequired = true;
+  let isRecaptcha = false;
+  let isTerms = false;
+  let isDummy = false;
+
+  flows?.forEach((flow) => {
+    if (isEmailRequired && flow.stages.indexOf('m.login.email.identity') === -1) isEmailRequired = false;
+    if (!isEmail) isEmail = flow.stages.indexOf('m.login.email.identity') > -1;
+    if (!isRecaptcha) isRecaptcha = flow.stages.indexOf('m.login.recaptcha') > -1;
+    if (!isTerms) isTerms = flow.stages.indexOf('m.login.terms') > -1;
+    if (!isDummy) isDummy = flow.stages.indexOf('m.login.dummy') > -1;
+  });
+
+  const initialValues = {
+    username: '', password: '', confirmPassword: '', email: '', other: '',
+  };
+
+  const validator = (values) => {
+    const errors = {};
+    if (values.username.list > 255) errors.username = USER_ID_TOO_LONG_ERROR;
+    if (values.username.length > 0 && !isValidInput(values.username, LOCALPART_SIGNUP_REGEX)) {
+      errors.username = BAD_LOCALPART_ERROR;
+    }
+    if (values.password.length > 0 && !isValidInput(values.password, PASSWORD_STRENGHT_REGEX)) {
+      errors.password = BAD_PASSWORD_ERROR;
+    }
+    if (values.confirmPassword.length > 0
+      && !isValidInput(values.confirmPassword, values.password)) {
+      errors.confirmPassword = CONFIRM_PASSWORD_ERROR;
+    }
+    if (values.email.length > 0 && !isValidInput(values.email, EMAIL_REGEX)) {
+      errors.email = BAD_EMAIL_ERROR;
+    }
+    return errors;
+  };
+  const submitter = (values, actions) => {
+    const tempClient = auth.createTemporaryClient(baseUrl);
+    clientSecret = tempClient.generateClientSecret();
+    return tempClient.isUsernameAvailable(values.username)
+      .then(async (isAvail) => {
+        if (!isAvail) {
+          actions.setErrors({ username: 'Username is already taken' });
+          actions.setSubmitting(false);
+        }
+        if (isEmail && values.email.length > 0) {
+          const result = await auth.verifyEmail(baseUrl, values.email, clientSecret, 1);
+          if (result.errcode) {
+            if (result.errcode === 'M_THREEPID_IN_USE') actions.setErrors({ email: result.error });
+            else actions.setErrors({ others: result.error || result.message });
+            actions.setSubmitting(false);
+            return;
+          }
+          sid = result.sid;
+        }
+        setProcess({ type: 'processing', message: 'Registration in progress....' });
+        actions.setSubmitting(false);
+      }).catch((err) => {
+        const msg = err.message || err.error;
+        if (['M_USER_IN_USE', 'M_INVALID_USERNAME', 'M_EXCLUSIVE'].indexOf(err.errcode) > 0) {
+          actions.setErrors({ username: err.errCode === 'M_USER_IN_USE' ? 'Username is already taken' : msg });
+        } else if (msg) actions.setErrors({ other: msg });
+
+        actions.setSubmitting(false);
+      });
+  };
+
+  const refreshWindow = () => window.location.reload();
+
+  const getInputs = () => {
+    const f = formRef.current;
+    return [f.username.value, f.password.value, f?.email?.value];
+  };
+
+  useEffect(() => {
+    if (process.type !== 'processing') return;
+    const asyncProcess = async () => {
+      const [username, password, email] = getInputs();
+      const d = await auth.completeRegisterStage(baseUrl, username, password, { session });
+
+      if (isRecaptcha && !d.completed.includes('m.login.recaptcha')) {
+        const sitekey = params['m.login.recaptcha'].public_key;
+        setProcess({ type: 'm.login.recaptcha', sitekey });
+        return;
+      }
+      if (isTerms && !d.completed.includes('m.login.terms')) {
+        const pp = params['m.login.terms'].policies.privacy_policy;
+        const url = pp?.en.url || pp[Object.keys(pp)[0]].url;
+        setProcess({ type: 'm.login.terms', url });
+        return;
+      }
+      if (isEmail && email.length > 0) {
+        setProcess({ type: 'm.login.email.identity', email });
+        return;
+      }
+      if (isDummy) {
+        const data = await auth.completeRegisterStage(baseUrl, username, password, {
+          type: 'm.login.dummy',
+          session,
+        });
+        if (data.done) refreshWindow();
+      }
+    };
+    asyncProcess();
+  }, [process]);
+
+  const handleRecaptcha = async (value) => {
+    if (typeof value !== 'string') return;
+    const [username, password] = getInputs();
+    const d = await auth.completeRegisterStage(baseUrl, username, password, {
+      type: 'm.login.recaptcha',
+      response: value,
+      session,
+    });
+    if (d.done) refreshWindow();
+    else setProcess({ type: 'processing', message: 'Registration in progress....' });
+  };
+  const handleTerms = async () => {
+    const [username, password] = getInputs();
+    const d = await auth.completeRegisterStage(baseUrl, username, password, {
+      type: 'm.login.terms',
+      session,
+    });
+    if (d.done) refreshWindow();
+    else setProcess({ type: 'processing', message: 'Registration in progress....' });
+  };
+  const handleEmailVerify = async () => {
+    const [username, password] = getInputs();
+    const d = await auth.completeRegisterStage(baseUrl, username, password, {
+      type: 'm.login.email.identity',
+      threepidCreds: { sid, client_secret: clientSecret },
+      threepid_creds: { sid, client_secret: clientSecret },
+      session,
+    });
+    if (d.done) refreshWindow();
+    else setProcess({ type: 'processing', message: 'Registration in progress....' });
+  };
+
+  return (
+    <>
+      {process.type === 'processing' && <LoadingScreen message={process.message} />}
+      {process.type === 'm.login.recaptcha' && <Recaptcha message="Please check the box below to proceed." sitekey={process.sitekey} onChange={handleRecaptcha} />}
+      {process.type === 'm.login.terms' && <Terms url={process.url} onSubmit={handleTerms} />}
+      {process.type === 'm.login.email.identity' && <EmailVerify email={process.email} onContinue={handleEmailVerify} />}
+      <div className="auth-form__heading">
+        {!isDisabled && <Text variant="h2">Register</Text>}
+        {isDisabled && <Text className="auth-form__error">{registerInfo.error}</Text>}
+      </div>
+      {!isDisabled && (
+        <Formik
+          initialValues={initialValues}
+          onSubmit={submitter}
+          validate={validator}
+        >
+          {({
+            values, errors, handleChange, handleSubmit, isSubmitting,
+          }) => (
+            <>
+              {process.type === undefined && isSubmitting && <LoadingScreen message="Registration in progress..." />}
+              <form className="auth-form" ref={formRef} onSubmit={handleSubmit}>
+                <Input values={values.username} name="username" onChange={handleChange} label="Username" type="username" required />
+                {errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
+                <Input values={values.password} name="password" onChange={handleChange} label="Password" type="password" required />
+                {errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
+                <Input values={values.confirmPassword} name="confirmPassword" onChange={handleChange} label="Confirm password" type="password" required />
+                {errors.confirmPassword && <Text className="auth-form__error" variant="b3">{errors.confirmPassword}</Text>}
+                {isEmail && <Input values={values.email} name="email" onChange={handleChange} label={`Email${isEmailRequired ? '' : ' (optional)'}`} type="email" required={isEmailRequired} />}
+                {errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}
+                {errors.other && <Text className="auth-form__error" variant="b3">{errors.other}</Text>}
+                <div className="auth-form__btns">
+                  <Button variant="primary" type="submit" disabled={isSubmitting}>Register</Button>
+                </div>
+              </form>
+            </>
+          )}
+        </Formik>
+      )}
+      {isDisabled && ssoProviders && (
+        <SSOButtons
+          type={ssoProviders.type.match(/^m.login.(sso|cas)$/)[1]}
+          identityProviders={ssoProviders.identity_providers}
+          baseUrl={baseUrl}
+        />
+      )}
+    </>
+  );
+}
+Register.propTypes = {
+  registerInfo: PropTypes.shape({}).isRequired,
+  loginFlow: PropTypes.arrayOf(
+    PropTypes.shape({}),
+  ).isRequired,
+  baseUrl: PropTypes.string.isRequired,
+};
+
+function AuthCardCopy() {
+  const [hsConfig, setHsConfig] = useState(null);
+  const [type, setType] = useState('login');
+
+  const handleHsChange = (info) => setHsConfig(info);
+
+  return (
+    <>
+      <Homeserver onChange={handleHsChange} />
+      { hsConfig !== null && (
+        type === 'login'
+          ? <Login loginFlow={hsConfig.login.flows} baseUrl={hsConfig.baseUrl} />
+          : (
+            <Register
+              registerInfo={hsConfig.register}
+              loginFlow={hsConfig.login.flows}
+              baseUrl={hsConfig.baseUrl}
+            />
+          )
+      )}
+      { hsConfig !== null && (
+        <Text variant="b2" className="auth-card__switch flex--center">
+          {`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`}
+          <button
+            type="button"
+            style={{ color: 'var(--tc-link)', cursor: 'pointer', margin: '0 var(--sp-ultra-tight)' }}
+            onClick={() => setType((type === 'login') ? 'register' : 'login')}
+          >
+            { type === 'login' ? ' Register' : ' Login' }
+          </button>
+        </Text>
+      )}
     </>
   );
 }
 
-function StaticWrapper({ children }) {
+function Auth() {
+  const [loginToken, setLoginToken] = useState(getUrlPrams('loginToken'));
+
+  useEffect(async () => {
+    if (!loginToken) return;
+    if (localStorage.getItem(cons.secretKey.BASE_URL) === undefined) {
+      setLoginToken(null);
+      return;
+    }
+    const baseUrl = localStorage.getItem(cons.secretKey.BASE_URL);
+    try {
+      await auth.loginWithToken(baseUrl, loginToken);
+
+      const { href } = window.location;
+      window.location.replace(href.slice(0, href.indexOf('?')));
+    } catch {
+      setLoginToken(null);
+    }
+  }, []);
+
   return (
     <ScrollView invisible>
-      <div className="auth__wrapper flex--center">
-        <div className="auth-card">
-          <div className="auth-card__interactive flex-v">
-            <div className="app-ident flex">
-              <img className="app-ident__logo noselect" src={CinnySvg} alt="Cinny logo" />
-              <div className="app-ident__text flex-v--center">
-                <Text variant="h2">Cinny</Text>
-                <Text variant="b2">Yet another matrix client</Text>
+      <div className="auth__base">
+        <div className="auth__wrapper">
+          {loginToken && <LoadingScreen message="Redirecting..." />}
+          {!loginToken && (
+            <div className="auth-card flex-v">
+              <Header>
+                <Avatar size="extra-small" imageSrc={CinnySvg} />
+                <TitleWrapper>
+                  <Text variant="h2">Cinny</Text>
+                </TitleWrapper>
+              </Header>
+              <div className="auth-card__content">
+                <AuthCardCopy />
               </div>
             </div>
-            { children }
-          </div>
+          )}
+        </div>
+
+        <div className="auth-footer">
+          <Text variant="b2">
+            <a href="https://cinny.in" target="_blank" rel="noreferrer">About</a>
+          </Text>
+          <Text variant="b2">
+            <a href="https://github.com/ajbura/cinny/releases" target="_blank" rel="noreferrer">{`v${cons.version}`}</a>
+          </Text>
+          <Text variant="b2">
+            <a href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">Twitter</a>
+          </Text>
+          <Text variant="b2">
+            <a href="https://matrix.org" target="_blank" rel="noreferrer">Powered by Matrix</a>
+          </Text>
         </div>
       </div>
     </ScrollView>
   );
 }
 
-StaticWrapper.propTypes = {
-  children: PropTypes.node.isRequired,
-};
-
 function LoadingScreen({ message }) {
   return (
     <ProcessWrapper>
@@ -396,7 +595,7 @@ Recaptcha.propTypes = {
 function Terms({ url, onSubmit }) {
   return (
     <ProcessWrapper>
-      <form onSubmit={() => onSubmit(undefined, true)}>
+      <form onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
         <div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
           <Text variant="h2">Agree with terms</Text>
           <div style={{ marginBottom: 'var(--sp-normal)' }} />
@@ -419,6 +618,27 @@ Terms.propTypes = {
   onSubmit: PropTypes.func.isRequired,
 };
 
+function EmailVerify({ email, onContinue }) {
+  return (
+    <ProcessWrapper>
+      <div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
+        <Text variant="h2">Verify email</Text>
+        <div style={{ margin: 'var(--sp-normal) 0' }}>
+          <Text variant="b1">
+            {'Please check your email '}
+            <b>{`(${email})`}</b>
+            {' and validate before continuing further.'}
+          </Text>
+        </div>
+        <Button variant="primary" onClick={onContinue}>Continue</Button>
+      </div>
+    </ProcessWrapper>
+  );
+}
+EmailVerify.propTypes = {
+  email: PropTypes.string.isRequired,
+};
+
 function ProcessWrapper({ children }) {
   return (
     <div className="process-wrapper">
index 678b90f145a777db4796b99f41713e2301540b52..ecc5011bfcfff407076bcf7534f8fe8e13106104 100644 (file)
-.auth__wrapper {
+.auth__base {
+  --pattern-size: 48px;
   min-height: 100vh;
-  padding: var(--sp-loose);
   background-color: var(--bg-surface-low);
 
-  background-image: url("https://images.unsplash.com/photo-1562619371-b67725b6fde2?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80");
-  background-size: cover;
-  background-repeat: no-repeat;
-  background-position: center;
-
-  .auth-card {
-    width: 462px;
-    min-height: 644px;
-    background-color: var(--bg-surface-low);
-    border-radius: var(--bo-radius);
-    box-shadow: var(--bs-popup);
-    overflow: hidden;
-    display: flex;
-    flex-flow: row nowrap;
-    
-    &__interactive{
-      flex: 1;
-      min-width: 0;
-    }
-
-    &__interactive {
-      padding: calc(var(--sp-normal) + var(--sp-extra-loose));
-      padding-bottom: var(--sp-extra-loose);
-      background-color: var(--bg-surface);
-    }
+  background-image: radial-gradient(rgba(0, 0, 0, 6%) 2px, rgba(0, 0, 0, 0%) 2px);
+  background-size: var(--pattern-size) var(--pattern-size);
 
-  }
+  display: flex;
+  flex-direction: column;
 }
+.auth__wrapper {
+  flex: 1;
+  padding: var(--sp-loose);
+  padding-bottom: 0;
+  display: flex;
+  justify-content: center;
+  align-items: flex-start;
+}
+.auth-footer {
+  padding: var(--sp-normal) 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
 
-.app-ident {
-  margin-bottom: var(--sp-extra-loose);
-
-  &__logo {
-    width: 60px;
-    height: 60px;
+  & > *:nth-child(2n) {
+    margin: 0 var(--sp-loose);
   }
-  &__text {
-    margin-left: calc(var(--sp-loose) + var(--sp-ultra-tight));
-
-    .text-s1 {
-      margin-top: var(--sp-tight);
-      color: var(--tc-surface-normal);
-    }
-
-    [dir=rtl] & {
-      margin-left: 0;
-      margin-right:  calc(var(--sp-loose) + var(--sp-ultra-tight));
-    }
+  & a {
+    color: var(--tc-surface-normal);
+    &:hover { text-decoration: underline; }
   }
 }
-
-.auth-form {
-
-  & > .text {
-    margin-bottom: var(--sp-loose);
-    margin-top: var(--sp-loose);
-  }
-  & > .input-container {
-    margin-top: var(--sp-tight);
+.auth-card {
+  width: 462px;
+  background-color: var(--bg-surface);
+  border-radius: var(--bo-radius);
+  box-shadow: var(--bs-popup);
+  overflow: hidden;
+
+  &__content {
+    padding: var(--sp-extra-loose) calc(var(--sp-normal) + var(--sp-extra-loose));
   }
-
-  .submit-btn__wrapper {
-    margin-top: var(--sp-extra-loose);
-    margin-bottom: var(--sp-loose);
-    align-items: flex-start;
-
-    & > .error-message {
-      display: none;
-      flex: 1;
-      color: var(--tc-danger-normal);
-      margin-right: var(--sp-normal);
-      word-break: break;
-
-      [dir=rtl] & {
-        margin: {
-          right: 0;
-          left: var(--sp-normal);
-        }
-      }
-    }
+  &__switch {
+    margin-top: var(--sp-loose) !important;
   }
+}
 
-  &__wrapper {
-    height: 100%;
+.homeserver-form,
+.auth-form__heading {
+  & .context-menu .btn-surface .ic-raw {
+    width: 0;
   }
 }
 
-.username__wrapper {
+.homeserver-form {
   display: flex;
-  align-items: flex-end;
-
-  & > :first-child {
+  margin-bottom: var(--sp-extra-tight);
+  & > .input-container {
     flex: 1;
-
-    .input {
+    & .input {
+      border-right: unset;
       border-radius: var(--bo-radius) 0 0 var(--bo-radius);
-
-      [dir=rtl] & {
-        border-radius:  0 var(--bo-radius) var(--bo-radius) 0;
-      }
+      background-color: var(--bg-surface);
     }
   }
-  & > :last-child {
-    width: 110px;
-
-    .input {
-      border-left-width: 0;
-      background-color: var(--bg-surface);
+  & .ic-btn {
+    height: 46px;
+    align-self: flex-end;
+    border: 1px solid var(--bg-surface-border);
+    border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
+  }
+  [dir=rtl] & {
+    & .input {
       border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
+      border-radius: 1px;
+      border-left: unset;
+    }
+    .ic-btn {
+      border-radius: var(--bo-radius) 0 0 var(--bo-radius);
+    }
+  }
 
-      [dir=rtl] & {
-        border-left-width: 1px;
-        border-right-width: 0;
-        border-radius: var(--bo-radius) 0 0 var(--bo-radius);
-      }
+  &__status {
+    margin-top: var(--sp-normal);
+    & .donut-spinner {
+      min-width: 28px;
+    }
+    & .text {
+      margin: 0 var(--sp-tight);
     }
   }
+  &__error {
+    margin-bottom: var(--sp-normal) !important;
+    color: var(--tc-danger-normal) !important;
+  }
 }
 
-.password__wrapper {
-  margin-top: var(--sp-tight);
-  position: relative;
+.auth-form {
+  & > .input-container {
+    margin: var(--sp-tight) 0 var(--sp-ultra-tight);
+  }
+  
+  &__heading {
+    display: flex;
+    justify-content: space-between;
+    margin-top: calc(var(--sp-extra-loose) + var(--sp-tight));
+  }
 
-  & .ic-btn {
-    position: absolute;
-    right: 6px;
-    bottom: 6px;
-    border-radius: calc(var(--bo-radius) / 2);
-    [dir=rtl] & {
-      left: 6px;
-      right: unset;
-    }
+  &__btns {
+    padding-top: var(--sp-loose);
+    margin-bottom: var(--sp-extra-loose);
+    display: flex;
+    justify-content: flex-end;
+  }
+
+  &__error {
+    color: var(--tc-danger-normal) !important;
+  }
+}
+.sso__divider {
+  margin-bottom: var(--sp-tight);
+  display: flex;
+  align-items: center;
+
+  &::before,
+  &::after {
+    flex: 1;
+    content: '';
+    margin: var(--sp-tight);
+    border-bottom: 1px solid var(--bg-surface-border);
   }
 }
 
 @media (max-width: 462px) {
   .auth__wrapper {
-    padding: 0;
-    background-image: none;
-    background-color: var(--bg-surface);
-
-    .auth-card {
-      border-radius: 0;
-      box-shadow: none;
-
-      &__interactive {
-        padding: var(--sp-extra-loose);
-      }
+    padding: var(--sp-tight);
+  }
+  .auth-card {
+    &__content {
+      padding: var(--sp-loose) var(--sp-normal);
     }
   }
 }
index 47fe2ba2615500dea85793f781f2a2d64ba59d22..5631164cb48dbdd17d26b5a59ff19115ba8119cf 100644 (file)
 import * as sdk from 'matrix-js-sdk';
 import cons from '../state/cons';
-import { getBaseUrl } from '../../util/matrixUtil';
 
-// This method inspired by a similar one in matrix-react-sdk
-async function createTemporaryClient(homeserver) {
-  let baseUrl = null;
-  try {
-    baseUrl = await getBaseUrl(homeserver);
-  } catch (e) {
-    baseUrl = `https://${homeserver}`;
-  }
-
-  if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found');
-
-  return sdk.createClient({ baseUrl });
+function updateLocalStore(accessToken, deviceId, userId, baseUrl) {
+  localStorage.setItem(cons.secretKey.ACCESS_TOKEN, accessToken);
+  localStorage.setItem(cons.secretKey.DEVICE_ID, deviceId);
+  localStorage.setItem(cons.secretKey.USER_ID, userId);
+  localStorage.setItem(cons.secretKey.BASE_URL, baseUrl);
 }
 
-async function getLoginFlows(client) {
-  const flows = await client.loginFlows();
-  if (flows !== undefined) {
-    return flows;
-  }
-  return null;
+function createTemporaryClient(baseUrl) {
+  return sdk.createClient({ baseUrl });
 }
 
-async function startSsoLogin(homeserver, type, idpId) {
-  const client = await createTemporaryClient(homeserver);
+async function startSsoLogin(baseUrl, type, idpId) {
+  const client = createTemporaryClient(baseUrl);
   localStorage.setItem(cons.secretKey.BASE_URL, client.baseUrl);
   window.location.href = client.getSsoLoginUrl(window.location.href, type, idpId);
 }
 
-async function login(username, homeserver, password) {
-  const client = await createTemporaryClient(homeserver);
-
-  const response = await client.login('m.login.password', {
-    identifier: {
-      type: 'm.id.user',
-      user: username,
-    },
+async function login(baseUrl, username, email, password) {
+  const identifier = {};
+  if (username) {
+    identifier.type = 'm.id.user';
+    identifier.user = username;
+  } else if (email) {
+    identifier.type = 'm.id.thirdparty';
+    identifier.medium = 'email';
+    identifier.address = email;
+  } else throw new Error('Bad Input');
+
+  const client = createTemporaryClient(baseUrl);
+  const res = await client.login('m.login.password', {
+    identifier,
     password,
     initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
   });
 
-  localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
-  localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
-  localStorage.setItem(cons.secretKey.USER_ID, response.user_id);
-  localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl);
+  const myBaseUrl = res?.well_known?.['m.homeserver']?.base_url || client.baseUrl;
+  updateLocalStore(res.access_token, res.device_id, res.user_id, myBaseUrl);
 }
 
 async function loginWithToken(baseUrl, token) {
-  const client = sdk.createClient(baseUrl);
+  const client = createTemporaryClient(baseUrl);
 
-  const response = await client.login('m.login.token', {
+  const res = await client.login('m.login.token', {
     token,
     initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
   });
 
-  localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
-  localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
-  localStorage.setItem(cons.secretKey.USER_ID, response.user_id);
-  localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl);
+  const myBaseUrl = res?.well_known?.['m.homeserver']?.base_url || client.baseUrl;
+  updateLocalStore(res.access_token, res.device_id, res.user_id, myBaseUrl);
 }
 
-async function getAdditionalInfo(baseUrl, content) {
-  try {
-    const res = await fetch(`${baseUrl}/_matrix/client/r0/register`, {
-      method: 'POST',
-      body: JSON.stringify(content),
-      headers: {
-        'Content-Type': 'application/json; charset=utf-8',
-      },
-      credentials: 'same-origin',
-    });
-    const data = await res.json();
-    return data;
-  } catch (e) {
-    throw new Error(e);
-  }
+// eslint-disable-next-line camelcase
+async function verifyEmail(baseUrl, email, client_secret, send_attempt, next_link) {
+  const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, {
+    method: 'POST',
+    body: JSON.stringify({
+      email, client_secret, send_attempt, next_link,
+    }),
+    headers: {
+      'Content-Type': 'application/json; charset=utf-8',
+    },
+    credentials: 'same-origin',
+  });
+  const data = await res.json();
+  return data;
 }
 
-async function verifyEmail(baseUrl, content) {
+async function completeRegisterStage(
+  baseUrl, username, password, auth,
+) {
+  const tempClient = createTemporaryClient(baseUrl);
+
   try {
-    const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, {
-      method: 'POST',
-      body: JSON.stringify(content),
-      headers: {
-        'Content-Type': 'application/json; charset=utf-8',
-      },
-      credentials: 'same-origin',
+    const result = await tempClient.registerRequest({
+      username, password, auth,
     });
-    const data = await res.json();
+    const data = { completed: result.completed || [] };
+    if (result.access_token) {
+      data.done = true;
+      updateLocalStore(result.access_token, result.device_id, result.user_id, baseUrl);
+    }
     return data;
   } catch (e) {
-    throw new Error(e);
-  }
-}
-
-let session = null;
-let clientSecret = null;
-let sid = null;
-async function register(username, homeserver, password, email, recaptchaValue, terms, verified) {
-  const baseUrl = await getBaseUrl(homeserver);
-
-  if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found');
-
-  const client = sdk.createClient({ baseUrl });
-
-  const isAvailable = await client.isUsernameAvailable(username);
-  if (!isAvailable) throw new Error('Username not available');
-
-  if (typeof recaptchaValue === 'string') {
-    await getAdditionalInfo(baseUrl, {
-      auth: {
-        type: 'm.login.recaptcha',
-        session,
-        response: recaptchaValue,
-      },
-    });
-  } else if (terms === true) {
-    await getAdditionalInfo(baseUrl, {
-      auth: {
-        type: 'm.login.terms',
-        session,
-      },
-    });
-  } else if (verified !== true) {
-    session = null;
-    clientSecret = client.generateClientSecret();
-    const verifyData = await verifyEmail(baseUrl, {
-      email,
-      client_secret: clientSecret,
-      send_attempt: 1,
-    });
-    if (typeof verifyData.error === 'string') {
-      throw new Error(verifyData.error);
-    }
-    sid = verifyData.sid;
-  }
-
-  const additionalInfo = await getAdditionalInfo(baseUrl, {
-    auth: { session: (session !== null) ? session : undefined },
-  });
-  session = additionalInfo.session;
-  if (typeof additionalInfo.completed === 'undefined' || additionalInfo.completed.length === 0) {
-    return ({
-      type: 'recaptcha',
-      public_key: additionalInfo.params['m.login.recaptcha'].public_key,
-    });
-  }
-  if (additionalInfo.completed.find((process) => process === 'm.login.recaptcha') === 'm.login.recaptcha'
-    && !additionalInfo.completed.find((process) => process === 'm.login.terms')) {
-    return ({
-      type: 'terms',
-      en: additionalInfo.params['m.login.terms'].policies.privacy_policy.en,
-    });
-  }
-  if (verified || additionalInfo.completed.find((process) => process === 'm.login.terms') === 'm.login.terms') {
-    const tpc = {
-      client_secret: clientSecret,
-      sid,
-    };
-    const verifyData = await getAdditionalInfo(baseUrl, {
-      auth: {
-        session,
-        type: 'm.login.email.identity',
-        threepidCreds: tpc,
-        threepid_creds: tpc,
-      },
-      username,
-      password,
-    });
-    if (verifyData.errcode === 'M_UNAUTHORIZED') {
-      return { type: 'email' };
+    const result = e.data;
+    const data = { completed: result.completed || [] };
+    if (result.access_token) {
+      data.done = true;
+      updateLocalStore(result.access_token, result.device_id, result.user_id, baseUrl);
     }
-
-    localStorage.setItem(cons.secretKey.ACCESS_TOKEN, verifyData.access_token);
-    localStorage.setItem(cons.secretKey.DEVICE_ID, verifyData.device_id);
-    localStorage.setItem(cons.secretKey.USER_ID, verifyData.user_id);
-    localStorage.setItem(cons.secretKey.BASE_URL, baseUrl);
-    return { type: 'done' };
+    return data;
   }
-  return {};
 }
 
 export {
-  createTemporaryClient, getLoginFlows, login,
-  loginWithToken, register, startSsoLogin,
+  createTemporaryClient, login, verifyEmail,
+  loginWithToken, startSsoLogin,
+  completeRegisterStage,
 };
index 6cd177e2e26b2a05ec573405d51412ae16feabac..f554c9a86d528f51a9aa261acce4a2dd38d4a33f 100644 (file)
@@ -1,4 +1,5 @@
 const cons = {
+  version: '1.4.0',
   secretKey: {
     ACCESS_TOKEN: 'cinny_access_token',
     DEVICE_ID: 'cinny_device_id',
index e40fa73ce6815dd83ff6081340562a03fd412bae..e0b07100d9b5b40251c1f577b3bb285bad1dbc6d 100644 (file)
@@ -2,15 +2,18 @@ import initMatrix from '../client/initMatrix';
 
 const WELL_KNOWN_URI = '/.well-known/matrix/client';
 
-async function getBaseUrl(homeserver) {
-  const serverDiscoveryUrl = `https://${homeserver}${WELL_KNOWN_URI}`;
+async function getBaseUrl(servername) {
+  let protocol = 'https://';
+  if (servername.match(/^https?:\/\//) !== null) protocol = '';
+  const serverDiscoveryUrl = `${protocol}${servername}${WELL_KNOWN_URI}`;
   try {
-    const result = await fetch(serverDiscoveryUrl, { method: 'GET' });
-    const data = await result.json();
+    const result = await (await fetch(serverDiscoveryUrl, { method: 'GET' })).json();
 
-    return data?.['m.homeserver']?.base_url;
+    const baseUrl = result?.['m.homeserver']?.base_url;
+    if (baseUrl === undefined) throw new Error();
+    return baseUrl;
   } catch (e) {
-    throw new Error('Homeserver not found');
+    throw new Error(`${protocol}${servername}`);
   }
 }