transplant frontend auth from writer app

This commit is contained in:
2020-10-11 21:43:42 +03:00
committed by Stepan Usatiuk
parent e7cde95d90
commit 60b0ce70bd
43 changed files with 3559 additions and 2314 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,10 @@
}
},
"dependencies": {
"@blueprintjs/core": "^3.33.0",
"@typescript-eslint/eslint-plugin": "^4.4.0",
"@typescript-eslint/parser": "^4.4.0",
"autoprefixer": "^9.8.6",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.5",
"eslint": "^7.10.0",
@@ -27,21 +29,36 @@
"eslint-plugin-react": "^7.21.3",
"eslint-plugin-react-hooks": "^4.1.2",
"jest": "^26.5.2",
"parcel": "^2.0.0-nightly.419",
"parcel": "^1.12.4",
"postcss": "^8.1.1",
"prettier": "^2.1.2",
"prettier-eslint": "^11.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-spring": "^8.0.27",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.8",
"redux-persist": "^6.0.0",
"redux-saga": "^1.1.3",
"sass": "^1.27.0",
"ts-jest": "^26.4.1",
"typescript": "^4.0.3"
},
"devDependencies": {
"@types/autoprefixer": "^9.7.2",
"@types/enzyme": "^3.10.7",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/eslint": "^7.2.4",
"@types/eslint-plugin-prettier": "^3.1.0",
"@types/prettier": "^2.1.1",
"@types/react": "^16.9.51",
"@types/react-dom": "^16.9.8"
"@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9",
"@types/react-router": "^5.1.8",
"@types/react-router-dom": "^5.1.6",
"@types/sass": "^1.16.0"
}
}

View File

View File

@@ -0,0 +1,76 @@
import { Button, Card, FormGroup, H2, InputGroup } from "@blueprintjs/core";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { IAppState } from "~redux/reducers";
import { userPassChange } from "~redux/user/actions";
export interface IAccountComponentProps {
username: string | undefined;
changePass: (password: string) => void;
}
export function AccountComponent(props: IAccountComponentProps) {
const [pass, setPass] = React.useState("");
return (
<Card className="AuthForm" elevation={2}>
<form
onSubmit={(e: React.FormEvent<any>) => {
e.preventDefault();
if (pass.trim()) {
props.changePass(pass);
}
}}
>
<div className="header">
<H2>Account</H2>
</div>
<FormGroup label="Username">
<InputGroup
name="username"
leftIcon="person"
disabled={true}
value={props.username}
/>
</FormGroup>
<FormGroup label="Password">
<InputGroup
name="password"
type="password"
leftIcon="key"
value={pass}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setPass(e.currentTarget.value)
}
/>
</FormGroup>
<div className="footer">
<Button
className="submit"
intent="primary"
icon="floppy-disk"
type="submit"
>
Save
</Button>
</div>
</form>
</Card>
);
}
function mapStateToProps(state: IAppState) {
return { username: state?.user?.user?.username };
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
changePass: (password: string) => dispatch(userPassChange(password)),
};
}
export const Account = connect(
mapStateToProps,
mapDispatchToProps,
)(AccountComponent);

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import { shallow } from "enzyme";
import { AccountComponent } from "../Account";
describe("<Account />", () => {
it("should not crash", () => {
const wrapper = shallow(
<AccountComponent
username="user"
changePass={(pass: string) => {
return null;
}}
/>,
);
});
});

32
frontend/src/App.scss Normal file
View File

@@ -0,0 +1,32 @@
@import "~@blueprintjs/core/lib/scss/variables";
.animationWrapper {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow-x: hidden;
}
.loadingWrapper {
position: fixed;
width: 100%;
height: 75%;
display: flex;
justify-content: center;
align-content: center;
overflow: hidden;
}
.bp3-navbar {
.bp3-button {
margin-right: 0.25rem;
}
}
body {
font-family: "PT Sans", "emoji", sans-serif;
background: $light-gray5;
}

42
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { connect } from "react-redux";
import {
Redirect,
Route,
RouteComponentProps,
Switch,
withRouter,
} from "react-router";
import { AuthScreen } from "~Auth/AuthScreen";
import { Home } from "~Home/Home";
import { Landing } from "~Landing/Landing";
import { IAppState } from "~redux/reducers";
interface IAppComponentProps extends RouteComponentProps {
loggedIn: boolean;
}
export function AppComponent(props: IAppComponentProps) {
const { loggedIn } = props;
return loggedIn ? (
<Switch>
<Route path="/signup" component={AuthScreen} />,
<Route path="/login" component={AuthScreen} />,
<Route path="/docs/:id" component={Home} />,
<Route path="/" component={Home} />,
</Switch>
) : (
<Switch>
<Route path="/signup" component={AuthScreen} />
<Route path="/login" component={AuthScreen} />
<Route exact={true} path="/" component={Landing} />
<Route path="/" component={() => <Redirect to="/login" />} />
</Switch>
);
}
function mapStateToProps(state: IAppState) {
return { loggedIn: state.auth.jwt !== null };
}
export const App = withRouter(connect(mapStateToProps)(AppComponent));

