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 (
+
+
+
+
+ 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 (
-
+
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 (
-
+
{/* 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;
+}