feat: URL navigation in auth (#1603)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 21 Jan 2024 12:50:56 +0000 (23:50 +1100)
committerGitHub <noreply@github.com>
Sun, 21 Jan 2024 12:50:56 +0000 (18:20 +0530)
* bump to react 18 and install react-router-dom

* Upgrade to react 18 root

* update vite

* add cs api's

* convert state/auth to ts

* add client config context

* add auto discovery context

* add spec version context

* add auth flow context

* add background dot pattern css

* add promise utils

* init url based routing

* update auth route server path as effect

* add auth server hook

* always use server from discovery info in context

* login - WIP

* upgrade jotai to v2

* add atom with localStorage util

* add multi account sessions atom

* add default IGNORE res to auto discovery

* add error type in async callback hook

* handle password login error

* fix async callback hook

* allow password login

* Show custom server not allowed error in mxId login

* add sso login component

* add token login

* fix hardcoded m.login.password in login func

* update server input on url change

* Improve sso login labels

* update folds

* fix async callback batching state update in safari

* wrap async callback set state in queueMicrotask

* wip

* wip - register

* arrange auth file structure

* add error codes

* extract filed error component form password login

* add register util function

* handle register flow - WIP

* update unsupported auth flow method reasons

* improve password input styles

* Improve UIA flow next stage calculation
complete stages can have any order so we will look for first stage which is not in completed

* process register UIA flow stages

* Extract register UIA stages component

* improve register error messages

* add focus trap & step count in UIA stages

* add reset password path and path utils

* add path with origin hook

* fix sso redirect url

* rename register token query param to token

* restyle auth screen header

* add reset password component - WIP

* add reset password form

* add netlify rewrites

* fix netlify file indentation

* test netlify redirect

* fix vite to include netlify toml

* add more netlify redirects

* add splat to public and assets path

* fix vite base name

* add option to use hash router in config and remove appVersion

* add splash screen component

* add client config loading and error screen

* fix server picker bug

* fix reset password email input type

* make auth page small screen responsive

* fix typo in reset password screen

104 files changed:
_redirects [deleted file]
build.config.ts [new file with mode: 0644]
config.json
index.html
netlify.toml [new file with mode: 0644]
package-lock.json
package.json
src/app/components/AuthFlowsLoader.tsx [new file with mode: 0644]
src/app/components/ClientConfigLoader.tsx [new file with mode: 0644]
src/app/components/ConfirmPasswordMatch.tsx [new file with mode: 0644]
src/app/components/SpecVersionsLoader.tsx [new file with mode: 0644]
src/app/components/SupportedUIAFlowsLoader.tsx [new file with mode: 0644]
src/app/components/UIAFlowOverlay.tsx [new file with mode: 0644]
src/app/components/password-input/PasswordInput.tsx [new file with mode: 0644]
src/app/components/splash-screen/SplashScreen.css.ts [new file with mode: 0644]
src/app/components/splash-screen/SplashScreen.tsx [new file with mode: 0644]
src/app/components/splash-screen/index.ts [new file with mode: 0644]
src/app/components/uia-stages/DummyStage.tsx [new file with mode: 0644]
src/app/components/uia-stages/EmailStage.tsx [new file with mode: 0644]
src/app/components/uia-stages/ReCaptchaStage.tsx [new file with mode: 0644]
src/app/components/uia-stages/RegistrationTokenStage.tsx [new file with mode: 0644]
src/app/components/uia-stages/TermsStage.tsx [new file with mode: 0644]
src/app/components/uia-stages/index.ts [new file with mode: 0644]
src/app/components/uia-stages/types.ts [new file with mode: 0644]
src/app/cs-api.ts [new file with mode: 0644]
src/app/cs-errorcode.ts [new file with mode: 0644]
src/app/hooks/types.ts [new file with mode: 0644]
src/app/hooks/useAsyncCallback.ts
src/app/hooks/useAuthFlows.ts [new file with mode: 0644]
src/app/hooks/useAuthServer.ts [new file with mode: 0644]
src/app/hooks/useAutoDiscoveryInfo.ts [new file with mode: 0644]
src/app/hooks/useClientConfig.ts [new file with mode: 0644]
src/app/hooks/useCrossSigningStatus.js
src/app/hooks/useParsedLoginFlows.ts [new file with mode: 0644]
src/app/hooks/usePasswordEmail.ts [new file with mode: 0644]
src/app/hooks/usePathWithOrigin.ts [new file with mode: 0644]
src/app/hooks/useRegisterEmail.ts [new file with mode: 0644]
src/app/hooks/useSpecVersions.ts [new file with mode: 0644]
src/app/hooks/useUIAFlows.ts [new file with mode: 0644]
src/app/molecules/room-aliases/RoomAliases.jsx
src/app/molecules/room-history-visibility/RoomHistoryVisibility.jsx
src/app/molecules/room-notification/RoomNotification.jsx
src/app/molecules/room-search/RoomSearch.jsx
src/app/molecules/room-visibility/RoomVisibility.jsx
src/app/organisms/emoji-verification/EmojiVerification.jsx
src/app/organisms/room/message/UrlPreviewCard.tsx
src/app/organisms/space-manage/SpaceManage.jsx
src/app/pages/App.jsx [deleted file]
src/app/pages/App.tsx [new file with mode: 0644]
src/app/pages/ConfigConfig.tsx [new file with mode: 0644]
src/app/pages/auth/AuthFooter.tsx [new file with mode: 0644]
src/app/pages/auth/AuthLayout.tsx [new file with mode: 0644]
src/app/pages/auth/FiledError.tsx [new file with mode: 0644]
src/app/pages/auth/OrDivider.tsx [new file with mode: 0644]
src/app/pages/auth/SSOLogin.tsx [new file with mode: 0644]
src/app/pages/auth/ServerPicker.tsx [new file with mode: 0644]
src/app/pages/auth/index.ts [new file with mode: 0644]
src/app/pages/auth/login/Login.tsx [new file with mode: 0644]
src/app/pages/auth/login/PasswordLoginForm.tsx [new file with mode: 0644]
src/app/pages/auth/login/TokenLogin.tsx [new file with mode: 0644]
src/app/pages/auth/login/index.ts [new file with mode: 0644]
src/app/pages/auth/login/loginUtil.ts [new file with mode: 0644]
src/app/pages/auth/register/PasswordRegisterForm.tsx [new file with mode: 0644]
src/app/pages/auth/register/Register.tsx [new file with mode: 0644]
src/app/pages/auth/register/index.ts [new file with mode: 0644]
src/app/pages/auth/register/registerUtil.ts [new file with mode: 0644]
src/app/pages/auth/reset-password/PasswordResetForm.tsx [new file with mode: 0644]
src/app/pages/auth/reset-password/ResetPassword.tsx [new file with mode: 0644]
src/app/pages/auth/reset-password/index.ts [new file with mode: 0644]
src/app/pages/auth/reset-password/resetPasswordUtil.ts [new file with mode: 0644]
src/app/pages/auth/styles.css.ts [new file with mode: 0644]
src/app/pages/pathUtils.ts [new file with mode: 0644]
src/app/pages/paths.ts [new file with mode: 0644]
src/app/state/hooks/inviteList.ts
src/app/state/hooks/roomList.ts
src/app/state/hooks/settings.ts
src/app/state/inviteList.ts
src/app/state/list.ts
src/app/state/mDirectList.ts
src/app/state/mutedRoomList.ts
src/app/state/roomList.ts
src/app/state/roomToParents.ts
src/app/state/roomToUnread.ts
src/app/state/sessions.ts [new file with mode: 0644]
src/app/state/settings.ts
src/app/state/tabToRoom.ts
src/app/state/typingMembers.ts
src/app/state/upload.ts
src/app/state/utils.ts
src/app/state/utils/atomWithLocalStorage.ts [new file with mode: 0644]
src/app/styles/Patterns.css.ts [new file with mode: 0644]
src/app/utils/common.ts
src/app/utils/matrix-uia.ts [new file with mode: 0644]
src/app/utils/regex.ts
src/client/action/auth.js
src/client/initMatrix.js
src/client/state/auth.js [deleted file]
src/client/state/auth.ts [new file with mode: 0644]
src/ext.d.ts
src/index.jsx [deleted file]
src/index.tsx [new file with mode: 0644]
src/types/utils.ts [new file with mode: 0644]
tsconfig.json
vite.config.js

diff --git a/_redirects b/_redirects
deleted file mode 100644 (file)
index 270cd33..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-# Redirects from what the browser requests to what we serve
-/login       /
-/register    /
diff --git a/build.config.ts b/build.config.ts
new file mode 100644 (file)
index 0000000..ec8a41d
--- /dev/null
@@ -0,0 +1,3 @@
+export default {
+  base: '/',
+};
index 0ff493a1746c41461a269bf2847b132e0ad1a0b4..484c7cd783f6221790f155702c27bad0b68bd037 100644 (file)
@@ -8,5 +8,10 @@
     "mozilla.org",
     "xmr.se"
   ],
-  "allowCustomHomeservers": true
+  "allowCustomHomeservers": true,
+
+  "hashRouter": {
+    "enabled": false,
+    "basename": "/"
+  }
 }
index 6bc955c146da36eef7df1a125dcc11be0ceefe12..48f8e69ebf2726d3c6ca6da08ca2774b34589bdf 100644 (file)
@@ -96,6 +96,6 @@
     <audio id="inviteSound">
       <source src="./public/sound/invite.ogg" type="audio/ogg" />
     </audio>
-    <script type="module" src="./src/index.jsx"></script>
+    <script type="module" src="./src/index.tsx"></script>
   </body>
 </html>
diff --git a/netlify.toml b/netlify.toml
new file mode 100644 (file)
index 0000000..e7d948e
--- /dev/null
@@ -0,0 +1,34 @@
+[[redirects]]
+  from = "/config.json"
+  to = "/config.json"
+  status = 200
+
+[[redirects]]
+  from = "/manifest.json"
+  to = "/manifest.json"
+  status = 200
+  
+[[redirects]]
+  from = "/olm.wasm"
+  to = "/olm.wasm"
+  status = 200
+  
+[[redirects]]
+  from = "/pdf.worker.min.js"
+  to = "/pdf.worker.min.js"
+  status = 200
+
+[[redirects]]
+  from = "/public/*"
+  to = "/public/:splat"
+  status = 200
+
+[[redirects]]
+  from = "/assets/*"
+  to = "/assets/:splat"
+  status = 200
+
+[[redirects]]
+  from = "/*"
+  to = "/index.html"
+  status = 200
\ No newline at end of file
index 6c944cea5cd7240dd29f547b2c043ee6f675d11f..fba4072e6408d0e2d939b3d0fe1b1be58050093d 100644 (file)
         "file-saver": "2.0.5",
         "flux": "4.0.3",
         "focus-trap-react": "10.0.2",
-        "folds": "1.5.0",
+        "folds": "1.5.1",
         "formik": "2.2.9",
         "html-dom-parser": "4.0.0",
         "html-react-parser": "4.2.0",
         "immer": "9.0.16",
         "is-hotkey": "0.2.0",
-        "jotai": "1.12.0",
+        "jotai": "2.6.0",
         "katex": "0.16.4",
         "linkify-html": "4.0.2",
         "linkify-react": "4.1.1",
         "pdfjs-dist": "3.10.111",
         "prismjs": "1.29.0",
         "prop-types": "15.8.1",
-        "react": "17.0.2",
+        "react": "18.2.0",
         "react-aria": "3.29.1",
         "react-autosize-textarea": "7.1.0",
         "react-blurhash": "0.2.0",
-        "react-dnd": "15.1.2",
-        "react-dnd-html5-backend": "15.1.3",
-        "react-dom": "17.0.2",
+        "react-dnd": "16.0.1",
+        "react-dnd-html5-backend": "16.0.1",
+        "react-dom": "18.2.0",
         "react-error-boundary": "4.0.10",
         "react-google-recaptcha": "2.1.0",
         "react-modal": "3.16.1",
         "react-range": "1.8.14",
+        "react-router-dom": "6.20.0",
         "sanitize-html": "2.8.0",
         "slate": "0.94.1",
         "slate-history": "0.93.0",
         "@types/file-saver": "2.0.5",
         "@types/node": "18.11.18",
         "@types/prismjs": "1.26.0",
-        "@types/react": "18.0.26",
-        "@types/react-dom": "18.0.9",
+        "@types/react": "18.2.39",
+        "@types/react-dom": "18.2.17",
+        "@types/react-google-recaptcha": "2.1.8",
         "@types/sanitize-html": "2.9.0",
         "@types/ua-parser-js": "0.7.36",
         "@typescript-eslint/eslint-plugin": "5.46.1",
         "@typescript-eslint/parser": "5.46.1",
-        "@vitejs/plugin-react": "3.0.0",
+        "@vitejs/plugin-react": "4.2.0",
         "buffer": "6.0.3",
         "eslint": "8.29.0",
         "eslint-config-airbnb": "19.0.4",
@@ -90,7 +92,7 @@
         "prettier": "2.8.1",
         "sass": "1.56.2",
         "typescript": "4.9.4",
-        "vite": "4.3.9",
+        "vite": "5.0.8",
         "vite-plugin-static-copy": "0.13.0"
       },
       "engines": {
       }
     },
     "node_modules/@babel/code-frame": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
