1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-22 10:22:30 +01:00

feat: implement full login flow

This commit is contained in:
Puyodead1 2023-04-23 22:42:40 -04:00
parent 5557d2c74c
commit 2bdd6e4ffd
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
8 changed files with 553 additions and 57 deletions

View File

@ -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",

View File

@ -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:

View File

@ -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={<AuthenticationGuard component={RootPage} />}
/>
<Route
index
path="/app"
element={<AuthenticationGuard component={RootPage} />}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegistrationPage />} />
<Route path="*" element={<NotFoundPage />} />

View File

@ -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<HCaptchaLib>;
onLoad?: () => void;
onChalExpired?: () => void;
onError?: (e: any) => void;
onExpire?: () => void;
onVerify?: (token: string) => void;
}
function HCaptcha(props: Props) {
return (
<Wrapper>
<AuthBox>
<HeaderContainer>
<Header>Welcome Back!</Header>
<SubHeader>Beep boop. Boop beep?</SubHeader>
<HCaptchaLib
sitekey={props.sitekey}
ref={props.captchaRef}
theme="dark" // TODO: make this dynamically change based on theme
onVerify={props.onVerify}
onLoad={props.onLoad}
onChalExpired={props.onChalExpired}
onError={props.onError}
onExpire={props.onExpire}
/>
</HeaderContainer>
</AuthBox>
</Wrapper>
);
}
export default HCaptcha;

303
src/components/MFA.tsx Normal file
View File

@ -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<FormValues>();
const onSubmit = handleSubmit((data) => {
setLoading(true);
app.rest
.post<IAPITOTPRequest, IAPILoginResponseSuccess>(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 (
<Wrapper>
<AuthBox>
<HeaderContainer>
<Header>Two-factor authentication</Header>
<SubHeader>
You can use a backup code or your two-factor
authentication mobile app.
</SubHeader>
<FormContainer onSubmit={onSubmit}>
<InputContainer
marginBottom={true}
style={{ marginTop: 0 }}
>
<LabelWrapper error={!!errors.code}>
<InputLabel>
Enter Spacebar Auth/Backup Code
</InputLabel>
{errors.code && (
<InputErrorText>
<>
<Divider>-</Divider>
{errors.code.message}
</>
</InputErrorText>
)}
</LabelWrapper>
<InputWrapper>
<Input
type="text"
autoFocus
{...register("code", { required: true })}
error={!!errors.code}
disabled={loading}
placeholder="6-digit authentication code/8-digit backup code"
/>
</InputWrapper>
</InputContainer>
<LoginButton
variant="primary"
type="submit"
disabled={loading}
>
Log In
</LoginButton>
{/* <Link
onClick={() => {
window.open(
"https://youtu.be/dQw4w9WgXcQ",
"_blank",
);
}}
type="button"
>
Recieve auth code from SMS
</Link> */}
<Link
onClick={() => {
window.open(
"https://youtu.be/dQw4w9WgXcQ",
"_blank",
);
}}
type="button"
>
Go Back to Login
</Link>
</FormContainer>
</HeaderContainer>
</AuthBox>
</Wrapper>
);
}
export default MFA;

View File

@ -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<string>();
const [mfaData, setMfaData] =
React.useState<IAPILoginResponseMFARequired>();
const captchaRef = React.useRef<HCaptchaLib>(null);
const {
@ -171,32 +183,94 @@ function LoginPage() {
formState: { errors },
setError,
setValue,
} = useForm<LoginFormValues>();
} = useForm<FormValues>();
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<IAPILoginRequest, IAPILoginResponse>(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 (
<HCaptcha
captchaRef={captchaRef}
sitekey={captchaSiteKey}
onVerify={onCaptchaVerify}
/>
);
}
if (mfaData) {
return <MFA {...mfaData} />;
}
return (
<Wrapper>
<LoginBox>
<AuthBox>
<HeaderContainer>
<Header>Welcome Back!</Header>
<SubHeader>We're so excited to see you again!</SubHeader>
@ -272,25 +360,7 @@ function LoginPage() {
>
Forgot your password?
</PasswordResetLink>
{captchaSiteKey && (
<HCaptchaLib
sitekey={captchaSiteKey}
onVerify={onCaptchaVerify}
ref={captchaRef}
onChalExpired={() => {
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?
}}
/>
)}
<LoginButton
variant="primary"
type="submit"
@ -313,7 +383,7 @@ function LoginPage() {
</RegisterLink>
</RegisterContainer>
</FormContainer>
</LoginBox>
</AuthBox>
</Wrapper>
);
}

View File

@ -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<RegisterFormValues>();
} = useForm<FormValues>();
const dobRegister = register("date_of_birth", {
required: true,
@ -183,7 +183,7 @@ function RegistrationPage() {
return (
<Wrapper>
<LoginBox>
<AuthBox>
<HeaderContainer>
<Header>Create an account</Header>
{/* <SubHeader>We're so excited to see you again!</SubHeader> */}
@ -313,7 +313,7 @@ function RegistrationPage() {
Already have an account?
</LoginLink>
</FormContainer>
</LoginBox>
</AuthBox>
</Wrapper>
);
}

View File

@ -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;
}