Compare commits
No commits in common. "3a907399bd4c9da902c277ebf203374a7758b9a4" and "719889e2bcf59848c9a96fdf9837ddccd8521a8d" have entirely different histories.
3a907399bd
...
719889e2bc
16
README.md
16
README.md
@ -2,19 +2,9 @@
|
|||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
### Development Workflow
|
### Development dependencies
|
||||||
|
|
||||||
- Format, lint and test with `npm run {format, lint, test}`.
|
- `prettier`: Formatter
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- React
|
|
||||||
- React helment async: manage changes to document head
|
|
||||||
- React router dom: route management
|
|
||||||
- Material UI: UI styled components
|
|
||||||
- Roboto font
|
|
||||||
- Formik: form management
|
|
||||||
- Yup: validate input data
|
|
||||||
|
|
||||||
### Frontend Technical Write-up
|
### Frontend Technical Write-up
|
||||||
|
|
||||||
@ -22,7 +12,7 @@ Inside the `frontend/` folder
|
|||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
### Development Workflow
|
### Development workflow
|
||||||
|
|
||||||
- Depends on the state of the database, run `pdm run recreate-db` to regenerate database.
|
- Depends on the state of the database, run `pdm run recreate-db` to regenerate database.
|
||||||
- Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root to get into the backend python virtual environment.
|
- Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root to get into the backend python virtual environment.
|
||||||
|
@ -1 +0,0 @@
|
|||||||
/home/minhradz/Desktop/WebDev/todo/backend/.venv/bin/python
|
|
@ -1,42 +1,3 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
# Backend Technical Write-up
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
- Initial infrastructure
|
|
||||||
- Rate Limiting
|
|
||||||
- JSON error handling
|
|
||||||
- Req/Res schema validation
|
|
||||||
- Setup database
|
|
||||||
- Database schema/models
|
|
||||||
- Usersessions
|
|
||||||
- CRUD routes functionalities
|
|
||||||
|
|
||||||
## Quart specific terminologies
|
|
||||||
|
|
||||||
`blueprint`: a collection of route handlers/API functionalities.
|
|
||||||
|
|
||||||
## API route trailing slashes
|
|
||||||
|
|
||||||
API paths should end with a slash i.e: `/sessions/` rather than `/session`.
|
|
||||||
This is because requests sent to `/sessions` will be redirected to `/sessions/` whereas `/sessions/` won't get redirected.
|
|
||||||
|
|
||||||
## Difference between database schema and database model
|
|
||||||
|
|
||||||
- A schema defines the structure of data within the database.
|
|
||||||
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
|
|
||||||
|
|
||||||
## Managing user's sessions (Authentication)
|
|
||||||
|
|
||||||
- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request.
|
|
||||||
The presence and value of this cookie are used to determine whether the member is logged in, and which member made the request.
|
|
||||||
- Logout results in the cookie being deleted.
|
|
||||||
|
|
||||||
## Idempotent routes
|
|
||||||
|
|
||||||
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
|
|
||||||
||||||| 3c78fe9
|
|
||||||
=======
|
|
||||||
# Backend Technical Write Up
|
# Backend Technical Write Up
|
||||||
|
|
||||||
## General Bits of Information
|
## General Bits of Information
|
||||||
@ -76,4 +37,3 @@ This is because requests sent to `/sessions` will be redirected to `/sessions/`
|
|||||||
### Idempotent routes
|
### Idempotent routes
|
||||||
|
|
||||||
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
|
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
|
||||||
>>>>>>> master
|
|
||||||
|
1075
backend/pdm.lock
generated
1075
backend/pdm.lock
generated
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ dependencies = [
|
|||||||
"bcrypt>=4.0.1",
|
"bcrypt>=4.0.1",
|
||||||
"itsdangerous>=2.1.2",
|
"itsdangerous>=2.1.2",
|
||||||
"quart-rate-limiter>=0.7.0",
|
"quart-rate-limiter>=0.7.0",
|
||||||
"pydantic[email]",
|
"pydantic[email]==1.10.11",
|
||||||
"quart-schema>=0.14.3",
|
"quart-schema>=0.14.3",
|
||||||
"quart-db[postgresql]>=0.4.1",
|
"quart-db[postgresql]>=0.4.1",
|
||||||
"httpx>=0.23.1",
|
"httpx>=0.23.1",
|
||||||
|
@ -1,38 +1,46 @@
|
|||||||
# Neo Todo App Frontend
|
# Getting Started with Create React App
|
||||||
|
|
||||||
## Dev Log
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
### Styling the frontend
|
## Available Scripts
|
||||||
|
|
||||||
- Used the [MUI](mui.com) React component library using Material Design by Google.
|
In the project directory, you can run:
|
||||||
- Font used is `Roboto`. Font is imported to `src/App.tsx`.
|
|
||||||
|
|
||||||
#### Use ThemeProvider
|
### `npm start`
|
||||||
|
|
||||||
To override the default MUI looks, we create a ThemeProvider at `src/ThemeProvider.tsx`.
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
#### React Hook useMemo
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
useMemo is a React Hook that lets you cache the result of a calculation between re-renders. Made for running expensive synchronous operations less often.
|
### `npm test`
|
||||||
|
|
||||||
#### MUI (Material UI)
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
##### Palette Mode
|
### `npm run build`
|
||||||
|
|
||||||
Change default component colors to suit one's needs.
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
##### CSSBaseline
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
Reset the CSS injected into `<head>`. A collection of HTML element and attribute style-normalizations, you can expect all of the elements to look the same across all browsers.
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
##### useMediaQuery
|
### `npm run eject`
|
||||||
|
|
||||||
This React hook listens for matches to a CSS media query. It allows the rendering of components based on whether the query matches or not.
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
##### Container's maxWidth
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
This will make sure Material UI's Container will not expand to fill the entire screen and instead just stop expanding at around 960px (which is `md`).
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
### Configure the page's title (Browser Tab Text)
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
Use `react-helmet-async`, wrap it around the `App` in `App.tsx`.
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
|
16717
frontend/package-lock.json
generated
16717
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,14 +3,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.9.3",
|
|
||||||
"@emotion/styled": "^11.9.3",
|
|
||||||
"@fontsource/roboto": "^4.5.7",
|
|
||||||
"@mui/icons-material": "^5.8.4",
|
|
||||||
"@mui/lab": "^5.0.0-alpha.91",
|
|
||||||
"@mui/material": "^5.9.1",
|
|
||||||
"@mui/x-date-pickers": "^5.0.0-beta.2",
|
|
||||||
"@tanstack/react-query": "^4.33.0",
|
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react": "^13.3.0",
|
"@testing-library/react": "^13.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
@ -18,18 +10,12 @@
|
|||||||
"@types/node": "^16.11.45",
|
"@types/node": "^16.11.45",
|
||||||
"@types/react": "^18.0.15",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"axios": "^0.27.2",
|
|
||||||
"date-fns": "^2.29.1",
|
|
||||||
"formik": "^2.2.9",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-helmet-async": "^1.3.0",
|
"react-scripts": "^5.0.1",
|
||||||
"react-router-dom": "^6.3.0",
|
|
||||||
"react-scripts": "5.0.1",
|
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
"web-vitals": "^2.1.4",
|
"web-vitals": "^2.1.4",
|
||||||
"yup": "^0.32.11",
|
"nth-check": ">=2.0.1"
|
||||||
"zxcvbn": "^4.4.2"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
|
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
|
||||||
@ -48,12 +34,7 @@
|
|||||||
"plugin:import/warnings",
|
"plugin:import/warnings",
|
||||||
"plugin:import/typescript",
|
"plugin:import/typescript",
|
||||||
"prettier"
|
"prettier"
|
||||||
],
|
]
|
||||||
"settings": {
|
|
||||||
"import/resolver": {
|
|
||||||
"typescript": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@ -68,16 +49,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/zxcvbn": "^4.4.1",
|
"eslint": "^8.29.0",
|
||||||
"eslint": "^8.20.0",
|
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-import-resolver-typescript": "^3.3.0",
|
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"source-map-explorer": "^2.5.2"
|
"source-map-explorer": "^2.5.2"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
},
|
}
|
||||||
"proxy": "http://127.0.0.1:5050"
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { render } from "@testing-library/react";
|
import { render, screen } from '@testing-library/react';
|
||||||
import App from "./App";
|
import App from './App';
|
||||||
|
|
||||||
test("renders the app", () => {
|
test('renders learn react link', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -1,55 +1,26 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import "./App.css";
|
import logo from './logo.svg';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
function App() {
|
||||||
|
|
||||||
// Roboto font and its weights
|
|
||||||
import "@fontsource/roboto/300.css";
|
|
||||||
import "@fontsource/roboto/400.css";
|
|
||||||
import "@fontsource/roboto/500.css";
|
|
||||||
import "@fontsource/roboto/700.css";
|
|
||||||
|
|
||||||
// Theming
|
|
||||||
import ThemeProvider from "./ThemeProvider";
|
|
||||||
|
|
||||||
// Material UI stuffs
|
|
||||||
import Container from "@mui/material/Container";
|
|
||||||
|
|
||||||
// React helmet async: configure the page's title
|
|
||||||
import { Helmet, HelmetProvider } from "react-helmet-async";
|
|
||||||
|
|
||||||
// Authentication Context: Check if user is logged in or not
|
|
||||||
import { AuthContextProvider } from "./AuthContext";
|
|
||||||
|
|
||||||
// React router
|
|
||||||
import Router from "./Router";
|
|
||||||
|
|
||||||
// Toasts
|
|
||||||
import Toasts from "./components/Toasts";
|
|
||||||
import { ToastContextProvider } from "./ToastContext";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<div className="App">
|
||||||
<AuthContextProvider>
|
<header className="App-header">
|
||||||
<HelmetProvider>
|
<img src={logo} className="App-logo" alt="logo" />
|
||||||
<Helmet>
|
<p>
|
||||||
<title>Todo</title>
|
Edit <code>src/App.tsx</code> and save to reload.
|
||||||
</Helmet>
|
</p>
|
||||||
<ThemeProvider>
|
<a
|
||||||
<ToastContextProvider>
|
className="App-link"
|
||||||
<Container maxWidth="md">
|
href="https://reactjs.org"
|
||||||
<Toasts />
|
target="_blank"
|
||||||
<Router />
|
rel="noopener noreferrer"
|
||||||
</Container>
|
>
|
||||||
</ToastContextProvider>
|
Learn React
|
||||||
</ThemeProvider>
|
</a>
|
||||||
</HelmetProvider>
|
</header>
|
||||||
</AuthContextProvider>
|
</div>
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
import { createContext, useState } from "react";
|
|
||||||
|
|
||||||
interface IAuth {
|
|
||||||
authenticated: boolean;
|
|
||||||
setAuthenticated: (value: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthContext = createContext<IAuth>({
|
|
||||||
authenticated: true,
|
|
||||||
setAuthenticated: (value: boolean) => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthContextProvider = ({ children }: IProps) => {
|
|
||||||
const [authenticated, setAuthenticated] = useState(true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ authenticated, setAuthenticated }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,64 +0,0 @@
|
|||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|
||||||
|
|
||||||
import ScrollToTop from "./components/ScrollToTop";
|
|
||||||
import TopBar from "./components/TopBar";
|
|
||||||
|
|
||||||
import Register from "./pages/Register";
|
|
||||||
import ConfirmEmail from "./pages/ConfirmEmail";
|
|
||||||
import Login from "./pages/Login";
|
|
||||||
import RequireAuth from "./components/RequireAuth";
|
|
||||||
import ChangePassword from "./pages/ChangePassword";
|
|
||||||
import ForgottenPassword from "./pages/ForgottenPassword";
|
|
||||||
import ResetPassword from "./pages/ResetPassword";
|
|
||||||
|
|
||||||
import CreateTodo from "./pages/CreateTodo";
|
|
||||||
import EditTodo from "./pages/EditTodo";
|
|
||||||
import Todos from "./pages/Todos";
|
|
||||||
|
|
||||||
const Router = () => (
|
|
||||||
<BrowserRouter>
|
|
||||||
<ScrollToTop />
|
|
||||||
<TopBar />
|
|
||||||
<Routes>
|
|
||||||
<Route path="/register/" element={<Register />} />
|
|
||||||
<Route path="/confirm-email/:token/" element={<ConfirmEmail />} />
|
|
||||||
<Route path="/login/" element={<Login />} />
|
|
||||||
<Route
|
|
||||||
path="/change-password/"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<ChangePassword />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/forgotten-password/" element={<ForgottenPassword />} />
|
|
||||||
<Route path="/reset-password/:token/" element={<ResetPassword />} />
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<Todos />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/todos/new/"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<CreateTodo />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/todos/:id/"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<EditTodo />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Router;
|
|
@ -1,29 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { PaletteMode } from "@mui/material";
|
|
||||||
import CssBaseline from "@mui/material/CssBaseline";
|
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
import {
|
|
||||||
createTheme,
|
|
||||||
ThemeProvider as MuiThemeProvider,
|
|
||||||
} from "@mui/material/styles";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeProvider = ({ children }: IProps) => {
|
|
||||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
|
||||||
const theme = useMemo(() => {
|
|
||||||
const palette = {
|
|
||||||
mode: (prefersDarkMode ? "dark" : "light") as PaletteMode,
|
|
||||||
};
|
|
||||||
return createTheme({ palette });
|
|
||||||
}, [prefersDarkMode]);
|
|
||||||
return (
|
|
||||||
<MuiThemeProvider theme={theme}>
|
|
||||||
<CssBaseline enableColorScheme />
|
|
||||||
{children}
|
|
||||||
</MuiThemeProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default ThemeProvider;
|
|
@ -1,49 +0,0 @@
|
|||||||
import { AlertColor } from "@mui/material/Alert";
|
|
||||||
|
|
||||||
import React, { createContext, useState } from "react";
|
|
||||||
|
|
||||||
export interface IToast {
|
|
||||||
category?: AlertColor;
|
|
||||||
key: number;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IToastContext {
|
|
||||||
addToast: (message: string, category: AlertColor | undefined) => void;
|
|
||||||
setToasts: React.Dispatch<React.SetStateAction<IToast[]>>;
|
|
||||||
toasts: IToast[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ToastContext = createContext<IToastContext>({
|
|
||||||
addToast: () => {},
|
|
||||||
setToasts: () => {},
|
|
||||||
toasts: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ToastContextProvider = ({ children }: IProps) => {
|
|
||||||
const [toasts, setToasts] = useState<IToast[]>([]);
|
|
||||||
|
|
||||||
const addToast = (
|
|
||||||
message: string,
|
|
||||||
category: AlertColor | undefined = undefined,
|
|
||||||
) => {
|
|
||||||
setToasts((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
category,
|
|
||||||
key: new Date().getTime(),
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={{ addToast, setToasts, toasts }}>
|
|
||||||
{children}
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,67 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import Divider from "@mui/material/Divider";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import Menu from "@mui/material/Menu";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import AccountCircle from "@mui/icons-material/AccountCircle";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import React, { useContext, useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { AuthContext } from "src/AuthContext";
|
|
||||||
import { useMutation } from "src/query";
|
|
||||||
|
|
||||||
const useLogout = () => {
|
|
||||||
const { setAuthenticated } = useContext(AuthContext);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { mutate: logout } = useMutation(
|
|
||||||
async () => await axios.delete("/sessions/"),
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setAuthenticated(false);
|
|
||||||
queryClient.clear();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return logout;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccountMenu = () => {
|
|
||||||
const logout = useLogout();
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
|
||||||
|
|
||||||
const onMenuOpen = (event: React.MouseEvent<HTMLElement>) =>
|
|
||||||
setAnchorEl(event.currentTarget);
|
|
||||||
const onMenuClose = () => setAnchorEl(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IconButton color="inherit" onClick={onMenuOpen}>
|
|
||||||
<AccountCircle />
|
|
||||||
</IconButton>
|
|
||||||
<Menu
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
anchorOrigin={{ horizontal: "right", vertical: "top" }}
|
|
||||||
keepMounted
|
|
||||||
onClose={onMenuClose}
|
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
|
||||||
>
|
|
||||||
<MenuItem component={Link} onClick={onMenuClose} to="/change-password/">
|
|
||||||
Change password
|
|
||||||
</MenuItem>
|
|
||||||
<Divider />
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
logout();
|
|
||||||
onMenuClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AccountMenu;
|
|
@ -1,38 +0,0 @@
|
|||||||
import Checkbox from "@mui/material/Checkbox";
|
|
||||||
import FormControl from "@mui/material/FormControl";
|
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
|
||||||
import FormHelperText from "@mui/material/FormHelperText";
|
|
||||||
import { FieldHookConfig, useField } from "formik";
|
|
||||||
|
|
||||||
import { combineHelperText } from "src/utils";
|
|
||||||
|
|
||||||
type IProps = FieldHookConfig<boolean> & {
|
|
||||||
fullWidth?: boolean;
|
|
||||||
helperText?: string;
|
|
||||||
label: string;
|
|
||||||
required?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CheckboxField = (props: IProps) => {
|
|
||||||
const [field, meta] = useField<boolean>(props);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormControl
|
|
||||||
component="fieldset"
|
|
||||||
error={Boolean(meta.error) && meta.touched}
|
|
||||||
fullWidth={props.fullWidth}
|
|
||||||
margin="normal"
|
|
||||||
required={props.required}
|
|
||||||
>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox {...field} checked={field.value} />}
|
|
||||||
label={props.label}
|
|
||||||
/>
|
|
||||||
<FormHelperText>
|
|
||||||
{combineHelperText(props.helperText, meta)}
|
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CheckboxField;
|
|
@ -1,30 +0,0 @@
|
|||||||
import TextField, { TextFieldProps } from "@mui/material/TextField";
|
|
||||||
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
|
||||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|
||||||
import { FieldHookConfig, useField } from "formik";
|
|
||||||
|
|
||||||
import { combineHelperText } from "src/utils";
|
|
||||||
|
|
||||||
const DateField = (props: FieldHookConfig<Date | null> & TextFieldProps) => {
|
|
||||||
const [field, meta, helpers] = useField<Date | null>(props);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
|
||||||
<DatePicker
|
|
||||||
label={props.label}
|
|
||||||
value={field.value}
|
|
||||||
onChange={(newValue) => helpers.setValue(newValue)}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
fullWidth={props.fullWidth}
|
|
||||||
{...params}
|
|
||||||
helperText={combineHelperText(props.helperText, meta)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</LocalizationProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DateField;
|
|
@ -1,22 +0,0 @@
|
|||||||
import TextField, { TextFieldProps } from "@mui/material/TextField";
|
|
||||||
import { FieldHookConfig, useField } from "formik";
|
|
||||||
|
|
||||||
import { combineHelperText } from "src/utils";
|
|
||||||
|
|
||||||
const EmailField = (props: FieldHookConfig<string> & TextFieldProps) => {
|
|
||||||
const [field, meta] = useField<string>(props);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
{...props}
|
|
||||||
autoComplete="email"
|
|
||||||
error={Boolean(meta.error) && meta.touched}
|
|
||||||
helperText={combineHelperText(props.helperText, meta)}
|
|
||||||
margin="normal"
|
|
||||||
type="email"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmailField;
|
|
@ -1,43 +0,0 @@
|
|||||||
import Button from "@mui/material/Button";
|
|
||||||
import LoadingButton from "@mui/lab/LoadingButton";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
interface ILink {
|
|
||||||
label: string;
|
|
||||||
to: string;
|
|
||||||
state?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
disabled: boolean;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
label: string;
|
|
||||||
links?: ILink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormActions = ({ disabled, isSubmitting, label, links }: IProps) => (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ marginTop: 2 }}>
|
|
||||||
<LoadingButton
|
|
||||||
disabled={disabled}
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</LoadingButton>
|
|
||||||
{(links ?? []).map(({ label, to, state }) => (
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
key={to}
|
|
||||||
state={state}
|
|
||||||
to={to}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default FormActions;
|
|
@ -1,19 +0,0 @@
|
|||||||
import { TextFieldProps } from "@mui/material/TextField";
|
|
||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { FieldHookConfig } from "formik";
|
|
||||||
|
|
||||||
import PasswordField from "src/components/PasswordField";
|
|
||||||
|
|
||||||
const PasswordWithStrengthField = lazy(
|
|
||||||
() => import("src/components/PasswordWithStrengthField"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const LazyPasswordWithStrengthField = (
|
|
||||||
props: FieldHookConfig<string> & TextFieldProps,
|
|
||||||
) => (
|
|
||||||
<Suspense fallback={<PasswordField {...props} />}>
|
|
||||||
<PasswordWithStrengthField {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default LazyPasswordWithStrengthField;
|
|
@ -1,39 +0,0 @@
|
|||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import InputAdornment from "@mui/material/InputAdornment";
|
|
||||||
import TextField, { TextFieldProps } from "@mui/material/TextField";
|
|
||||||
import Visibility from "@mui/icons-material/Visibility";
|
|
||||||
import VisibilityOff from "@mui/icons-material/VisibilityOff";
|
|
||||||
import { FieldHookConfig, useField } from "formik";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { combineHelperText } from "src/utils";
|
|
||||||
|
|
||||||
const PasswordField = (props: FieldHookConfig<string> & TextFieldProps) => {
|
|
||||||
const [field, meta] = useField<string>(props);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
{...props}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowPassword((value) => !value)}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showPassword ? <Visibility /> : <VisibilityOff />}
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
error={Boolean(meta.error) && meta.touched}
|
|
||||||
helperText={combineHelperText(props.helperText, meta)}
|
|
||||||
margin="normal"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PasswordField;
|
|
@ -1,58 +0,0 @@
|
|||||||
import LinearProgress from "@mui/material/LinearProgress";
|
|
||||||
import { TextFieldProps } from "@mui/material/TextField";
|
|
||||||
import { FieldHookConfig, useField } from "formik";
|
|
||||||
import zxcvbn from "zxcvbn";
|
|
||||||
|
|
||||||
import PasswordField from "src/components/PasswordField";
|
|
||||||
|
|
||||||
const scoreToDisplay = (score: number) => {
|
|
||||||
let progressColor = "other.red";
|
|
||||||
let helperText = "Weak";
|
|
||||||
|
|
||||||
switch (score) {
|
|
||||||
case 25:
|
|
||||||
progressColor = "other.pink";
|
|
||||||
break;
|
|
||||||
case 50:
|
|
||||||
progressColor = "other.orange";
|
|
||||||
break;
|
|
||||||
case 75:
|
|
||||||
progressColor = "other.yellow";
|
|
||||||
helperText = "Good";
|
|
||||||
break;
|
|
||||||
case 100:
|
|
||||||
progressColor = "other.green";
|
|
||||||
helperText = "Strong";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return [progressColor, helperText];
|
|
||||||
};
|
|
||||||
|
|
||||||
const PasswordWithStrengthField = (
|
|
||||||
props: FieldHookConfig<string> & TextFieldProps,
|
|
||||||
) => {
|
|
||||||
const [field] = useField<string>(props);
|
|
||||||
const result = zxcvbn(field.value ?? "");
|
|
||||||
const score = (result.score * 100) / 4;
|
|
||||||
|
|
||||||
const [progressColor, helperText] = scoreToDisplay(score);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PasswordField {...props} helperText={helperText} />
|
|
||||||
<LinearProgress
|
|
||||||
sx={{
|
|
||||||
"& .MuiLinearProgress-barColorPrimary": {
|
|
||||||
backgroundColor: progressColor,
|
|
||||||
},
|
|
||||||
backgroundColor: "action.selected",
|
|
||||||
margin: "0 4px 24px 4px",
|
|
||||||
}}
|
|
||||||
value={score}
|
|
||||||
variant="determinate"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PasswordWithStrengthField;
|
|
@ -1,21 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
import { AuthContext } from "src/AuthContext";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RequireAuth = ({ children }: IProps) => {
|
|
||||||
const { authenticated } = useContext(AuthContext);
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (authenticated) {
|
|
||||||
return <>{children}</>;
|
|
||||||
} else {
|
|
||||||
return <Navigate state={{ from: location }} to="/login/" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RequireAuth;
|
|
@ -1,14 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useLocation } from "react-router";
|
|
||||||
|
|
||||||
const ScrollToTop = () => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ScrollToTop;
|
|
@ -1,21 +0,0 @@
|
|||||||
import MUITextField, { TextFieldProps } from "@mui/material/TextField";
|
|
||||||
import { FieldHookConfig, useField } from "formik";
|
|
||||||
|
|
||||||
import { combineHelperText } from "src/utils";
|
|
||||||
|
|
||||||
const TextField = (props: FieldHookConfig<string> & TextFieldProps) => {
|
|
||||||
const [field, meta] = useField<string>(props);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MUITextField
|
|
||||||
{...props}
|
|
||||||
error={Boolean(meta.error) && meta.touched}
|
|
||||||
helperText={combineHelperText(props.helperText, meta)}
|
|
||||||
margin="normal"
|
|
||||||
type="text"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TextField;
|
|
@ -1,19 +0,0 @@
|
|||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Title = ({ title }: IProps) => (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>Todo | {title}</title>
|
|
||||||
</Helmet>
|
|
||||||
<Typography component="h1" variant="h5">
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Title;
|
|
@ -1,47 +0,0 @@
|
|||||||
import Alert from "@mui/material/Alert";
|
|
||||||
import Snackbar from "@mui/material/Snackbar";
|
|
||||||
import React, { useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { ToastContext, IToast } from "src/ToastContext";
|
|
||||||
|
|
||||||
const Toasts = () => {
|
|
||||||
const { toasts, setToasts } = useContext(ToastContext);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [currentToast, setCurrentToast] = useState<IToast | undefined>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open && toasts.length) {
|
|
||||||
setCurrentToast(toasts[0]);
|
|
||||||
setToasts((prev) => prev.slice(1));
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}, [open, setCurrentToast, setOpen, setToasts, toasts]);
|
|
||||||
|
|
||||||
const onClose = (event?: React.SyntheticEvent | Event, reason?: string) => {
|
|
||||||
if (reason !== "clickaway") {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Snackbar
|
|
||||||
anchorOrigin={{
|
|
||||||
horizontal: "center",
|
|
||||||
vertical: "top",
|
|
||||||
}}
|
|
||||||
autoHideDuration={6000}
|
|
||||||
key={currentToast?.key}
|
|
||||||
onClose={onClose}
|
|
||||||
open={open}
|
|
||||||
TransitionProps={{
|
|
||||||
onExited: () => setCurrentToast(undefined),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Alert onClose={onClose} severity={currentToast?.category}>
|
|
||||||
{currentToast?.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Toasts;
|
|
@ -1,61 +0,0 @@
|
|||||||
import Checkbox from "@mui/material/Checkbox";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import ListItem from "@mui/material/ListItem";
|
|
||||||
import ListItemButton from "@mui/material/ListItemButton";
|
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
|
||||||
import Skeleton from "@mui/material/Skeleton";
|
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Todo as TodoModel } from "../models";
|
|
||||||
import { useDeleteTodoMutation } from "../queries";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
todo?: TodoModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Todo = ({ todo }: IProps) => {
|
|
||||||
const { mutateAsync: deleteTodo } = useDeleteTodoMutation();
|
|
||||||
let secondary;
|
|
||||||
if (todo === undefined) {
|
|
||||||
secondary = <Skeleton width="200px" />;
|
|
||||||
} else if (todo.due !== null) {
|
|
||||||
secondary = format(todo.due, "P");
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ListItem
|
|
||||||
secondaryAction={
|
|
||||||
<IconButton
|
|
||||||
disabled={todo === undefined}
|
|
||||||
edge="end"
|
|
||||||
onClick={() => deleteTodo(todo?.id!)}
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemButton
|
|
||||||
component={Link}
|
|
||||||
disabled={todo === undefined}
|
|
||||||
to={`/todos/${todo?.id}/`}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Checkbox
|
|
||||||
checked={todo?.complete ?? false}
|
|
||||||
disabled
|
|
||||||
disableRipple
|
|
||||||
edge="start"
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={todo?.task ?? <Skeleton />}
|
|
||||||
secondary={secondary}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Todo;
|
|
@ -1,44 +0,0 @@
|
|||||||
import { Form, Formik } from "formik";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import CheckboxField from "../components/CheckboxField";
|
|
||||||
import DateField from "../components/DateField";
|
|
||||||
import FormActions from "../components/FormActions";
|
|
||||||
import TextField from "../components/TextField";
|
|
||||||
import type { ITodoData } from "../queries";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
initialValues: ITodoData;
|
|
||||||
label: string;
|
|
||||||
onSubmit: (data: ITodoData) => Promise<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
|
||||||
complete: yup.boolean(),
|
|
||||||
due: yup.date().nullable(),
|
|
||||||
task: yup.string().required("Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const TodoForm = ({ initialValues, label, onSubmit }: IProps) => (
|
|
||||||
<Formik<ITodoData>
|
|
||||||
initialValues={initialValues}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
>
|
|
||||||
{({ dirty, isSubmitting }) => (
|
|
||||||
<Form>
|
|
||||||
<TextField fullWidth label="Task" name="task" required />
|
|
||||||
<DateField fullWidth label="Due" name="due" />
|
|
||||||
<CheckboxField fullWidth label="Complete" name="complete" />
|
|
||||||
<FormActions
|
|
||||||
disabled={!dirty}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
label={label}
|
|
||||||
links={[{ label: "Back", to: "/" }]}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default TodoForm;
|
|
@ -1,37 +0,0 @@
|
|||||||
import AppBar from "@mui/material/AppBar";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
|
||||||
import React, { useContext } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { AuthContext } from "src/AuthContext";
|
|
||||||
import AccountMenu from "src/components/AccountMenu";
|
|
||||||
|
|
||||||
const sxToolbar = {
|
|
||||||
paddingLeft: "env(safe-area-inset-left)",
|
|
||||||
paddingRight: "env(safe-area-inset-right)",
|
|
||||||
paddingTop: "env(safe-area-inset-top)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TopBar = () => {
|
|
||||||
const { authenticated } = useContext(AuthContext);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppBar position="fixed">
|
|
||||||
<Toolbar sx={sxToolbar}>
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
|
||||||
<Button color="inherit" component={Link} to="/">
|
|
||||||
Todo
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{authenticated ? <AccountMenu /> : null}
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
<Toolbar sx={{ ...sxToolbar, marginBottom: 2 }} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TopBar;
|
|
@ -1,14 +1,16 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { createRoot } from "react-dom/client";
|
import ReactDOM from 'react-dom/client';
|
||||||
import "./index.css";
|
import './index.css';
|
||||||
import App from "./App";
|
import App from './App';
|
||||||
import reportWebVitals from "./reportWebVitals";
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
const root = createRoot(document.getElementById("root") as HTMLElement);
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import { formatISO } from "date-fns";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
const todoSchema = yup.object({
|
|
||||||
complete: yup.boolean().required(),
|
|
||||||
due: yup.date().nullable(),
|
|
||||||
id: yup.number().required().positive().integer(),
|
|
||||||
task: yup.string().trim().min(1).defined().strict(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
export class Todo {
|
|
||||||
complete: boolean;
|
|
||||||
due: Date | null;
|
|
||||||
id: number;
|
|
||||||
task: string;
|
|
||||||
|
|
||||||
constructor(data: any) {
|
|
||||||
const validatedData = todoSchema.validateSync(data);
|
|
||||||
this.complete = validatedData.complete;
|
|
||||||
this.due = validatedData.due ?? null;
|
|
||||||
this.id = validatedData.id;
|
|
||||||
this.task = validatedData.task;
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): any {
|
|
||||||
return {
|
|
||||||
complete: this.complete,
|
|
||||||
due:
|
|
||||||
this.due !== null
|
|
||||||
? formatISO(this.due, { representation: "date" })
|
|
||||||
: null,
|
|
||||||
id: this.id,
|
|
||||||
task: this.task,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,90 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { Form, Formik, FormikHelpers } from "formik";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import FormActions from "src/components/FormActions";
|
|
||||||
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
|
|
||||||
import PasswordField from "src/components/PasswordField";
|
|
||||||
import Title from "src/components/Title";
|
|
||||||
import { ToastContext } from "src/ToastContext";
|
|
||||||
import { useMutation } from "src/query";
|
|
||||||
|
|
||||||
interface IForm {
|
|
||||||
currentPassword: string;
|
|
||||||
newPassword: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useChangePassword = () => {
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
const { mutateAsync: changePassword } = useMutation(
|
|
||||||
async (data: IForm) => await axios.put("/members/password/", data),
|
|
||||||
);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
|
|
||||||
try {
|
|
||||||
await changePassword(data);
|
|
||||||
addToast("Changed", "success");
|
|
||||||
navigate("/");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
if (error.response?.status === 400) {
|
|
||||||
setFieldError("newPassword", "Password is too weak");
|
|
||||||
} else if (error.response?.status === 401) {
|
|
||||||
setFieldError("currentPassword", "Incorrect password");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addToast("Try again", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
|
||||||
currentPassword: yup.string().required("Required"),
|
|
||||||
newPassword: yup.string().required("Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ChangePassword = () => {
|
|
||||||
const onSubmit = useChangePassword();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title title="Change Password" />
|
|
||||||
<Formik<IForm>
|
|
||||||
initialValues={{ currentPassword: "", newPassword: "" }}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
>
|
|
||||||
{({ dirty, isSubmitting }) => (
|
|
||||||
<Form>
|
|
||||||
<PasswordField
|
|
||||||
autoComplete="current-password"
|
|
||||||
fullWidth
|
|
||||||
label="Current password"
|
|
||||||
name="currentPassword"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<LazyPasswordWithStrengthField
|
|
||||||
autoComplete="new-password"
|
|
||||||
fullWidth
|
|
||||||
label="New password"
|
|
||||||
name="newPassword"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormActions
|
|
||||||
disabled={!dirty}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
label="Change"
|
|
||||||
links={[{ label: "Back", to: "/" }]}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChangePassword;
|
|
@ -1,44 +0,0 @@
|
|||||||
import LinearProgress from "@mui/material/LinearProgress";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { Navigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useQuery } from "../query";
|
|
||||||
import { ToastContext } from "../ToastContext";
|
|
||||||
|
|
||||||
interface IParams {
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConfirmEmail = () => {
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
const params = useParams() as IParams;
|
|
||||||
const token = params.token ?? "";
|
|
||||||
const { isLoading } = useQuery(
|
|
||||||
["Email"],
|
|
||||||
async () => await axios.put("/members/email/", { token }),
|
|
||||||
{
|
|
||||||
onError: (error: any) => {
|
|
||||||
if (error.response?.status === 400) {
|
|
||||||
if (error.response?.data.code === "TOKEN_INVALID") {
|
|
||||||
addToast("Invalid token", "error");
|
|
||||||
} else if (error.response?.data.code === "TOKEN_EXPIRED") {
|
|
||||||
addToast("Token expired", "error");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addToast("Try again", "error");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => addToast("Thanks", "success"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LinearProgress />;
|
|
||||||
} else {
|
|
||||||
return <Navigate to="/" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConfirmEmail;
|
|
@ -1,36 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import TodoForm from "../components/TodoForm";
|
|
||||||
import Title from "../components/Title";
|
|
||||||
import type { ITodoData } from "../queries";
|
|
||||||
import { useCreateTodoMutation } from "../queries";
|
|
||||||
import { ToastContext } from "../ToastContext";
|
|
||||||
|
|
||||||
const CreateTodo = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
const { mutateAsync: createTodo } = useCreateTodoMutation();
|
|
||||||
|
|
||||||
const onSubmit = async (data: ITodoData) => {
|
|
||||||
try {
|
|
||||||
await createTodo(data);
|
|
||||||
navigate("/");
|
|
||||||
} catch {
|
|
||||||
addToast("Try Again", "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title title="Create a Todo" />
|
|
||||||
<TodoForm
|
|
||||||
initialValues={{ complete: false, due: null, task: "" }}
|
|
||||||
label="Create"
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateTodo;
|
|
@ -1,52 +0,0 @@
|
|||||||
import Skeleton from "@mui/material/Skeleton";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
|
|
||||||
import TodoForm from "../components/TodoForm";
|
|
||||||
import Title from "../components/Title";
|
|
||||||
import type { ITodoData } from "../queries";
|
|
||||||
import { useEditTodoMutation, useTodoQuery } from "../queries";
|
|
||||||
import { ToastContext } from "../ToastContext";
|
|
||||||
|
|
||||||
interface Iparams {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditTodo = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const params = useParams<keyof Iparams>() as Iparams;
|
|
||||||
const todoId = parseInt(params.id, 10);
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
const { data: todo } = useTodoQuery(todoId);
|
|
||||||
const { mutateAsync: editTodo } = useEditTodoMutation(todoId);
|
|
||||||
|
|
||||||
const onSubmit = async (data: ITodoData) => {
|
|
||||||
try {
|
|
||||||
await editTodo(data);
|
|
||||||
navigate("/");
|
|
||||||
} catch {
|
|
||||||
addToast("Try again", "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title title="Edit todo" />
|
|
||||||
{todo === undefined ? (
|
|
||||||
<Skeleton height="80px" />
|
|
||||||
) : (
|
|
||||||
<TodoForm
|
|
||||||
initialValues={{
|
|
||||||
complete: todo.complete,
|
|
||||||
due: todo.due,
|
|
||||||
task: todo.task,
|
|
||||||
}}
|
|
||||||
label="Edit"
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditTodo;
|
|
@ -1,82 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { Form, Formik } from "formik";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import EmailField from "src/components/EmailField";
|
|
||||||
import FormActions from "src/components/FormActions";
|
|
||||||
import Title from "src/components/Title";
|
|
||||||
import { useMutation } from "src/query";
|
|
||||||
import { ToastContext } from "src/ToastContext";
|
|
||||||
|
|
||||||
interface IForm {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useForgottenPassword = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
|
|
||||||
const { mutateAsync: forgottenPassword } = useMutation(
|
|
||||||
async (data: IForm) =>
|
|
||||||
await axios.post("/members/forgotten-password/", data),
|
|
||||||
);
|
|
||||||
|
|
||||||
return async (data: IForm) => {
|
|
||||||
try {
|
|
||||||
await forgottenPassword(data);
|
|
||||||
addToast("Reset link sent to your email", "success");
|
|
||||||
navigate("/login/");
|
|
||||||
} catch {
|
|
||||||
addToast("Try again", "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
|
||||||
email: yup.string().email("Email invalid").required("Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ForgottenPassword = () => {
|
|
||||||
const onSubmit = useForgottenPassword();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title title="Forgotten password" />
|
|
||||||
<Formik<IForm>
|
|
||||||
initialValues={{
|
|
||||||
email: (location.state as any)?.email ?? "",
|
|
||||||
}}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
>
|
|
||||||
{({ dirty, isSubmitting, values }) => (
|
|
||||||
<Form>
|
|
||||||
<EmailField fullWidth label="Email" name="email" required />
|
|
||||||
<FormActions
|
|
||||||
disabled={!dirty}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
label="Send email"
|
|
||||||
links={[
|
|
||||||
{
|
|
||||||
label: "Login",
|
|
||||||
to: "/login/",
|
|
||||||
state: { email: values.email },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Register",
|
|
||||||
to: "/register/",
|
|
||||||
state: { email: values.email },
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ForgottenPassword;
|
|
@ -1,99 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { Form, Formik, FormikHelpers } from "formik";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import { AuthContext } from "../AuthContext";
|
|
||||||
import EmailField from "../components/EmailField";
|
|
||||||
import FormActions from "../components/FormActions";
|
|
||||||
import PasswordField from "../components/PasswordField";
|
|
||||||
import Title from "../components/Title";
|
|
||||||
import { ToastContext } from "../ToastContext";
|
|
||||||
import { useMutation } from "../query";
|
|
||||||
|
|
||||||
interface IForm {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useLogin = () => {
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
const { setAuthenticated } = useContext(AuthContext);
|
|
||||||
const { mutateAsync: login } = useMutation(
|
|
||||||
async (data: IForm) => await axios.post("/sessions/", data),
|
|
||||||
);
|
|
||||||
|
|
||||||
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
|
|
||||||
try {
|
|
||||||
await login(data);
|
|
||||||
setAuthenticated(true);
|
|
||||||
navigate((location.state as any)?.from ?? "/");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
setFieldError("email", "Invalid credentials");
|
|
||||||
setFieldError("password", "Invalid credentials");
|
|
||||||
} else {
|
|
||||||
addToast("Try again", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
|
||||||
email: yup.string().email("Email invalid").required("Required"),
|
|
||||||
password: yup.string().required("Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const Login = () => {
|
|
||||||
const onSubmit = useLogin();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title title="Login" />
|
|
||||||
<Formik<IForm>
|
|
||||||
initialValues={{
|
|
||||||
email: (location.state as any)?.email ?? "",
|
|
||||||
password: "",
|
|
||||||
}}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
>
|
|
||||||
{({ dirty, isSubmitting, values }) => (
|
|
||||||
<Form>
|
|
||||||
<EmailField fullWidth label="Email" name="email" required />
|
|
||||||
<PasswordField
|
|
||||||
autoComplete="password"
|
|
||||||
fullWidth
|
|
||||||
label="Password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormActions
|
|
||||||
disabled={!dirty}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
label="Login"
|
|
||||||
links={[
|
|
||||||
{
|
|
||||||
label: "Reset password",
|
|
||||||
to: "/forgotten-password/",
|
|
||||||
state: { email: values.email },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Register",
|
|
||||||
to: "/register/",
|
|
||||||
state: { email: values.email },
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Login;
|
|
@ -1,99 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { Form, Formik, FormikHelpers } from "formik";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import EmailField from "src/components/EmailField";
|
|
||||||
import FormActions from "src/components/FormActions";
|
|
||||||
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
|
|
||||||
import Title from "src/components/Title";
|
|
||||||
import { ToastContext } from "src/ToastContext";
|
|
||||||
import { useMutation } from "src/query";
|
|
||||||
|
|
||||||
interface IForm {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useRegister = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
const { mutateAsync: register } = useMutation(
|
|
||||||
async (data: IForm) => await axios.post("/members/", data),
|
|
||||||
);
|
|
||||||
|
|
||||||
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
|
|
||||||
try {
|
|
||||||
await register(data);
|
|
||||||
addToast("Registered", "success");
|
|
||||||
navigate("/login/", { state: { email: data.email } });
|
|
||||||
} catch (error: any) {
|
|
||||||
if (
|
|
||||||
error.response?.status === 400 &&
|
|
||||||
error.response?.data.code === "WEAK_PASSWORD"
|
|
||||||
) {
|
|
||||||
setFieldError("password", "Password is too weak");
|
|
||||||
} else {
|
|
||||||
addToast("Try again", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
|
||||||
email: yup.string().email("Email invalid").required("Required"),
|
|
||||||
password: yup.string().required("Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const Register = () => {
|
|
||||||
const location = useLocation();
|
|
||||||
const onSubmit = useRegister();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title title="Register" />
|
|
||||||
<Formik<IForm>
|
|
||||||
initialValues={{
|
|
||||||
email: (location.state as any)?.email ?? "",
|
|
||||||
password: "",
|
|
||||||
}}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
>
|
|
||||||
{({ dirty, isSubmitting, values }) => (
|
|
||||||
<Form>
|
|
||||||
<EmailField fullWidth label="Email" name="email" required />
|
|
||||||
<LazyPasswordWithStrengthField
|
|
||||||
autoComplete="new-password"
|
|
||||||
fullWidth
|
|
||||||
label="Password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormActions
|
|
||||||
disabled={!dirty}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
label="Register"
|
|
||||||
links={[
|
|
||||||
{
|
|
||||||
label: "Login",
|
|
||||||
to: "/login/",
|
|
||||||
state: { email: values.email },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Reset password",
|
|
||||||
to: "/forgotten-password/",
|
|
||||||
state: { email: values.email },
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Register;
|
|
@ -1,90 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { Form, Formik, FormikHelpers } from "formik";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
|
|
||||||
import FormActions from "src/components/FormActions";
|
|
||||||
import Title from "src/components/Title";
|
|
||||||
import { useMutation } from "src/query";
|
|
||||||
import { ToastContext } from "src/ToastContext";
|
|
||||||
|
|
||||||
interface IForm {
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IParams {
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useResetPassword = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const params = useParams() as IParams;
|
|
||||||
const token = params.token ?? "";
|
|
||||||
const { addToast } = useContext(ToastContext);
|
|
||||||
|
|
||||||
const { mutateAsync: reset } = useMutation(
|
|
||||||
async (password: string) =>
|
|
||||||
await axios.put("/members/reset-password/", { password, token }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
|
|
||||||
try {
|
|
||||||
await reset(data.password);
|
|
||||||
addToast("Success", "success");
|
|
||||||
navigate("/login/");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 400) {
|
|
||||||
if (error.response?.data.code === "WEAK_PASSWORD") {
|
|
||||||
setFieldError("newPassword", "Password is too weak");
|
|
||||||
} else if (error.response?.data.code === "TOKEN_INVALID") {
|
|
||||||
addToast("Invalid token", "error");
|
|
||||||
} else if (error.response?.data.code === "TOKEN_EXPIRED") {
|
|
||||||
addToast("Token expired", "error");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addToast("Try again", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
|
||||||
email: yup.string().email("Email invalid").required("Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ResetPassword = () => {
|
|
||||||
const onSubmit = useResetPassword();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title title="Reset password" />
|
|
||||||
<Formik<IForm>
|
|
||||||
initialValues={{ password: "" }}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
>
|
|
||||||
{({ dirty, isSubmitting, values }) => (
|
|
||||||
<Form>
|
|
||||||
<LazyPasswordWithStrengthField
|
|
||||||
autoComplete="new-password"
|
|
||||||
fullWidth
|
|
||||||
label="Password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormActions
|
|
||||||
disabled={!dirty}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
label="Reset password"
|
|
||||||
links={[{ label: "Login", to: "/login/" }]}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ResetPassword;
|
|
@ -1,38 +0,0 @@
|
|||||||
import Fab from "@mui/material/Fab";
|
|
||||||
import List from "@mui/material/List";
|
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
|
||||||
import { Link, Navigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import Todo from "../components/Todo";
|
|
||||||
import { useTodosQuery } from "../queries";
|
|
||||||
|
|
||||||
const Todos = () => {
|
|
||||||
const { data: todos } = useTodosQuery();
|
|
||||||
|
|
||||||
if (todos?.length === 0) {
|
|
||||||
return <Navigate to="/todos/new/" />;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List>
|
|
||||||
{todos !== undefined
|
|
||||||
? todos.map((todo) => <Todo key={todo.id} todo={todo} />)
|
|
||||||
: [1, 2, 3].map((id) => <Todo key={-id} />)}
|
|
||||||
</List>
|
|
||||||
<Fab
|
|
||||||
component={Link}
|
|
||||||
sx={{
|
|
||||||
bottom: (theme) => theme.spacing(2),
|
|
||||||
position: "fixed",
|
|
||||||
right: (theme) => theme.spacing(2),
|
|
||||||
}}
|
|
||||||
to="/todos/new/"
|
|
||||||
>
|
|
||||||
<AddIcon />
|
|
||||||
</Fab>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Todos;
|
|
@ -1,72 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { Todo } from "./models";
|
|
||||||
import { useMutation, useQuery } from "./query";
|
|
||||||
|
|
||||||
export const STALE_TIME = 1000 * 60 * 5; // 5 mins
|
|
||||||
|
|
||||||
export const useTodosQuery = () =>
|
|
||||||
useQuery<Todo[]>(
|
|
||||||
["todos"],
|
|
||||||
async () => {
|
|
||||||
const response = await axios.get("/todos/");
|
|
||||||
return response.data.todos.map((json: any) => new Todo(json));
|
|
||||||
},
|
|
||||||
{ staleTime: STALE_TIME },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useTodoQuery = (id: number) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useQuery<Todo>(
|
|
||||||
["todos", id.toString()],
|
|
||||||
async () => {
|
|
||||||
const response = await axios.get(`/todos/${id}/`);
|
|
||||||
return new Todo(response.data);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initialData: () => {
|
|
||||||
return queryClient
|
|
||||||
.getQueryData<Todo[]>(["todos"])
|
|
||||||
?.filter((todo: Todo) => todo.id === id)[0];
|
|
||||||
},
|
|
||||||
staleTime: STALE_TIME,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ITodoData {
|
|
||||||
complete: boolean;
|
|
||||||
due: Date | null;
|
|
||||||
task: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCreateTodoMutation = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation(
|
|
||||||
async (data: ITodoData) => await axios.post("/todos/", data),
|
|
||||||
{
|
|
||||||
onSuccess: () => queryClient.invalidateQueries(["todos"]),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useEditTodoMutation = (id: number) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation(
|
|
||||||
async (data: ITodoData) => await axios.put(`/todos/${id}/`, data),
|
|
||||||
{
|
|
||||||
onSuccess: () => queryClient.invalidateQueries(["todos"]),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteTodoMutation = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation(
|
|
||||||
async (id: number) => await axios.delete(`/todos/${id}/`),
|
|
||||||
{
|
|
||||||
onSuccess: () => queryClient.invalidateQueries(["todos"]),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,80 +0,0 @@
|
|||||||
import axios, { AxiosError } from "axios";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import {
|
|
||||||
MutationFunction,
|
|
||||||
QueryFunction,
|
|
||||||
QueryFunctionContext,
|
|
||||||
QueryKey,
|
|
||||||
useMutation as useReactMutation,
|
|
||||||
UseMutationOptions,
|
|
||||||
UseMutationResult,
|
|
||||||
useQuery as useReactQuery,
|
|
||||||
UseQueryOptions,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { AuthContext } from "src/AuthContext";
|
|
||||||
|
|
||||||
const MAX_FAILURES = 2;
|
|
||||||
|
|
||||||
export function useQuery<
|
|
||||||
TQueryFnData = unknown,
|
|
||||||
TData = TQueryFnData,
|
|
||||||
TQueryKey extends QueryKey = QueryKey,
|
|
||||||
>(
|
|
||||||
queryKey: TQueryKey,
|
|
||||||
queryFn: QueryFunction<TQueryFnData, TQueryKey>,
|
|
||||||
options?: UseQueryOptions<TQueryFnData, AxiosError, TData, TQueryKey>,
|
|
||||||
): UseQueryResult<TData, AxiosError> {
|
|
||||||
const { setAuthenticated } = useContext(AuthContext);
|
|
||||||
|
|
||||||
return useReactQuery<TQueryFnData, AxiosError, TData, TQueryKey>(
|
|
||||||
queryKey,
|
|
||||||
async (context: QueryFunctionContext<TQueryKey>) => {
|
|
||||||
try {
|
|
||||||
return await queryFn(context);
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
|
||||||
setAuthenticated(false);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retry: (failureCount: number, error: AxiosError) =>
|
|
||||||
failureCount < MAX_FAILURES &&
|
|
||||||
(!error.response || error.response.status >= 500),
|
|
||||||
...options,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMutation<
|
|
||||||
TData = unknown,
|
|
||||||
TVariables = void,
|
|
||||||
TContext = unknown,
|
|
||||||
>(
|
|
||||||
mutationFn: MutationFunction<TData, TVariables>,
|
|
||||||
options?: UseMutationOptions<TData, AxiosError, TVariables, TContext>,
|
|
||||||
): UseMutationResult<TData, AxiosError, TVariables, TContext> {
|
|
||||||
const { setAuthenticated } = useContext(AuthContext);
|
|
||||||
|
|
||||||
return useReactMutation<TData, AxiosError, TVariables, TContext>(
|
|
||||||
async (variables: TVariables) => {
|
|
||||||
try {
|
|
||||||
return await mutationFn(variables);
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
|
||||||
setAuthenticated(false);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retry: (failureCount: number, error: AxiosError) =>
|
|
||||||
failureCount < MAX_FAILURES &&
|
|
||||||
(!error.response || error.response.status >= 500),
|
|
||||||
...options,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,8 +1,8 @@
|
|||||||
import { ReportHandler } from "web-vitals";
|
import { ReportHandler } from 'web-vitals';
|
||||||
|
|
||||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
getCLS(onPerfEntry);
|
getCLS(onPerfEntry);
|
||||||
getFID(onPerfEntry);
|
getFID(onPerfEntry);
|
||||||
getFCP(onPerfEntry);
|
getFCP(onPerfEntry);
|
||||||
|
@ -2,8 +2,4 @@
|
|||||||
// allows you to do things like:
|
// allows you to do things like:
|
||||||
// expect(element).toHaveTextContent(/react/i)
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
import "@testing-library/jest-dom";
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
window.scrollTo = (x, y) => {
|
|
||||||
document.documentElement.scrollTop = y;
|
|
||||||
};
|
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
import { FieldMetaProps } from "formik";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const combineHelperText = <T,>(
|
|
||||||
helperText: React.ReactNode | string | undefined,
|
|
||||||
meta: FieldMetaProps<T>,
|
|
||||||
) => {
|
|
||||||
if (Boolean(meta.error) && meta.touched) {
|
|
||||||
if (typeof helperText === "string") {
|
|
||||||
return `${meta.error}. ${helperText ?? ""}`;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{meta.error}. {helperText}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return helperText;
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
@ -17,5 +20,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user