-      "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
+      "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==",
       "dependencies": {
-        "@babel/highlight": "^7.18.6"
+        "@babel/highlight": "^7.23.4",
+        "chalk": "^2.4.2"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/compat-data": {
-      "version": "7.20.10",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.10.tgz",
-      "integrity": "sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==",
+      "version": "7.23.3",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz",
+      "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.20.12",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz",
-      "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==",
-      "dependencies": {
-        "@ampproject/remapping": "^2.1.0",
-        "@babel/code-frame": "^7.18.6",
-        "@babel/generator": "^7.20.7",
-        "@babel/helper-compilation-targets": "^7.20.7",
-        "@babel/helper-module-transforms": "^7.20.11",
-        "@babel/helpers": "^7.20.7",
-        "@babel/parser": "^7.20.7",
-        "@babel/template": "^7.20.7",
-        "@babel/traverse": "^7.20.12",
-        "@babel/types": "^7.20.7",
-        "convert-source-map": "^1.7.0",
+      "version": "7.23.3",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz",
+      "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==",
+      "dependencies": {
+        "@ampproject/remapping": "^2.2.0",
+        "@babel/code-frame": "^7.22.13",
+        "@babel/generator": "^7.23.3",
+        "@babel/helper-compilation-targets": "^7.22.15",
+        "@babel/helper-module-transforms": "^7.23.3",
+        "@babel/helpers": "^7.23.2",
+        "@babel/parser": "^7.23.3",
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.23.3",
+        "@babel/types": "^7.23.3",
+        "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
-        "json5": "^2.2.2",
-        "semver": "^6.3.0"
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.20.7",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.7.tgz",
-      "integrity": "sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz",
+      "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==",
       "dependencies": {
-        "@babel/types": "^7.20.7",
+        "@babel/types": "^7.23.4",
         "@jridgewell/gen-mapping": "^0.3.2",
+        "@jridgewell/trace-mapping": "^0.3.17",
         "jsesc": "^2.5.1"
       },
       "engines": {
       }
     },
     "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": {
-      "version": "0.3.2",
-      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
-      "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+      "version": "0.3.3",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
+      "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
       "dependencies": {
         "@jridgewell/set-array": "^1.0.1",
         "@jridgewell/sourcemap-codec": "^1.4.10",
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.20.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
-      "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz",
+      "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==",
       "dependencies": {
-        "@babel/compat-data": "^7.20.5",
-        "@babel/helper-validator-option": "^7.18.6",
-        "browserslist": "^4.21.3",
+        "@babel/compat-data": "^7.22.9",
+        "@babel/helper-validator-option": "^7.22.15",
+        "browserslist": "^4.21.9",
         "lru-cache": "^5.1.1",
-        "semver": "^6.3.0"
+        "semver": "^6.3.1"
       },
       "engines": {
         "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
       }
     },
     "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
     },
     "node_modules/@babel/helper-environment-visitor": {
-      "version": "7.18.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
-      "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
+      "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-function-name": {
-      "version": "7.19.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
-      "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
+      "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
       "dependencies": {
-        "@babel/template": "^7.18.10",
-        "@babel/types": "^7.19.0"
+        "@babel/template": "^7.22.15",
+        "@babel/types": "^7.23.0"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-hoist-variables": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
-      "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
+      "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==",
       "dependencies": {
-        "@babel/types": "^7.18.6"
+        "@babel/types": "^7.22.5"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-module-imports": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
-      "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz",
+      "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==",
       "dependencies": {
-        "@babel/types": "^7.18.6"
+        "@babel/types": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-module-transforms": {
-      "version": "7.20.11",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz",
-      "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==",
-      "dependencies": {
-        "@babel/helper-environment-visitor": "^7.18.9",
-        "@babel/helper-module-imports": "^7.18.6",
-        "@babel/helper-simple-access": "^7.20.2",
-        "@babel/helper-split-export-declaration": "^7.18.6",
-        "@babel/helper-validator-identifier": "^7.19.1",
-        "@babel/template": "^7.20.7",
-        "@babel/traverse": "^7.20.10",
-        "@babel/types": "^7.20.7"
+      "version": "7.23.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz",
+      "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==",
+      "dependencies": {
+        "@babel/helper-environment-visitor": "^7.22.20",
+        "@babel/helper-module-imports": "^7.22.15",
+        "@babel/helper-simple-access": "^7.22.5",
+        "@babel/helper-split-export-declaration": "^7.22.6",
+        "@babel/helper-validator-identifier": "^7.22.20"
       },
       "engines": {
         "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
       }
     },
     "node_modules/@babel/helper-plugin-utils": {
-      "version": "7.20.2",
-      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz",
-      "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==",
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz",
+      "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-simple-access": {
-      "version": "7.20.2",
-      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
-      "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz",
+      "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==",
       "dependencies": {
-        "@babel/types": "^7.20.2"
+        "@babel/types": "^7.22.5"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-split-export-declaration": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
-      "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+      "version": "7.22.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
+      "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
       "dependencies": {
-        "@babel/types": "^7.18.6"
+        "@babel/types": "^7.22.5"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-string-parser": {
-      "version": "7.19.4",
-      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
-      "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
+      "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.19.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
-      "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+      "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-option": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
-      "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
+      "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.20.13",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.13.tgz",
-      "integrity": "sha512-nzJ0DWCL3gB5RCXbUO3KIMMsBY2Eqbx8mBpKGE/02PgyRQFcPQLbkQ1vyy596mZLaP+dAfD+R4ckASzNVmW3jg==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.4.tgz",
+      "integrity": "sha512-HfcMizYz10cr3h29VqyfGL6ZWIjTwWfvYBMsBVGwpcbhNGe3wQ1ZXZRPzZoAHhd9OqHadHqjQ89iVKINXnbzuw==",
       "dependencies": {
-        "@babel/template": "^7.20.7",
-        "@babel/traverse": "^7.20.13",
-        "@babel/types": "^7.20.7"
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.23.4",
+        "@babel/types": "^7.23.4"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/highlight": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
-      "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
+      "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
       "dependencies": {
-        "@babel/helper-validator-identifier": "^7.18.6",
-        "chalk": "^2.0.0",
+        "@babel/helper-validator-identifier": "^7.22.20",
+        "chalk": "^2.4.2",
         "js-tokens": "^4.0.0"
       },
       "engines": {
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.20.13",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.13.tgz",
-      "integrity": "sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz",
+      "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==",
       "bin": {
         "parser": "bin/babel-parser.js"
       },
       }
     },
     "node_modules/@babel/plugin-transform-react-jsx-self": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.18.6.tgz",
-      "integrity": "sha512-A0LQGx4+4Jv7u/tWzoJF7alZwnBDQd6cGLh9P+Ttk4dpiL+J5p7NSNv/9tlEFFJDq3kjxOavWmbm6t0Gk+A3Ig==",
+      "version": "7.23.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.23.3.tgz",
+      "integrity": "sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-plugin-utils": "^7.18.6"
+        "@babel/helper-plugin-utils": "^7.22.5"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/plugin-transform-react-jsx-source": {
-      "version": "7.19.6",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.19.6.tgz",
-      "integrity": "sha512-RpAi004QyMNisst/pvSanoRdJ4q+jMCWyk9zdw/CyLB9j8RXEahodR6l2GyttDRyEVWZtbN+TpLiHJ3t34LbsQ==",
+      "version": "7.23.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.23.3.tgz",
+      "integrity": "sha512-91RS0MDnAWDNvGC6Wio5XYkyWI39FMFO+JK9+4AlgaTH+yWwVTsw7/sn6LK0lH7c5F+TFkpv/3LfCJ1Ydwof/g==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-plugin-utils": "^7.19.0"
+        "@babel/helper-plugin-utils": "^7.22.5"
       },
       "engines": {
         "node": ">=6.9.0"
       "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
     },
     "node_modules/@babel/template": {
-      "version": "7.20.7",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
-      "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
+      "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
       "dependencies": {
-        "@babel/code-frame": "^7.18.6",
-        "@babel/parser": "^7.20.7",
-        "@babel/types": "^7.20.7"
+        "@babel/code-frame": "^7.22.13",
+        "@babel/parser": "^7.22.15",
+        "@babel/types": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.20.13",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.13.tgz",
-      "integrity": "sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ==",
-      "dependencies": {
-        "@babel/code-frame": "^7.18.6",
-        "@babel/generator": "^7.20.7",
-        "@babel/helper-environment-visitor": "^7.18.9",
-        "@babel/helper-function-name": "^7.19.0",
-        "@babel/helper-hoist-variables": "^7.18.6",
-        "@babel/helper-split-export-declaration": "^7.18.6",
-        "@babel/parser": "^7.20.13",
-        "@babel/types": "^7.20.7",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz",
+      "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==",
+      "dependencies": {
+        "@babel/code-frame": "^7.23.4",
+        "@babel/generator": "^7.23.4",
+        "@babel/helper-environment-visitor": "^7.22.20",
+        "@babel/helper-function-name": "^7.23.0",
+        "@babel/helper-hoist-variables": "^7.22.5",
+        "@babel/helper-split-export-declaration": "^7.22.6",
+        "@babel/parser": "^7.23.4",
+        "@babel/types": "^7.23.4",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       },
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.20.7",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz",
-      "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz",
+      "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==",
       "dependencies": {
-        "@babel/helper-string-parser": "^7.19.4",
-        "@babel/helper-validator-identifier": "^7.19.1",
+        "@babel/helper-string-parser": "^7.23.4",
+        "@babel/helper-validator-identifier": "^7.22.20",
         "to-fast-properties": "^2.0.0"
       },
       "engines": {
       }
     },
     "node_modules/@react-dnd/asap": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
-      "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
+      "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
     },
     "node_modules/@react-dnd/invariant": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-3.0.1.tgz",
-      "integrity": "sha512-blqduwV86oiKw2Gr44wbe3pj3Z/OsXirc7ybCv9F/pLAR+Aih8F3rjeJzK0ANgtYKv5lCpkGVoZAeKitKDaD/g=="
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
+      "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
     },
     "node_modules/@react-dnd/shallowequal": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-3.0.1.tgz",
-      "integrity": "sha512-XjDVbs3ZU16CO1h5Q3Ew2RPJqmZBDE/EVf1LYp6ePEffs3V/MX9ZbL5bJr8qiK5SbGmUMuDoaFgyKacYz8prRA=="
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
+      "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
     },
     "node_modules/@react-stately/calendar": {
       "version": "3.4.1",
         "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
       }
     },
+    "node_modules/@remix-run/router": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz",
+      "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/@rollup/plugin-inject": {
       "version": "5.0.3",
       "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.3.tgz",
       "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
       "dev": true
     },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.8.0.tgz",
+      "integrity": "sha512-zdTObFRoNENrdPpnTNnhOljYIcOX7aI7+7wyrSpPFFIOf/nRdedE6IYsjaBE7tjukphh1tMTojgJ7p3lKY8x6Q==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.8.0.tgz",
+      "integrity": "sha512-aiItwP48BiGpMFS9Znjo/xCNQVwTQVcRKkFKsO81m8exrGjHkCBDvm9PHay2kpa8RPnZzzKcD1iQ9KaLY4fPQQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.8.0.tgz",
+      "integrity": "sha512-zhNIS+L4ZYkYQUjIQUR6Zl0RXhbbA0huvNIWjmPc2SL0cB1h5Djkcy+RZ3/Bwszfb6vgwUvcVJYD6e6Zkpsi8g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.8.0.tgz",
+      "integrity": "sha512-A/FAHFRNQYrELrb/JHncRWzTTXB2ticiRFztP4ggIUAfa9Up1qfW8aG2w/mN9jNiZ+HB0t0u0jpJgFXG6BfRTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.8.0.tgz",
+      "integrity": "sha512-JsidBnh3p2IJJA4/2xOF2puAYqbaczB3elZDT0qHxn362EIoIkq7hrR43Xa8RisgI6/WPfvb2umbGsuvf7E37A==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.8.0.tgz",
+      "integrity": "sha512-hBNCnqw3EVCkaPB0Oqd24bv8SklETptQWcJz06kb9OtiShn9jK1VuTgi7o4zPSt6rNGWQOTDEAccbk0OqJmS+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.8.0.tgz",
+      "integrity": "sha512-Fw9ChYfJPdltvi9ALJ9wzdCdxGw4wtq4t1qY028b2O7GwB5qLNSGtqMsAel1lfWTZvf4b6/+4HKp0GlSYg0ahA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.8.0.tgz",
+      "integrity": "sha512-BH5xIh7tOzS9yBi8dFrCTG8Z6iNIGWGltd3IpTSKp6+pNWWO6qy8eKoRxOtwFbMrid5NZaidLYN6rHh9aB8bEw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.8.0.tgz",
+      "integrity": "sha512-PmvAj8k6EuWiyLbkNpd6BLv5XeYFpqWuRvRNRl80xVfpGXK/z6KYXmAgbI4ogz7uFiJxCnYcqyvZVD0dgFog7Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.8.0.tgz",
+      "integrity": "sha512-mdxnlW2QUzXwY+95TuxZ+CurrhgrPAMveDWI97EQlA9bfhR8tw3Pt7SUlc/eSlCNxlWktpmT//EAA8UfCHOyXg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.8.0.tgz",
+      "integrity": "sha512-ge7saUz38aesM4MA7Cad8CHo0Fyd1+qTaqoIo+Jtk+ipBi4ATSrHWov9/S4u5pbEQmLjgUjB7BJt+MiKG2kzmA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.8.0.tgz",
+      "integrity": "sha512-p9E3PZlzurhlsN5h9g7zIP1DnqKXJe8ZUkFwAazqSvHuWfihlIISPxG9hCHCoA+dOOspL/c7ty1eeEVFTE0UTw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.8.0.tgz",
+      "integrity": "sha512-kb4/auKXkYKqlUYTE8s40FcJIj5soOyRLHKd4ugR0dCq0G2EfcF54eYcfQiGkHzjidZ40daB4ulsFdtqNKZtBg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
     "node_modules/@swc/helpers": {
       "version": "0.5.3",
       "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.3.tgz",
         "react-dom": ">=16.8"
       }
     },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.6.7",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.7.tgz",
+      "integrity": "sha512-6Sfsq+EaaLrw4RmdFWE9Onp63TOUue71AWb4Gpa6JxzgTYtimbM086WnYTy2U67AofR++QKCo08ZP6pwx8YFHQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.20.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.4.tgz",
+      "integrity": "sha512-mSM/iKUk5fDDrEV/e83qY+Cr3I1+Q3qqTuEn++HAWYjEa1+NxZr6CNrcJGf2ZTnq4HoFGC3zaTPZTobCzCFukA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.20.7"
+      }
+    },
     "node_modules/@types/estree": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
       "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
     },
     "node_modules/@types/react": {
-      "version": "18.0.26",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.26.tgz",
-      "integrity": "sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug==",
+      "version": "18.2.39",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
+      "integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
       "dependencies": {
         "@types/prop-types": "*",
         "@types/scheduler": "*",
       }
     },
     "node_modules/@types/react-dom": {
-      "version": "18.0.9",
-      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.9.tgz",
-      "integrity": "sha512-qnVvHxASt/H7i+XG1U1xMiY5t+IHcPGUK7TDMDzom08xa7e86eCeKOiLZezwCKVxJn6NEiiy2ekgX8aQssjIKg==",
+      "version": "18.2.17",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz",
+      "integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-google-recaptcha": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.8.tgz",
+      "integrity": "sha512-nYI3ZDoteZ0g4FYusyKWqz7AZqRdu70R3wDkosCcN0peb2WLn57i0Alm4IPiCRIx59yTUVPTiOELZH08gV1wXA==",
       "dev": true,
       "dependencies": {
         "@types/react": "*"
       }
     },
     "node_modules/@vitejs/plugin-react": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-3.0.0.tgz",
-      "integrity": "sha512-1mvyPc0xYW5G8CHQvJIJXLoMjl5Ct3q2g5Y2s6Ccfgwm45y48LBvsla7az+GkkAtYikWQ4Lxqcsq5RHLcZgtNQ==",
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.0.tgz",
+      "integrity": "sha512-+MHTH/e6H12kRp5HUkzOGqPMksezRMmW+TNzlh/QXfI8rRf6l2Z2yH/v12no1UvTwhZgEDMuQ7g7rrfMseU6FQ==",
       "dev": true,
       "dependencies": {
-        "@babel/core": "^7.20.5",
-        "@babel/plugin-transform-react-jsx-self": "^7.18.6",
-        "@babel/plugin-transform-react-jsx-source": "^7.19.6",
-        "magic-string": "^0.27.0",
+        "@babel/core": "^7.23.3",
+        "@babel/plugin-transform-react-jsx-self": "^7.23.3",
+        "@babel/plugin-transform-react-jsx-source": "^7.23.3",
+        "@types/babel__core": "^7.20.4",
         "react-refresh": "^0.14.0"
       },
       "engines": {
         "node": "^14.18.0 || >=16.0.0"
       },
       "peerDependencies": {
-        "vite": "^4.0.0"
+        "vite": "^4.2.0 || ^5.0.0"
       }
     },
     "node_modules/abbrev": {
       "integrity": "sha512-L7siI766UCH6+arP9yT5wpA5AFxnmGbKiGSsxEVACl1tE0pvDJeQvMmbY2UmJiuffrr0ZJ2+U6Om46wQBqh1Lw=="
     },
     "node_modules/browserslist": {
-      "version": "4.21.4",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
-      "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz",
+      "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==",
       "funding": [
         {
           "type": "opencollective",
         {
           "type": "tidelift",
           "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
         }
       ],
       "dependencies": {
-        "caniuse-lite": "^1.0.30001400",
-        "electron-to-chromium": "^1.4.251",
-        "node-releases": "^2.0.6",
-        "update-browserslist-db": "^1.0.9"
+        "caniuse-lite": "^1.0.30001541",
+        "electron-to-chromium": "^1.4.535",
+        "node-releases": "^2.0.13",
+        "update-browserslist-db": "^1.0.13"
       },
       "bin": {
         "browserslist": "cli.js"
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001446",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001446.tgz",
-      "integrity": "sha512-fEoga4PrImGcwUUGEol/PoFCSBnSkA9drgdkxXkJLsUBOnJ8rs3zDv6ApqYXGQFOyMPsjh79naWhF4DAxbF8rw==",
+      "version": "1.0.30001565",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz",
+      "integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==",
       "funding": [
         {
           "type": "opencollective",
         {
           "type": "tidelift",
           "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
         }
       ]
     },
       }
     },
     "node_modules/convert-source-map": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
-      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
     },
     "node_modules/core-js-pure": {
       "version": "3.26.1",
       }
     },
     "node_modules/dnd-core": {
-      "version": "15.1.2",
-      "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.2.tgz",
-      "integrity": "sha512-EOec1LyJUuGRFg0LDa55rSRAUe97uNVKVkUo8iyvzQlcECYTuPblVQfRWXWj1OyPseFIeebWpNmKFy0h6BcF1A==",
+      "version": "16.0.1",
+      "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
+      "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
       "dependencies": {
-        "@react-dnd/asap": "4.0.1",
-        "@react-dnd/invariant": "3.0.1",
-        "redux": "^4.1.2"
+        "@react-dnd/asap": "^5.0.1",
+        "@react-dnd/invariant": "^4.0.1",
+        "redux": "^4.2.0"
       }
     },
     "node_modules/doctrine": {
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.284",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz",
-      "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA=="
+      "version": "1.4.596",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.596.tgz",
+      "integrity": "sha512-zW3zbZ40Icb2BCWjm47nxwcFGYlIgdXkAx85XDO7cyky9J4QQfq8t0W19/TLZqq3JPQXtlv8BPIGmfa9Jb4scg=="
     },
     "node_modules/emoji-regex": {
       "version": "9.2.2",
       }
     },
     "node_modules/folds": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.0.tgz",
-      "integrity": "sha512-1QNHzD57OxFZT5SOe0nWcrKQvWmfMRv1f5sTF8xhGtwx9rajjv36T9SwCcj9Fh58PbERqOdBiwvpdhu+BQTVjg==",
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.1.tgz",
+      "integrity": "sha512-2QxyA+FRKjPKXDTMDoD7NmOUiReWrKYO0Msg44QqlzTkTrRVEzJgyPIfC/Ia4/u0ByQpk6dbq8UQxomKmneJ/g==",
       "peerDependencies": {
         "@vanilla-extract/css": "^1.9.2",
         "@vanilla-extract/recipes": "^0.3.0",
       "devOptional": true
     },
     "node_modules/fsevents": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
       "dev": true,
       "hasInstallScript": true,
       "optional": true,
       "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="
     },
     "node_modules/jotai": {
-      "version": "1.12.0",
-      "resolved": "https://registry.npmjs.org/jotai/-/jotai-1.12.0.tgz",
-      "integrity": "sha512-IhyBmjxU1sE2Ni/MUK7gQAb8QvCM6yd1/K5jtQzgQBmmjCjgfXZkkk1rYlQAIRp2KoQk0Y+yzhm1f5cZ7kegnw==",
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.6.0.tgz",
+      "integrity": "sha512-Vt6hsc04Km4j03l+Ax+Sc+FVft5cRJhqgxt6GTz6GM2eM3DyX3CdBdzcG0z2FrlZToL1/0OAkqDghIyARWnSuQ==",
       "engines": {
         "node": ">=12.20.0"
       },
       "peerDependencies": {
-        "@babel/core": "*",
-        "@babel/template": "*",
-        "jotai-immer": "*",
-        "jotai-optics": "*",
-        "jotai-redux": "*",
-        "jotai-tanstack-query": "*",
-        "jotai-urql": "*",
-        "jotai-valtio": "*",
-        "jotai-xstate": "*",
-        "jotai-zustand": "*",
-        "react": ">=16.8"
+        "@types/react": ">=17.0.0",
+        "react": ">=17.0.0"
       },
       "peerDependenciesMeta": {
-        "@babel/core": {
-          "optional": true
-        },
-        "@babel/template": {
-          "optional": true
-        },
-        "jotai-immer": {
-          "optional": true
-        },
-        "jotai-optics": {
-          "optional": true
-        },
-        "jotai-redux": {
-          "optional": true
-        },
-        "jotai-tanstack-query": {
-          "optional": true
-        },
-        "jotai-urql": {
-          "optional": true
-        },
-        "jotai-valtio": {
-          "optional": true
-        },
-        "jotai-xstate": {
+        "@types/react": {
           "optional": true
         },
-        "jotai-zustand": {
+        "react": {
           "optional": true
         }
       }
       "optional": true
     },
     "node_modules/nanoid": {
-      "version": "3.3.6",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
-      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+      "version": "3.3.7",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+      "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
       "funding": [
         {
           "type": "github",
       }
     },
     "node_modules/node-releases": {
-      "version": "2.0.8",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz",
-      "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A=="
+      "version": "2.0.13",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
+      "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ=="
     },
     "node_modules/nopt": {
       "version": "5.0.0",
       }
     },
     "node_modules/postcss": {
-      "version": "8.4.24",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
-      "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
+      "version": "8.4.32",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
+      "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
       "funding": [
         {
           "type": "opencollective",
         }
       ],
       "dependencies": {
-        "nanoid": "^3.3.6",
+        "nanoid": "^3.3.7",
         "picocolors": "^1.0.0",
         "source-map-js": "^1.0.2"
       },
       ]
     },
     "node_modules/react": {
-      "version": "17.0.2",
-      "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
-      "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+      "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
       "dependencies": {
-        "loose-envify": "^1.1.0",
-        "object-assign": "^4.1.1"
+        "loose-envify": "^1.1.0"
       },
       "engines": {
         "node": ">=0.10.0"
       }
     },
     "node_modules/react-dnd": {
-      "version": "15.1.2",
-      "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-15.1.2.tgz",
-      "integrity": "sha512-EaSbMD9iFJDY/o48T3c8wn3uWU+2uxfFojhesZN3LhigJoAIvH2iOjxofSA9KbqhAKP6V9P853G6XG8JngKVtA==",
+      "version": "16.0.1",
+      "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
+      "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
       "dependencies": {
-        "@react-dnd/invariant": "3.0.1",
-        "@react-dnd/shallowequal": "3.0.1",
-        "dnd-core": "15.1.2",
+        "@react-dnd/invariant": "^4.0.1",
+        "@react-dnd/shallowequal": "^4.0.1",
+        "dnd-core": "^16.0.1",
         "fast-deep-equal": "^3.1.3",
         "hoist-non-react-statics": "^3.3.2"
       },
       }
     },
     "node_modules/react-dnd-html5-backend": {
-      "version": "15.1.3",
-      "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-15.1.3.tgz",
-      "integrity": "sha512-HH/8nOEmrrcRGHMqJR91FOwhnLlx5SRLXmsQwZT3IPcBjx88WT+0pWC5A4tDOYDdoooh9k+KMPvWfxooR5TcOA==",
+      "version": "16.0.1",
+      "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
+      "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
       "dependencies": {
-        "dnd-core": "15.1.2"
+        "dnd-core": "^16.0.1"
       }
     },
     "node_modules/react-dom": {
-      "version": "17.0.2",
-      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
-      "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+      "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
       "dependencies": {
         "loose-envify": "^1.1.0",
-        "object-assign": "^4.1.1",
-        "scheduler": "^0.20.2"
+        "scheduler": "^0.23.0"
       },
       "peerDependencies": {
-        "react": "17.0.2"
+        "react": "^18.2.0"
       }
     },
     "node_modules/react-error-boundary": {
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-router": {
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz",
+      "integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==",
+      "dependencies": {
+        "@remix-run/router": "1.13.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz",
+      "integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==",
+      "dependencies": {
+        "@remix-run/router": "1.13.0",
+        "react-router": "6.20.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "3.6.2",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
       }
     },
     "node_modules/redux": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz",
-      "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==",
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
+      "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
       "dependencies": {
         "@babel/runtime": "^7.9.2"
       }
       }
     },
     "node_modules/rollup": {
-      "version": "3.25.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.25.1.tgz",
-      "integrity": "sha512-tywOR+rwIt5m2ZAWSe5AIJcTat8vGlnPFAv15ycCrw33t6iFsXZ6mzHVFh2psSjxQPmI+xgzMZZizUAukBI4aQ==",
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.8.0.tgz",
+      "integrity": "sha512-NpsklK2fach5CdI+PScmlE5R4Ao/FSWtF7LkoIrHDxPACY/xshNasPsbpG0VVHxUTbf74tJbVT4PrP8JsJ6ZDA==",
       "dev": true,
       "bin": {
         "rollup": "dist/bin/rollup"
       },
       "engines": {
-        "node": ">=14.18.0",
+        "node": ">=18.0.0",
         "npm": ">=8.0.0"
       },
       "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.8.0",
+        "@rollup/rollup-android-arm64": "4.8.0",
+        "@rollup/rollup-darwin-arm64": "4.8.0",
+        "@rollup/rollup-darwin-x64": "4.8.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.8.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.8.0",
+        "@rollup/rollup-linux-arm64-musl": "4.8.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.8.0",
+        "@rollup/rollup-linux-x64-gnu": "4.8.0",
+        "@rollup/rollup-linux-x64-musl": "4.8.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.8.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.8.0",
+        "@rollup/rollup-win32-x64-msvc": "4.8.0",
         "fsevents": "~2.3.2"
       }
     },
       }
     },
     "node_modules/scheduler": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