View File

@@ -0,0 +1,22 @@
import { Position, Toaster } from "@blueprintjs/core";
export const AppToaster = Toaster.create({
className: "recipe-toaster",
position: Position.TOP,
});
export function showPasswordSavedToast() {
AppToaster.show({
message: "Password saved!",
intent: "success",
timeout: 2000,
});
}
export function showPasswordNotSavedToast(error: string) {
AppToaster.show({
message: "Password not saved! " + error,
intent: "danger",
timeout: 2000,
});
}

View File

@@ -0,0 +1,41 @@
@import "~@blueprintjs/core/lib/scss/variables";
.AuthForm {
margin: auto;
margin-top: 10rem;
width: 20rem;
left: 0;
right: 0;
position: absolute;
form {
display: flex;
flex-direction: column;
h2 {
margin-bottom: 1rem;
}
.header,
.footer {
display: flex;
flex-direction: row;
align-items: baseline;
button.change {
margin-left: auto;
}
}
.footer {
#error {
height: 1rem;
color: $pt-intent-danger;
}
button.submit {
margin-left: auto;
}
}
}
}

View File

@@ -0,0 +1,69 @@
import * as React from "react";
import { connect } from "react-redux";
import {
Redirect,
Route,
RouteComponentProps,
Switch,
withRouter,
} from "react-router";
import { animated, Transition } from "react-spring/renderprops";
import { IAppState } from "~redux/reducers";
import { Login } from "./Login";
import { Signup } from "./Signup";
interface IAuthScreenProps extends RouteComponentProps {
loggedIn: boolean;
}
export class AuthScreenComponent extends React.PureComponent<IAuthScreenProps> {
constructor(props: IAuthScreenProps) {
super(props);
}
public render() {
const { location } = this.props.history;
const { from } = this.props.location.state || { from: "/" };
const { loggedIn } = this.props;
return loggedIn ? (
<Redirect to={from} />
) : (
<div className="animationWrapper">
<Transition
native={true}
items={location}
keys={location.pathname}
from={{
opacity: 0,
transform: "translate3d(-400px,0,0)",
}}
enter={{
opacity: 1,
transform: "translate3d(0,0,0)",
}}
leave={{
opacity: 0,
transform: "translate3d(400px,0,0)",
}}
>
{(_location: any) => (style: any) => (
<animated.div style={style}>
<Switch location={_location}>
<Route path="/login" component={Login} />
<Route path="/signup" component={Signup} />
</Switch>
</animated.div>
)}
</Transition>
</div>
);
}
}
function mapStateToProps(state: IAppState) {
return { loggedIn: !(state.auth.jwt === null) };
}
export const AuthScreen = withRouter(
connect(mapStateToProps)(AuthScreenComponent),
);

123
frontend/src/Auth/Login.tsx Normal file
View File

@@ -0,0 +1,123 @@
import "./Auth.scss";
import { Button, Card, FormGroup, H2, InputGroup } from "@blueprintjs/core";
import * as React from "react";
import { connect } from "react-redux";
import { RouteComponentProps, withRouter } from "react-router-dom";
import { Dispatch } from "redux";
import { authStart } from "~redux/auth/actions";
import { IAppState } from "~redux/reducers";
interface ILoginComponentProps extends RouteComponentProps {
inProgress: boolean;
error: string;
spinner: boolean;
login: (username: string, password: string) => void;
}
interface ILoginComponentState {
username: string;
password: string;
}
export class LoginComponent extends React.PureComponent<
ILoginComponentProps,
ILoginComponentState
> {
constructor(props: ILoginComponentProps) {
super(props);
this.submit = this.submit.bind(this);
this.change = this.change.bind(this);
this.updateFields = this.updateFields.bind(this);
this.state = { username: "", password: "" };
}
public change() {
this.props.history.push("/signup");
}
public submit(e: React.FormEvent<any>) {
e.preventDefault();
const { username, password } = this.state;
if (!this.props.inProgress) {
this.props.login(username, password);
}
}
public updateFields(e: React.FormEvent<HTMLInputElement>) {
const { value, name } = e.currentTarget;
this.setState({ ...this.state, [name]: value });
}
public render() {
return (
<>
<Card className="AuthForm" elevation={2}>
<form onSubmit={this.submit}>
<div className="header">
<H2>Login</H2>
<Button
icon="new-person"
minimal={true}
onClick={this.change}
className="change"
>
Signup
</Button>
</div>
<FormGroup label="Username">
<InputGroup
name="username"
value={this.state.username}
onChange={this.updateFields}
leftIcon="person"
/>
</FormGroup>
<FormGroup label="Password">
<InputGroup
name="password"
value={this.state.password}
onChange={this.updateFields}
type="password"
leftIcon="key"
/>
</FormGroup>
<div className="footer">
<div id="error">{this.props.error}</div>
<Button
loading={this.props.spinner}
className="submit"
intent="primary"
icon="log-in"
type="submit"
onClick={this.submit}
disabled={this.props.spinner}
>
Login
</Button>
</div>
</form>
</Card>
</>
);
}
}
function mapStateToProps(state: IAppState) {
return {
inProgress: state.auth.inProgress,
error: state.auth.formError,
spinner: state.auth.formSpinner,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
login: (username: string, password: string) =>
dispatch(authStart(username, password)),
};
}
export const Login = withRouter(
connect(mapStateToProps, mapDispatchToProps)(LoginComponent),
);

