diff --git a/package.json b/package.json index 705fdd7..16fbc19 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@hcaptcha/react-hcaptcha": "^1.8.0", "@mattjennings/react-modal-stack": "^1.0.4", "@mui/material": "^5.11.13", - "@spacebarchat/spacebar-api-types": "^0.37.44", + "@spacebarchat/spacebar-api-types": "^0.37.46", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a816e4a..261cb56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ dependencies: specifier: ^5.11.13 version: 5.11.13(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0) '@spacebarchat/spacebar-api-types': - specifier: ^0.37.44 - version: 0.37.44 + specifier: ^0.37.46 + version: 0.37.46 '@testing-library/jest-dom': specifier: ^5.16.5 version: 5.16.5 @@ -2899,8 +2899,8 @@ packages: dependencies: '@sinonjs/commons': 1.8.6 - /@spacebarchat/spacebar-api-types@0.37.44: - resolution: {integrity: sha512-Kudf7A/vRCvpgi+JW3xkh+jAHHDYYHUdhN4gFBlJtNT+BNOVxuFIVpVWMuUdgNvQLaK41MmPMbLrDvTLUory2g==} + /@spacebarchat/spacebar-api-types@0.37.46: + resolution: {integrity: sha512-fuNZIMBWKezpkyvZHvAfQ1boCfEoncP+odg+bqr824WiGldiiAqhBuye1v/TMYkAm6A4R2p9/ifkCqmFQgemKA==} dev: false /@surma/rollup-plugin-off-main-thread@2.2.3: diff --git a/src/App.tsx b/src/App.tsx index ab0dec7..10ec409 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,7 +27,6 @@ function App() { reaction( () => app.token, (value) => { - console.log(value); if (value) { app.rest.setToken(value); if (app.gateway.readyState === WebSocket.CLOSED) { @@ -60,6 +59,11 @@ function App() { path="/" element={} /> + } + /> } /> } /> } /> diff --git a/src/components/HCaptcha.tsx b/src/components/HCaptcha.tsx new file mode 100644 index 0000000..33c304a --- /dev/null +++ b/src/components/HCaptcha.tsx @@ -0,0 +1,87 @@ +import HCaptchaLib from "@hcaptcha/react-hcaptcha"; +import React from "react"; +import styled from "styled-components"; +import Container from "./Container"; + +export const Wrapper = styled(Container)` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: var(--secondary); +`; + +export const AuthBox = styled(Container)` + background-color: var(--primary-alt); + padding: 32px; + font-size: 18px; + color: var(--text-muted); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + + @media (max-width: 480px) { + width: 100%; + height: 100%; + } + + @media (min-width: 480px) { + width: 480px; + border-radius: 18px; + } +`; + +export const HeaderContainer = styled.div` + width: 100%; +`; + +export const Header = styled.h1` + font-weight: 600; + margin-bottom: 8px; + font-size: 24px; + color: var(--text); +`; + +export const SubHeader = styled.h2` + color: var(--text-muted); + font-weight: 400; + font-size: 16px; + margin-bottom: 40px; +`; + +interface Props { + sitekey: string; + captchaRef: React.RefObject; + onLoad?: () => void; + onChalExpired?: () => void; + onError?: (e: any) => void; + onExpire?: () => void; + onVerify?: (token: string) => void; +} + +function HCaptcha(props: Props) { + return ( + + + +
Welcome Back!
+ Beep boop. Boop beep? + + +
+
+
+ ); +} + +export default HCaptcha; diff --git a/src/components/MFA.tsx b/src/components/MFA.tsx new file mode 100644 index 0000000..d2c37a5 --- /dev/null +++ b/src/components/MFA.tsx @@ -0,0 +1,303 @@ +import { Routes } from "@spacebarchat/spacebar-api-types/v9"; +import React from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; +import { useAppStore } from "../stores/AppStore"; +import { + IAPIError, + IAPILoginResponseMFARequired, + IAPILoginResponseSuccess, + IAPITOTPRequest, +} from "../utils/interfaces/api"; +import { messageFromFieldError } from "../utils/messageFromFieldError"; +import Button from "./Button"; +import Container from "./Container"; + +export const Wrapper = styled(Container)` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: var(--secondary); +`; + +export const AuthBox = styled(Container)` + background-color: var(--primary-alt); + padding: 32px; + font-size: 18px; + color: var(--text-muted); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + + @media (max-width: 480px) { + width: 100%; + height: 100%; + } + + @media (min-width: 480px) { + width: 480px; + border-radius: 18px; + } +`; + +export const HeaderContainer = styled.div` + width: 100%; +`; + +export const Header = styled.h1` + font-weight: 600; + margin-bottom: 8px; + font-size: 24px; + color: var(--text); +`; + +export const SubHeader = styled.h2` + color: var(--text-muted); + font-weight: 400; + font-size: 16px; + margin-bottom: 40px; +`; + +export const FormContainer = styled.form` + width: 100%; +`; + +export const InputContainer = styled.h1<{ marginBottom: boolean }>` + margin-bottom: ${(props) => (props.marginBottom ? "20px" : "0")}; + display: flex; + flex-direction: column; + align-items: flex-start; +`; + +export const LabelWrapper = styled.div<{ error?: boolean }>` + display: flex; + flex-direction: row; + margin-bottom: 8px; + color: ${(props) => (props.error ? "var(--error)" : "#b1b5bc")}; +`; + +export const InputErrorText = styled.label` + font-size: 14px; + font-weight: 400; + font-style: italic; +`; + +export const InputLabel = styled.label` + font-size: 14px; + font-weight: 700; +`; + +export const InputWrapper = styled.div` + width: 100%; + display: flex; +`; + +export const Input = styled.input<{ error?: boolean }>` + outline: none; + background: var(--secondary); + padding: 10px; + font-size: 16px; + flex: 1; + border-radius: 12px; + color: var(--text); + margin: 0; + border: none; + aria-invalid: ${(props) => (props.error ? "true" : "false")}; +`; + +export const Link = styled.button` + margin-bottom: 20px; + margin-top: 4px; + padding: 2px 0; + font-size: 14px; + display: flex; + color: var(--text-link); + background: none; + border: none; + + &:hover { + text-decoration: underline; + cursor: pointer; + } +`; + +export const LoginButton = styled(Button)` + margin-bottom: 8px; + width: 100%; + min-width: 130px; + min-height: 44px; +`; + +export const RegisterContainer = styled.div` + margin-top: 4px; + text-align: initial; +`; + +export const RegisterLabel = styled.label` + font-size: 14px; +`; + +export const RegisterLink = styled.button` + font-size: 14px; + background: none; + border: none; + color: var(--text-link); + + @media (max-width: 480px) { + display: inline-block; + } + + &:hover { + text-decoration: underline; + cursor: pointer; + } +`; + +export const Divider = styled.span` + padding: 0 4px; +`; + +type FormValues = { + code: string; +}; + +function MFA(props: IAPILoginResponseMFARequired) { + const app = useAppStore(); + const navigate = useNavigate(); + const [loading, setLoading] = React.useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + setError, + } = useForm(); + + const onSubmit = handleSubmit((data) => { + setLoading(true); + + app.rest + .post(Routes.mfaTotp(), { + ...data, + ticket: props.ticket, + }) + .then((r) => { + app.setToken(r.token); + navigate("/app", { replace: true }); + }) + .catch((r: IAPIError) => { + if ("message" in r) { + // error + if (r.errors) { + const t = messageFromFieldError(r.errors); + if (t) { + setError(t.field as keyof FormValues, { + type: "manual", + message: t.error, + }); + } else { + setError("code", { + type: "manual", + message: r.message, + }); + } + } else { + setError("code", { + type: "manual", + message: r.message, + }); + } + } else { + // unknown error + console.error(r); + setError("code", { + type: "manual", + message: "Unknown Error", + }); + } + }) + .finally(() => setLoading(false)); + }); + + return ( + + + +
Two-factor authentication
+ + You can use a backup code or your two-factor + authentication mobile app. + + + + + + + Enter Spacebar Auth/Backup Code + + {errors.code && ( + + <> + - + {errors.code.message} + + + )} + + + + + + + + Log In + + + {/* { + window.open( + "https://youtu.be/dQw4w9WgXcQ", + "_blank", + ); + }} + type="button" + > + Recieve auth code from SMS + */} + + { + window.open( + "https://youtu.be/dQw4w9WgXcQ", + "_blank", + ); + }} + type="button" + > + Go Back to Login + + +
+
+
+ ); +} + +export default MFA; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 8668d72..1c8bec9 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,11 +1,21 @@ import HCaptchaLib from "@hcaptcha/react-hcaptcha"; +import { Routes } from "@spacebarchat/spacebar-api-types/v9"; import React from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import styled from "styled-components"; import Button from "../components/Button"; import Container from "../components/Container"; +import HCaptcha from "../components/HCaptcha"; +import MFA from "../components/MFA"; import { useAppStore } from "../stores/AppStore"; +import { + IAPILoginRequest, + IAPILoginResponse, + IAPILoginResponseError, + IAPILoginResponseMFARequired, +} from "../utils/interfaces/api"; +import { messageFromFieldError } from "../utils/messageFromFieldError"; export const Wrapper = styled(Container)` display: flex; @@ -15,7 +25,7 @@ export const Wrapper = styled(Container)` background-color: var(--secondary); `; -export const LoginBox = styled(Container)` +export const AuthBox = styled(Container)` background-color: var(--primary-alt); padding: 32px; font-size: 18px; @@ -152,7 +162,7 @@ export const Divider = styled.span` padding: 0 4px; `; -type LoginFormValues = { +type FormValues = { login: string; password: string; captcha_key?: string; @@ -163,6 +173,8 @@ function LoginPage() { const navigate = useNavigate(); const [loading, setLoading] = React.useState(false); const [captchaSiteKey, setCaptchaSiteKey] = React.useState(); + const [mfaData, setMfaData] = + React.useState(); const captchaRef = React.useRef(null); const { @@ -171,32 +183,94 @@ function LoginPage() { formState: { errors }, setError, setValue, - } = useForm(); + } = useForm(); const onSubmit = handleSubmit((data) => { setLoading(true); - // app.api - // .login(data) - // .catch((e) => { - // if (e instanceof MFAError) { - // console.log("MFA Required", e); - // } else if (e instanceof CaptchaError) { - // // TODO: other captcha services - // setCaptchaSiteKey(e.captcha_sitekey); - // captchaRef.current?.execute(); - // } else if (e instanceof APIError) { - // console.log("APIError", e.message, e.code, e.fieldErrors); - // e.fieldErrors.forEach((fieldError) => { - // setError(fieldError.field as any, { - // type: "manual", - // message: fieldError.error, - // }); - // }); - // } else { - // console.log("General Error", e); - // } - // }) - // .finally(() => setLoading(false)); + setCaptchaSiteKey(undefined); + setMfaData(undefined); + + app.rest + .post(Routes.login(), { + ...data, + undelete: false, + }) + .then((r) => { + if ("token" in r && "settings" in r) { + // success + app.setToken(r.token); + return; + } else if ("ticket" in r) { + // mfa + console.log("MFA Required", r); + setMfaData(r); + return; + } else { + // unknown error + console.error(r); + setError("login", { + type: "manual", + message: "Unknown Error", + }); + } + }) + .catch((r: IAPILoginResponseError) => { + if ("captcha_key" in r) { + // catcha required + if (r.captcha_key[0] !== "captcha-required") { + // some kind of captcha error + setError("login", { + type: "manual", + message: `Captcha Error: ${r.captcha_key[0]}`, + }); + } else if (r.captcha_service !== "hcaptcha") { + // recaptcha or something else + setError("login", { + type: "manual", + message: `Unsupported captcha service: ${r.captcha_service}`, + }); + } else { + // hcaptcha + setCaptchaSiteKey(r.captcha_sitekey); + captchaRef.current?.execute(); + return; + } + + captchaRef.current?.resetCaptcha(); + } else if ("message" in r) { + // error + if (r.errors) { + const t = messageFromFieldError(r.errors); + if (t) { + setError(t.field as keyof FormValues, { + type: "manual", + message: t.error, + }); + } else { + setError("login", { + type: "manual", + message: r.message, + }); + } + } else { + setError("login", { + type: "manual", + message: r.message, + }); + } + + captchaRef.current?.resetCaptcha(); + } else { + // unknown error + console.error(r); + setError("login", { + type: "manual", + message: "Unknown Error", + }); + captchaRef.current?.resetCaptcha(); + } + }) + .finally(() => setLoading(false)); }); const onCaptchaVerify = (token: string) => { @@ -204,9 +278,23 @@ function LoginPage() { onSubmit(); }; + if (captchaSiteKey) { + return ( + + ); + } + + if (mfaData) { + return ; + } + return ( - +
Welcome Back!
We're so excited to see you again! @@ -272,25 +360,7 @@ function LoginPage() { > Forgot your password? - {captchaSiteKey && ( - { - console.log("Captcha challenge Expired"); - // TODO: red outline? - }} - onError={(e) => { - console.log("Captcha Error", e); - // TODO: red outline? - }} - onExpire={() => { - console.log("Captcha Expired"); - // TODO: red outline? - }} - /> - )} + -
+
); } diff --git a/src/pages/RegistrationPage.tsx b/src/pages/RegistrationPage.tsx index 7468ffb..219c806 100644 --- a/src/pages/RegistrationPage.tsx +++ b/src/pages/RegistrationPage.tsx @@ -14,7 +14,7 @@ const Wrapper = styled(Container)` background-color: var(--secondary); `; -const LoginBox = styled(Container)` +const AuthBox = styled(Container)` background-color: var(--primary-alt); padding: 32px; font-size: 18px; @@ -129,7 +129,7 @@ const Divider = styled.span` padding: 0 4px; `; -type RegisterFormValues = { +type FormValues = { email: string; username: string; password: string; @@ -147,7 +147,7 @@ function RegistrationPage() { formState: { errors }, setError, clearErrors, - } = useForm(); + } = useForm(); const dobRegister = register("date_of_birth", { required: true, @@ -183,7 +183,7 @@ function RegistrationPage() { return ( - +
Create an account
{/* We're so excited to see you again! */} @@ -313,7 +313,7 @@ function RegistrationPage() { Already have an account? -
+
); } diff --git a/src/utils/messageFromFieldError.ts b/src/utils/messageFromFieldError.ts new file mode 100644 index 0000000..b937fd0 --- /dev/null +++ b/src/utils/messageFromFieldError.ts @@ -0,0 +1,32 @@ +export function messageFromFieldError( + e: + | { + [key: string]: { + _errors: { + code: string; + message: string; + }[]; + }; + } + | { + [key: string]: { + code: string; + message: string; + }[]; + }, + prevKey?: string, +): { field: string | undefined; error: string } | null { + for (const key in e) { + const obj = e[key]; + if (obj) { + if (key === "_errors" && Array.isArray(obj)) { + const r = obj[0]; + return r ? { field: prevKey, error: r.message } : null; + } + if (typeof obj === "object") { + return messageFromFieldError(obj as any, key); + } + } + } + return null; +}