-      "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+      "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
       "dependencies": {
-        "loose-envify": "^1.1.0",
-        "object-assign": "^4.1.1"
+        "loose-envify": "^1.1.0"
       }
     },
     "node_modules/scroll-into-view-if-needed": {
       }
     },
     "node_modules/semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "bin": {
         "semver": "bin/semver.js"
       }
       }
     },
     "node_modules/update-browserslist-db": {
-      "version": "1.0.10",
-      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
-      "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==",
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
+      "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
       "funding": [
         {
           "type": "opencollective",
         {
           "type": "tidelift",
           "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
         }
       ],
       "dependencies": {
         "picocolors": "^1.0.0"
       },
       "bin": {
-        "browserslist-lint": "cli.js"
+        "update-browserslist-db": "cli.js"
       },
       "peerDependencies": {
         "browserslist": ">= 4.21.0"
       }
     },
     "node_modules/vite": {
-      "version": "4.3.9",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz",
-      "integrity": "sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==",
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.8.tgz",
+      "integrity": "sha512-jYMALd8aeqR3yS9xlHd0OzQJndS9fH5ylVgWdB+pxTwxLKdO1pgC5Dlb398BUxpfaBxa4M9oT7j1g503Gaj5IQ==",
       "dev": true,
       "dependencies": {
-        "esbuild": "^0.17.5",
-        "postcss": "^8.4.23",
-        "rollup": "^3.21.0"
+        "esbuild": "^0.19.3",
+        "postcss": "^8.4.32",
+        "rollup": "^4.2.0"
       },
       "bin": {
         "vite": "bin/vite.js"
       },
       "engines": {
-        "node": "^14.18.0 || >=16.0.0"
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
       },
       "optionalDependencies": {
-        "fsevents": "~2.3.2"
+        "fsevents": "~2.3.3"
       },
       "peerDependencies": {
-        "@types/node": ">= 14",
+        "@types/node": "^18.0.0 || >=20.0.0",
         "less": "*",
+        "lightningcss": "^1.21.0",
         "sass": "*",
         "stylus": "*",
         "sugarss": "*",
         "less": {
           "optional": true
         },
+        "lightningcss": {
+          "optional": true
+        },
         "sass": {
           "optional": true
         },
       }
     },
     "node_modules/vite/node_modules/@esbuild/android-arm": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
-      "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.9.tgz",
+      "integrity": "sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA==",
       "cpu": [
         "arm"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/android-arm64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz",
-      "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.9.tgz",
+      "integrity": "sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ==",
       "cpu": [
         "arm64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/android-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz",
-      "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.9.tgz",
+      "integrity": "sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz",
-      "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.9.tgz",
+      "integrity": "sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw==",
       "cpu": [
         "arm64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/darwin-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz",
-      "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.9.tgz",
+      "integrity": "sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz",
-      "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.9.tgz",
+      "integrity": "sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g==",
       "cpu": [
         "arm64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz",
-      "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.9.tgz",
+      "integrity": "sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-arm": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz",
-      "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.9.tgz",
+      "integrity": "sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw==",
       "cpu": [
         "arm"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-arm64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz",
-      "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.9.tgz",
+      "integrity": "sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ==",
       "cpu": [
         "arm64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-ia32": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz",
-      "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.9.tgz",
+      "integrity": "sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q==",
       "cpu": [
         "ia32"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-loong64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz",
-      "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.9.tgz",
+      "integrity": "sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA==",
       "cpu": [
         "loong64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz",
-      "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.9.tgz",
+      "integrity": "sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw==",
       "cpu": [
         "mips64el"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz",
-      "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.9.tgz",
+      "integrity": "sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ==",
       "cpu": [
         "ppc64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz",
-      "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.9.tgz",
+      "integrity": "sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg==",
       "cpu": [
         "riscv64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-s390x": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz",
-      "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.9.tgz",
+      "integrity": "sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw==",
       "cpu": [
         "s390x"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/linux-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz",
-      "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.9.tgz",
+      "integrity": "sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz",
-      "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.9.tgz",
+      "integrity": "sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz",
-      "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.9.tgz",
+      "integrity": "sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/sunos-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz",
-      "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.9.tgz",
+      "integrity": "sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/win32-arm64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz",
-      "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.9.tgz",
+      "integrity": "sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg==",
       "cpu": [
         "arm64"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/win32-ia32": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz",
-      "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.9.tgz",
+      "integrity": "sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg==",
       "cpu": [
         "ia32"
       ],
       }
     },
     "node_modules/vite/node_modules/@esbuild/win32-x64": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz",
-      "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.9.tgz",
+      "integrity": "sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ==",
       "cpu": [
         "x64"
       ],
       }
     },
     "node_modules/vite/node_modules/esbuild": {
-      "version": "0.17.19",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz",
-      "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==",
+      "version": "0.19.9",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.9.tgz",
+      "integrity": "sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==",
       "dev": true,
       "hasInstallScript": true,
       "bin": {
         "node": ">=12"
       },
       "optionalDependencies": {
-        "@esbuild/android-arm": "0.17.19",
-        "@esbuild/android-arm64": "0.17.19",
-        "@esbuild/android-x64": "0.17.19",
-        "@esbuild/darwin-arm64": "0.17.19",
-        "@esbuild/darwin-x64": "0.17.19",
-        "@esbuild/freebsd-arm64": "0.17.19",
-        "@esbuild/freebsd-x64": "0.17.19",
-        "@esbuild/linux-arm": "0.17.19",
-        "@esbuild/linux-arm64": "0.17.19",
-        "@esbuild/linux-ia32": "0.17.19",
-        "@esbuild/linux-loong64": "0.17.19",
-        "@esbuild/linux-mips64el": "0.17.19",
-        "@esbuild/linux-ppc64": "0.17.19",
-        "@esbuild/linux-riscv64": "0.17.19",
-        "@esbuild/linux-s390x": "0.17.19",
-        "@esbuild/linux-x64": "0.17.19",
-        "@esbuild/netbsd-x64": "0.17.19",
-        "@esbuild/openbsd-x64": "0.17.19",
-        "@esbuild/sunos-x64": "0.17.19",
-        "@esbuild/win32-arm64": "0.17.19",
-        "@esbuild/win32-ia32": "0.17.19",
-        "@esbuild/win32-x64": "0.17.19"
+        "@esbuild/android-arm": "0.19.9",
+        "@esbuild/android-arm64": "0.19.9",
+        "@esbuild/android-x64": "0.19.9",
+        "@esbuild/darwin-arm64": "0.19.9",
+        "@esbuild/darwin-x64": "0.19.9",
+        "@esbuild/freebsd-arm64": "0.19.9",
+        "@esbuild/freebsd-x64": "0.19.9",
+        "@esbuild/linux-arm": "0.19.9",
+        "@esbuild/linux-arm64": "0.19.9",
+        "@esbuild/linux-ia32": "0.19.9",
+        "@esbuild/linux-loong64": "0.19.9",
+        "@esbuild/linux-mips64el": "0.19.9",
+        "@esbuild/linux-ppc64": "0.19.9",
+        "@esbuild/linux-riscv64": "0.19.9",
+        "@esbuild/linux-s390x": "0.19.9",
+        "@esbuild/linux-x64": "0.19.9",
+        "@esbuild/netbsd-x64": "0.19.9",
+        "@esbuild/openbsd-x64": "0.19.9",
+        "@esbuild/sunos-x64": "0.19.9",
+        "@esbuild/win32-arm64": "0.19.9",
+        "@esbuild/win32-ia32": "0.19.9",
+        "@esbuild/win32-x64": "0.19.9"
       }
     },
     "node_modules/warning": {
index cb6111b5d8fe00e00c0a09e4e435f0bf37807850..56e7b8c01eb44a70fdfc582c4a6a73c917405d84 100644 (file)
     "file-saver": "2.0.5",
     "flux": "4.0.3",
     "focus-trap-react": "10.0.2",
-    "folds": "1.5.0",
+    "folds": "1.5.1",
     "formik": "2.2.9",
     "html-dom-parser": "4.0.0",
     "html-react-parser": "4.2.0",
     "immer": "9.0.16",
     "is-hotkey": "0.2.0",
-    "jotai": "1.12.0",
+    "jotai": "2.6.0",
     "katex": "0.16.4",
     "linkify-html": "4.0.2",
     "linkify-react": "4.1.1",
     "pdfjs-dist": "3.10.111",
     "prismjs": "1.29.0",
     "prop-types": "15.8.1",
-    "react": "17.0.2",
+    "react": "18.2.0",
     "react-aria": "3.29.1",
     "react-autosize-textarea": "7.1.0",
     "react-blurhash": "0.2.0",
-    "react-dnd": "15.1.2",
-    "react-dnd-html5-backend": "15.1.3",
-    "react-dom": "17.0.2",
+    "react-dnd": "16.0.1",
+    "react-dnd-html5-backend": "16.0.1",
+    "react-dom": "18.2.0",
     "react-error-boundary": "4.0.10",
     "react-google-recaptcha": "2.1.0",
     "react-modal": "3.16.1",
     "react-range": "1.8.14",
+    "react-router-dom": "6.20.0",
     "sanitize-html": "2.8.0",
     "slate": "0.94.1",
     "slate-history": "0.93.0",
     "@types/file-saver": "2.0.5",
     "@types/node": "18.11.18",
     "@types/prismjs": "1.26.0",
-    "@types/react": "18.0.26",
-    "@types/react-dom": "18.0.9",
+    "@types/react": "18.2.39",
+    "@types/react-dom": "18.2.17",
+    "@types/react-google-recaptcha": "2.1.8",
     "@types/sanitize-html": "2.9.0",
     "@types/ua-parser-js": "0.7.36",
     "@typescript-eslint/eslint-plugin": "5.46.1",
     "@typescript-eslint/parser": "5.46.1",
-    "@vitejs/plugin-react": "3.0.0",
+    "@vitejs/plugin-react": "4.2.0",
     "buffer": "6.0.3",
     "eslint": "8.29.0",
     "eslint-config-airbnb": "19.0.4",
     "prettier": "2.8.1",
     "sass": "1.56.2",
     "typescript": "4.9.4",
-    "vite": "4.3.9",
+    "vite": "5.0.8",
     "vite-plugin-static-copy": "0.13.0"
   }
 }
diff --git a/src/app/components/AuthFlowsLoader.tsx b/src/app/components/AuthFlowsLoader.tsx
new file mode 100644 (file)
index 0000000..f21bad0
--- /dev/null
@@ -0,0 +1,64 @@
+import { ReactNode, useCallback, useEffect, useMemo } from 'react';
+import { MatrixError, createClient } from 'matrix-js-sdk';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
+import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common';
+import {
+  AuthFlows,
+  RegisterFlowStatus,
+  RegisterFlowsResponse,
+  parseRegisterErrResp,
+} from '../hooks/useAuthFlows';
+
+type AuthFlowsLoaderProps = {
+  fallback?: () => ReactNode;
+  error?: (err: unknown) => ReactNode;
+  children: (authFlows: AuthFlows) => ReactNode;
+};
+export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderProps) {
+  const autoDiscoveryInfo = useAutoDiscoveryInfo();
+  const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
+
+  const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
+
+  const [state, load] = useAsyncCallback(
+    useCallback(async () => {
+      const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
+      const loginFlows = promiseFulfilledResult(result[0]);
+      const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
+      let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
+
+      if (typeof registerResp === 'object' && registerResp.httpStatus) {
+        registerFlows = parseRegisterErrResp(registerResp);
+      }
+
+      if (!loginFlows) {
+        throw new Error('Missing auth flow!');
+      }
+      if ('errcode' in loginFlows) {
+        throw new Error('Failed to load auth flow!');
+      }
+
+      const authFlows: AuthFlows = {
+        loginFlows,
+        registerFlows,
+      };
+
+      return authFlows;
+    }, [mx])
+  );
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
+    return fallback?.();
+  }
+
+  if (state.status === AsyncStatus.Error) {
+    return error?.(state.error);
+  }
+
+  return children(state.data);
+}
diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx
new file mode 100644 (file)
index 0000000..72d367c
--- /dev/null
@@ -0,0 +1,38 @@
+import { ReactNode, useCallback, useEffect, useState } from 'react';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { ClientConfig } from '../hooks/useClientConfig';
+import { trimTrailingSlash } from '../utils/common';
+
+const getClientConfig = async (): Promise<ClientConfig> => {
+  const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
+  const config = await fetch(url, { method: 'GET' });
+  return config.json();
+};
+
+type ClientConfigLoaderProps = {
+  fallback?: () => ReactNode;
+  error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
+  children: (config: ClientConfig) => ReactNode;
+};
+export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) {
+  const [state, load] = useAsyncCallback(getClientConfig);
+  const [ignoreError, setIgnoreError] = useState(false);
+
+  const ignoreCallback = useCallback(() => setIgnoreError(true), []);
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
+    return fallback?.();
+  }
+
+  if (!ignoreError && state.status === AsyncStatus.Error) {
+    return error?.(state.error, load, ignoreCallback);
+  }
+
+  const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {};
+
+  return children(config);
+}
diff --git a/src/app/components/ConfirmPasswordMatch.tsx b/src/app/components/ConfirmPasswordMatch.tsx
new file mode 100644 (file)
index 0000000..bb50eb6
--- /dev/null
@@ -0,0 +1,35 @@
+import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
+import { useDebounce } from '../hooks/useDebounce';
+
+type ConfirmPasswordMatchProps = {
+  initialValue: boolean;
+  children: (
+    match: boolean,
+    doMatch: () => void,
+    passRef: RefObject<HTMLInputElement>,
+    confPassRef: RefObject<HTMLInputElement>
+  ) => ReactNode;
+};
+export function ConfirmPasswordMatch({ initialValue, children }: ConfirmPasswordMatchProps) {
+  const [match, setMatch] = useState(initialValue);
+  const passRef = useRef<HTMLInputElement>(null);
+  const confPassRef = useRef<HTMLInputElement>(null);
+
+  const doMatch = useDebounce(
+    useCallback(() => {
+      const pass = passRef.current?.value;
+      const confPass = confPassRef.current?.value;
+      if (!confPass) {
+        setMatch(initialValue);
+        return;
+      }
+      setMatch(pass === confPass);
+    }, [initialValue]),
+    {
+      wait: 500,
+      immediate: false,
+    }
+  );
+
+  return children(match, doMatch, passRef, confPassRef);
+}
diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx
new file mode 100644 (file)
index 0000000..56d7f8b
--- /dev/null
@@ -0,0 +1,32 @@
+import { ReactNode, useCallback, useEffect } from 'react';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { SpecVersions, specVersions } from '../cs-api';
+import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
+
+type SpecVersionsLoaderProps = {
+  fallback?: () => ReactNode;
+  error?: (err: unknown) => ReactNode;
+  children: (versions: SpecVersions) => ReactNode;
+};
+export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
+  const autoDiscoveryInfo = useAutoDiscoveryInfo();
+  const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
+
+  const [state, load] = useAsyncCallback(
+    useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
+  );
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
+    return fallback?.();
+  }
+
+  if (state.status === AsyncStatus.Error) {
+    return error?.(state.error);
+  }
+
+  return children(state.data);
+}
diff --git a/src/app/components/SupportedUIAFlowsLoader.tsx b/src/app/components/SupportedUIAFlowsLoader.tsx
new file mode 100644 (file)
index 0000000..442eb57
--- /dev/null
@@ -0,0 +1,17 @@
+import { ReactNode } from 'react';
+import { UIAFlow } from 'matrix-js-sdk';
+import { useSupportedUIAFlows } from '../hooks/useUIAFlows';
+
+export function SupportedUIAFlowsLoader({
+  flows,
+  supportedStages,
+  children,
+}: {
+  supportedStages: string[];
+  flows: UIAFlow[];
+  children: (supportedFlows: UIAFlow[]) => ReactNode;
+}) {
+  const supportedFlows = useSupportedUIAFlows(flows, supportedStages);
+
+  return children(supportedFlows);
+}
diff --git a/src/app/components/UIAFlowOverlay.tsx b/src/app/components/UIAFlowOverlay.tsx
new file mode 100644 (file)
index 0000000..f788eb0
--- /dev/null
@@ -0,0 +1,72 @@
+import React, { ReactNode } from 'react';
+import {
+  Overlay,
+  OverlayBackdrop,
+  Box,
+  config,
+  Text,
+  TooltipProvider,
+  Tooltip,
+  Icons,
+  Icon,
+  Chip,
+  IconButton,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+
+export type UIAFlowOverlayProps = {
+  currentStep: number;
+  stepCount: number;
+  children: ReactNode;
+  onCancel: () => void;
+};
+export function UIAFlowOverlay({
+  currentStep,
+  stepCount,
+  children,
+  onCancel,
+}: UIAFlowOverlayProps) {
+  return (
+    <Overlay open backdrop={<OverlayBackdrop />}>
+      <FocusTrap focusTrapOptions={{ initialFocus: false }}>
+        <Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
+          <Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
+            {children}
+          </Box>
+          <Box
+            style={{ padding: config.space.S200 }}
+            shrink="No"
+            justifyContent="Center"
+            alignItems="Center"
+            gap="200"
+          >
+            <Chip as="div" radii="Pill" outlined>
+              <Text as="span" size="T300">{`Step ${currentStep}/${stepCount}`}</Text>
+            </Chip>
+            <TooltipProvider
+              tooltip={
+                <Tooltip variant="Critical">
+                  <Text>Exit</Text>
+                </Tooltip>
+              }
+              position="Top"
+            >
+              {(anchorRef) => (
+                <IconButton
+                  ref={anchorRef}
+                  variant="Critical"
+                  size="300"
+                  onClick={onCancel}
+                  radii="Pill"
+                  outlined
+                >
+                  <Icon size="50" src={Icons.Cross} />
+                </IconButton>
+              )}
+            </TooltipProvider>
+          </Box>
+        </Box>
+      </FocusTrap>
+    </Overlay>
+  );
+}
diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx
new file mode 100644 (file)
index 0000000..184a097
--- /dev/null
@@ -0,0 +1,45 @@
+import React, { ComponentProps, forwardRef } from 'react';
+import { Icon, IconButton, Input, config, Icons } from 'folds';
+import { UseStateProvider } from '../UseStateProvider';
+
+type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
+  size: '400' | '500';
+};
+export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
+  ({ variant, size, style, after, ...props }, ref) => {
+    const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
+
+    return (
+      <UseStateProvider initial={false}>
+        {(visible, setVisible) => (
+          <Input
+            {...props}
+            ref={ref}
+            style={{ paddingRight, ...style }}
+            type={visible ? 'text' : 'password'}
+            size={size}
+            variant={variant}
+            after={
+              <>
+                {after}
+                <IconButton
+                  onClick={() => setVisible(!visible)}
+                  type="button"
+                  variant={visible ? 'Warning' : variant}
+                  size="300"
+                  radii="300"
+                >
+                  <Icon
+                    style={{ opacity: config.opacity.P300 }}
+                    size="100"
+                    src={visible ? Icons.Eye : Icons.EyeBlind}
+                  />
+                </IconButton>
+              </>
+            }
+          />
+        )}
+      </UseStateProvider>
+    );
+  }
+);
diff --git a/src/app/components/splash-screen/SplashScreen.css.ts b/src/app/components/splash-screen/SplashScreen.css.ts
new file mode 100644 (file)
index 0000000..bd3c300
--- /dev/null
@@ -0,0 +1,12 @@
+import { style } from '@vanilla-extract/css';
+import { color, config } from 'folds';
+
+export const SplashScreen = style({
+  minHeight: '100%',
+  backgroundColor: color.Background.Container,
+  color: color.Background.OnContainer,
+});
+
+export const SplashScreenFooter = style({
+  padding: config.space.S400,
+});
diff --git a/src/app/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx
new file mode 100644 (file)
index 0000000..27adadb
--- /dev/null
@@ -0,0 +1,29 @@
+import { Box, Text } from 'folds';
+import React, { ReactNode } from 'react';
+import classNames from 'classnames';
+import * as patternsCSS from '../../styles/Patterns.css';
+import * as css from './SplashScreen.css';
+
+type SplashScreenProps = {
+  children: ReactNode;
+};
+export function SplashScreen({ children }: SplashScreenProps) {
+  return (
+    <Box
+      className={classNames(css.SplashScreen, patternsCSS.BackgroundDotPattern)}
+      direction="Column"
+    >
+      {children}
+      <Box
+        className={css.SplashScreenFooter}
+        shrink="No"
+        alignItems="Center"
+        justifyContent="Center"
+      >
+        <Text size="H2" align="Center">
+          Cinny
+        </Text>
+      </Box>
+    </Box>
+  );
+}
diff --git a/src/app/components/splash-screen/index.ts b/src/app/components/splash-screen/index.ts
new file mode 100644 (file)
index 0000000..e3e5dd3
--- /dev/null
@@ -0,0 +1 @@
+export * from './SplashScreen';
diff --git a/src/app/components/uia-stages/DummyStage.tsx b/src/app/components/uia-stages/DummyStage.tsx
new file mode 100644 (file)
index 0000000..7e0f858
--- /dev/null
@@ -0,0 +1,65 @@
+import React, { useEffect, useCallback } from 'react';
+import { Dialog, Text, Box, Button, config } from 'folds';
+import { AuthType } from 'matrix-js-sdk';
+import { StageComponentProps } from './types';
+
+function DummyErrorDialog({
+  title,
+  message,
+  onRetry,
+  onCancel,
+}: {
+  title: string;
+  message: string;
+  onRetry: () => void;
+  onCancel: () => void;
+}) {
+  return (
+    <Dialog>
+      <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+        <Box direction="Column" gap="100">
+          <Text size="H4">{title}</Text>
+          <Text>{message}</Text>
+        </Box>
+        <Button variant="Critical" onClick={onRetry}>
+          <Text as="span" size="B400">
+            Retry
+          </Text>
+        </Button>
+        <Button variant="Critical" fill="None" outlined onClick={onCancel}>
+          <Text as="span" size="B400">
+            Cancel
+          </Text>
+        </Button>
+      </Box>
+    </Dialog>
+  );
+}
+
+export function AutoDummyStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
+  const { errorCode, error, session } = stageData;
+
+  const handleSubmit = useCallback(() => {
+    submitAuthDict({
+      type: AuthType.Dummy,
+      session,
+    });
+  }, [session, submitAuthDict]);
+
+  useEffect(() => {
+    if (!errorCode) handleSubmit();
+  }, [handleSubmit, errorCode]);
+
+  if (errorCode) {
+    return (
+      <DummyErrorDialog
+        title={errorCode}
+        message={error ?? 'Failed to register.'}
+        onRetry={handleSubmit}
+        onCancel={onCancel}
+      />
+    );
+  }
+
+  return null;
+}
diff --git a/src/app/components/uia-stages/EmailStage.tsx b/src/app/components/uia-stages/EmailStage.tsx
new file mode 100644 (file)
index 0000000..fdc2b61
--- /dev/null
@@ -0,0 +1,172 @@
+import React, { useEffect, useCallback, FormEventHandler } from 'react';
+import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
+import { AuthType, MatrixError } from 'matrix-js-sdk';
+import { StageComponentProps } from './types';
+import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
+import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
+
+function EmailErrorDialog({
+  title,
+  message,
+  defaultEmail,
+  onRetry,
+  onCancel,
+}: {
+  title: string;
+  message: string;
+  defaultEmail?: string;
+  onRetry: (email: string) => void;
+  onCancel: () => void;
+}) {
+  const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const { retryEmailInput } = evt.target as HTMLFormElement & {
+      retryEmailInput: HTMLInputElement;
+    };
+    const t = retryEmailInput.value;
+    onRetry(t);
+  };
+
+  return (
+    <Dialog>
+      <Box
+        as="form"
+        onSubmit={handleFormSubmit}
+        style={{ padding: config.space.S400 }}
+        direction="Column"
+        gap="400"
+      >
+        <Box direction="Column" gap="100">
+          <Text size="H4">{title}</Text>
+          <Text>{message}</Text>
+          <Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
+            Email
+          </Text>
+          <Input
+            name="retryEmailInput"
+            variant="Background"
+            size="500"
+            outlined
+            defaultValue={defaultEmail}
+            required
+          />
+        </Box>
+        <Button variant="Primary" type="submit">
+          <Text as="span" size="B400">
+            Send Verification Email
+          </Text>
+        </Button>
+        <Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
+          <Text as="span" size="B400">
+            Cancel
+          </Text>
+        </Button>
+      </Box>
+    </Dialog>
+  );
+}
+
+export function EmailStageDialog({
+  email,
+  clientSecret,
+  stageData,
+  emailTokenState,
+  requestEmailToken,
+  submitAuthDict,
+  onCancel,
+}: StageComponentProps & {
+  email?: string;
+  clientSecret: string;
+  emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
+  requestEmailToken: RequestEmailTokenCallback;
+}) {
+  const { errorCode, error, session } = stageData;
+
+  const handleSubmit = useCallback(
+    (sessionId: string) => {
+      const threepIDCreds = {
+        sid: sessionId,
+        client_secret: clientSecret,
+      };
+      submitAuthDict({
+        type: AuthType.Email,
+        threepid_creds: threepIDCreds,
+        threepidCreds: threepIDCreds,
+        session,
+      });
+    },
+    [submitAuthDict, session, clientSecret]
+  );
+
+  const handleEmailSubmit = useCallback(
+    (userEmail: string) => {
+      requestEmailToken(userEmail, clientSecret);
+    },
+    [clientSecret, requestEmailToken]
+  );
+
+  useEffect(() => {
+    if (email && !errorCode && emailTokenState.status === AsyncStatus.Idle) {
+      requestEmailToken(email, clientSecret);
+    }
+  }, [email, errorCode, clientSecret, emailTokenState, requestEmailToken]);
+
+  if (emailTokenState.status === AsyncStatus.Loading) {
+    return (
+      <Box direction="Column" alignItems="Center" gap="400">
+        <Spinner variant="Secondary" size="600" />
+        <Text style={{ color: color.Secondary.Main }}>Sending verification email...</Text>
+      </Box>
+    );
+  }
+
+  if (emailTokenState.status === AsyncStatus.Error) {
+    return (
+      <EmailErrorDialog
+        title={emailTokenState.error.errcode ?? 'Verify Email'}
+        message={
+          emailTokenState.error?.data?.error ??
+          emailTokenState.error.message ??
+          'Failed to send verification Email request.'
+        }
+        onRetry={handleEmailSubmit}
+        onCancel={onCancel}
+      />
+    );
+  }
+
+  if (emailTokenState.status === AsyncStatus.Success) {
+    return (
+      <Dialog>
+        <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+          <Box direction="Column" gap="100">
+            <Text size="H4">Verification Request Sent</Text>
+            <Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
+
+            {errorCode && (
+              <Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
+            )}
+          </Box>
+          <Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
+            <Text as="span" size="B400">
+              Continue
+            </Text>
+          </Button>
+        </Box>
+      </Dialog>
+    );
+  }
+
+  if (!email) {
+    return (
+      <EmailErrorDialog
+        title="Provide Email"
+        message="Please provide email to send verification request."
+        onRetry={handleEmailSubmit}
+        onCancel={onCancel}
+      />
+    );
+  }
+
+  return null;
+}
diff --git a/src/app/components/uia-stages/ReCaptchaStage.tsx b/src/app/components/uia-stages/ReCaptchaStage.tsx
new file mode 100644 (file)
index 0000000..68b3fcf
--- /dev/null
@@ -0,0 +1,64 @@
+import React from 'react';
+import { Dialog, Text, Box, Button, config } from 'folds';
+import { AuthType } from 'matrix-js-sdk';
+import ReCAPTCHA from 'react-google-recaptcha';
+import { StageComponentProps } from './types';
+
+function ReCaptchaErrorDialog({
+  title,
+  message,
+  onCancel,
+}: {
+  title: string;
+  message: string;
+  onCancel: () => void;
+}) {
+  return (
+    <Dialog>
+      <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+        <Box direction="Column" gap="100">
+          <Text size="H4">{title}</Text>
+          <Text>{message}</Text>
+        </Box>
+        <Button variant="Critical" fill="None" outlined onClick={onCancel}>
+          <Text as="span" size="B400">
+            Cancel
+          </Text>
+        </Button>
+      </Box>
+    </Dialog>
+  );
+}
+
+export function ReCaptchaStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
+  const { info, session } = stageData;
+
+  const publicKey = info?.public_key;
+
+  const handleChange = (token: string | null) => {
+    submitAuthDict({
+      type: AuthType.Recaptcha,
+      response: token,
+      session,
+    });
+  };
+
+  if (typeof publicKey !== 'string' || !session) {
+    return (
+      <ReCaptchaErrorDialog
+        title="Invalid Data"
+        message="No valid data found to proceed with ReCAPTCHA."
+        onCancel={onCancel}
+      />
+    );
+  }
+
+  return (
+    <Dialog>
+      <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+        <Text>Please check the box below to proceed.</Text>
+        <ReCAPTCHA sitekey={publicKey} onChange={handleChange} />
+      </Box>
+    </Dialog>
+  );
+}
diff --git a/src/app/components/uia-stages/RegistrationTokenStage.tsx b/src/app/components/uia-stages/RegistrationTokenStage.tsx
new file mode 100644 (file)
index 0000000..ed8a304
--- /dev/null
@@ -0,0 +1,117 @@
+import React, { useEffect, useCallback, FormEventHandler } from 'react';
+import { Dialog, Text, Box, Button, config, Input } from 'folds';
+import { AuthType } from 'matrix-js-sdk';
+import { StageComponentProps } from './types';
+
+function RegistrationTokenErrorDialog({
+  title,
+  message,
+  defaultToken,
+  onRetry,
+  onCancel,
+}: {
+  title: string;
+  message: string;
+  defaultToken?: string;
+  onRetry: (token: string) => void;
+  onCancel: () => void;
+}) {
+  const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const { retryTokenInput } = evt.target as HTMLFormElement & {
+      retryTokenInput: HTMLInputElement;
+    };
+    const t = retryTokenInput.value;
+    onRetry(t);
+  };
+
+  return (
+    <Dialog>
+      <Box
+        as="form"
+        onSubmit={handleFormSubmit}
+        style={{ padding: config.space.S400 }}
+        direction="Column"
+        gap="400"
+      >
+        <Box direction="Column" gap="100">
+          <Text size="H4">{title}</Text>
+          <Text>{message}</Text>
+          <Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
+            Registration Token
+          </Text>
+          <Input
+            name="retryTokenInput"
+            variant="Background"
+            size="500"
+            outlined
+            defaultValue={defaultToken}
+            required
+          />
+        </Box>
+        <Button variant="Critical" type="submit">
+          <Text as="span" size="B400">
+            Retry
+          </Text>
+        </Button>
+        <Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
+          <Text as="span" size="B400">
+            Cancel
+          </Text>
+        </Button>
+      </Box>
+    </Dialog>
+  );
+}
+
+export function RegistrationTokenStageDialog({
+  token,
+  stageData,
+  submitAuthDict,
+  onCancel,
+}: StageComponentProps & {
+  token?: string;
+}) {
+  const { errorCode, error, session } = stageData;
+
+  const handleSubmit = useCallback(
+    (t: string) => {
+      submitAuthDict({
+        type: AuthType.RegistrationToken,
+        token: t,
+        session,
+      });
+    },
+    [session, submitAuthDict]
+  );
+
+  useEffect(() => {
+    if (token && !errorCode) handleSubmit(token);
+  }, [handleSubmit, token, errorCode]);
+
+  if (errorCode) {
+    return (
+      <RegistrationTokenErrorDialog
+        defaultToken={token}
+        title={errorCode}
+        message={error ?? 'Invalid registration token provided.'}
+        onRetry={handleSubmit}
+        onCancel={onCancel}
+      />
+    );
+  }
+
+  if (!token) {
+    return (
+      <RegistrationTokenErrorDialog
+        defaultToken={token}
+        title="Registration Token"
+        message="Please submit registration token provided by you homeserver admin."
+        onRetry={handleSubmit}
+        onCancel={onCancel}
+      />
+    );
+  }
+
+  return null;
+}
diff --git a/src/app/components/uia-stages/TermsStage.tsx b/src/app/components/uia-stages/TermsStage.tsx
new file mode 100644 (file)
index 0000000..f697705
--- /dev/null
@@ -0,0 +1,69 @@
+import React, { useEffect, useCallback } from 'react';
+import { Dialog, Text, Box, Button, config } from 'folds';
+import { AuthType } from 'matrix-js-sdk';
+import { StageComponentProps } from './types';
+
+function TermsErrorDialog({
+  title,
+  message,
+  onRetry,
+  onCancel,
+}: {
+  title: string;
+  message: string;
+  onRetry: () => void;
+  onCancel: () => void;
+}) {
+  return (
+    <Dialog>
+      <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+        <Box direction="Column" gap="100">
+          <Text size="H4">{title}</Text>
+          <Text>{message}</Text>
+        </Box>
+        <Button variant="Critical" onClick={onRetry}>
+          <Text as="span" size="B400">
+            Retry
+          </Text>
+        </Button>
+        <Button variant="Critical" fill="None" outlined onClick={onCancel}>
+          <Text as="span" size="B400">
+            Cancel
+          </Text>
+        </Button>
+      </Box>
+    </Dialog>
+  );
+}
+
+export function AutoTermsStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
+  const { errorCode, error, session } = stageData;
+
+  const handleSubmit = useCallback(
+    () =>
+      submitAuthDict({
+        type: AuthType.Terms,
+        session,
+      }),
+    [session, submitAuthDict]
+  );
+
+  useEffect(() => {
+    if (!errorCode) {
+      handleSubmit();
+    }
+  }, [session, errorCode, handleSubmit]);
+
+  if (errorCode) {
+    return (
+      <TermsErrorDialog
+        title={errorCode}
+        message={error ?? 'Failed to submit Terms and Condition Acceptance.'}
+        onRetry={handleSubmit}
+        onCancel={onCancel}
+      />
+    );
+  }
+
+  return null;
+}
diff --git a/src/app/components/uia-stages/index.ts b/src/app/components/uia-stages/index.ts
new file mode 100644 (file)
index 0000000..95c19a7
--- /dev/null
@@ -0,0 +1,6 @@
+export * from './types';
+export * from './DummyStage';
+export * from './EmailStage';
+export * from './ReCaptchaStage';
+export * from './RegistrationTokenStage';
+export * from './TermsStage';
diff --git a/src/app/components/uia-stages/types.ts b/src/app/components/uia-stages/types.ts
new file mode 100644 (file)
index 0000000..cc6674c
--- /dev/null
@@ -0,0 +1,8 @@
+import { AuthDict } from 'matrix-js-sdk';
+import { AuthStageData } from '../../hooks/useUIAFlows';
+
+export type StageComponentProps = {
+  stageData: AuthStageData;
+  submitAuthDict: (authDict: AuthDict) => void;
+  onCancel: () => void;
+};
diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts
new file mode 100644 (file)
index 0000000..b9c6771
--- /dev/null
@@ -0,0 +1,115 @@
+import to from 'await-to-js';
+import { trimTrailingSlash } from './utils/common';
+
+export enum AutoDiscoveryAction {
+  PROMPT = 'PROMPT',
+  IGNORE = 'IGNORE',
+  FAIL_PROMPT = 'FAIL_PROMPT',
+  FAIL_ERROR = 'FAIL_ERROR',
+}
+
+export type AutoDiscoveryError = {
+  host: string;
+  action: AutoDiscoveryAction;
+};
+
+export type AutoDiscoveryInfo = Record<string, unknown> & {
+  'm.homeserver': {
+    base_url: string;
+  };
+  'm.identity_server'?: {
+    base_url: string;
+  };
+};
+
+export const autoDiscovery = async (
+  request: typeof fetch,
+  server: string
+): Promise<[AutoDiscoveryError, undefined] | [undefined, AutoDiscoveryInfo]> => {
+  const host = /^https?:\/\//.test(server) ? trimTrailingSlash(server) : `https://${server}`;
+  const autoDiscoveryUrl = `${host}/.well-known/matrix/client`;
+
+  const [err, response] = await to(request(autoDiscoveryUrl, { method: 'GET' }));
+
+  if (err || response.status === 404) {
+    // AutoDiscoveryAction.IGNORE
+    // We will use default value for IGNORE action
+    return [
+      undefined,
+      {
+        'm.homeserver': {
+          base_url: host,
+        },
+      },
+    ];
+  }
+  if (response.status !== 200) {
+    return [
+      {
+        host,
+        action: AutoDiscoveryAction.FAIL_PROMPT,
+      },
+      undefined,
+    ];
+  }
+
+  const [contentErr, content] = await to<AutoDiscoveryInfo>(response.json());
+
+  if (contentErr || typeof content !== 'object') {
+    return [
+      {
+        host,
+        action: AutoDiscoveryAction.FAIL_PROMPT,
+      },
+      undefined,
+    ];
+  }
+
+  const baseUrl = content['m.homeserver']?.base_url;
+  if (typeof baseUrl !== 'string') {
+    return [
+      {
+        host,
+        action: AutoDiscoveryAction.FAIL_PROMPT,
+      },
+      undefined,
+    ];
+  }
+
+  if (/^https?:\/\//.test(baseUrl) === false) {
+    return [
+      {
+        host,
+        action: AutoDiscoveryAction.FAIL_ERROR,
+      },
+      undefined,
+    ];
+  }
+
+  content['m.homeserver'].base_url = trimTrailingSlash(baseUrl);
+  if (content['m.identity_server']) {
+    content['m.identity_server'].base_url = trimTrailingSlash(
+      content['m.identity_server'].base_url
+    );
+  }
+
+  return [undefined, content];
+};
+
+export type SpecVersions = {
+  versions: string[];
+  unstable_features?: Record<string, boolean>;
+};
+export const specVersions = async (
+  request: typeof fetch,
+  baseUrl: string
+): Promise<SpecVersions> => {
+  const res = await request(`${baseUrl}/_matrix/client/versions`);
+
+  const data = (await res.json()) as unknown;
+
+  if (data && typeof data === 'object' && 'versions' in data && Array.isArray(data.versions)) {
+    return data as SpecVersions;
+  }
+  throw new Error('Homeserver URL does not appear to be a valid Matrix homeserver');
+};
diff --git a/src/app/cs-errorcode.ts b/src/app/cs-errorcode.ts
new file mode 100644 (file)
index 0000000..6c21d67
--- /dev/null
@@ -0,0 +1,37 @@
+export enum ErrorCode {
+  M_FORBIDDEN = 'M_FORBIDDEN',
+  M_UNKNOWN_TOKEN = 'M_UNKNOWN_TOKEN',
+  M_MISSING_TOKEN = 'M_MISSING_TOKEN',
+  M_BAD_JSON = 'M_BAD_JSON',
+  M_NOT_JSON = 'M_NOT_JSON',
+  M_NOT_FOUND = 'M_NOT_FOUND',
+  M_LIMIT_EXCEEDED = 'M_LIMIT_EXCEEDED',
+  M_UNRECOGNIZED = 'M_UNRECOGNIZED',
+  M_UNKNOWN = 'M_UNKNOWN',
+
+  M_UNAUTHORIZED = 'M_UNAUTHORIZED',
+  M_USER_DEACTIVATED = 'M_USER_DEACTIVATED',
+  M_USER_IN_USE = 'M_USER_IN_USE',
+  M_INVALID_USERNAME = 'M_INVALID_USERNAME',
+  M_WEAK_PASSWORD = 'M_WEAK_PASSWORD',
+  M_PASSWORD_TOO_SHORT = 'M_PASSWORD_TOO_SHORT',
+  M_ROOM_IN_USE = 'M_ROOM_IN_USE',
+  M_INVALID_ROOM_STATE = 'M_INVALID_ROOM_STATE',
+  M_THREEPID_IN_USE = 'M_THREEPID_IN_USE',
+  M_THREEPID_NOT_FOUND = 'M_THREEPID_NOT_FOUND',
+  M_THREEPID_AUTH_FAILED = 'M_THREEPID_AUTH_FAILED',
+  M_THREEPID_DENIED = 'M_THREEPID_DENIED',
+  M_SERVER_NOT_TRUSTED = 'M_SERVER_NOT_TRUSTED',
+  M_UNSUPPORTED_ROOM_VERSION = 'M_UNSUPPORTED_ROOM_VERSION',
+  M_INCOMPATIBLE_ROOM_VERSION = 'M_INCOMPATIBLE_ROOM_VERSION',
+  M_BAD_STATE = 'M_BAD_STATE',
+  M_GUEST_ACCESS_FORBIDDEN = 'M_GUEST_ACCESS_FORBIDDEN',
+  M_CAPTCHA_NEEDED = 'M_CAPTCHA_NEEDED',
+  M_CAPTCHA_INVALID = 'M_CAPTCHA_INVALID',
+  M_MISSING_PARAM = 'M_MISSING_PARAM',
+  M_INVALID_PARAM = 'M_INVALID_PARAM',
+  M_TOO_LARGE = 'M_TOO_LARGE',
+  M_EXCLUSIVE = 'M_EXCLUSIVE',
+  M_RESOURCE_LIMIT_EXCEEDED = 'M_RESOURCE_LIMIT_EXCEEDED',
+  M_CANNOT_LEAVE_SERVER_NOTICE_ROOM = 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM',
+}
diff --git a/src/app/hooks/types.ts b/src/app/hooks/types.ts
new file mode 100644 (file)
index 0000000..9aac2b3
--- /dev/null
@@ -0,0 +1,12 @@
+import { IRequestTokenResponse } from 'matrix-js-sdk';
+
+export type RequestEmailTokenResponse = {
+  email: string;
+  clientSecret: string;
+  result: IRequestTokenResponse;
+};
+export type RequestEmailTokenCallback = (
+  email: string,
+  clientSecret: string,
+  nextLink?: string
+) => Promise<RequestEmailTokenResponse>;
index 18b63ecc6a14a92113adad12dc83070dbdccd103..fc7dca63fe90fa30bb062a299eb506bd4462c908 100644 (file)
@@ -1,4 +1,5 @@
-import { useCallback, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
+import { flushSync } from 'react-dom';
 import { useAlive } from './useAlive';
 
 export enum AsyncStatus {
@@ -16,36 +17,56 @@ export type AsyncLoading = {
   status: AsyncStatus.Loading;
 };
 
-export type AsyncSuccess<T> = {
+export type AsyncSuccess<D> = {
   status: AsyncStatus.Success;
-  data: T;
+  data: D;
 };
 
-export type AsyncError = {
+export type AsyncError<E = unknown> = {
   status: AsyncStatus.Error;
-  error: unknown;
+  error: E;
 };
 
-export type AsyncState<T> = AsyncIdle | AsyncLoading | AsyncSuccess<T> | AsyncError;
+export type AsyncState<D, E = unknown> = AsyncIdle | AsyncLoading | AsyncSuccess<D> | AsyncError<E>;
 
 export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
 
-export const useAsyncCallback = <TArgs extends unknown[], TData>(
+export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
   asyncCallback: AsyncCallback<TArgs, TData>
-): [AsyncState<TData>, AsyncCallback<TArgs, TData>] => {
-  const [state, setState] = useState<AsyncState<TData>>({
+): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
+  const [state, setState] = useState<AsyncState<TData, TError>>({
     status: AsyncStatus.Idle,
   });
   const alive = useAlive();
 
+  // Tracks the request number.
+  // If two or more requests are made subsequently
+  // we will throw all old request's response after they resolved.
+  const reqNumberRef = useRef(0);
+
   const callback: AsyncCallback<TArgs, TData> = useCallback(
     async (...args) => {
-      setState({
-        status: AsyncStatus.Loading,
+      queueMicrotask(() => {
+        // Warning: flushSync was called from inside a lifecycle method.
+        // React cannot flush when React is already rendering.
+        // Consider moving this call to a scheduler task or micro task.
+        flushSync(() => {
+          // flushSync because
+          // https://github.com/facebook/react/issues/26713#issuecomment-1872085134
+          setState({
+            status: AsyncStatus.Loading,
+          });
+        });
       });
 
+      reqNumberRef.current += 1;
+
+      const currentReqNumber = reqNumberRef.current;
       try {
         const data = await asyncCallback(...args);
+        if (currentReqNumber !== reqNumberRef.current) {
+          throw new Error('AsyncCallbackHook: Request replaced!');
+        }
         if (alive()) {
           setState({
             status: AsyncStatus.Success,
@@ -54,10 +75,13 @@ export const useAsyncCallback = <TArgs extends unknown[], TData>(
         }
         return data;
       } catch (e) {
+        if (currentReqNumber !== reqNumberRef.current) {
+          throw new Error('AsyncCallbackHook: Request replaced!');
+        }
         if (alive()) {
           setState({
             status: AsyncStatus.Error,
-            error: e,
+            error: e as TError,
           });
         }
         throw e;
diff --git a/src/app/hooks/useAuthFlows.ts b/src/app/hooks/useAuthFlows.ts
new file mode 100644 (file)
index 0000000..7bb7ddc
--- /dev/null
@@ -0,0 +1,59 @@
+import { createContext, useContext } from 'react';
+import { IAuthData, MatrixError } from 'matrix-js-sdk';
+import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
+
+export enum RegisterFlowStatus {
+  FlowRequired = 401,
+  InvalidRequest = 400,
+  RegistrationDisabled = 403,
+  RateLimited = 429,
+}
+
+export type RegisterFlowsResponse =
+  | {
+      status: RegisterFlowStatus.FlowRequired;
+      data: IAuthData;
+    }
+  | {
+      status: Exclude<RegisterFlowStatus, RegisterFlowStatus.FlowRequired>;
+    };
+
+export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsResponse => {
+  switch (matrixError.httpStatus) {
+    case RegisterFlowStatus.InvalidRequest: {
+      return { status: RegisterFlowStatus.InvalidRequest };
+    }
+    case RegisterFlowStatus.RateLimited: {
+      return { status: RegisterFlowStatus.RateLimited };
+    }
+    case RegisterFlowStatus.RegistrationDisabled: {
+      return { status: RegisterFlowStatus.RegistrationDisabled };
+    }
+    case RegisterFlowStatus.FlowRequired: {
+      return {
+        status: RegisterFlowStatus.FlowRequired,
+        data: matrixError.data as IAuthData,
+      };
+    }
+    default: {
+      return { status: RegisterFlowStatus.InvalidRequest };
+    }
+  }
+};
+
+export type AuthFlows = {
+  loginFlows: ILoginFlowsResponse;
+  registerFlows: RegisterFlowsResponse;
+};
+
+const AuthFlowsContext = createContext<AuthFlows | null>(null);
+
+export const AuthFlowsProvider = AuthFlowsContext.Provider;
+
+export const useAuthFlows = (): AuthFlows => {
+  const authFlows = useContext(AuthFlowsContext);
+  if (!authFlows) {
+    throw new Error('Auth Flow info is not loaded!');
+  }
+  return authFlows;
+};
diff --git a/src/app/hooks/useAuthServer.ts b/src/app/hooks/useAuthServer.ts
new file mode 100644 (file)
index 0000000..f77566f
--- /dev/null
@@ -0,0 +1,14 @@
+import { createContext, useContext } from 'react';
+
+const AuthServerContext = createContext<string | null>(null);
+
+export const AuthServerProvider = AuthServerContext.Provider;
+
+export const useAuthServer = (): string => {
+  const server = useContext(AuthServerContext);
+  if (server === null) {
+    throw new Error('Auth server is not provided!');
+  }
+
+  return server;
+};
diff --git a/src/app/hooks/useAutoDiscoveryInfo.ts b/src/app/hooks/useAutoDiscoveryInfo.ts
new file mode 100644 (file)
index 0000000..b2f8bcb
--- /dev/null
@@ -0,0 +1,15 @@
+import { createContext, useContext } from 'react';
+import { AutoDiscoveryInfo } from '../cs-api';
+
+const AutoDiscoverInfoContext = createContext<AutoDiscoveryInfo | null>(null);
+
+export const AutoDiscoveryInfoProvider = AutoDiscoverInfoContext.Provider;
+
+export const useAutoDiscoveryInfo = (): AutoDiscoveryInfo => {
+  const autoDiscoveryInfo = useContext(AutoDiscoverInfoContext);
+  if (!autoDiscoveryInfo) {
+    throw new Error('Auto Discovery Info not loaded');
+  }
+
+  return autoDiscoveryInfo;
+};
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
new file mode 100644 (file)
index 0000000..8406668
--- /dev/null
@@ -0,0 +1,33 @@
+import { createContext, useContext } from 'react';
+
+export type ClientConfig = {
+  defaultHomeserver?: number;
+  homeserverList?: string[];
+  allowCustomHomeservers?: boolean;
+
+  hashRouter?: {
+    enabled?: boolean;
+    basename?: string;
+  };
+};
+
+const ClientConfigContext = createContext<ClientConfig | null>(null);
+
+export const ClientConfigProvider = ClientConfigContext.Provider;
+
+export function useClientConfig(): ClientConfig {
+  const config = useContext(ClientConfigContext);
+  if (!config) throw new Error('Client config are not provided!');
+  return config;
+}
+
+export const clientDefaultServer = (clientConfig: ClientConfig): string =>
+  clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
+
+export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => {
+  const { homeserverList, allowCustomHomeservers } = clientConfig;
+
+  if (allowCustomHomeservers) return true;
+
+  return homeserverList?.includes(server) === true;
+};
index 61b69d1dc99932f8e04e3ad65bd6fe42833f7439..845c54629e8aa53a5bc64872c688446dc887c84c 100644 (file)
@@ -9,7 +9,7 @@ export function useCrossSigningStatus() {
   const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData());
 
   useEffect(() => {
-    if (isCSEnabled) return null;
+    if (isCSEnabled) return undefined;
     const handleAccountData = (event) => {
       if (event.getType() === 'm.cross_signing.master') {
         setIsCSEnabled(true);
diff --git a/src/app/hooks/useParsedLoginFlows.ts b/src/app/hooks/useParsedLoginFlows.ts
new file mode 100644 (file)
index 0000000..14ecfb9
--- /dev/null
@@ -0,0 +1,38 @@
+import { useMemo } from 'react';
+import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
+import { WithRequiredProp } from '../../types/utils';
+
+export type Required_SSOFlow = WithRequiredProp<ISSOFlow, 'identity_providers'>;
+export const getSSOFlow = (loginFlows: LoginFlow[]): Required_SSOFlow | undefined =>
+  loginFlows.find(
+    (flow) =>
+      (flow.type === 'm.login.sso' || flow.type === 'm.login.cas') &&
+      'identity_providers' in flow &&
+      Array.isArray(flow.identity_providers) &&
+      flow.identity_providers.length > 0
+  ) as Required_SSOFlow | undefined;
+
+export const getPasswordFlow = (loginFlows: LoginFlow[]): IPasswordFlow | undefined =>
+  loginFlows.find((flow) => flow.type === 'm.login.password') as IPasswordFlow;
+export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined =>
+  loginFlows.find((flow) => flow.type === 'm.login.token') as ILoginFlow & {
+    type: 'm.login.token';
+  };
+
+export type ParsedLoginFlows = {
+  password?: LoginFlow;
+  token?: LoginFlow;
+  sso?: Required_SSOFlow;
+};
+export const useParsedLoginFlows = (loginFlows: LoginFlow[]) => {
+  const parsedFlow: ParsedLoginFlows = useMemo<ParsedLoginFlows>(
+    () => ({
+      password: getPasswordFlow(loginFlows),
+      token: getTokenFlow(loginFlows),
+      sso: getSSOFlow(loginFlows),
+    }),
+    [loginFlows]
+  );
+
+  return parsedFlow;
+};
diff --git a/src/app/hooks/usePasswordEmail.ts b/src/app/hooks/usePasswordEmail.ts
new file mode 100644 (file)
index 0000000..37e9643
--- /dev/null
@@ -0,0 +1,32 @@
+import { MatrixClient, MatrixError } from 'matrix-js-sdk';
+import { useCallback, useRef } from 'react';
+import { AsyncState, useAsyncCallback } from './useAsyncCallback';
+import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types';
+
+export const usePasswordEmail = (
+  mx: MatrixClient
+): [AsyncState<RequestEmailTokenResponse, MatrixError>, RequestEmailTokenCallback] => {
+  const sendAttemptRef = useRef(1);
+
+  const passwordEmailCallback: RequestEmailTokenCallback = useCallback(
+    async (email, clientSecret, nextLink) => {
+      const sendAttempt = sendAttemptRef.current;
+      sendAttemptRef.current += 1;
+      const result = await mx.requestPasswordEmailToken(email, clientSecret, sendAttempt, nextLink);
+      return {
+        email,
+        clientSecret,
+        result,
+      };
+    },
+    [mx]
+  );
+
+  const [passwordEmailState, passwordEmail] = useAsyncCallback<
+    RequestEmailTokenResponse,
+    MatrixError,
+    Parameters<RequestEmailTokenCallback>
+  >(passwordEmailCallback);
+
+  return [passwordEmailState, passwordEmail];
+};
diff --git a/src/app/hooks/usePathWithOrigin.ts b/src/app/hooks/usePathWithOrigin.ts
new file mode 100644 (file)
index 0000000..4430d06
--- /dev/null
@@ -0,0 +1,26 @@
+import { useMemo } from 'react';
+import { useClientConfig } from './useClientConfig';
+import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
+
+export const usePathWithOrigin = (path: string): string => {
+  const { hashRouter } = useClientConfig();
+  const { origin } = window.location;
+
+  const pathWithOrigin = useMemo(() => {
+    let url: string = trimSlash(origin);
+
+    url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
+    url = trimTrailingSlash(url);
+
+    if (hashRouter?.enabled) {
+      url += `/#/${trimSlash(hashRouter.basename ?? '')}`;
+      url = trimTrailingSlash(url);
+    }
+
+    url += `/${trimLeadingSlash(path)}`;
+
+    return url;
+  }, [path, hashRouter, origin]);
+
+  return pathWithOrigin;
+};
diff --git a/src/app/hooks/useRegisterEmail.ts b/src/app/hooks/useRegisterEmail.ts
new file mode 100644 (file)
index 0000000..d29c9e6
--- /dev/null
@@ -0,0 +1,32 @@
+import { MatrixClient, MatrixError } from 'matrix-js-sdk';
+import { useCallback, useRef } from 'react';
+import { AsyncState, useAsyncCallback } from './useAsyncCallback';
+import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types';
+
+export const useRegisterEmail = (
+  mx: MatrixClient
+): [AsyncState<RequestEmailTokenResponse, MatrixError>, RequestEmailTokenCallback] => {
+  const sendAttemptRef = useRef(1);
+
+  const registerEmailCallback: RequestEmailTokenCallback = useCallback(
+    async (email, clientSecret, nextLink) => {
+      const sendAttempt = sendAttemptRef.current;
+      sendAttemptRef.current += 1;
+      const result = await mx.requestRegisterEmailToken(email, clientSecret, sendAttempt, nextLink);
+      return {
+        email,
+        clientSecret,
+        result,
+      };
+    },
+    [mx]
+  );
+
+  const [registerEmailState, registerEmail] = useAsyncCallback<
+    RequestEmailTokenResponse,
+    MatrixError,
+    Parameters<RequestEmailTokenCallback>
+  >(registerEmailCallback);
+
+  return [registerEmailState, registerEmail];
+};
diff --git a/src/app/hooks/useSpecVersions.ts b/src/app/hooks/useSpecVersions.ts
new file mode 100644 (file)
index 0000000..42403c6
--- /dev/null
@@ -0,0 +1,12 @@
+import { createContext, useContext } from 'react';
+import { SpecVersions } from '../cs-api';
+
+const SpecVersionsContext = createContext<SpecVersions | null>(null);
+
+export const SpecVersionsProvider = SpecVersionsContext.Provider;
+
+export function useSpecVersions(): SpecVersions {
+  const versions = useContext(SpecVersionsContext);
+  if (!versions) throw new Error('Server versions are not provided!');
+  return versions;
+}
diff --git a/src/app/hooks/useUIAFlows.ts b/src/app/hooks/useUIAFlows.ts
new file mode 100644 (file)
index 0000000..22acd6b
--- /dev/null
@@ -0,0 +1,96 @@
+import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
+import { useCallback, useMemo } from 'react';
+import {
+  getSupportedUIAFlows,
+  getUIACompleted,
+  getUIAError,
+  getUIAErrorCode,
+  getUIAParams,
+  getUIASession,
+} from '../utils/matrix-uia';
+
+export const SUPPORTED_FLOW_TYPES = [
+  AuthType.Dummy,
+  AuthType.Password,
+  AuthType.Email,
+  AuthType.Terms,
+  AuthType.Recaptcha,
+  AuthType.RegistrationToken,
+] as const;
+
+export const useSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] =>
+  useMemo(() => getSupportedUIAFlows(uiaFlows, supportedStages), [uiaFlows, supportedStages]);
+
+export const useUIACompleted = (authData: IAuthData): string[] =>
+  useMemo(() => getUIACompleted(authData), [authData]);
+
+export const useUIAParams = (authData: IAuthData) =>
+  useMemo(() => getUIAParams(authData), [authData]);
+
+export const useUIASession = (authData: IAuthData) =>
+  useMemo(() => getUIASession(authData), [authData]);
+
+export const useUIAErrorCode = (authData: IAuthData) =>
+  useMemo(() => getUIAErrorCode(authData), [authData]);
+
+export const useUIAError = (authData: IAuthData) =>
+  useMemo(() => getUIAError(authData), [authData]);
+
+export type StageInfo = Record<string, unknown>;
+export type AuthStageData = {
+  type: string;
+  info?: StageInfo;
+  session?: string;
+  errorCode?: string;
+  error?: string;
+};
+export type AuthStageDataGetter = () => AuthStageData | undefined;
+
+export type UIAFlowInterface = {
+  getStageToComplete: AuthStageDataGetter;
+  hasStage: (stageType: string) => boolean;
+  getStageInfo: (stageType: string) => StageInfo | undefined;
+};
+export const useUIAFlow = (authData: IAuthData, uiaFlow: UIAFlow): UIAFlowInterface => {
+  const completed = useUIACompleted(authData);
+  const params = useUIAParams(authData);
+  const session = useUIASession(authData);
+  const errorCode = useUIAErrorCode(authData);
+  const error = useUIAError(authData);
+
+  const getStageToComplete: AuthStageDataGetter = useCallback(() => {
+    const { stages } = uiaFlow;
+    const nextStage = stages.find((stage) => !completed.includes(stage));
+    if (!nextStage) return undefined;
+
+    const info = params[nextStage];
+
+    return {
+      type: nextStage,
+      info,
+      session,
+      errorCode,
+      error,
+    };
+  }, [uiaFlow, completed, params, errorCode, error, session]);
+
+  const hasStage = useCallback(
+    (stageType: string): boolean => uiaFlow.stages.includes(stageType),
+    [uiaFlow]
+  );
+
+  const getStageInfo = useCallback(
+    (stageType: string): StageInfo | undefined => {
+      if (!hasStage(stageType)) return undefined;
+
+      return params[stageType];
+    },
+    [hasStage, params]
+  );
+
+  return {
+    getStageToComplete,
+    hasStage,
+    getStageInfo,
+  };
+};
index 201c523ae0190db62a2eacfbbe2b3679bd77beb9..d573f7d6a484e15bf0ae139b0a96fba55faa48e4 100644 (file)
@@ -110,7 +110,9 @@ function RoomAliases({ roomId }) {
 
   const canPublishAlias = room.currentState.maySendStateEvent('m.room.canonical_alias', userId);
 
-  useEffect(() => isMountedStore.setItem(true), []);
+  useEffect(() => {
+    isMountedStore.setItem(true)
+  }, []);
 
   useEffect(() => {
     let isUnmounted = false;
index 6a72a99bb0528a94cd3e63026d503465986f44a5..d9dd9540fa0e2dc96a993baf4a5b82fc82559b8a 100644 (file)
@@ -49,7 +49,9 @@ function useVisibility(roomId) {
   const room = mx.getRoom(roomId);
 
   const [activeType, setActiveType] = useState(room.getHistoryVisibility());
-  useEffect(() => setActiveType(room.getHistoryVisibility()), [roomId]);
+  useEffect(() => {
+    setActiveType(room.getHistoryVisibility());
+  }, [roomId]);
 
   const setVisibility = useCallback((item) => {
     if (item.type === activeType.type) return;
index 1c088e5f0073485ea853af2a15e21fc0af48946c..4adb1169e5ff6c74d1acccd703c61f8438ec5048 100644 (file)
@@ -103,7 +103,9 @@ function setRoomNotifType(roomId, newType) {
 function useNotifications(roomId) {
   const { notifications } = initMatrix;
   const [activeType, setActiveType] = useState(notifications.getNotiType(roomId));
-  useEffect(() => setActiveType(notifications.getNotiType(roomId)), [roomId]);
+  useEffect(() => {
+    setActiveType(notifications.getNotiType(roomId));
+  }, [roomId]);
 
   const setNotification = useCallback((item) => {
     if (item.type === activeType.type) return;
index 2612aed1342f816cc886f366bd12d857836577f4..6009649f1a308fac939d85b710601ff46ca5ca3b 100644 (file)
@@ -29,7 +29,9 @@ function useRoomSearch(roomId) {
   const mountStore = useStore(roomId);
   const mx = initMatrix.matrixClient;
 
-  useEffect(() => mountStore.setItem(true), [roomId]);
+  useEffect(() => {
+    mountStore.setItem(true)
+  }, [roomId]);
 
   useEffect(() => {
     if (searchData?.results?.length > 0) {
index 7a8528765d6ce860246c1ff7f4bbe8a96d6688b7..a5e8e2d08e48d92d80c2c067e605e82b76931275 100644 (file)
@@ -50,7 +50,9 @@ function useVisibility(roomId) {
   const room = mx.getRoom(roomId);
 
   const [activeType, setActiveType] = useState(room.getJoinRule());
-  useEffect(() => setActiveType(room.getJoinRule()), [roomId]);
+  useEffect(() => {
+    setActiveType(room.getJoinRule());
+  }, [roomId]);
 
   const setNotification = useCallback((item) => {
     if (item.type === activeType.type) return;
index 6fe81cddf5a1145ebbf8cc1592a1f6e7efd270ce..3ae1f2948c663910a640f738c2ff92cd928d4c12 100644 (file)
@@ -80,7 +80,7 @@ function EmojiVerificationContent({ data, requestClose }) {
       }
     };
 
-    if (request === null) return null;
+    if (request === null) return undefined;
     const req = request;
     req.on('change', handleChange);
     return () => {
index 9ae4d298b33c38edcabb820d51f26a338c39b953..b085e184aa0a4b1f81fab10e0da8ccf2f1d389ab 100644 (file)
@@ -23,7 +23,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
     const [previewStatus, loadPreview] = useAsyncCallback(
       useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
     );
-    if (previewStatus.status === AsyncStatus.Idle) loadPreview();
+
+    useEffect(() => {
+      loadPreview();
+    }, [loadPreview]);
 
     if (previewStatus.status === AsyncStatus.Error) return null;
 
index cf042da4657f9c23cb373685578d354c78dcb992..60f00ad31b6cd486f1dbcaed47ec660cc56b9435 100644 (file)
@@ -302,7 +302,9 @@ function SpaceManageContent({ roomId, requestClose }) {
     };
   }, [roomId]);
 
-  useEffect(() => setSelected([]), [spacePath]);
+  useEffect(() => {
+    setSelected([]);
+  }, [spacePath]);
 
   const handleSelected = (selectedRoomId) => {
     const newSelected = [...selected];
diff --git a/src/app/pages/App.jsx b/src/app/pages/App.jsx
deleted file mode 100644 (file)
index 2828d7b..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-import React, { StrictMode } from 'react';
-import { Provider } from 'jotai';
-
-import { isAuthenticated } from '../../client/state/auth';
-
-import Auth from '../templates/auth/Auth';
-import Client from '../templates/client/Client';
-
-function App() {
-  return (
-    <StrictMode>
-      <Provider>{isAuthenticated() ? <Client /> : <Auth />}</Provider>
-    </StrictMode>
-  );
-}
-
-export default App;
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
new file mode 100644 (file)
index 0000000..6cefe99
--- /dev/null
@@ -0,0 +1,82 @@
+import React from 'react';
+import { Provider as JotaiProvider } from 'jotai';
+import {
+  Route,
+  RouterProvider,
+  createBrowserRouter,
+  createHashRouter,
+  createRoutesFromElements,
+  redirect,
+} from 'react-router-dom';
+
+import { ClientConfigLoader } from '../components/ClientConfigLoader';
+import { ClientConfig, ClientConfigProvider } from '../hooks/useClientConfig';
+import { AuthLayout, Login, Register, ResetPassword, authLayoutLoader } from './auth';
+import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
+import { isAuthenticated } from '../../client/state/auth';
+import Client from '../templates/client/Client';
+import { getLoginPath } from './pathUtils';
+import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
+
+const createRouter = (clientConfig: ClientConfig) => {
+  const { hashRouter } = clientConfig;
+
+  const routes = createRoutesFromElements(
+    <Route>
+      <Route
+        path={ROOT_PATH}
+        loader={() => {
+          if (isAuthenticated()) return redirect('/home');
+          return redirect(getLoginPath());
+        }}
+      />
+      <Route loader={authLayoutLoader} element={<AuthLayout />}>
+        <Route path={LOGIN_PATH} element={<Login />} />
+        <Route path={REGISTER_PATH} element={<Register />} />
+        <Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
+      </Route>
+
+      <Route
+        loader={() => {
+          if (!isAuthenticated()) return redirect(getLoginPath());
+          return null;
+        }}
+      >
+        <Route path="/home" element={<Client />} />
+        <Route path="/direct" element={<p>direct</p>} />
+        <Route path="/:spaceIdOrAlias" element={<p>:spaceIdOrAlias</p>} />
+        <Route path="/explore" element={<p>explore</p>} />
+      </Route>
+      <Route path="/*" element={<p>Page not found</p>} />
+    </Route>
+  );
+
+  if (hashRouter?.enabled) {
+    return createHashRouter(routes, { basename: hashRouter.basename });
+  }
+  return createBrowserRouter(routes, {
+    basename: import.meta.env.BASE_URL,
+  });
+};
+
+// TODO: app crash boundary
+function App() {
+  return (
+    <ClientConfigLoader
+      fallback={() => <ConfigConfigLoading />}
+      error={(err, retry, ignore) => (
+        <ConfigConfigError error={err} retry={retry} ignore={ignore} />
+      )}
+    >
+      {(clientConfig) => (
+        <ClientConfigProvider value={clientConfig}>
+          <JotaiProvider>
+            <RouterProvider router={createRouter(clientConfig)} />
+          </JotaiProvider>
+        </ClientConfigProvider>
+      )}
+    </ClientConfigLoader>
+  );
+}
+
+export default App;
diff --git a/src/app/pages/ConfigConfig.tsx b/src/app/pages/ConfigConfig.tsx
new file mode 100644 (file)
index 0000000..dbcdca7
--- /dev/null
@@ -0,0 +1,53 @@
+import { Box, Button, Dialog, Spinner, Text, color, config } from 'folds';
+import React from 'react';
+import { SplashScreen } from '../components/splash-screen';
+
+export function ConfigConfigLoading() {
+  return (
+    <SplashScreen>
+      <Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
+        <Spinner variant="Secondary" size="600" />
+        <Text>Heating up</Text>
+      </Box>
+    </SplashScreen>
+  );
+}
+
+type ConfigConfigErrorProps = {
+  error: unknown;
+  retry: () => void;
+  ignore: () => void;
+};
+export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) {
+  return (
+    <SplashScreen>
+      <Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
+        <Dialog>
+          <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+            <Box direction="Column" gap="100">
+              <Text>Failed to load client configuration file.</Text>
+              {typeof error === 'object' &&
+                error &&
+                'message' in error &&
+                typeof error.message === 'string' && (
+                  <Text size="T300" style={{ color: color.Critical.Main }}>
+                    {error.message}
+                  </Text>
+                )}
+            </Box>
+            <Button variant="Critical" onClick={retry}>
+              <Text as="span" size="B400">
+                Retry
+              </Text>
+            </Button>
+            <Button variant="Critical" onClick={ignore} fill="Soft">
+              <Text as="span" size="B400">
+                Continue
+              </Text>
+            </Button>
+          </Box>
+        </Dialog>
+      </Box>
+    </SplashScreen>
+  );
+}
diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx
new file mode 100644 (file)
index 0000000..6454161
--- /dev/null
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Box, Text } from 'folds';
+import * as css from './styles.css';
+
+export function AuthFooter() {
+  return (
+    <Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
+      <Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
+        About
+      </Text>
+      <Text
+        as="a"
+        size="T300"
+        href="https://github.com/ajbura/cinny/releases"
+        target="_blank"
+        rel="noreferrer"
+      >
+        v3.2.0
+      </Text>
+      <Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
+        Twitter
+      </Text>
+      <Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
+        Powered by Matrix
+      </Text>
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx
new file mode 100644 (file)
index 0000000..c58ecdd
--- /dev/null
@@ -0,0 +1,215 @@
+import React, { useCallback, useEffect } from 'react';
+import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
+import {
+  LoaderFunction,
+  Outlet,
+  generatePath,
+  matchPath,
+  redirect,
+  useLocation,
+  useNavigate,
+  useParams,
+} from 'react-router-dom';
+import classNames from 'classnames';
+
+import { AuthFooter } from './AuthFooter';
+import * as css from './styles.css';
+import * as PatternsCss from '../../styles/Patterns.css';
+import { isAuthenticated } from '../../../client/state/auth';
+import {
+  clientAllowedServer,
+  clientDefaultServer,
+  useClientConfig,
+} from '../../hooks/useClientConfig';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { LOGIN_PATH, REGISTER_PATH } from '../paths';
+import CinnySVG from '../../../../public/res/svg/cinny.svg';
+import { ServerPicker } from './ServerPicker';
+import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
+import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
+import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
+import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
+import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
+import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
+import { AuthServerProvider } from '../../hooks/useAuthServer';
+
+export const authLayoutLoader: LoaderFunction = () => {
+  if (isAuthenticated()) {
+    return redirect('/');
+  }
+
+  return null;
+};
+
+const currentAuthPath = (pathname: string): string => {
+  if (matchPath(LOGIN_PATH, pathname)) {
+    return LOGIN_PATH;
+  }
+  if (matchPath(REGISTER_PATH, pathname)) {
+    return REGISTER_PATH;
+  }
+  return LOGIN_PATH;
+};
+
+function AuthLayoutLoading({ message }: { message: string }) {
+  return (
+    <Box justifyContent="Center" alignItems="Center" gap="200">
+      <Spinner size="100" variant="Secondary" />
+      <Text align="Center" size="T300">
+        {message}
+      </Text>
+    </Box>
+  );
+}
+
+function AuthLayoutError({ message }: { message: string }) {
+  return (
+    <Box justifyContent="Center" alignItems="Center" gap="200">
+      <Text align="Center" style={{ color: color.Critical.Main }} size="T300">
+        {message}
+      </Text>
+    </Box>
+  );
+}
+
+export function AuthLayout() {
+  const navigate = useNavigate();
+  const location = useLocation();
+  const { server: urlEncodedServer } = useParams();
+
+  const clientConfig = useClientConfig();
+
+  const defaultServer = clientDefaultServer(clientConfig);
+  let server: string = urlEncodedServer ? decodeURIComponent(urlEncodedServer) : defaultServer;
+
+  if (!clientAllowedServer(clientConfig, server)) {
+    server = defaultServer;
+  }
+
+  const [discoveryState, discoverServer] = useAsyncCallback(
+    useCallback(async (serverName: string) => {
+      const response = await autoDiscovery(fetch, serverName);
+      return {
+        serverName,
+        response,
+      };
+    }, [])
+  );
+
+  useEffect(() => {
+    if (server) discoverServer(server);
+  }, [discoverServer, server]);
+
+  // if server is mismatches with path server, update path
+  useEffect(() => {
+    if (!urlEncodedServer || decodeURIComponent(urlEncodedServer) !== server) {
+      navigate(
+        generatePath(currentAuthPath(location.pathname), {
+          server: encodeURIComponent(server),
+        }),
+        { replace: true }
+      );
+    }
+  }, [urlEncodedServer, navigate, location, server]);
+
+  const selectServer = useCallback(
+    (newServer: string) => {
+      if (newServer === server) {
+        if (discoveryState.status === AsyncStatus.Loading) return;
+        discoverServer(server);
+        return;
+      }
+      navigate(
+        generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer) })
+      );
+    },
+    [navigate, location, discoveryState, server, discoverServer]
+  );
+
+  const [autoDiscoveryError, autoDiscoveryInfo] =
+    discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
+
+  return (
+    <Scroll variant="Background" visibility="Hover" size="300" hideTrack>
+      <Box
+        className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
+        direction="Column"
+        alignItems="Center"
+        justifyContent="SpaceBetween"
+        gap="400"
+      >
+        <Box direction="Column" className={css.AuthCard}>
+          <Header className={css.AuthHeader} size="600" variant="Surface">
+            <Box grow="Yes" direction="Row" gap="300" alignItems="Center">
+              <img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
+              <Text size="H3">Cinny</Text>
+            </Box>
+          </Header>
+          <Box className={css.AuthCardContent} direction="Column">
+            <Box direction="Column" gap="100">
+              <Text as="label" size="L400" priority="300">
+                Homeserver
+              </Text>
+              <ServerPicker
+                server={server}
+                serverList={clientConfig.homeserverList ?? []}
+                allowCustomServer={clientConfig.allowCustomHomeservers}
+                onServerChange={selectServer}
+              />
+            </Box>
+            {discoveryState.status === AsyncStatus.Loading && (
+              <AuthLayoutLoading message="Looking for homeserver..." />
+            )}
+            {discoveryState.status === AsyncStatus.Error && (
+              <AuthLayoutError message="Failed to find homeserver." />
+            )}
+            {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
+              <AuthLayoutError
+                message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
+              />
+            )}
+            {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
+              <AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
+            )}
+            {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
+              <AuthServerProvider value={discoveryState.data.serverName}>
+                <AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
+                  <SpecVersionsLoader
+                    fallback={() => (
+                      <AuthLayoutLoading
+                        message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
+                      />
+                    )}
+                    error={() => (
+                      <AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
+                    )}
+                  >
+                    {(specVersions) => (
+                      <SpecVersionsProvider value={specVersions}>
+                        <AuthFlowsLoader
+                          fallback={() => (
+                            <AuthLayoutLoading message="Loading authentication flow..." />
+                          )}
+                          error={() => (
+                            <AuthLayoutError message="Failed to get authentication flow information." />
+                          )}
+                        >
+                          {(authFlows) => (
+                            <AuthFlowsProvider value={authFlows}>
+                              <Outlet />
+                            </AuthFlowsProvider>
+                          )}
+                        </AuthFlowsLoader>
+                      </SpecVersionsProvider>
+                    )}
+                  </SpecVersionsLoader>
+                </AutoDiscoveryInfoProvider>
+              </AuthServerProvider>
+            )}
+          </Box>
+        </Box>
+        <AuthFooter />
+      </Box>
+    </Scroll>
+  );
+}
diff --git a/src/app/pages/auth/FiledError.tsx b/src/app/pages/auth/FiledError.tsx
new file mode 100644 (file)
index 0000000..d96fc87
--- /dev/null
@@ -0,0 +1,13 @@
+import React from 'react';
+import { Box, Icon, Icons, color, Text } from 'folds';
+
+export function FieldError({ message }: { message: string }) {
+  return (
+    <Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
+      <Icon size="50" filled src={Icons.Warning} />
+      <Text size="T200">
+        <b>{message}</b>
+      </Text>
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/OrDivider.tsx b/src/app/pages/auth/OrDivider.tsx
new file mode 100644 (file)
index 0000000..629d3f5
--- /dev/null
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Box, Line, Text } from 'folds';
+
+export function OrDivider() {
+  return (
+    <Box gap="400" alignItems="Center">
+      <Line style={{ flexGrow: 1 }} direction="Horizontal" size="300" variant="Surface" />
+      <Text>OR</Text>
+      <Line style={{ flexGrow: 1 }} direction="Horizontal" size="300" variant="Surface" />
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/SSOLogin.tsx b/src/app/pages/auth/SSOLogin.tsx
new file mode 100644 (file)
index 0000000..a9c1c54
--- /dev/null
@@ -0,0 +1,68 @@
+import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
+import { IIdentityProvider, createClient } from 'matrix-js-sdk';
+import React, { useMemo } from 'react';
+import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
+
+type SSOLoginProps = {
+  providers: IIdentityProvider[];
+  asIcons?: boolean;
+  redirectUrl: string;
+};
+export function SSOLogin({ providers, redirectUrl, asIcons }: SSOLoginProps) {
+  const discovery = useAutoDiscoveryInfo();
+  const baseUrl = discovery['m.homeserver'].base_url;
+  const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
+
+  const getSSOIdUrl = (ssoId: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
+
+  return (
+    <Box justifyContent="Center" gap="600" wrap="Wrap">
+      {providers.map((provider) => {
+        const { id, name, icon } = provider;
+        const iconUrl = icon && mx.mxcUrlToHttp(icon, 96, 96, 'crop', false);
+
+        const buttonTitle = `Continue with ${name}`;
+
+        if (iconUrl && asIcons) {
+          return (
+            <Avatar
+              style={{ cursor: 'pointer' }}
+              key={id}
+              as="a"
+              href={getSSOIdUrl(id)}
+              aria-label={buttonTitle}
+              size="300"
+              radii="300"
+            >
+              <AvatarImage src={iconUrl} alt={name} title={buttonTitle} />
+            </Avatar>
+          );
+        }
+
+        return (
+          <Button
+            style={{ width: '100%' }}
+            key={id}
+            as="a"
+            href={getSSOIdUrl(id)}
+            size="500"
+            variant="Secondary"
+            fill="Soft"
+            outlined
+            before={
+              iconUrl && (
+                <Avatar size="200" radii="300">
+                  <AvatarImage src={iconUrl} alt={name} />
+                </Avatar>
+              )
+            }
+          >
+            <Text align="Center" size="B500" truncate>
+              {buttonTitle}
+            </Text>
+          </Button>
+        );
+      })}
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx
new file mode 100644 (file)
index 0000000..5f5dcf6
--- /dev/null
@@ -0,0 +1,140 @@
+import React, {
+  ChangeEventHandler,
+  KeyboardEventHandler,
+  MouseEventHandler,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Menu,
+  MenuItem,
+  PopOut,
+  Text,
+  config,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+
+import { useDebounce } from '../../hooks/useDebounce';
+
+export function ServerPicker({
+  server,
+  serverList,
+  allowCustomServer,
+  onServerChange,
+}: {
+  server: string;
+  serverList: string[];
+  allowCustomServer?: boolean;
+  onServerChange: (server: string) => void;
+}) {
+  const [serverMenu, setServerMenu] = useState(false);
+  const serverInputRef = useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    // sync input with it outside server changes
+    if (serverInputRef.current && serverInputRef.current.value !== server) {
+      serverInputRef.current.value = server;
+    }
+  }, [server]);
+
+  const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
+
+  const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+    const inputServer = evt.target.value.trim();
+    if (inputServer) debounceServerSelect(inputServer);
+  };
+
+  const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
+    if (evt.key === 'ArrowDown') {
+      evt.preventDefault();
+      setServerMenu(true);
+    }
+    if (evt.key === 'Enter') {
+      evt.preventDefault();
+      const inputServer = evt.currentTarget.value.trim();
+      if (inputServer) onServerChange(inputServer);
+    }
+  };
+
+  const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const selectedServer = evt.currentTarget.getAttribute('data-server');
+    if (selectedServer) {
+      onServerChange(selectedServer);
+    }
+    setServerMenu(false);
+  };
+
+  return (
+    <Input
+      ref={serverInputRef}
+      style={{ paddingRight: config.space.S200 }}
+      variant={allowCustomServer ? 'Background' : 'Surface'}
+      outlined
+      defaultValue={server}
+      onChange={handleServerChange}
+      onKeyDown={handleKeyDown}
+      size="500"
+      readOnly={!allowCustomServer}
+      onClick={allowCustomServer ? undefined : () => setServerMenu(true)}
+      after={
+        serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
+          <PopOut
+            open={serverMenu}
+            position="Bottom"
+            align="End"
+            offset={4}
+            content={
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  onDeactivate: () => setServerMenu(false),
+                  clickOutsideDeactivates: true,
+                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                }}
+              >
+                <Menu>
+                  <Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
+                    <Text size="L400">Homeserver List</Text>
+                  </Header>
+                  <div style={{ padding: config.space.S100, paddingTop: 0 }}>
+                    {serverList?.map((serverName) => (
+                      <MenuItem
+                        key={serverName}
+                        radii="300"
+                        aria-pressed={serverName === server}
+                        data-server={serverName}
+                        onClick={handleServerSelect}
+                      >
+                        <Text>{serverName}</Text>
+                      </MenuItem>
+                    ))}
+                  </div>
+                </Menu>
+              </FocusTrap>
+            }
+          >
+            {(anchorRef) => (
+              <IconButton
+                ref={anchorRef}
+                onClick={() => setServerMenu(true)}
+                variant={allowCustomServer ? 'Background' : 'Surface'}
+                size="300"
+                aria-pressed={serverMenu}
+                radii="300"
+              >
+                <Icon src={Icons.ChevronBottom} />
+              </IconButton>
+            )}
+          </PopOut>
+        )
+      }
+    />
+  );
+}
diff --git a/src/app/pages/auth/index.ts b/src/app/pages/auth/index.ts
new file mode 100644 (file)
index 0000000..c4bd047
--- /dev/null
@@ -0,0 +1,4 @@
+export * from './AuthLayout';
+export * from './login';
+export * from './register';
+export * from './reset-password';
diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx
new file mode 100644 (file)
index 0000000..29a7b0c
--- /dev/null
@@ -0,0 +1,73 @@
+import React from 'react';
+import { Box, Text, color } from 'folds';
+import { Link, useSearchParams } from 'react-router-dom';
+import { useAuthFlows } from '../../../hooks/useAuthFlows';
+import { useAuthServer } from '../../../hooks/useAuthServer';
+import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
+import { PasswordLoginForm } from './PasswordLoginForm';
+import { SSOLogin } from '../SSOLogin';
+import { TokenLogin } from './TokenLogin';
+import { OrDivider } from '../OrDivider';
+import { getLoginPath, getRegisterPath } from '../../pathUtils';
+import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
+import { LoginPathSearchParams } from '../../paths';
+
+const getLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams => ({
+  username: searchParams.get('username') ?? undefined,
+  email: searchParams.get('email') ?? undefined,
+  loginToken: searchParams.get('loginToken') ?? undefined,
+});
+
+export function Login() {
+  const server = useAuthServer();
+  const { loginFlows } = useAuthFlows();
+  const [searchParams] = useSearchParams();
+  const loginSearchParams = getLoginSearchParams(searchParams);
+  const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
+
+  const parsedFlows = useParsedLoginFlows(loginFlows.flows);
+
+  return (
+    <Box direction="Column" gap="500">
+      <Text size="H2" priority="400">
+        Login
+      </Text>
+      {parsedFlows.token && loginSearchParams.loginToken && (
+        <TokenLogin token={loginSearchParams.loginToken} />
+      )}
+      {parsedFlows.password && (
+        <>
+          <PasswordLoginForm
+            defaultUsername={loginSearchParams.username}
+            defaultEmail={loginSearchParams.email}
+          />
+          <span data-spacing-node />
+          {parsedFlows.sso && <OrDivider />}
+        </>
+      )}
+      {parsedFlows.sso && (
+        <>
+          <SSOLogin
+            providers={parsedFlows.sso.identity_providers}
+            redirectUrl={ssoRedirectUrl}
+            asIcons={
+              parsedFlows.password !== undefined && parsedFlows.sso.identity_providers.length > 2
+            }
+          />
+          <span data-spacing-node />
+        </>
+      )}
+      {!parsedFlows.password && !parsedFlows.sso && (
+        <>
+          <Text style={{ color: color.Critical.Main }}>
+            {`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`}
+          </Text>
+          <span data-spacing-node />
+        </>
+      )}
+      <Text align="Center">
+        Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
+      </Text>
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx
new file mode 100644 (file)
index 0000000..ea52aad
--- /dev/null
@@ -0,0 +1,272 @@
+import React, { FormEventHandler, useCallback, useState } from 'react';
+import {
+  Box,
+  Button,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Menu,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  PopOut,
+  Spinner,
+  Text,
+  config,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { Link } from 'react-router-dom';
+import { MatrixError } from 'matrix-js-sdk';
+import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
+import { EMAIL_REGEX } from '../../../utils/regex';
+import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAuthServer } from '../../../hooks/useAuthServer';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import {
+  CustomLoginResponse,
+  LoginError,
+  factoryGetBaseUrl,
+  login,
+  useLoginComplete,
+} from './loginUtil';
+import { PasswordInput } from '../../../components/password-input/PasswordInput';
+import { FieldError } from '../FiledError';
+import { getResetPasswordPath } from '../../pathUtils';
+
+function UsernameHint({ server }: { server: string }) {
+  const [open, setOpen] = useState(false);
+  return (
+    <PopOut
+      open={open}
+      position="Top"
+      align="End"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setOpen(false),
+            clickOutsideDeactivates: true,
+          }}
+        >
+          <Menu>
+            <Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
+              <Text size="L400">Hint</Text>
+            </Header>
+            <Box
+              style={{ padding: config.space.S200, paddingTop: 0 }}
+              direction="Column"
+              tabIndex={0}
+              gap="100"
+            >
+              <Text size="T300">
+                <Text as="span" size="Inherit" priority="300">
+                  Username:
+                </Text>{' '}
+                johndoe
+              </Text>
+              <Text size="T300">
+                <Text as="span" size="Inherit" priority="300">
+                  Matrix ID:
+                </Text>
+                {` @johndoe:${server}`}
+              </Text>
+              <Text size="T300">
+                <Text as="span" size="Inherit" priority="300">
+                  Email:
+                </Text>
+                {` johndoe@${server}`}
+              </Text>
+            </Box>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      {(targetRef) => (
+        <IconButton
+          tabIndex={-1}
+          onClick={() => setOpen(true)}
+          ref={targetRef}
+          type="button"
+          variant="Background"
+          size="300"
+          radii="300"
+          aria-pressed={open}
+        >
+          <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
+        </IconButton>
+      )}
+    </PopOut>
+  );
+}
+
+type PasswordLoginFormProps = {
+  defaultUsername?: string;
+  defaultEmail?: string;
+};
+export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLoginFormProps) {
+  const server = useAuthServer();
+  const clientConfig = useClientConfig();
+
+  const serverDiscovery = useAutoDiscoveryInfo();
+  const baseUrl = serverDiscovery['m.homeserver'].base_url;
+
+  const [loginState, startLogin] = useAsyncCallback<
+    CustomLoginResponse,
+    MatrixError,
+    Parameters<typeof login>
+  >(useCallback(login, []));
+
+  useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
+
+  const handleUsernameLogin = (username: string, password: string) => {
+    startLogin(baseUrl, {
+      type: 'm.login.password',
+      identifier: {
+        type: 'm.id.user',
+        user: username,
+      },
+      password,
+      initial_device_display_name: 'Cinny Web',
+    });
+  };
+
+  const handleMxIdLogin = async (mxId: string, password: string) => {
+    const mxIdServer = getMxIdServer(mxId);
+    const mxIdUsername = getMxIdLocalPart(mxId);
+    if (!mxIdServer || !mxIdUsername) return;
+
+    const getBaseUrl = factoryGetBaseUrl(clientConfig, mxIdServer);
+
+    startLogin(getBaseUrl, {
+      type: 'm.login.password',
+      identifier: {
+        type: 'm.id.user',
+        user: mxIdUsername,
+      },
+      password,
+      initial_device_display_name: 'Cinny Web',
+    });
+  };
+  const handleEmailLogin = (email: string, password: string) => {
+    startLogin(baseUrl, {
+      type: 'm.login.password',
+      identifier: {
+        type: 'm.id.thirdparty',
+        medium: 'email',
+        address: email,
+      },
+      password,
+      initial_device_display_name: 'Cinny Web',
+    });
+  };
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const { usernameInput, passwordInput } = evt.target as HTMLFormElement & {
+      usernameInput: HTMLInputElement;
+      passwordInput: HTMLInputElement;
+    };
+
+    const username = usernameInput.value.trim();
+    const password = passwordInput.value;
+    if (!username) {
+      usernameInput.focus();
+      return;
+    }
+    if (!password) {
+      passwordInput.focus();
+      return;
+    }
+
+    if (isUserId(username)) {
+      handleMxIdLogin(username, password);
+      return;
+    }
+    if (EMAIL_REGEX.test(username)) {
+      handleEmailLogin(username, password);
+      return;
+    }
+    handleUsernameLogin(username, password);
+  };
+
+  return (
+    <Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
+      <Box direction="Column" gap="100">
+        <Text as="label" size="L400" priority="300">
+          Username
+        </Text>
+        <Input
+          defaultValue={defaultUsername ?? defaultEmail}
+          style={{ paddingRight: config.space.S300 }}
+          name="usernameInput"
+          variant="Background"
+          size="500"
+          required
+          outlined
+          after={<UsernameHint server={server} />}
+        />
+        {loginState.status === AsyncStatus.Error && (
+          <>
+            {loginState.error.errcode === LoginError.ServerNotAllowed && (
+              <FieldError message="Login with custom server not allowed by your client instance." />
+            )}
+            {loginState.error.errcode === LoginError.InvalidServer && (
+              <FieldError message="Failed to find your Matrix ID server." />
+            )}
+          </>
+        )}
+      </Box>
+      <Box direction="Column" gap="100">
+        <Text as="label" size="L400" priority="300">
+          Password
+        </Text>
+        <PasswordInput name="passwordInput" variant="Background" size="500" outlined required />
+        <Box alignItems="Start" justifyContent="SpaceBetween" gap="200">
+          {loginState.status === AsyncStatus.Error && (
+            <>
+              {loginState.error.errcode === LoginError.Forbidden && (
+                <FieldError message="Invalid Username or Password." />
+              )}
+              {loginState.error.errcode === LoginError.UserDeactivated && (
+                <FieldError message="This account has been deactivated." />
+              )}
+              {loginState.error.errcode === LoginError.InvalidRequest && (
+                <FieldError message="Failed to login. Part of your request data is invalid." />
+              )}
+              {loginState.error.errcode === LoginError.RateLimited && (
+                <FieldError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
+              )}
+              {loginState.error.errcode === LoginError.Unknown && (
+                <FieldError message="Failed to login. Unknown reason." />
+              )}
+            </>
+          )}
+          <Box grow="Yes" shrink="No" justifyContent="End">
+            <Text as="span" size="T200" priority="400" align="Right">
+              <Link to={getResetPasswordPath(server)}>Forget Password?</Link>
+            </Text>
+          </Box>
+        </Box>
+      </Box>
+      <Button type="submit" variant="Primary" size="500">
+        <Text as="span" size="B500">
+          Login
+        </Text>
+      </Button>
+
+      <Overlay
+        open={
+          loginState.status === AsyncStatus.Loading || loginState.status === AsyncStatus.Success
+        }
+        backdrop={<OverlayBackdrop />}
+      >
+        <OverlayCenter>
+          <Spinner variant="Secondary" size="600" />
+        </OverlayCenter>
+      </Overlay>
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx
new file mode 100644 (file)
index 0000000..761d5dc
--- /dev/null
@@ -0,0 +1,94 @@
+import {
+  Box,
+  Icon,
+  Icons,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  color,
+  config,
+} from 'folds';
+import React, { useCallback, useEffect } from 'react';
+import { MatrixError } from 'matrix-js-sdk';
+import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil';
+
+function LoginTokenError({ message }: { message: string }) {
+  return (
+    <Box
+      style={{
+        backgroundColor: color.Critical.Container,
+        color: color.Critical.OnContainer,
+        padding: config.space.S300,
+        borderRadius: config.radii.R400,
+      }}
+      justifyContent="Start"
+      alignItems="Start"
+      gap="300"
+    >
+      <Icon size="300" filled src={Icons.Warning} />
+      <Box direction="Column" gap="100">
+        <Text size="L400">Token Login</Text>
+        <Text size="T300">
+          <b>{message}</b>
+        </Text>
+      </Box>
+    </Box>
+  );
+}
+
+type TokenLoginProps = {
+  token: string;
+};
+export function TokenLogin({ token }: TokenLoginProps) {
+  const discovery = useAutoDiscoveryInfo();
+  const baseUrl = discovery['m.homeserver'].base_url;
+
+  const [loginState, startLogin] = useAsyncCallback<
+    CustomLoginResponse,
+    MatrixError,
+    Parameters<typeof login>
+  >(useCallback(login, []));
+
+  useEffect(() => {
+    startLogin(baseUrl, {
+      type: 'm.login.token',
+      token,
+      initial_device_display_name: 'Cinny Web',
+    });
+  }, [baseUrl, token, startLogin]);
+
+  useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
+
+  return (
+    <>
+      {loginState.status === AsyncStatus.Error && (
+        <>
+          {loginState.error.errcode === LoginError.Forbidden && (
+            <LoginTokenError message="Invalid login token." />
+          )}
+          {loginState.error.errcode === LoginError.UserDeactivated && (
+            <LoginTokenError message="This account has been deactivated." />
+          )}
+          {loginState.error.errcode === LoginError.InvalidRequest && (
+            <LoginTokenError message="Failed to login. Part of your request data is invalid." />
+          )}
+          {loginState.error.errcode === LoginError.RateLimited && (
+            <LoginTokenError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
+          )}
+          {loginState.error.errcode === LoginError.Unknown && (
+            <LoginTokenError message="Failed to login. Unknown reason." />
+          )}
+        </>
+      )}
+      <Overlay open={loginState.status !== AsyncStatus.Error} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <Spinner size="600" variant="Secondary" />
+        </OverlayCenter>
+      </Overlay>
+    </>
+  );
+}
diff --git a/src/app/pages/auth/login/index.ts b/src/app/pages/auth/login/index.ts
new file mode 100644 (file)
index 0000000..a10c3a8
--- /dev/null
@@ -0,0 +1 @@
+export * from './Login';
diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts
new file mode 100644 (file)
index 0000000..b2fd387
--- /dev/null
@@ -0,0 +1,118 @@
+import to from 'await-to-js';
+import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
+import { autoDiscovery, specVersions } from '../../../cs-api';
+import { updateLocalStore } from '../../../../client/action/auth';
+import { ROOT_PATH } from '../../paths';
+import { ErrorCode } from '../../../cs-errorcode';
+
+export enum GetBaseUrlError {
+  NotAllow = 'NotAllow',
+  NotFound = 'NotFound',
+}
+export const factoryGetBaseUrl = (clientConfig: ClientConfig, server: string) => {
+  const getBaseUrl = async (): Promise<string> => {
+    if (!clientAllowedServer(clientConfig, server)) {
+      throw new Error(GetBaseUrlError.NotAllow);
+    }
+
+    const [, discovery] = await to(autoDiscovery(fetch, server));
+
+    let mxIdBaseUrl: string | undefined;
+    const [, discoveryInfo] = discovery ?? [];
+
+    if (discoveryInfo) {
+      mxIdBaseUrl = discoveryInfo['m.homeserver'].base_url;
+    }
+
+    if (!mxIdBaseUrl) {
+      throw new Error(GetBaseUrlError.NotFound);
+    }
+    const [, versions] = await to(specVersions(fetch, mxIdBaseUrl));
+    if (!versions) {
+      throw new Error(GetBaseUrlError.NotFound);
+    }
+    return mxIdBaseUrl;
+  };
+  return getBaseUrl;
+};
+
+export enum LoginError {
+  ServerNotAllowed = 'ServerNotAllowed',
+  InvalidServer = 'InvalidServer',
+  Forbidden = 'Forbidden',
+  UserDeactivated = 'UserDeactivated',
+  InvalidRequest = 'InvalidRequest',
+  RateLimited = 'RateLimited',
+  Unknown = 'Unknown',
+}
+
+export type CustomLoginResponse = {
+  baseUrl: string;
+  response: LoginResponse;
+};
+export const login = async (
+  serverBaseUrl: string | (() => Promise<string>),
+  data: LoginRequest
+): Promise<CustomLoginResponse> => {
+  const [urlError, url] =
+    typeof serverBaseUrl === 'function' ? await to(serverBaseUrl()) : [undefined, serverBaseUrl];
+  if (urlError) {
+    throw new MatrixError({
+      errcode:
+        urlError.message === GetBaseUrlError.NotAllow
+          ? LoginError.ServerNotAllowed
+          : LoginError.InvalidServer,
+    });
+  }
+
+  const mx = createClient({ baseUrl: url });
+  const [err, res] = await to<LoginResponse, MatrixError>(mx.login(data.type, data));
+
+  if (err) {
+    if (err.httpStatus === 400) {
+      throw new MatrixError({
+        errcode: LoginError.InvalidRequest,
+      });
+    }
+    if (err.httpStatus === 429) {
+      throw new MatrixError({
+        errcode: LoginError.RateLimited,
+      });
+    }
+    if (err.errcode === ErrorCode.M_USER_DEACTIVATED) {
+      throw new MatrixError({
+        errcode: LoginError.UserDeactivated,
+      });
+    }
+
+    if (err.httpStatus === 403) {
+      throw new MatrixError({
+        errcode: LoginError.Forbidden,
+      });
+    }
+
+    throw new MatrixError({
+      errcode: LoginError.Unknown,
+    });
+  }
+  return {
+    baseUrl: url,
+    response: res,
+  };
+};
+
+export const useLoginComplete = (data?: CustomLoginResponse) => {
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    if (data) {
+      const { response: loginRes, baseUrl: loginBaseUrl } = data;
+      updateLocalStore(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
+      // TODO: add after login redirect url
+      navigate(ROOT_PATH, { replace: true });
+    }
+  }, [data, navigate]);
+};
diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx
new file mode 100644 (file)
index 0000000..f4439dd
--- /dev/null
@@ -0,0 +1,420 @@
+import {
+  Box,
+  Button,
+  Checkbox,
+  Input,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  color,
+} from 'folds';
+import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+  AuthDict,
+  AuthType,
+  IAuthData,
+  MatrixError,
+  RegisterRequest,
+  UIAFlow,
+  createClient,
+} from 'matrix-js-sdk';
+import { PasswordInput } from '../../../components/password-input/PasswordInput';
+import {
+  getLoginTermUrl,
+  getUIAFlowForStages,
+  hasStageInFlows,
+  requiredStageInFlows,
+} from '../../../utils/matrix-uia';
+import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows';
+import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
+import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
+import { FieldError } from '../FiledError';
+import {
+  AutoDummyStageDialog,
+  AutoTermsStageDialog,
+  EmailStageDialog,
+  ReCaptchaStageDialog,
+  RegistrationTokenStageDialog,
+} from '../../../components/uia-stages';
+import { useRegisterEmail } from '../../../hooks/useRegisterEmail';
+import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
+import { UIAFlowOverlay } from '../../../components/UIAFlowOverlay';
+import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../../hooks/types';
+
+export const SUPPORTED_REGISTER_STAGES = [
+  AuthType.RegistrationToken,
+  AuthType.Terms,
+  AuthType.Recaptcha,
+  AuthType.Email,
+  AuthType.Dummy,
+];
+type RegisterFormInputs = {
+  usernameInput: HTMLInputElement;
+  passwordInput: HTMLInputElement;
+  confirmPasswordInput: HTMLInputElement;
+  tokenInput?: HTMLInputElement;
+  emailInput?: HTMLInputElement;
+  termsInput?: HTMLInputElement;
+};
+
+type FormData = {
+  username: string;
+  password: string;
+  token?: string;
+  email?: string;
+  terms?: boolean;
+  clientSecret: string;
+};
+
+const pickStages = (uiaFlows: UIAFlow[], formData: FormData): string[] => {
+  const pickedStages: string[] = [];
+  if (formData.token) pickedStages.push(AuthType.RegistrationToken);
+  if (formData.email) pickedStages.push(AuthType.Email);
+  if (formData.terms) pickedStages.push(AuthType.Terms);
+  if (hasStageInFlows(uiaFlows, AuthType.Recaptcha)) {
+    pickedStages.push(AuthType.Recaptcha);
+  }
+
+  return pickedStages;
+};
+
+type RegisterUIAFlowProps = {
+  formData: FormData;
+  flow: UIAFlow;
+  authData: IAuthData;
+  registerEmailState: AsyncState<RequestEmailTokenResponse, MatrixError>;
+  registerEmail: RequestEmailTokenCallback;
+  onRegister: (registerReqData: RegisterRequest) => void;
+};
+function RegisterUIAFlow({
+  formData,
+  flow,
+  authData,
+  registerEmailState,
+  registerEmail,
+  onRegister,
+}: RegisterUIAFlowProps) {
+  const completed = useUIACompleted(authData);
+  const { getStageToComplete } = useUIAFlow(authData, flow);
+
+  const stageToComplete = getStageToComplete();
+
+  const handleAuthDict = useCallback(
+    (authDict: AuthDict) => {
+      const { password, username } = formData;
+      onRegister({
+        auth: authDict,
+        password,
+        username,
+        initial_device_display_name: 'Cinny Web',
+      });
+    },
+    [onRegister, formData]
+  );
+
+  const handleCancel = useCallback(() => {
+    window.location.reload();
+  }, []);
+
+  if (!stageToComplete) return null;
+  return (
+    <UIAFlowOverlay
+      currentStep={completed.length + 1}
+      stepCount={flow.stages.length}
+      onCancel={handleCancel}
+    >
+      {stageToComplete.type === AuthType.RegistrationToken && (
+        <RegistrationTokenStageDialog
+          token={formData.token}
+          stageData={stageToComplete}
+          submitAuthDict={handleAuthDict}
+          onCancel={handleCancel}
+        />
+      )}
+      {stageToComplete.type === AuthType.Terms && (
+        <AutoTermsStageDialog
+          stageData={stageToComplete}
+          submitAuthDict={handleAuthDict}
+          onCancel={handleCancel}
+        />
+      )}
+      {stageToComplete.type === AuthType.Recaptcha && (
+        <ReCaptchaStageDialog
+          stageData={stageToComplete}
+          submitAuthDict={handleAuthDict}
+          onCancel={handleCancel}
+        />
+      )}
+      {stageToComplete.type === AuthType.Email && (
+        <EmailStageDialog
+          email={formData.email}
+          clientSecret={formData.clientSecret}
+          stageData={stageToComplete}
+          requestEmailToken={registerEmail}
+          emailTokenState={registerEmailState}
+          submitAuthDict={handleAuthDict}
+          onCancel={handleCancel}
+        />
+      )}
+      {stageToComplete.type === AuthType.Dummy && (
+        <AutoDummyStageDialog
+          stageData={stageToComplete}
+          submitAuthDict={handleAuthDict}
+          onCancel={handleCancel}
+        />
+      )}
+    </UIAFlowOverlay>
+  );
+}
+
+type PasswordRegisterFormProps = {
+  authData: IAuthData;
+  uiaFlows: UIAFlow[];
+  defaultUsername?: string;
+  defaultEmail?: string;
+  defaultRegisterToken?: string;
+};
+export function PasswordRegisterForm({
+  authData,
+  uiaFlows,
+  defaultUsername,
+  defaultEmail,
+  defaultRegisterToken,
+}: PasswordRegisterFormProps) {
+  const serverDiscovery = useAutoDiscoveryInfo();
+  const baseUrl = serverDiscovery['m.homeserver'].base_url;
+  const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
+  const params = useUIAParams(authData);
+  const termUrl = getLoginTermUrl(params);
+  const [formData, setFormData] = useState<FormData>();
+
+  const [ongoingFlow, setOngoingFlow] = useState<UIAFlow>();
+
+  const [registerEmailState, registerEmail] = useRegisterEmail(mx);
+
+  const [registerState, handleRegister] = useAsyncCallback<
+    RegisterResult,
+    MatrixError,
+    [RegisterRequest]
+  >(useCallback(async (registerReqData) => register(mx, registerReqData), [mx]));
+  const [ongoingAuthData, customRegisterResp] =
+    registerState.status === AsyncStatus.Success ? registerState.data : [];
+  const registerError =
+    registerState.status === AsyncStatus.Error ? registerState.error : undefined;
+
+  useRegisterComplete(customRegisterResp);
+
+  const handleSubmit: ChangeEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const {
+      usernameInput,
+      passwordInput,
+      confirmPasswordInput,
+      emailInput,
+      tokenInput,
+      termsInput,
+    } = evt.target as HTMLFormElement & RegisterFormInputs;
+    const token = tokenInput?.value.trim();
+    const username = usernameInput.value.trim();
+    const password = passwordInput.value;
+    const confirmPassword = confirmPasswordInput.value;
+    if (password !== confirmPassword) {
+      return;
+    }
+    const email = emailInput?.value.trim();
+    const terms = termsInput?.value === 'on';
+
+    if (!username) {
+      usernameInput.focus();
+      return;
+    }
+
+    const fData: FormData = {
+      username,
+      password,
+      token,
+      email,
+      terms,
+      clientSecret: mx.generateClientSecret(),
+    };
+    const pickedStages = pickStages(uiaFlows, fData);
+    const pickedFlow = getUIAFlowForStages(uiaFlows, pickedStages);
+    setOngoingFlow(pickedFlow);
+    setFormData(fData);
+    handleRegister({
+      username,
+      password,
+      auth: {
+        session: authData.session,
+      },
+      initial_device_display_name: 'Cinny Web',
+    });
+  };
+
+  return (
+    <>
+      <Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
+        <Box direction="Column" gap="100">
+          <Text as="label" size="L400" priority="300">
+            Username
+          </Text>
+          <Input
+            variant="Background"
+            defaultValue={defaultUsername}
+            name="usernameInput"
+            size="500"
+            outlined
+            required
+          />
+          {registerError?.errcode === RegisterError.UserTaken && (
+            <FieldError message="This username is already taken." />
+          )}
+          {registerError?.errcode === RegisterError.UserInvalid && (
+            <FieldError message="This username contains invalid characters." />
+          )}
+          {registerError?.errcode === RegisterError.UserExclusive && (
+            <FieldError message="This username is reserved." />
+          )}
+        </Box>
+        <ConfirmPasswordMatch initialValue>
+          {(match, doMatch, passRef, confPassRef) => (
+            <>
+              <Box direction="Column" gap="100">
+                <Text as="label" size="L400" priority="300">
+                  Password
+                </Text>
+                <PasswordInput
+                  ref={passRef}
+                  onChange={doMatch}
+                  name="passwordInput"
+                  variant="Background"
+                  size="500"
+                  outlined
+                  required
+                />
+                {registerError?.errcode === RegisterError.PasswordWeak && (
+                  <FieldError
+                    message={
+                      registerError.data.error ??
+                      'Weak Password. Password rejected by server please choosing more strong Password.'
+                    }
+                  />
+                )}
+                {registerError?.errcode === RegisterError.PasswordShort && (
+                  <FieldError
+                    message={
+                      registerError.data.error ??
+                      'Short Password. Password rejected by server please choosing more long Password.'
+                    }
+                  />
+                )}
+              </Box>
+              <Box direction="Column" gap="100">
+                <Text as="label" size="L400" priority="300">
+                  Confirm Password
+                </Text>
+                <PasswordInput
+                  ref={confPassRef}
+                  onChange={doMatch}
+                  name="confirmPasswordInput"
+                  variant="Background"
+                  size="500"
+                  style={{ color: match ? undefined : color.Critical.Main }}
+                  outlined
+                  required
+                />
+              </Box>
+            </>
+          )}
+        </ConfirmPasswordMatch>
+        {hasStageInFlows(uiaFlows, AuthType.RegistrationToken) && (
+          <Box direction="Column" gap="100">
+            <Text as="label" size="L400" priority="300">
+              {requiredStageInFlows(uiaFlows, AuthType.RegistrationToken)
+                ? 'Registration Token'
+                : 'Registration Token (Optional)'}
+            </Text>
+            <Input
+              variant="Background"
+              defaultValue={defaultRegisterToken}
+              name="tokenInput"
+              size="500"
+              required={requiredStageInFlows(uiaFlows, AuthType.RegistrationToken)}
+              outlined
+            />
+          </Box>
+        )}
+        {hasStageInFlows(uiaFlows, AuthType.Email) && (
+          <Box direction="Column" gap="100">
+            <Text as="label" size="L400" priority="300">
+              {requiredStageInFlows(uiaFlows, AuthType.Email) ? 'Email' : 'Email (Optional)'}
+            </Text>
+            <Input
+              variant="Background"
+              defaultValue={defaultEmail}
+              name="emailInput"
+              type="email"
+              size="500"
+              required={requiredStageInFlows(uiaFlows, AuthType.Email)}
+              outlined
+            />
+          </Box>
+        )}
+
+        {hasStageInFlows(uiaFlows, AuthType.Terms) && termUrl && (
+          <Box alignItems="Center" gap="200">
+            <Checkbox name="termsInput" size="300" variant="Primary" required />
+            <Text size="T300">
+              I accept server{' '}
+              <a href={termUrl} target="_blank" rel="noreferrer">
+                Terms and Conditions
+              </a>
+              .
+            </Text>
+          </Box>
+        )}
+        {registerError?.errcode === RegisterError.RateLimited && (
+          <FieldError message="Failed to register. Your register request has been rate-limited by server, Please try after some time." />
+        )}
+        {registerError?.errcode === RegisterError.Forbidden && (
+          <FieldError message="Failed to register. The homeserver does not permit registration." />
+        )}
+        {registerError?.errcode === RegisterError.InvalidRequest && (
+          <FieldError message="Failed to register. Invalid request." />
+        )}
+        {registerError?.errcode === RegisterError.Unknown && (
+          <FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
+        )}
+        <span data-spacing-node />
+        <Button variant="Primary" size="500" type="submit">
+          <Text as="span" size="B500">
+            Register
+          </Text>
+        </Button>
+      </Box>
+      {registerState.status === AsyncStatus.Success &&
+        formData &&
+        ongoingFlow &&
+        ongoingAuthData && (
+          <RegisterUIAFlow
+            formData={formData}
+            flow={ongoingFlow}
+            authData={ongoingAuthData}
+            registerEmail={registerEmail}
+            registerEmailState={registerEmailState}
+            onRegister={handleRegister}
+          />
+        )}
+      {registerState.status === AsyncStatus.Loading && (
+        <Overlay open backdrop={<OverlayBackdrop />}>
+          <OverlayCenter>
+            <Spinner variant="Secondary" size="600" />
+          </OverlayCenter>
+        </Overlay>
+      )}
+    </>
+  );
+}
diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx
new file mode 100644 (file)
index 0000000..756b13b
--- /dev/null
@@ -0,0 +1,95 @@
+import React from 'react';
+import { Box, Text, color } from 'folds';
+import { Link, useSearchParams } from 'react-router-dom';
+import { useAuthServer } from '../../../hooks/useAuthServer';
+import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
+import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
+import { PasswordRegisterForm, SUPPORTED_REGISTER_STAGES } from '../register/PasswordRegisterForm';
+import { OrDivider } from '../OrDivider';
+import { SSOLogin } from '../SSOLogin';
+import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLoader';
+import { getLoginPath } from '../../pathUtils';
+import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
+import { RegisterPathSearchParams } from '../../paths';
+
+const getRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams => ({
+  username: searchParams.get('username') ?? undefined,
+  email: searchParams.get('email') ?? undefined,
+  token: searchParams.get('token') ?? undefined,
+});
+
+export function Register() {
+  const server = useAuthServer();
+  const { loginFlows, registerFlows } = useAuthFlows();
+  const [searchParams] = useSearchParams();
+  const registerSearchParams = getRegisterSearchParams(searchParams);
+  const { sso } = useParsedLoginFlows(loginFlows.flows);
+
+  // redirect to /login because only that path handle m.login.token
+  const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
+
+  return (
+    <Box direction="Column" gap="500">
+      <Text size="H2" priority="400">
+        Register
+      </Text>
+      {registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !sso && (
+        <Text style={{ color: color.Critical.Main }} size="T300">
+          Registration has been disabled on this homeserver.
+        </Text>
+      )}
+      {registerFlows.status === RegisterFlowStatus.RateLimited && !sso && (
+        <Text style={{ color: color.Critical.Main }} size="T300">
+          You have been rate-limited! Please try after some time.
+        </Text>
+      )}
+      {registerFlows.status === RegisterFlowStatus.InvalidRequest && !sso && (
+        <Text style={{ color: color.Critical.Main }} size="T300">
+          Invalid Request! Failed to get any registration options.
+        </Text>
+      )}
+      {registerFlows.status === RegisterFlowStatus.FlowRequired && (
+        <>
+          <SupportedUIAFlowsLoader
+            flows={registerFlows.data.flows ?? []}
+            supportedStages={SUPPORTED_REGISTER_STAGES}
+          >
+            {(supportedFlows) =>
+              supportedFlows.length === 0 ? (
+                <Text style={{ color: color.Critical.Main }} size="T300">
+                  This application does not support registration on this homeserver.
+                </Text>
+              ) : (
+                <PasswordRegisterForm
+                  authData={registerFlows.data}
+                  uiaFlows={supportedFlows}
+                  defaultUsername={registerSearchParams.username}
+                  defaultEmail={registerSearchParams.email}
+                  defaultRegisterToken={registerSearchParams.token}
+                />
+              )
+            }
+          </SupportedUIAFlowsLoader>
+          <span data-spacing-node />
+          {sso && <OrDivider />}
+        </>
+      )}
+      {sso && (
+        <>
+          <SSOLogin
+            providers={sso.identity_providers}
+            redirectUrl={ssoRedirectUrl}
+            asIcons={
+              registerFlows.status === RegisterFlowStatus.FlowRequired &&
+              sso.identity_providers.length > 2
+            }
+          />
+          <span data-spacing-node />
+        </>
+      )}
+      <Text align="Center">
+        Already have an account? <Link to={getLoginPath(server)}>Login</Link>
+      </Text>
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/register/index.ts b/src/app/pages/auth/register/index.ts
new file mode 100644 (file)
index 0000000..7eb55fd
--- /dev/null
@@ -0,0 +1 @@
+export * from './Register';
diff --git a/src/app/pages/auth/register/registerUtil.ts b/src/app/pages/auth/register/registerUtil.ts
new file mode 100644 (file)
index 0000000..23c3d6a
--- /dev/null
@@ -0,0 +1,125 @@
+import to from 'await-to-js';
+import {
+  IAuthData,
+  MatrixClient,
+  MatrixError,
+  RegisterRequest,
+  RegisterResponse,
+} from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { updateLocalStore } from '../../../../client/action/auth';
+import { ROOT_PATH } from '../../paths';
+import { ErrorCode } from '../../../cs-errorcode';
+
+export enum RegisterError {
+  UserTaken = 'UserTaken',
+  UserInvalid = 'UserInvalid',
+  UserExclusive = 'UserExclusive',
+  PasswordWeak = 'PasswordWeak',
+  PasswordShort = 'PasswordShort',
+  InvalidRequest = 'InvalidRequest',
+  Forbidden = 'Forbidden',
+  RateLimited = 'RateLimited',
+  Unknown = 'Unknown',
+}
+
+export type CustomRegisterResponse = {
+  baseUrl: string;
+  response: RegisterResponse;
+};
+export type RegisterResult = [IAuthData, undefined] | [undefined, CustomRegisterResponse];
+export const register = async (
+  mx: MatrixClient,
+  requestData: RegisterRequest
+): Promise<RegisterResult> => {
+  const [err, res] = await to<RegisterResponse, MatrixError>(mx.registerRequest(requestData));
+
+  if (err) {
+    if (err.httpStatus === 401) {
+      const authData = err.data as IAuthData;
+      return [authData, undefined];
+    }
+
+    if (err.errcode === ErrorCode.M_USER_IN_USE) {
+      throw new MatrixError({
+        errcode: RegisterError.UserTaken,
+      });
+    }
+    if (err.errcode === ErrorCode.M_INVALID_USERNAME) {
+      throw new MatrixError({
+        errcode: RegisterError.UserInvalid,
+      });
+    }
+    if (err.errcode === ErrorCode.M_EXCLUSIVE) {
+      throw new MatrixError({
+        errcode: RegisterError.UserExclusive,
+      });
+    }
+    if (err.errcode === ErrorCode.M_WEAK_PASSWORD) {
+      throw new MatrixError({
+        errcode: RegisterError.PasswordWeak,
+        error: err.data.error,
+      });
+    }
+    if (err.errcode === ErrorCode.M_PASSWORD_TOO_SHORT) {
+      throw new MatrixError({
+        errcode: RegisterError.PasswordShort,
+        error: err.data.error,
+      });
+    }
+
+    if (err.httpStatus === 429) {
+      throw new MatrixError({
+        errcode: RegisterError.RateLimited,
+      });
+    }
+
+    if (err.httpStatus === 400) {
+      throw new MatrixError({
+        errcode: RegisterError.InvalidRequest,
+      });
+    }
+
+    if (err.httpStatus === 403) {
+      throw new MatrixError({
+        errcode: RegisterError.Forbidden,
+      });
+    }
+
+    throw new MatrixError({
+      errcode: RegisterError.Unknown,
+      error: err.data.error,
+    });
+  }
+  return [
+    undefined,
+    {
+      baseUrl: mx.baseUrl,
+      response: res,
+    },
+  ];
+};
+
+export const useRegisterComplete = (data?: CustomRegisterResponse) => {
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    if (data) {
+      const { response, baseUrl } = data;
+
+      const userId = response.user_id;
+      const accessToken = response.access_token;
+      const deviceId = response.device_id;
+
+      if (accessToken && deviceId) {
+        updateLocalStore(accessToken, deviceId, userId, baseUrl);
+        // TODO: add after register redirect url
+        navigate(ROOT_PATH, { replace: true });
+      } else {
+        // TODO: navigate to login with userId
+        navigate(ROOT_PATH, { replace: true });
+      }
+    }
+  }, [data, navigate]);
+};
diff --git a/src/app/pages/auth/reset-password/PasswordResetForm.tsx b/src/app/pages/auth/reset-password/PasswordResetForm.tsx
new file mode 100644 (file)
index 0000000..7c71de0
--- /dev/null
@@ -0,0 +1,274 @@
+import React, { FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
+import {
+  Box,
+  Button,
+  Dialog,
+  Input,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  color,
+  config,
+} from 'folds';
+import { useNavigate } from 'react-router-dom';
+import FocusTrap from 'focus-trap-react';
+import { AuthDict, AuthType, MatrixError, createClient } from 'matrix-js-sdk';
+import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAuthServer } from '../../../hooks/useAuthServer';
+import { usePasswordEmail } from '../../../hooks/usePasswordEmail';
+import { PasswordInput } from '../../../components/password-input/PasswordInput';
+import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
+import { FieldError } from '../FiledError';
+import { UIAFlowOverlay } from '../../../components/UIAFlowOverlay';
+import { EmailStageDialog } from '../../../components/uia-stages';
+import { ResetPasswordResult, resetPassword } from './resetPasswordUtil';
+import { getLoginPath, withSearchParam } from '../../pathUtils';
+import { LoginPathSearchParams } from '../../paths';
+import { getUIAError, getUIAErrorCode } from '../../../utils/matrix-uia';
+
+type FormData = {
+  email: string;
+  password: string;
+  clientSecret: string;
+};
+
+function ResetPasswordComplete({ email }: { email?: string }) {
+  const server = useAuthServer();
+
+  const navigate = useNavigate();
+
+  const handleClick = () => {
+    const path = getLoginPath(server);
+    if (email) {
+      navigate(withSearchParam<LoginPathSearchParams>(path, { email }));
+      return;
+    }
+    navigate(path);
+  };
+
+  return (
+    <Overlay open backdrop={<OverlayBackdrop />}>
+      <OverlayCenter>
+        <FocusTrap>
+          <Dialog>
+            <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+              <Text>
+                Password has been reset successfully. Please login with your new password.
+              </Text>
+              <Button variant="Primary" onClick={handleClick}>
+                <Text size="B400" as="span">
+                  Login
+                </Text>
+              </Button>
+            </Box>
+          </Dialog>
+        </FocusTrap>
+      </OverlayCenter>
+    </Overlay>
+  );
+}
+
+type PasswordResetFormProps = {
+  defaultEmail?: string;
+};
+export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
+  const server = useAuthServer();
+
+  const serverDiscovery = useAutoDiscoveryInfo();
+  const baseUrl = serverDiscovery['m.homeserver'].base_url;
+  const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
+
+  const [formData, setFormData] = useState<FormData>();
+
+  const [passwordEmailState, passwordEmail] = usePasswordEmail(mx);
+
+  const [resetPasswordState, handleResetPassword] = useAsyncCallback<
+    ResetPasswordResult,
+    MatrixError,
+    [AuthDict, string]
+  >(useCallback(async (authDict, newPassword) => resetPassword(mx, authDict, newPassword), [mx]));
+
+  const [ongoingAuthData, resetPasswordResult] =
+    resetPasswordState.status === AsyncStatus.Success ? resetPasswordState.data : [];
+  const resetPasswordError =
+    resetPasswordState.status === AsyncStatus.Error ? resetPasswordState.error : undefined;
+
+  const flowErrorCode = ongoingAuthData && getUIAErrorCode(ongoingAuthData);
+  const flowError = ongoingAuthData && getUIAError(ongoingAuthData);
+
+  let waitingToVerifyEmail = true;
+  if (resetPasswordResult) waitingToVerifyEmail = false;
+  if (ongoingAuthData && flowErrorCode === undefined) waitingToVerifyEmail = false;
+  if (resetPasswordError) waitingToVerifyEmail = false;
+  if (resetPasswordState.status === AsyncStatus.Loading) waitingToVerifyEmail = false;
+
+  // We only support UIA m.login.password stage for reset password
+  // So we will assume to process it as soon as
+  // we have 401 with no error on initial request.
+  useEffect(() => {
+    if (formData && ongoingAuthData && !flowErrorCode) {
+      handleResetPassword(
+        {
+          type: AuthType.Password,
+          identifier: {
+            type: 'm.id.thirdparty',
+            medium: 'email',
+            address: formData.email,
+          },
+          password: formData.password,
+        },
+        formData.password
+      );
+    }
+  }, [ongoingAuthData, flowErrorCode, formData, handleResetPassword]);
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const { emailInput, passwordInput, confirmPasswordInput } = evt.target as HTMLFormElement & {
+      emailInput: HTMLInputElement;
+      passwordInput: HTMLInputElement;
+      confirmPasswordInput: HTMLInputElement;
+    };
+
+    const email = emailInput.value.trim();
+    const password = passwordInput.value;
+    const confirmPassword = confirmPasswordInput.value;
+    if (!email) {
+      emailInput.focus();
+      return;
+    }
+    if (password !== confirmPassword) return;
+
+    const clientSecret = mx.generateClientSecret();
+    passwordEmail(email, clientSecret);
+    setFormData({
+      email,
+      password,
+      clientSecret,
+    });
+  };
+
+  const handleCancel = () => {
+    window.location.reload();
+  };
+
+  const handleSubmitRequest = useCallback(
+    (authDict: AuthDict) => {
+      if (!formData) return;
+      const { password } = formData;
+      handleResetPassword(authDict, password);
+    },
+    [formData, handleResetPassword]
+  );
+
+  return (
+    <Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
+      <Text size="T300" priority="400">
+        Homeserver <strong>{server}</strong> will send you an email to let you reset your password.
+      </Text>
+      <Box direction="Column" gap="100">
+        <Text as="label" size="L400" priority="300">
+          Email
+        </Text>
+        <Input
+          defaultValue={defaultEmail}
+          type="email"
+          name="emailInput"
+          variant="Background"
+          size="500"
+          required
+          outlined
+        />
+        {passwordEmailState.status === AsyncStatus.Error && (
+          <FieldError
+            message={`${passwordEmailState.error.errcode}: ${passwordEmailState.error.data?.error}`}
+          />
+        )}
+      </Box>
+      <ConfirmPasswordMatch initialValue>
+        {(match, doMatch, passRef, confPassRef) => (
+          <>
+            <Box direction="Column" gap="100">
+              <Text as="label" size="L400" priority="300">
+                New Password
+              </Text>
+              <PasswordInput
+                ref={passRef}
+                onChange={doMatch}
+                name="passwordInput"
+                variant="Background"
+                size="500"
+                outlined
+                required
+              />
+            </Box>
+            <Box direction="Column" gap="100">
+              <Text as="label" size="L400" priority="300">
+                Confirm Password
+              </Text>
+              <PasswordInput
+                ref={confPassRef}
+                onChange={doMatch}
+                name="confirmPasswordInput"
+                variant="Background"
+                size="500"
+                style={{ color: match ? undefined : color.Critical.Main }}
+                outlined
+                required
+              />
+            </Box>
+          </>
+        )}
+      </ConfirmPasswordMatch>
+      {resetPasswordError && (
+        <FieldError
+          message={`${resetPasswordError.errcode}: ${
+            resetPasswordError.data?.error ?? 'Failed to reset password.'
+          }`}
+        />
+      )}
+      <span data-spacing-node />
+      <Button type="submit" variant="Primary" size="500">
+        <Text as="span" size="B500">
+          Reset Password
+        </Text>
+      </Button>
+
+      {resetPasswordResult && <ResetPasswordComplete email={formData?.email} />}
+
+      {passwordEmailState.status === AsyncStatus.Success && formData && waitingToVerifyEmail && (
+        <UIAFlowOverlay currentStep={1} stepCount={1} onCancel={handleCancel}>
+          <EmailStageDialog
+            stageData={{
+              type: AuthType.Email,
+              errorCode: flowErrorCode,
+              error: flowError,
+              session: ongoingAuthData?.session,
+            }}
+            submitAuthDict={handleSubmitRequest}
+            email={formData.email}
+            clientSecret={formData.clientSecret}
+            requestEmailToken={passwordEmail}
+            emailTokenState={passwordEmailState}
+            onCancel={handleCancel}
+          />
+        </UIAFlowOverlay>
+      )}
+
+      <Overlay
+        open={
+          passwordEmailState.status === AsyncStatus.Loading ||
+          resetPasswordState.status === AsyncStatus.Loading
+        }
+        backdrop={<OverlayBackdrop />}
+      >
+        <OverlayCenter>
+          <Spinner variant="Secondary" size="600" />
+        </OverlayCenter>
+      </Overlay>
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/reset-password/ResetPassword.tsx b/src/app/pages/auth/reset-password/ResetPassword.tsx
new file mode 100644 (file)
index 0000000..1ada9af
--- /dev/null
@@ -0,0 +1,36 @@
+import { Box, Text } from 'folds';
+import React from 'react';
+import { Link, useSearchParams } from 'react-router-dom';
+import { getLoginPath } from '../../pathUtils';
+import { useAuthServer } from '../../../hooks/useAuthServer';
+import { PasswordResetForm } from './PasswordResetForm';
+
+export type ResetPasswordSearchParams = {
+  email?: string;
+};
+
+const getResetPasswordSearchParams = (
+  searchParams: URLSearchParams
+): ResetPasswordSearchParams => ({
+  email: searchParams.get('email') ?? undefined,
+});
+
+export function ResetPassword() {
+  const server = useAuthServer();
+  const [searchParams] = useSearchParams();
+  const resetPasswordSearchParams = getResetPasswordSearchParams(searchParams);
+
+  return (
+    <Box direction="Column" gap="500">
+      <Text size="H2" priority="400">
+        Reset Password
+      </Text>
+      <PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
+      <span data-spacing-node />
+
+      <Text align="Center">
+        Remember your password? <Link to={getLoginPath(server)}>Login</Link>
+      </Text>
+    </Box>
+  );
+}
diff --git a/src/app/pages/auth/reset-password/index.ts b/src/app/pages/auth/reset-password/index.ts
new file mode 100644 (file)
index 0000000..0e85ecf
--- /dev/null
@@ -0,0 +1 @@
+export * from './ResetPassword';
diff --git a/src/app/pages/auth/reset-password/resetPasswordUtil.ts b/src/app/pages/auth/reset-password/resetPasswordUtil.ts
new file mode 100644 (file)
index 0000000..5eb436f
--- /dev/null
@@ -0,0 +1,23 @@
+import to from 'await-to-js';
+import { AuthDict, IAuthData, MatrixClient, MatrixError } from 'matrix-js-sdk';
+
+export type ResetPasswordResponse = Record<string, never>;
+export type ResetPasswordResult = [IAuthData, undefined] | [undefined, ResetPasswordResponse];
+export const resetPassword = async (
+  mx: MatrixClient,
+  authDict: AuthDict,
+  newPassword: string
+): Promise<ResetPasswordResult> => {
+  const [err, res] = await to<ResetPasswordResponse, MatrixError>(
+    mx.setPassword(authDict, newPassword, false)
+  );
+
+  if (err) {
+    if (err.httpStatus === 401) {
+      const authData = err.data as IAuthData;
+      return [authData, undefined];
+    }
+    throw err;
+  }
+  return [undefined, res];
+};
diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts
new file mode 100644 (file)
index 0000000..5834ad8
--- /dev/null
@@ -0,0 +1,53 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const AuthLayout = style({
+  minHeight: '100%',
+  backgroundColor: color.Background.Container,
+  color: color.Background.OnContainer,
+  padding: config.space.S400,
+  paddingRight: config.space.S200,
+  paddingBottom: 0,
+  position: 'relative',
+});
+
+export const AuthCard = style({
+  marginTop: '1vh',
+  maxWidth: toRem(460),
+  width: '100%',
+  backgroundColor: color.Surface.Container,
+  color: color.Surface.OnContainer,
+  borderRadius: config.radii.R400,
+  boxShadow: config.shadow.E100,
+  border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+  overflow: 'hidden',
+});
+
+export const AuthLogo = style([
+  DefaultReset,
+  {
+    width: toRem(26),
+    height: toRem(26),
+
+    borderRadius: '50%',
+  },
+]);
+
+export const AuthHeader = style({
+  padding: `0 ${config.space.S400}`,
+  borderBottomWidth: config.borderWidth.B300,
+});
+
+export const AuthCardContent = style({
+  maxWidth: toRem(402),
+  width: '100%',
+  margin: 'auto',
+  padding: config.space.S400,
+  paddingTop: config.space.S700,
+  paddingBottom: toRem(44),
+  gap: toRem(44),
+});
+
+export const AuthFooter = style({
+  padding: config.space.S200,
+});
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
new file mode 100644 (file)
index 0000000..db39ce3
--- /dev/null
@@ -0,0 +1,28 @@
+import { generatePath } from 'react-router-dom';
+import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
+
+export const withSearchParam = <T extends Record<string, string>>(
+  path: string,
+  searchParam: T
+): string => {
+  const params = new URLSearchParams(searchParam);
+
+  return `${path}?${params}`;
+};
+
+export const getRootPath = (): string => ROOT_PATH;
+
+export const getLoginPath = (server?: string): string => {
+  const params = server ? { server: encodeURIComponent(server) } : undefined;
+  return generatePath(LOGIN_PATH, params);
+};
+
+export const getRegisterPath = (server?: string): string => {
+  const params = server ? { server: encodeURIComponent(server) } : undefined;
+  return generatePath(REGISTER_PATH, params);
+};
+
+export const getResetPasswordPath = (server?: string): string => {
+  const params = server ? { server: encodeURIComponent(server) } : undefined;
+  return generatePath(RESET_PASSWORD_PATH, params);
+};
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
new file mode 100644 (file)
index 0000000..cd62264
--- /dev/null
@@ -0,0 +1,17 @@
+export const ROOT_PATH = '/';
+
+export type LoginPathSearchParams = {
+  username?: string;
+  email?: string;
+  loginToken?: string;
+};
+export const LOGIN_PATH = '/login/:server?/';
+
+export type RegisterPathSearchParams = {
+  username?: string;
+  email?: string;
+  token?: string;
+};
+export const REGISTER_PATH = '/register/:server?/';
+
+export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
index f8b7e057c906b6830db215e4f12d464edbde7011..ffe44445d9e9f9873d40840ff28630881ea4bd86 100644 (file)
@@ -1,28 +1,26 @@
-import { useAtomValue, WritableAtom } from 'jotai';
+import { useAtomValue } from 'jotai';
 import { selectAtom } from 'jotai/utils';
 import { MatrixClient } from 'matrix-js-sdk';
 import { useCallback } from 'react';
 import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
-import { compareRoomsEqual, RoomsAction } from '../utils';
-import { MDirectAction } from '../mDirectList';
+import { compareRoomsEqual } from '../utils';
+import { mDirectAtom } from '../mDirectList';
+import { allInvitesAtom } from '../inviteList';
 
-export const useSpaceInvites = (
-  mx: MatrixClient,
-  allInvitesAtom: WritableAtom<string[], RoomsAction>
-) => {
+export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
   const selector = useCallback(
     (rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
     [mx]
   );
-  return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
 };
 
 export const useRoomInvites = (
   mx: MatrixClient,
-  allInvitesAtom: WritableAtom<string[], RoomsAction>,
-  mDirectAtom: WritableAtom<Set<string>, MDirectAction>
+  invitesAtom: typeof allInvitesAtom,
+  directAtom: typeof mDirectAtom
 ) => {
-  const mDirects = useAtomValue(mDirectAtom);
+  const mDirects = useAtomValue(directAtom);
   const selector = useCallback(
     (rooms: string[]) =>
       rooms.filter(
@@ -32,15 +30,15 @@ export const useRoomInvites = (
       ),
     [mx, mDirects]
   );
-  return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
 };
 
 export const useDirectInvites = (
   mx: MatrixClient,
-  allInvitesAtom: WritableAtom<string[], RoomsAction>,
-  mDirectAtom: WritableAtom<Set<string>, MDirectAction>
+  invitesAtom: typeof allInvitesAtom,
+  directAtom: typeof mDirectAtom
 ) => {
-  const mDirects = useAtomValue(mDirectAtom);
+  const mDirects = useAtomValue(directAtom);
   const selector = useCallback(
     (rooms: string[]) =>
       rooms.filter(
@@ -48,16 +46,13 @@ export const useDirectInvites = (
       ),
     [mx, mDirects]
   );
-  return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
 };
 
-export const useUnsupportedInvites = (
-  mx: MatrixClient,
-  allInvitesAtom: WritableAtom<string[], RoomsAction>
-) => {
+export const useUnsupportedInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
   const selector = useCallback(
     (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
     [mx]
   );
-  return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
 };
index 5d0890bddb6e75c5a82cde33f8a0ca05eae10243..c0a7bfb88ef10c2967dc5445816cc4fd45c776b0 100644 (file)
@@ -1,54 +1,52 @@
-import { useAtomValue, WritableAtom } from 'jotai';
+import { useAtomValue } from 'jotai';
 import { selectAtom } from 'jotai/utils';
 import { MatrixClient } from 'matrix-js-sdk';
 import { useCallback } from 'react';
 import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
-import { compareRoomsEqual, RoomsAction } from '../utils';
-import { MDirectAction } from '../mDirectList';
+import { compareRoomsEqual } from '../utils';
+import { mDirectAtom } from '../mDirectList';
+import { allRoomsAtom } from '../roomList';
 
-export const useSpaces = (mx: MatrixClient, allRoomsAtom: WritableAtom<string[], RoomsAction>) => {
+export const useSpaces = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
   const selector = useCallback(
     (rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
     [mx]
   );
-  return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
 };
 
 export const useRooms = (
   mx: MatrixClient,
-  allRoomsAtom: WritableAtom<string[], RoomsAction>,
-  mDirectAtom: WritableAtom<Set<string>, MDirectAction>
+  roomsAtom: typeof allRoomsAtom,
+  directAtom: typeof mDirectAtom
 ) => {
-  const mDirects = useAtomValue(mDirectAtom);
+  const mDirects = useAtomValue(directAtom);
   const selector = useCallback(
     (rooms: string[]) =>
       rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
     [mx, mDirects]
   );
-  return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
 };
 
 export const useDirects = (
   mx: MatrixClient,
-  allRoomsAtom: WritableAtom<string[], RoomsAction>,
-  mDirectAtom: WritableAtom<Set<string>, MDirectAction>
+  roomsAtom: typeof allRoomsAtom,
+  directAtom: typeof mDirectAtom
 ) => {
-  const mDirects = useAtomValue(mDirectAtom);
+  const mDirects = useAtomValue(directAtom);
   const selector = useCallback(
     (rooms: string[]) =>
       rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
     [mx, mDirects]
   );
-  return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
 };
 
-export const useUnsupportedRooms = (
-  mx: MatrixClient,
-  allRoomsAtom: WritableAtom<string[], RoomsAction>
-) => {
+export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
   const selector = useCallback(
     (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
     [mx]
   );
-  return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
+  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
 };
index 43b565534a95d7ce1ba750f7bbf9ecebbfef8267..d90c7664616bf016fe8f199700fcca499988e891 100644 (file)
@@ -1,16 +1,16 @@
-import { atom, useAtomValue, useSetAtom, WritableAtom } from 'jotai';
-import { SetAtom } from 'jotai/core/atom';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
 import { selectAtom } from 'jotai/utils';
 import { useMemo } from 'react';
-import { Settings } from '../settings';
+import { Settings, settingsAtom as sAtom } from '../settings';
 
-export const useSetSetting = <K extends keyof Settings>(
-  settingsAtom: WritableAtom<Settings, Settings>,
-  key: K
-) => {
+export type SettingSetter<K extends keyof Settings> =
+  | Settings[K]
+  | ((s: Settings[K]) => Settings[K]);
+
+export const useSetSetting = <K extends keyof Settings>(settingsAtom: typeof sAtom, key: K) => {
   const setterAtom = useMemo(
     () =>
-      atom<null, Settings[K] | ((s: Settings[K]) => Settings[K])>(null, (get, set, value) => {
+      atom<null, [SettingSetter<K>], undefined>(null, (get, set, value) => {
         const s = { ...get(settingsAtom) };
         s[key] = typeof value === 'function' ? value(s[key]) : value;
         set(settingsAtom, s);
@@ -22,9 +22,9 @@ export const useSetSetting = <K extends keyof Settings>(
 };
 
 export const useSetting = <K extends keyof Settings>(
-  settingsAtom: WritableAtom<Settings, Settings>,
+  settingsAtom: typeof sAtom,
   key: K
-): [Settings[K], SetAtom<Settings[K] | ((s: Settings[K]) => Settings[K]), void>] => {
+): [Settings[K], ReturnType<typeof useSetSetting<K>>] => {
   const selector = useMemo(() => (s: Settings) => s[key], [key]);
   const setting = useAtomValue(selectAtom(settingsAtom, selector));
 
index 463fd352d14e2fa51e0ed93c36d716e92fc462f8..a6dc79668b37ba73a83838573566b0a6d7bfcc0e 100644 (file)
@@ -5,7 +5,7 @@ import { Membership } from '../../types/matrix/room';
 import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
 
 const baseRoomsAtom = atom<string[]>([]);
-export const allInvitesAtom = atom<string[], RoomsAction>(
+export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
   (get) => get(baseRoomsAtom),
   (get, set, action) => {
     if (action.type === 'INITIALIZE') {
@@ -22,7 +22,7 @@ export const allInvitesAtom = atom<string[], RoomsAction>(
 
 export const useBindAllInvitesAtom = (
   mx: MatrixClient,
-  allRooms: WritableAtom<string[], RoomsAction>
+  allRooms: WritableAtom<string[], [RoomsAction], undefined>
 ) => {
   useBindRoomsWithMembershipsAtom(
     mx,
index 4f5a61914802d294b63a37496544b25d38029d54..670e6db18b54468a9b73c6c9db88388b71585748 100644 (file)
@@ -12,7 +12,7 @@ export type ListAction<T> =
 
 export const createListAtom = <T>() => {
   const baseListAtom = atom<T[]>([]);
-  return atom<T[], ListAction<T>>(
+  return atom<T[], [ListAction<T>], undefined>(
     (get) => get(baseListAtom),
     (get, set, action) => {
       const items = get(baseListAtom);
index 96e2f0d03e00d03d53e6c4d9634e5b25f2c89d19..1fa8311f4a5a37c958affafb4c7985216b2169c8 100644 (file)
@@ -1,4 +1,4 @@
-import { atom, useSetAtom, WritableAtom } from 'jotai';
+import { atom, useSetAtom } from 'jotai';
 import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
 import { useEffect } from 'react';
 import { AccountDataEvent } from '../../types/matrix/accountData';
@@ -10,17 +10,14 @@ export type MDirectAction = {
 };
 
 const baseMDirectAtom = atom(new Set<string>());
-export const mDirectAtom = atom<Set<string>, MDirectAction>(
+export const mDirectAtom = atom<Set<string>, [MDirectAction], undefined>(
   (get) => get(baseMDirectAtom),
   (get, set, action) => {
     set(baseMDirectAtom, action.rooms);
   }
 );
 
-export const useBindMDirectAtom = (
-  mx: MatrixClient,
-  mDirect: WritableAtom<Set<string>, MDirectAction>
-) => {
+export const useBindMDirectAtom = (mx: MatrixClient, mDirect: typeof mDirectAtom) => {
   const setMDirect = useSetAtom(mDirect);
 
   useEffect(() => {
index d456f8533b7d345e2c0e01cddebbbad5c10259ac..f818450bbeb067f5a86bd8c5761cc70cf37196e9 100644 (file)
@@ -1,4 +1,4 @@
-import { atom, WritableAtom, useSetAtom } from 'jotai';
+import { atom, useSetAtom } from 'jotai';
 import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
 import { useEffect } from 'react';
 import { MuteChanges } from '../../types/matrix/room';
@@ -21,7 +21,7 @@ export const muteChangesAtom = atom<MuteChanges>({
 });
 
 const baseMutedRoomsAtom = atom(new Set<string>());
-export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
+export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
   (get) => get(baseMutedRoomsAtom),
   (get, set, action) => {
     const mutedRooms = new Set([...get(mutedRoomsAtom)]);
@@ -45,10 +45,7 @@ export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
   }
 );
 
-export const useBindMutedRoomsAtom = (
-  mx: MatrixClient,
-  mutedAtom: WritableAtom<Set<string>, MutedRoomsUpdate>
-) => {
+export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
   const setMuted = useSetAtom(mutedAtom);
 
   useEffect(() => {
index 7a793d8c313cdf2d0293bde3f7bc6e1f84347b75..e0fa170fb0a0df4a899c89ac7e852bf12f4983a4 100644 (file)
@@ -1,11 +1,11 @@
-import { atom, WritableAtom } from 'jotai';
+import { atom } from 'jotai';
 import { MatrixClient } from 'matrix-js-sdk';
 import { useMemo } from 'react';
 import { Membership } from '../../types/matrix/room';
 import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
 
 const baseRoomsAtom = atom<string[]>([]);
-export const allRoomsAtom = atom<string[], RoomsAction>(
+export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
   (get) => get(baseRoomsAtom),
   (get, set, action) => {
     if (action.type === 'INITIALIZE') {
@@ -19,10 +19,7 @@ export const allRoomsAtom = atom<string[], RoomsAction>(
     });
   }
 );
-export const useBindAllRoomsAtom = (
-  mx: MatrixClient,
-  allRooms: WritableAtom<string[], RoomsAction>
-) => {
+export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
   useBindRoomsWithMembershipsAtom(
     mx,
     allRooms,
index 374ddd5752087d385e765023bd0ba8288c4c74e8..1e2ef18c4d0f9dd455158841dc013b023ea3c797 100644 (file)
@@ -1,5 +1,5 @@
 import produce from 'immer';
-import { atom, useSetAtom, WritableAtom } from 'jotai';
+import { atom, useSetAtom } from 'jotai';
 import {
   ClientEvent,
   MatrixClient,
@@ -34,7 +34,7 @@ export type RoomToParentsAction =
     };
 
 const baseRoomToParents = atom<RoomToParents>(new Map());
-export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
+export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
   (get) => get(baseRoomToParents),
   (get, set, action) => {
     if (action.type === 'INITIALIZE') {
@@ -69,7 +69,7 @@ export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
 
 export const useBindRoomToParentsAtom = (
   mx: MatrixClient,
-  roomToParents: WritableAtom<RoomToParents, RoomToParentsAction>
+  roomToParents: typeof roomToParentsAtom
 ) => {
   const setRoomToParents = useSetAtom(roomToParents);
 
index 0c7b6bd697b379f434980e06ec198850f093dc82..ad388763ed70f3a973cacf9ed493b3eb88e75cc8 100644 (file)
@@ -1,5 +1,5 @@
 import produce from 'immer';
-import { atom, useSetAtom, PrimitiveAtom, WritableAtom, useAtomValue } from 'jotai';
+import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
 import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
 import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
 import { useEffect } from 'react';
@@ -82,7 +82,7 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
 };
 
 const baseRoomToUnread = atom<RoomToUnread>(new Map());
-export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
+export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
   (get) => get(baseRoomToUnread),
   (get, set, action) => {
     if (action.type === 'RESET') {
@@ -127,7 +127,7 @@ export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
 
 export const useBindRoomToUnreadAtom = (
   mx: MatrixClient,
-  unreadAtom: WritableAtom<RoomToUnread, RoomToUnreadAction>,
+  unreadAtom: typeof roomToUnreadAtom,
   muteChangesAtom: PrimitiveAtom<MuteChanges>
 ) => {
   const setUnreadAtom = useSetAtom(unreadAtom);
diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts
new file mode 100644 (file)
index 0000000..85bcd10
--- /dev/null
@@ -0,0 +1,129 @@
+import { atom } from 'jotai';
+import {
+  atomWithLocalStorage,
+  getLocalStorageItem,
+  setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+export type Session = {
+  baseUrl: string;
+  userId: string;
+  deviceId: string;
+  accessToken: string;
+  expiresInMs?: number;
+  refreshToken?: string;
+  fallbackSdkStores?: boolean;
+};
+
+export type Sessions = Session[];
+export type SessionStoreName = {
+  sync: string;
+  crypto: string;
+};
+
+/**
+ * Migration code for old session
+ */
+const FALLBACK_STORE_NAME: SessionStoreName = {
+  sync: 'web-sync-store',
+  crypto: 'crypto-store',
+} as const;
+
+const removeFallbackSession = () => {
+  localStorage.removeItem('cinny_hs_base_url');
+  localStorage.removeItem('cinny_user_id');
+  localStorage.removeItem('cinny_device_id');
+  localStorage.removeItem('cinny_access_token');
+};
+const getFallbackSession = (): Session | undefined => {
+  const baseUrl = localStorage.getItem('cinny_hs_base_url');
+  const userId = localStorage.getItem('cinny_user_id');
+  const deviceId = localStorage.getItem('cinny_device_id');
+  const accessToken = localStorage.getItem('cinny_access_token');
+
+  if (baseUrl && userId && deviceId && accessToken) {
+    const session: Session = {
+      baseUrl,
+      userId,
+      deviceId,
+      accessToken,
+      fallbackSdkStores: true,
+    };
+
+    return session;
+  }
+
+  return undefined;
+};
+/**
+ * End of migration code for old session
+ */
+
+export const getSessionStoreName = (session: Session): SessionStoreName => {
+  if (session.fallbackSdkStores) {
+    return FALLBACK_STORE_NAME;
+  }
+
+  return {
+    sync: `sync${session.userId}`,
+    crypto: `crypto${session.userId}`,
+  };
+};
+
+export const MATRIX_SESSIONS_KEY = 'matrixSessions';
+const baseSessionsAtom = atomWithLocalStorage<Sessions>(
+  MATRIX_SESSIONS_KEY,
+  (key) => {
+    const defaultSessions: Sessions = [];
+    const sessions = getLocalStorageItem(key, defaultSessions);
+
+    // Before multi account support session was stored
+    // as multiple item in local storage.
+    // So we need these migration code.
+    const fallbackSession = getFallbackSession();
+    if (fallbackSession) {
+      removeFallbackSession();
+      sessions.push(fallbackSession);
+      setLocalStorageItem(key, sessions);
+    }
+    return sessions;
+  },
+  (key, value) => {
+    setLocalStorageItem(key, value);
+  }
+);
+
+export type SessionsAction =
+  | {
+      type: 'PUT';
+      session: Session;
+    }
+  | {
+      type: 'DELETE';
+      session: Session;
+    };
+
+export const sessionsAtom = atom<Sessions, [SessionsAction], undefined>(
+  (get) => get(baseSessionsAtom),
+  (get, set, action) => {
+    if (action.type === 'PUT') {
+      const sessions = [...get(baseSessionsAtom)];
+      const sessionIndex = sessions.findIndex(
+        (session) => session.userId === action.session.userId
+      );
+      if (sessionIndex === -1) {
+        sessions.push(action.session);
+      } else {
+        sessions.splice(sessionIndex, 1, action.session);
+      }
+      set(baseSessionsAtom, sessions);
+      return;
+    }
+    if (action.type === 'DELETE') {
+      const sessions = get(baseSessionsAtom).filter(
+        (session) => session.userId !== action.session.userId
+      );
+      set(baseSessionsAtom, sessions);
+    }
+  }
+);
index 92d40ff8c671dbb713f1505333b80d48938322ff..061931ea82e20f53e2bdf266cda84705e45747fc 100644 (file)
@@ -64,7 +64,7 @@ export const setSettings = (settings: Settings) => {
 };
 
 const baseSettings = atom<Settings>(getSettings());
-export const settingsAtom = atom<Settings, Settings>(
+export const settingsAtom = atom<Settings, [Settings], undefined>(
   (get) => get(baseSettings),
   (get, set, update) => {
     set(baseSettings, update);
index 2f4ee92a49b014a622ff53e7ac892408d0ed9828..b9472d9f9151dc15cccf124349ad955dc3f23f5e 100644 (file)
@@ -14,7 +14,7 @@ type TabToRoomAction = {
 };
 
 const baseTabToRoom = atom<TabToRoom>(new Map());
-export const tabToRoomAtom = atom<TabToRoom, TabToRoomAction>(
+export const tabToRoomAtom = atom<TabToRoom, [TabToRoomAction], undefined>(
   (get) => get(baseTabToRoom),
   (get, set, action) => {
     if (action.type === 'PUT') {
index b87817d190390385fe0654d32b2b6404a8d624b0..c77c91be6b923c47d4d9d07cd2b735375a528b58 100644 (file)
@@ -23,7 +23,11 @@ export type IRoomIdToTypingMembersAction =
     };
 
 const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(new Map());
-export const roomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers, IRoomIdToTypingMembersAction>(
+export const roomIdToTypingMembersAtom = atom<
+  IRoomIdToTypingMembers,
+  [IRoomIdToTypingMembersAction],
+  undefined
+>(
   (get) => get(baseRoomIdToTypingMembersAtom),
   (get, set, action) => {
     const roomIdToTypingMembers = get(baseRoomIdToTypingMembersAtom);
index d92b93d3d1aff9608cd0b437ac1d2db10d92f2cd..13869afb25ed096ebb35b45e1f03fa5a52012a02 100644 (file)
@@ -57,7 +57,7 @@ export const createUploadAtom = (file: TUploadContent) => {
     file,
     status: UploadStatus.Idle,
   });
-  return atom<Upload, UploadAtomAction>(
+  return atom<Upload, [UploadAtomAction], undefined>(
     (get) => get(baseUploadAtom),
     (get, set, update) => {
       const uploadState = get(baseUploadAtom);
index 355c941106860a592e5bea60b51cbbb8306add9f..4c4caa5caeb36dae4dbeafb14463a2e82715320d 100644 (file)
@@ -15,7 +15,7 @@ export type RoomsAction =
 
 export const useBindRoomsWithMembershipsAtom = (
   mx: MatrixClient,
-  roomsAtom: WritableAtom<string[], RoomsAction>,
+  roomsAtom: WritableAtom<string[], [RoomsAction], undefined>,
   memberships: Membership[]
 ) => {
   const setRoomsAtom = useSetAtom(roomsAtom);
diff --git a/src/app/state/utils/atomWithLocalStorage.ts b/src/app/state/utils/atomWithLocalStorage.ts
new file mode 100644 (file)
index 0000000..f17d3a3
--- /dev/null
@@ -0,0 +1,51 @@
+import { atom } from 'jotai';
+
+export const getLocalStorageItem = <T>(key: string, defaultValue: T): T => {
+  const item = localStorage.getItem(key);
+  if (item === null) return defaultValue;
+  if (item === 'undefined') return undefined as T;
+  try {
+    return JSON.parse(item) as T;
+  } catch {
+    return defaultValue;
+  }
+};
+
+export const setLocalStorageItem = <T>(key: string, value: T) => {
+  localStorage.setItem(key, JSON.stringify(value));
+};
+
+export type GetLocalStorageItem<T> = (key: string) => T;
+export type SetLocalStorageItem<T> = (key: string, value: T) => void;
+
+export const atomWithLocalStorage = <T>(
+  key: string,
+  getItem: GetLocalStorageItem<T>,
+  setItem: SetLocalStorageItem<T>
+) => {
+  const value = getItem(key);
+
+  const baseAtom = atom<T>(value);
+
+  baseAtom.onMount = (setAtom) => {
+    const handleChange = (evt: StorageEvent) => {
+      if (evt.key !== key) return;
+      setAtom(getItem(key));
+    };
+
+    window.addEventListener('storage', handleChange);
+    return () => {
+      window.removeEventListener('storage', handleChange);
+    };
+  };
+
+  const localStorageAtom = atom<T, [T], undefined>(
+    (get) => get(baseAtom),
+    (get, set, newValue) => {
+      set(baseAtom, newValue);
+      setItem(key, newValue);
+    }
+  );
+
+  return localStorageAtom;
+};
diff --git a/src/app/styles/Patterns.css.ts b/src/app/styles/Patterns.css.ts
new file mode 100644 (file)
index 0000000..e455941
--- /dev/null
@@ -0,0 +1,9 @@
+import { style } from '@vanilla-extract/css';
+import { color, toRem } from 'folds';
+
+export const BackgroundDotPattern = style({
+  backgroundImage: `radial-gradient(${color.Background.ContainerActive} ${toRem(2)}, ${
+    color.Background.Container
+  } ${toRem(2)})`,
+  backgroundSize: `${toRem(40)} ${toRem(40)}`,
+});
index e007f222f98eb9e9bef5db4a186857b78fb11fa6..5cbe3806b4b413a96cc2c71a471b7e550f179136 100644 (file)
@@ -44,6 +44,17 @@ export const fulfilledPromiseSettledResult = <T>(prs: PromiseSettledResult<T>[])
     return values;
   }, []);
 
+export const promiseFulfilledResult = <T>(
+  settledResult: PromiseSettledResult<T>
+): T | undefined => {
+  if (settledResult.status === 'fulfilled') return settledResult.value;
+  return undefined;
+};
+export const promiseRejectedResult = <T>(settledResult: PromiseSettledResult<T>): any => {
+  if (settledResult.status === 'rejected') return settledResult.reason;
+  return undefined;
+};
+
 export const binarySearch = <T>(items: T[], match: (item: T) => -1 | 0 | 1): T | undefined => {
   const search = (start: number, end: number): T | undefined => {
     if (start > end) return undefined;
@@ -77,3 +88,10 @@ export const parseGeoUri = (location: string) => {
     longitude,
   };
 };
+
+const START_SLASHES_REG = /^\/+/g;
+const END_SLASHES_REG = /\/+$/g;
+export const trimLeadingSlash = (str: string): string => str.replace(START_SLASHES_REG, '');
+export const trimTrailingSlash = (str: string): string => str.replace(END_SLASHES_REG, '');
+
+export const trimSlash = (str: string): string => trimLeadingSlash(trimTrailingSlash(str));
diff --git a/src/app/utils/matrix-uia.ts b/src/app/utils/matrix-uia.ts
new file mode 100644 (file)
index 0000000..15c5799
--- /dev/null
@@ -0,0 +1,84 @@
+import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
+
+export const getSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] => {
+  const supportedUIAFlows = uiaFlows.filter((flow) =>
+    flow.stages.every((stage) => supportedStages.includes(stage))
+  );
+
+  return supportedUIAFlows;
+};
+
+export const getUIACompleted = (authData: IAuthData): string[] => {
+  const completed = authData.completed ?? [];
+  return completed;
+};
+
+export type UIAParams = Record<string, Record<string, unknown>>;
+export const getUIAParams = (authData: IAuthData): UIAParams => {
+  const params = authData.params ?? {};
+  return params;
+};
+
+export const getUIASession = (authData: IAuthData): string | undefined => {
+  const session = authData.session ?? undefined;
+  return session;
+};
+
+export const getUIAErrorCode = (authData: IAuthData): string | undefined => {
+  const errorCode =
+    'errcode' in authData && typeof authData.errcode === 'string' ? authData.errcode : undefined;
+
+  return errorCode;
+};
+
+export const getUIAError = (authData: IAuthData): string | undefined => {
+  const errorCode =
+    'error' in authData && typeof authData.error === 'string' ? authData.error : undefined;
+
+  return errorCode;
+};
+
+export const getUIAFlowForStages = (uiaFlows: UIAFlow[], stages: string[]): UIAFlow | undefined => {
+  const matchedFlows = uiaFlows
+    .filter((flow) => {
+      if (flow.stages.length < stages.length) return false;
+      if (flow.stages.length > stages.length) {
+        // As a valid flow can also have m.login.dummy type,
+        // we will pick one extra length flow only if it has dummy
+        if (flow.stages.length > stages.length + 1) return false;
+        if (stages.includes(AuthType.Dummy)) return false;
+        if (flow.stages.includes(AuthType.Dummy)) return true;
+        return false;
+      }
+      return true;
+    })
+    .filter((flow) => stages.every((stage) => flow.stages.includes(stage)));
+
+  if (matchedFlows.length === 0) return undefined;
+
+  matchedFlows.sort((a, b) => a.stages.length - b.stages.length);
+  return matchedFlows[0];
+};
+
+export const hasStageInFlows = (uiaFlows: UIAFlow[], stage: string) =>
+  uiaFlows.some((flow) => flow.stages.includes(stage));
+
+export const requiredStageInFlows = (uiaFlows: UIAFlow[], stage: string) =>
+  uiaFlows.every((flow) => flow.stages.includes(stage));
+
+export const getLoginTermUrl = (params: UIAParams): string | undefined => {
+  const terms = params[AuthType.Terms];
+  if (terms && 'policies' in terms && typeof terms.policies === 'object') {
+    if (terms.policies === null) return undefined;
+    if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') {
+      if (terms.policies.privacy_policy === null) return undefined;
+      const langToPolicy = terms.policies.privacy_policy as Record<string, any>;
+      const url = langToPolicy.en?.url;
+      if (typeof url === 'string') return url;
+
+      const firstKey = Object.keys(langToPolicy)[0];
+      return langToPolicy[firstKey]?.url;
+    }
+  }
+  return undefined;
+};
index 5188bef0fb6174aec4ea6a9f642592f6885dc86a..281f12006e3fec2d7c20deb187b129d6c2df9d6d 100644 (file)
@@ -1,5 +1,8 @@
 export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?<![.,:;!/?()[\\]\\s]+)`;
 
+export const EMAIL_REGEX =
+  /^(([^<>()[\]\\.,;:\s@\\"]+(\.[^<>()[\]\\.,;:\s@\\"]+)*)|(\\".+\\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
 export const URL_NEG_LB = '(?<!(https?|ftp|mailto|magnet):\\/\\/\\S*)';
 
 // https://en.wikipedia.org/wiki/Variation_Selectors_(Unicode_block)
index f9be13bc0ecc95f01225c5fc32ff9d1e1bdfdaaf..f04306b8feb7edea4d0dc21d8be6049090c1e47b 100644 (file)
@@ -98,7 +98,7 @@ async function completeRegisterStage(
 }
 
 export {
-  createTemporaryClient, login, verifyEmail,
+  updateLocalStore, createTemporaryClient, login, verifyEmail,
   loginWithToken, startSsoLogin,
   completeRegisterStage,
 };
index 9b8d1d82ee0982403ff4e4e69f08be87fb5c6ef7..211cf114250e1f0c410a41cec4990d5b3b3a4f28 100644 (file)
@@ -3,7 +3,7 @@ import * as sdk from 'matrix-js-sdk';
 import Olm from '@matrix-org/olm';
 // import { logger } from 'matrix-js-sdk/lib/logger';
 
-import { secret } from './state/auth';
+import { getSecret } from './state/auth';
 import RoomList from './state/RoomList';
 import AccountData from './state/AccountData';
 import RoomsInput from './state/RoomsInput';
@@ -40,6 +40,7 @@ class InitMatrix extends EventEmitter {
       dbName: 'web-sync-store',
     });
     await indexedDBStore.startup();
+    const secret = getSecret();
 
     this.matrixClient = sdk.createClient({
       baseUrl: secret.baseUrl,
diff --git a/src/client/state/auth.js b/src/client/state/auth.js
deleted file mode 100644 (file)
index fbc23f6..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import cons from './cons';
-
-function getSecret(key) {
-  return localStorage.getItem(key);
-}
-
-const isAuthenticated = () => getSecret(cons.secretKey.ACCESS_TOKEN) !== null;
-
-const secret = {
-  accessToken: getSecret(cons.secretKey.ACCESS_TOKEN),
-  deviceId: getSecret(cons.secretKey.DEVICE_ID),
-  userId: getSecret(cons.secretKey.USER_ID),
-  baseUrl: getSecret(cons.secretKey.BASE_URL),
-};
-
-export {
-  isAuthenticated,
-  secret,
-};
diff --git a/src/client/state/auth.ts b/src/client/state/auth.ts
new file mode 100644 (file)
index 0000000..9536a92
--- /dev/null
@@ -0,0 +1,12 @@
+import cons from './cons';
+
+const isAuthenticated = () => localStorage.getItem(cons.secretKey.ACCESS_TOKEN) !== null;
+
+const getSecret = () => ({
+  accessToken: localStorage.getItem(cons.secretKey.ACCESS_TOKEN),
+  deviceId: localStorage.getItem(cons.secretKey.DEVICE_ID),
+  userId: localStorage.getItem(cons.secretKey.USER_ID),
+  baseUrl: localStorage.getItem(cons.secretKey.BASE_URL),
+});
+
+export { isAuthenticated, getSecret };
index 5593b6e7be84a7fb8512c28b679255c1c361b070..72acc587d76be3bf8cbe833b17ee41866a52efd9 100644 (file)
@@ -1,3 +1,5 @@
+/// <reference types="vite/client" />
+
 declare module 'browser-encrypt-attachment' {
   export interface EncryptedAttachmentInfo {
     v: string;
@@ -26,3 +28,8 @@ declare module 'browser-encrypt-attachment' {
     info: EncryptedAttachmentInfo
   ): Promise<ArrayBuffer>;
 }
+
+declare module '*.svg' {
+  const content: string;
+  export default content;
+}
diff --git a/src/index.jsx b/src/index.jsx
deleted file mode 100644 (file)
index a8a7657..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-/* eslint-disable import/first */
-import React from 'react';
-import ReactDom from 'react-dom';
-import { enableMapSet } from 'immer';
-import '@fontsource/inter/variable.css';
-import 'folds/dist/style.css';
-import { configClass, varsClass } from 'folds';
-
-enableMapSet();
-
-import './index.scss';
-
-import settings from './client/state/settings';
-
-import App from './app/pages/App';
-
-document.body.classList.add(configClass, varsClass);
-
-settings.applyTheme();
-
-ReactDom.render(<App />, document.getElementById('root'));
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644 (file)
index 0000000..1d86420
--- /dev/null
@@ -0,0 +1,32 @@
+/* eslint-disable import/first */
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { enableMapSet } from 'immer';
+import '@fontsource/inter/variable.css';
+import 'folds/dist/style.css';
+import { configClass, varsClass } from 'folds';
+
+enableMapSet();
+
+import './index.scss';
+
+import settings from './client/state/settings';
+
+import App from './app/pages/App';
+
+document.body.classList.add(configClass, varsClass);
+settings.applyTheme();
+
+const mountApp = () => {
+  const rootContainer = document.getElementById('root');
+
+  if (rootContainer === null) {
+    console.error('Root container element not found!');
+    return;
+  }
+
+  const root = createRoot(rootContainer);
+  root.render(<App />);
+};
+
+mountApp();
diff --git a/src/types/utils.ts b/src/types/utils.ts
new file mode 100644 (file)
index 0000000..353ace6
--- /dev/null
@@ -0,0 +1,3 @@
+export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & {
+  [Property in Key]-?: Type[Property];
+};
index d2f1e8a1081bae64c5844645b25699e85732e808..60ff185386886358e82a4022aebf9f638ca29fde 100644 (file)
@@ -8,6 +8,7 @@
     "strict": true,
     "esModuleInterop": true,
     "moduleResolution": "Node",
+    "resolveJsonModule": true,
     "outDir": "dist",
     "skipLibCheck": true
   },
index 8357339842d3b1e0ec74825cad1c767fd26bab41..20c7765c5881d0aff0b0a9561f8b3629df0ef401 100644 (file)
@@ -5,7 +5,8 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
 import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
 import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
 import inject from '@rollup/plugin-inject';
-import { svgLoader } from './viteSvgLoader';
+import { svgLoader } from './viteSvgLoader'
+import buildConfig from "./build.config"
 
 const copyFiles = {
   targets: [
@@ -18,7 +19,7 @@ const copyFiles = {
       dest: '',
     },
     {
-      src: '_redirects',
+      src: 'netlify.toml',
       dest: '',
     },
     {
@@ -39,7 +40,7 @@ const copyFiles = {
 export default defineConfig({
   appType: 'spa',
   publicDir: false,
-  base: "",
+  base: buildConfig.base,
   server: {
     port: 8080,
     host: true,