View File

@@ -0,0 +1,134 @@
import "./Auth.scss";
import { Button, Card, FormGroup, H2, InputGroup } from "@blueprintjs/core";
import * as React from "react";
import { connect } from "react-redux";
import { RouteComponentProps, withRouter } from "react-router";
import { Dispatch } from "redux";
import { signupStart } from "~redux/auth/actions";
import { IAppState } from "~redux/reducers";
interface ISignupComponentProps extends RouteComponentProps {
inProgress: boolean;
error: string;
spinner: boolean;
signup: (username: string, password: string, email: string) => void;
}
interface ISignupComponentState {
username: string;
password: string;
email: string;
}
export class SignupComponent extends React.PureComponent<
ISignupComponentProps,
ISignupComponentState
> {
constructor(props: ISignupComponentProps) {
super(props);
this.submit = this.submit.bind(this);
this.change = this.change.bind(this);
this.updateFields = this.updateFields.bind(this);
this.state = { username: "", password: "", email: "" };
}
public change() {
this.props.history.push("/login");
}
public submit(e: React.FormEvent<any>) {
e.preventDefault();
const { username, password, email } = this.state;
if (!this.props.inProgress) {
this.props.signup(username, password, email);
}
}
public updateFields(e: React.FormEvent<HTMLInputElement>) {
const { value, name } = e.currentTarget;
this.setState({ ...this.state, [name]: value });
}
public render() {
return (
<>
<Card className="AuthForm" elevation={2}>
<form onSubmit={this.submit}>
<div className="header">
<H2>Signup</H2>
<Button
icon="log-in"
minimal={true}
onClick={this.change}
className="change"
>
Login
</Button>
</div>
<FormGroup label="Username">
<InputGroup
name="username"
value={this.state.username}
onChange={this.updateFields}
leftIcon="person"
/>
</FormGroup>
<FormGroup label="Email">
<InputGroup
name="email"
value={this.state.email}
onChange={this.updateFields}
type="email"
leftIcon="envelope"
/>
</FormGroup>
<FormGroup label="Password">
<InputGroup
name="password"
value={this.state.password}
onChange={this.updateFields}
type="password"
leftIcon="key"
/>
</FormGroup>
<div className="footer">
<div id="error">{this.props.error}</div>
<Button
loading={this.props.spinner}
icon="new-person"
className="submit"
intent="primary"
type="submit"
onClick={this.submit}
disabled={this.props.spinner}
>
Signup
</Button>
</div>
</form>
</Card>
</>
);
}
}
function mapStateToProps(state: IAppState) {
return {
inProgress: state.auth.inProgress,
error: state.auth.formError,
spinner: state.auth.formSpinner,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
signup: (username: string, password: string, email: string) =>
dispatch(signupStart(username, password, email)),
};
}
export const Signup = withRouter(
connect(mapStateToProps, mapDispatchToProps)(SignupComponent),
);

View File

@@ -0,0 +1,53 @@
@import "~@blueprintjs/core/lib/scss/variables";
.viewComponent {
position: absolute;
max-width: 100%;
width: 50rem;
left: 0;
right: 0;
top: -$pt-navbar-height;
bottom: 0;
margin-left: auto;
margin-right: auto;
padding-top: 2 * $pt-navbar-height + 20px;
max-height: 100%;
}
#mainContainer {
transition: 0.3s;
#MainScreen {
transition: 0.3s;
}
.bp3-navbar {
transition: 0.3s;
> * {
// keeps the breadcrumbs from taking all the space
max-width: 65%;
}
* {
transition: 0.3s;
}
}
#uploadingStatusButton {
width: 40px;
}
}
#mainContainer.bp3-dark {
transition: 0.3s;
#MainScreen {
transition: 0.3s;
background: $dark-gray3;
}
.bp3-navbar {
transition: 0.3s;
}
}

161
frontend/src/Home/Home.tsx Normal file
View File

@@ -0,0 +1,161 @@
import "./Home.scss";
import {
Alignment,
Breadcrumbs,
Button,
Classes,
IBreadcrumbProps,
Icon,
Menu,
Navbar,
Popover,
Spinner,
} from "@blueprintjs/core";
import * as React from "react";
import { connect } from "react-redux";
import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { animated, config, Transition } from "react-spring/renderprops";
import { Dispatch } from "redux";
import { IUserJSON } from "~../../src/entity/User";
import { Account } from "~Account/Account";
import { Overview } from "~Photos/Overview";
import { toggleDarkMode } from "~redux/localSettings/actions";
import { IAppState } from "~redux/reducers";
import { logoutUser } from "~redux/user/actions";
export interface IHomeProps extends RouteComponentProps {
user: IUserJSON | null;
darkMode: boolean;
logout: () => void;
dispatchToggleDarkMode: () => void;
}
export class HomeComponent extends React.PureComponent<IHomeProps> {
constructor(props: IHomeProps) {
super(props);
}
public render() {
const { location } = this.props.history;
return (
this.props.user && (
<div
id="mainContainer"
className={this.props.darkMode ? Classes.DARK : undefined}
>
<Navbar fixedToTop={true}>
<Navbar.Group align={Alignment.LEFT}>
<Button
minimal={true}
onClick={() => this.props.history.push("/")}
>
Writer
</Button>
<Navbar.Divider />
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<Popover
target={
<Button id="userButton">
{this.props.user.username}
</Button>
}
content={this.menu()}
/>
</Navbar.Group>
</Navbar>
<div id="MainScreen" className="animationWrapper">
<Transition
native={true}
config={{
...config.default,
clamp: true,
precision: 0.1,
}}
items={location}
keys={location.pathname}
from={{
opacity: 0,
transform: "translate3d(-400px,0,0)",
}}
enter={{
opacity: 1,
transform: "translate3d(0,0,0)",
}}
leave={{
opacity: 0,
transform: "translate3d(400px,0,0)",
}}
>
{(_location: any) => (style: any) => (
<animated.div
style={style}
className="viewComponent"
>
<Switch location={_location}>
<Route
path="/account"
component={Account}
/>
<Route path="/" component={Overview} />
</Switch>
</animated.div>
)}
</Transition>
</div>
</div>
)
);
}
private menu() {
return (
<Menu>
<Menu.Item
icon="user"
text="Account"
onClick={() => this.props.history.push("/account")}
/>
<Menu.Item
icon="log-out"
text="Logout"
onClick={this.props.logout}
/>
{this.props.darkMode ? (
<Menu.Item
icon="flash"
text="Light Mode"
onClick={this.props.dispatchToggleDarkMode}
/>
) : (
<Menu.Item
icon="moon"
text="Dark Mode"
onClick={this.props.dispatchToggleDarkMode}
/>
)}
</Menu>
);
}
}
function mapStateToProps(state: IAppState) {
return {
user: state.user.user,
darkMode: state.localSettings.darkMode,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
logout: () => dispatch(logoutUser()),
dispatchToggleDarkMode: () => dispatch(toggleDarkMode()),
};
}
export const Home = withRouter(
connect(mapStateToProps, mapDispatchToProps)(HomeComponent),
);

View File

@@ -0,0 +1,27 @@
import { shallow } from "enzyme";
import * as React from "react";
import { HomeComponent, IHomeProps } from "../Home";
const defaultHomeProps: IHomeProps = {
user: { id: 1, username: "test" },
darkMode: false,
logout: jest.fn(),
dispatchToggleDarkMode: jest.fn(),
history: { location: { pathname: "/" } } as any,
location: { pathname: "/" } as any,
match: {
params: {
id: null,
},
} as any,
};
describe("<Home />", () => {
it("should not crash", () => {
const wrapper = shallow(<HomeComponent {...defaultHomeProps} />);
});
});

View File

@@ -0,0 +1,26 @@
import { Alignment, Button, Navbar } from "@blueprintjs/core";
import * as React from "react";
import { RouteComponentProps, withRouter } from "react-router";
export function LandingComponent(props: RouteComponentProps) {
function login() {
props.history.push("/login");
}
return (
<>
<Navbar>
<Navbar.Group align={Alignment.LEFT}>
<Navbar.Heading>Writer</Navbar.Heading>
<Navbar.Divider />
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<Button icon="log-in" minimal={true} onClick={login}>
Login
</Button>
</Navbar.Group>
</Navbar>
</>
);
}
export const Landing = withRouter(LandingComponent);

View File

@@ -0,0 +1,10 @@
import { Spinner } from "@blueprintjs/core";
import * as React from "react";
export function LoadingStub() {
return (
<div className="loadingWrapper">
<Spinner />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import * as React from "react";
export function NotFound() {
return <div>404</div>;
}

View File

@@ -0,0 +1,29 @@
import "./Photos.scss";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { IAppState } from "~redux/reducers";
export interface IOverviewComponentProps {
fetching: boolean;
spinner: boolean;
fetchDocs: () => void;
}
export function OverviewComponent() {
return <div id="overview">Overview!</div>;
}
function mapStateToProps(state: IAppState) {
return {};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {};
}
export const Overview = connect(
mapStateToProps,
mapDispatchToProps,
)(OverviewComponent);

View File

@@ -0,0 +1,10 @@
@import "~@blueprintjs/core/lib/scss/variables";
#overview {
display: flex;
flex-direction: column;
}
.bp3-dark {
#overview {}
}

View File

@@ -0,0 +1,15 @@
import * as React from "react";
import { shallow } from "enzyme";
import { OverviewComponent } from "../Overview";
afterEach(() => {
jest.restoreAllMocks();
});
describe("<Overview />", () => {
it("should not crash", () => {
const wrapper = shallow(<OverviewComponent />);
expect(wrapper.contains("Overview!")).toBeTruthy();
});
});

View File

@@ -1,11 +0,0 @@
import * as React from "react";
import { shallow } from "enzyme";
import { TestTest } from "~TestTest";
describe("<TestTest />", () => {
it("should not crash", () => {
const wrapper = shallow(<TestTest />);
expect(wrapper.contains(<div>Hello!</div>)).toBeTruthy();
});
});

View File

@@ -1,5 +0,0 @@
import * as React from "react";
export function TestTest() {
return <div>Hello!</div>;
}

2
frontend/src/env.ts Normal file
View File

@@ -0,0 +1,2 @@
export const apiRoot = process.env.API_ROOT || "http://localhost:3000";
export const webRoot = process.env.WEB_ROOT || "http://localhost:1234";

1
frontend/src/fileMock.ts Normal file
View File

@@ -0,0 +1 @@
module.exports = "test-file-stub";

View File

@@ -1,14 +1,20 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<head>
<meta charset="utf-8" />
<title>My Parcel Project</title>
</head>
<link
href="https://fonts.googleapis.com/css?family=PT+Sans:400,400i,700,700i&display=swap&subset=cyrillic,cyrillic-ext,latin-ext"
rel="stylesheet"
/>
<body>
<div id="root"></div>
<script src="./index.tsx"></script>
</body>
<title>Writer</title>
</head>
<body>
<div id="body"></div>
<script src="./index.tsx"></script>
</body>
</html>

View File

@@ -1,4 +1,23 @@
import "@blueprintjs/core/lib/css/blueprint.css";
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
import "normalize.css/normalize.css";
import "~App.scss";
import * as React from "react";
import { render } from "react-dom";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom";
import { PersistGate } from "redux-persist/integration/react";
import { App } from "~App";
import { persistor, store } from "~redux/store";
render(<h1>Hello World</h1>, document.getElementById("root"));
render(
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<BrowserRouter>
<App />
</BrowserRouter>
</PersistGate>
</Provider>,
document.getElementById("body"),
);

View File

@@ -0,0 +1,25 @@
import { IUserAuthJSON } from "~../../src/entity/User";
import { IAPIResponse } from "~../../src/types";
import { fetchJSON } from "../utils";
export async function login(
username: string,
password: string,
): Promise<IAPIResponse<IUserAuthJSON>> {
return (fetchJSON("/users/login", "POST", {
username,
password,
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
}
export async function signup(
username: string,
password: string,
email: string,
): Promise<IAPIResponse<IUserAuthJSON>> {
return (fetchJSON("/users/signup", "POST", {
username,
password,
email,
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
}

View File

@@ -0,0 +1,15 @@
import { fetchJSON, fetchJSONAuth } from "../utils";
import { IAPIResponse } from "~/../../src/types";
import { IUserAuthJSON, IUserJSON } from "~../../src/entity/User";
export async function fetchUser(): Promise<IAPIResponse<IUserJSON>> {
return (fetchJSONAuth("/users/user", "GET") as unknown) as Promise<
IAPIResponse<IUserAuthJSON>
>;
}
export async function changeUserPassword(newPassword: string) {
return (fetchJSONAuth("/users/edit", "POST", {
password: newPassword,
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
}

View File

@@ -0,0 +1,52 @@
import { apiRoot } from "~env";
let token: string | null;
export function setToken(_token: string): void {
token = _token;
}
export function getToken(): string | null {
return token;
}
export function deleteToken(): void {
token = null;
}
export async function fetchJSON(
path: string,
method: string,
body?: string | Record<string, unknown>,
headers?: Record<string, string>,
): Promise<Record<string, unknown>> {
if (typeof body === "object") {
body = JSON.stringify(body);
}
const response = await fetch(apiRoot + path, {
method,
body,
headers: {
...headers,
"Content-Type": "application/json",
},
});
const json = (await response.json()) as Record<string, unknown>;
return json;
}
export async function fetchJSONAuth(
path: string,
method: string,
body?: string | Record<string, unknown>,
headers?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (token) {
return fetchJSON(path, method, body, {
...headers,
Authorization: `Bearer ${token}`,
});
} else {
throw new Error("Not logged in");
}
}

View File

@@ -0,0 +1,80 @@
import { Action } from "redux";
import { IUserAuthJSON } from "~../../src/entity/User";
export enum AuthTypes {
AUTH_START = "AUTH_START",
SIGNUP_START = "SIGNUP_START",
AUTH_SUCCESS = "AUTH_SUCCESS",
AUTH_FAIL = "AUTH_FAIL",
AUTH_START_FORM_SPINNER = "AUTH_START_FORM_SPINNER",
}
export interface IAuthStartAction extends Action {
type: AuthTypes.AUTH_START;
payload: {
username: string;
password: string;
};
}
export interface ISignupStartAction extends Action {
type: AuthTypes.SIGNUP_START;
payload: {
username: string;
password: string;
email: string;
};
}
export interface IAuthSuccessAction extends Action {
type: AuthTypes.AUTH_SUCCESS;
payload: IUserAuthJSON;
}
export interface IAuthFailureAction extends Action {
type: AuthTypes.AUTH_FAIL;
payload: {
error: string;
};
}
export interface IAuthStartFormSpinnerAction extends Action {
type: AuthTypes.AUTH_START_FORM_SPINNER;
}
export function startFormSpinner(): IAuthStartFormSpinnerAction {
return { type: AuthTypes.AUTH_START_FORM_SPINNER };
}
export function authStart(
username: string,
password: string,
): IAuthStartAction {
return { type: AuthTypes.AUTH_START, payload: { username, password } };
}
export function signupStart(
username: string,
password: string,
email: string,
): ISignupStartAction {
return {
type: AuthTypes.SIGNUP_START,
payload: { username, password, email },
};
}
export function authSuccess(user: IUserAuthJSON): IAuthSuccessAction {
return { type: AuthTypes.AUTH_SUCCESS, payload: user };
}
export function authFail(error: string): IAuthFailureAction {
return { type: AuthTypes.AUTH_FAIL, payload: { error } };
}
export type AuthAction =
| IAuthStartAction
| IAuthSuccessAction
| IAuthFailureAction
| IAuthStartFormSpinnerAction
| ISignupStartAction;

View File

@@ -0,0 +1,56 @@
import { Reducer } from "react";
import { setToken } from "~redux/api/utils";
import { UserAction, UserTypes } from "~redux/user/actions";
import { AuthAction, AuthTypes } from "./actions";
export interface IAuthState {
jwt: string | null;
inProgress: boolean;
formError: string | null;
formSpinner: boolean;
}
const defaultAuthState: IAuthState = {
jwt: null,
inProgress: false,
formError: null,
formSpinner: false,
};
export const authReducer: Reducer<IAuthState, AuthAction> = (
state: IAuthState = defaultAuthState,
action: AuthAction | UserAction,
): IAuthState => {
switch (action.type) {
case AuthTypes.AUTH_START:
case AuthTypes.SIGNUP_START:
return { ...defaultAuthState, inProgress: true };
break;
case AuthTypes.AUTH_SUCCESS:
case UserTypes.USER_GET_SUCCESS:
setToken(action.payload.jwt);
return {
...defaultAuthState,
jwt: action.payload.jwt,
};
break;
case UserTypes.USER_GET_FAIL:
if (action.payload.logout) {
return defaultAuthState;
}
break;
case AuthTypes.AUTH_FAIL:
return { ...defaultAuthState, formError: action.payload.error };
break;
case AuthTypes.AUTH_START_FORM_SPINNER:
return { ...state, formSpinner: true };
case UserTypes.USER_LOGOUT:
return defaultAuthState;
break;
default:
return state;
break;
}
return state;
};

View File

@@ -0,0 +1,86 @@
import {
all,
call,
cancel,
delay,
fork,
put,
race,
takeLatest,
} from "redux-saga/effects";
import { login, signup } from "~redux/api/auth";
import {
authFail,
authSuccess,
AuthTypes,
IAuthStartAction,
ISignupStartAction,
startFormSpinner,
} from "./actions";
function* startSpinner() {
yield delay(300);
yield put(startFormSpinner());
}
function* authStart(action: IAuthStartAction) {
const { username, password } = action.payload;
try {
const spinner = yield fork(startSpinner);
const { response, timeout } = yield race({
response: call(login, username, password),
timeout: delay(10000),
});
yield cancel(spinner);
if (timeout) {
yield put(authFail("Timeout"));
return;
}
if (response.data) {
const user = response.data;
yield put(authSuccess(user));
} else {
yield put(authFail(response.error));
}
} catch (e) {
yield put(authFail("Internal error"));
}
}
function* signupStart(action: ISignupStartAction) {
const { username, password, email } = action.payload;
try {
const spinner = yield fork(startSpinner);
const { response, timeout } = yield race({
response: call(signup, username, password, email),
timeout: delay(10000),
});
yield cancel(spinner);
if (timeout) {
yield put(authFail("Timeout"));
return;
}
if (response.data) {
const user = response.data;
yield put(authSuccess(user));
} else {
yield put(authFail(response.error));
}
} catch (e) {
yield put(authFail(e.toString()));
}
}
export function* authSaga() {
yield all([
takeLatest(AuthTypes.AUTH_START, authStart),
takeLatest(AuthTypes.SIGNUP_START, signupStart),
]);
}

View File

@@ -0,0 +1,15 @@
import { Action } from "redux";
export enum LocalSettingsTypes {
TOGGLE_DARK_MODE = "TOGGLE_DARK_MODE",
}
export interface IToggleDarkModeAction extends Action {
type: LocalSettingsTypes.TOGGLE_DARK_MODE;
}
export function toggleDarkMode(): IToggleDarkModeAction {
return { type: LocalSettingsTypes.TOGGLE_DARK_MODE };
}
export type LocalSettingsAction = IToggleDarkModeAction;

View File

@@ -0,0 +1,32 @@
import { Reducer } from "react";
import { UserAction, UserTypes } from "~redux/user/actions";
import { LocalSettingsAction, LocalSettingsTypes } from "./actions";
export interface ILocalSettingsState {
darkMode: boolean;
}
const defaultLocalSettingsState: ILocalSettingsState = {
darkMode: false,
};
export const localSettingsReducer: Reducer<
ILocalSettingsState,
LocalSettingsAction
> = (
state: ILocalSettingsState = defaultLocalSettingsState,
action: LocalSettingsAction | UserAction,
): ILocalSettingsState => {
const { darkMode } = state;
switch (action.type) {
case LocalSettingsTypes.TOGGLE_DARK_MODE:
return { ...state, darkMode: !darkMode };
case UserTypes.USER_LOGOUT:
return defaultLocalSettingsState;
default:
return state;
break;
}
return state;
};

View File

@@ -0,0 +1,36 @@
import { combineReducers } from "redux";
import { persistReducer } from "redux-persist";
import { PersistPartial } from "redux-persist/es/persistReducer";
import storage from "redux-persist/lib/storage";
import { authReducer, IAuthState } from "~redux/auth/reducer";
import {
ILocalSettingsState,
localSettingsReducer,
} from "./localSettings/reducer";
import { IUserState, userReducer } from "./user/reducer";
export interface IAppState {
auth: IAuthState & PersistPartial;
user: IUserState;
localSettings: ILocalSettingsState & PersistPartial;
}
const authPersistConfig = {
key: "auth",
storage,
whitelist: ["jwt"],
};
const localSettingsPersistConfig = {
key: "localSettings",
storage,
};
export const rootReducer = combineReducers({
auth: persistReducer<IAuthState>(authPersistConfig, authReducer),
user: userReducer,
localSettings: persistReducer(
localSettingsPersistConfig,
localSettingsReducer,
),
});

View File

@@ -0,0 +1,28 @@
import { applyMiddleware, createStore } from "redux";
import { composeWithDevTools } from "redux-devtools-extension";
import { persistStore } from "redux-persist";
import createSagaMiddlware from "redux-saga";
import { rootReducer } from "~redux/reducers";
import { setToken } from "./api/utils";
import { authSaga } from "./auth/sagas";
import { getUser } from "./user/actions";
import { userSaga } from "./user/sagas";
const sagaMiddleware = createSagaMiddlware();
export const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(sagaMiddleware)),
);
export const persistor = persistStore(store, null, () => {
const state = store.getState();
if (state.auth.jwt) {
setToken(state.auth.jwt);
store.dispatch(getUser());
}
});
sagaMiddleware.run(authSaga);
sagaMiddleware.run(userSaga);

View File

View File

@@ -0,0 +1,102 @@
import { Action } from "redux";
import { IUserAuthJSON, IUserJSON } from "~../../src/entity/User";
import { showPasswordNotSavedToast, showPasswordSavedToast } from "~AppToaster";
export enum UserTypes {
USER_GET = "USER_GET",
USER_GET_SUCCESS = "USER_GET_SUCCESS",
USER_GET_FAIL = "USER_GET_FAIL",
USER_LOGOUT = "USER_LOGOUT",
USER_PASS_CHANGE = "USER_PASS_CHANGE",
USER_PASS_CHANGE_SUCCESS = "USER_PASS_CHANGE_SUCCESS",
USER_PASS_CHANGE_FAIL = "USER_PASS_CHANGE_FAIL",
}
export interface IUserGetAction extends Action {
type: UserTypes.USER_GET;
}
export interface IUserLogoutAction extends Action {
type: UserTypes.USER_LOGOUT;
}
export interface IUserGetSuccessAction extends Action {
type: UserTypes.USER_GET_SUCCESS;
payload: IUserAuthJSON;
}
export interface IUserGetFailAction extends Action {
type: UserTypes.USER_GET_FAIL;
payload: {
error: string;
logout: boolean;
};
}
export interface IUserPassChangeAction extends Action {
type: UserTypes.USER_PASS_CHANGE;
password: string;
}
export interface IUserPassChangeSuccessAction extends Action {
type: UserTypes.USER_PASS_CHANGE_SUCCESS;
payload: IUserAuthJSON;
}
export interface IUserPassChangeFailAction extends Action {
type: UserTypes.USER_PASS_CHANGE_FAIL;
payload: {
error: string;
logout: boolean;
};
}
export function getUser(): IUserGetAction {
return { type: UserTypes.USER_GET };
}
export function logoutUser(): IUserLogoutAction {
return { type: UserTypes.USER_LOGOUT };
}
export function getUserSuccess(user: IUserAuthJSON): IUserGetSuccessAction {
return { type: UserTypes.USER_GET_SUCCESS, payload: user };
}
export function getUserFail(
error: string,
logout: boolean,
): IUserGetFailAction {
return { type: UserTypes.USER_GET_FAIL, payload: { error, logout } };
}
export function userPassChange(password: string): IUserPassChangeAction {
return { type: UserTypes.USER_PASS_CHANGE, password };
}
export function userPassChangeSuccess(
user: IUserAuthJSON,
): IUserPassChangeSuccessAction {
showPasswordSavedToast();
return { type: UserTypes.USER_PASS_CHANGE_SUCCESS, payload: user };
}
export function userPassChangeFail(
error: string,
logout: boolean,
): IUserPassChangeFailAction {
showPasswordNotSavedToast(error);
return {
type: UserTypes.USER_PASS_CHANGE_FAIL,
payload: { error, logout },
};
}
export type UserAction =
| IUserGetAction
| IUserGetSuccessAction
| IUserGetFailAction
| IUserLogoutAction
| IUserPassChangeAction
| IUserPassChangeFailAction
| IUserPassChangeSuccessAction;

View File

@@ -0,0 +1,39 @@
import { Reducer } from "react";
import { IUserJSON } from "~../../src/entity/User";
import { AuthAction, AuthTypes } from "~redux/auth/actions";
import { UserAction, UserTypes } from "./actions";
export interface IUserState {
user: IUserJSON | null;
}
const defaultUserState: IUserState = {
user: null,
};
export const userReducer: Reducer<IUserState, AuthAction> = (
state: IUserState = defaultUserState,
action: AuthAction | UserAction,
): IUserState => {
switch (action.type) {
case AuthTypes.AUTH_SUCCESS:
case UserTypes.USER_GET_SUCCESS:
case UserTypes.USER_PASS_CHANGE_SUCCESS:
return {
...defaultUserState,
user: action.payload,
};
break;
case UserTypes.USER_GET_FAIL:
case UserTypes.USER_PASS_CHANGE_FAIL:
return defaultUserState;
break;
case UserTypes.USER_LOGOUT:
return defaultUserState;
break;
default:
return state;
break;
}
return state;
};

View File

@@ -0,0 +1,62 @@
import { all, call, delay, put, race, takeLatest } from "redux-saga/effects";
import { changeUserPassword, fetchUser } from "~redux/api/user";
import {
getUserFail,
getUserSuccess,
IUserPassChangeAction,
userPassChangeFail,
userPassChangeSuccess,
UserTypes,
} from "./actions";
function* getUser() {
try {
const { response, timeout } = yield race({
response: call(fetchUser),
timeout: delay(10000),
});
if (timeout) {
yield put(getUserFail("Timeout", false));
return;
}
if (response.data) {
const user = response.data;
yield put(getUserSuccess(user));
} else {
yield put(getUserFail(response.error, true));
}
} catch (e) {
yield put(getUserFail("Internal error", false));
}
}
function* userPassChange(action: IUserPassChangeAction) {
try {
const { response, timeout } = yield race({
response: call(changeUserPassword, action.password),
timeout: delay(10000),
});
if (timeout) {
yield put(userPassChangeFail("Timeout", false));
return;
}
if (response.data) {
const user = response.data;
yield put(userPassChangeSuccess(user));
} else {
yield put(userPassChangeFail(response.error, true));
}
} catch (e) {
yield put(userPassChangeFail("Internal error", false));
}
}
export function* userSaga() {
yield all([
takeLatest(UserTypes.USER_GET, getUser),
takeLatest(UserTypes.USER_PASS_CHANGE, userPassChange),
]);
}

View File

@@ -0,0 +1 @@
module.exports = {};

View File

@@ -0,0 +1,3 @@
export function flushPromises() {
return new Promise(setImmediate);
}