Compare commits
17 Commits
71cb9bdff6
...
master
Author | SHA1 | Date | |
---|---|---|---|
0b36de6705
|
|||
8f3973c961
|
|||
4e6f6f5e10
|
|||
181dfb06a2
|
|||
0688648d75 | |||
3a907399bd
|
|||
c8ba1f2676 | |||
141eac7d17
|
|||
5e72b2ebc9
|
|||
056401bb22
|
|||
bf31eeb652
|
|||
1b9d1ee7d5
|
|||
1f7066a2d9
|
|||
36fadc059a
|
|||
4aba3db888
|
|||
7611c9c2fa | |||
719889e2bc
|
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM node:18-bullseye-slim as frontend
|
||||
|
||||
WORKDIR /frontend/
|
||||
COPY frontend/package.json frontend/package-lock.json /frontend/
|
||||
RUN npm install
|
||||
|
||||
COPY frontend /frontend/
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.10.1-slim-bullseye
|
||||
RUN apt-get update && apt install dumb-init
|
||||
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
|
||||
EXPOSE 8080
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
|
||||
COPY hypercorn.toml /app/
|
||||
CMD ["pdm", "run", "hypercorn", "--config", "hypercorn.toml", "backend.run:app"]
|
||||
|
||||
RUN python -m venv /ve
|
||||
ENV PATH=/ve/bin:${PATH}
|
||||
RUN pip install --no-cache-dir pdm
|
||||
|
||||
COPY backend/pdm.lock backend/pyproject.toml /app/
|
||||
RUN pdm install --prod --no-lock --no-editable
|
||||
|
||||
COPY --from=frontend /frontend/build/*.js* /app/backend/static/
|
||||
COPY --from=frontend /frontend/build/*.png /frontend/build/*.svg /app/backend/static/
|
||||
COPY --from=frontend /frontend/build/index.html \
|
||||
/app/backend/templates/
|
||||
COPY --from=frontend /frontend/build/static/. /app/backend/static/
|
||||
|
||||
COPY backend/src/ /app/
|
||||
|
||||
USER nobody
|
11
README.md
11
README.md
@@ -1,5 +1,9 @@
|
||||
# Todo API
|
||||
|
||||
## To run
|
||||
|
||||
`docker-compose up -d`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Development Workflow
|
||||
@@ -9,9 +13,12 @@
|
||||
### Dependencies
|
||||
|
||||
- React
|
||||
- React helment async: manage changes to document head.
|
||||
- Material UI
|
||||
- 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
|
||||
|
||||
|
@@ -1,4 +1,3 @@
|
||||
<<<<<<< HEAD
|
||||
# Backend Technical Write-up
|
||||
|
||||
## Steps
|
||||
@@ -16,16 +15,6 @@
|
||||
|
||||
`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.
|
||||
@@ -35,11 +24,6 @@ This is because requests sent to `/sessions` will be redirected to `/sessions/`
|
||||
## 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
|
||||
|
||||
## General Bits of Information
|
||||
|
||||
### SameSite setting
|
||||
|
||||
@@ -53,10 +37,6 @@ Pydantic is to validate the schema/the shape of our input/output (works with JSO
|
||||
|
||||
Class full of data. Meant to be used to serialize data into JSON objects.
|
||||
|
||||
### 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`.
|
||||
@@ -66,14 +46,3 @@ This is because requests sent to `/sessions` will be redirected to `/sessions/`
|
||||
|
||||
- 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.
|
||||
>>>>>>> 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",
|
||||
"itsdangerous>=2.1.2",
|
||||
"quart-rate-limiter>=0.7.0",
|
||||
"pydantic[email]==1.10.11",
|
||||
"pydantic[email]",
|
||||
"quart-schema>=0.14.3",
|
||||
"quart-db[postgresql]>=0.4.1",
|
||||
"httpx>=0.23.1",
|
||||
|
@@ -7,4 +7,7 @@ blueprint = Blueprint("control", __name__)
|
||||
@blueprint.get("/control/ping/")
|
||||
@rate_exempt
|
||||
async def ping() -> ResponseReturnValue:
|
||||
"""Ping the server
|
||||
Check if server is up and running.
|
||||
"""
|
||||
return {"ping": "pong"}
|
||||
|
11
backend/src/backend/blueprints/serving.py
Normal file
11
backend/src/backend/blueprints/serving.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from quart import Blueprint, ResponseReturnValue, render_template
|
||||
from quart_rate_limiter import rate_exempt
|
||||
|
||||
blueprint = Blueprint("serving", __name__)
|
||||
|
||||
|
||||
@blueprint.get("/")
|
||||
@blueprint.get("/<path:path>")
|
||||
@rate_exempt
|
||||
async def index(path: str | None = None) -> ResponseReturnValue:
|
||||
return await render_template("index.html")
|
@@ -21,6 +21,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError
|
||||
# Each blueprint is a logical collection of features in our web app
|
||||
from backend.blueprints.control import blueprint as control_blueprint
|
||||
from backend.blueprints.members import blueprint as members_blueprint
|
||||
from backend.blueprints.serving import blueprint as serving_blueprint
|
||||
from backend.blueprints.sessions import blueprint as sessions_blueprint
|
||||
from backend.blueprints.todos import blueprint as todos_blueprint
|
||||
|
||||
@@ -48,6 +49,7 @@ app.register_blueprint(control_blueprint)
|
||||
app.register_blueprint(sessions_blueprint)
|
||||
app.register_blueprint(members_blueprint)
|
||||
app.register_blueprint(todos_blueprint)
|
||||
app.register_blueprint(serving_blueprint)
|
||||
|
||||
|
||||
# Rate limiting
|
||||
|
38
docker-compose.yaml
Normal file
38
docker-compose.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
version: "3"
|
||||
services:
|
||||
web-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- my_network
|
||||
environment:
|
||||
TODO_SECRET_KEY: "secret key"
|
||||
TODO_QUART_DB_DATABASE_URL: postgres://postgres:postgres_password@postgres:5432/todo
|
||||
TODO_QUART_DB_DATA_PATH: migrations/data.py
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres_password
|
||||
POSTGRES_DB: todo
|
||||
POSTGRES_USER: postgres
|
||||
networks:
|
||||
- my_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
my_network:
|
4909
frontend/package-lock.json
generated
4909
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,35 +3,42 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/roboto": "^5.0.5",
|
||||
"@mui/icons-material": "^5.14.1",
|
||||
"@mui/lab": "^5.0.0-alpha.137",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@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/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.38",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/node": "^16.11.45",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"axios": "^0.27.2",
|
||||
"date-fns": "^2.29.1",
|
||||
"formik": "^2.2.9",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet-async": "^1.3.0",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.7.4",
|
||||
"web-vitals": "^2.1.4",
|
||||
"webpack": ">=5.76.0"
|
||||
"yup": "^0.32.11",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"scripts": {
|
||||
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
|
||||
"format": "eslint --fix \"src/**/*.{ts,tsx}\" && prettier --parser typescript --write \"src/**/*.{ts,tsx}\"",
|
||||
"lint": " eslint \"src/**/*.{ts,tsx}\" && prettier --parser typescript --list-different \"src/**/*.{ts,tsx}\"",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
|
||||
"format": "eslint --fix \"src/**/*.{ts,tsx}\" && prettier --parser typescript --write \"src/**/*.{ts,tsx}\"",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\" && prettier --parser typescript --list-different \"src/**/*.{ts,tsx}\""
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@@ -61,17 +68,16 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.5",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-explorer": "^2.5.3"
|
||||
"@types/zxcvbn": "^4.4.1",
|
||||
"eslint": "^8.20.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.3.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"prettier": "^2.7.1",
|
||||
"source-map-explorer": "^2.5.2"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "all"
|
||||
},
|
||||
"proxy": "http://localhost:5050"
|
||||
"proxy": "http://127.0.0.1:5050"
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import "./App.css";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Roboto font and its weights
|
||||
import "@fontsource/roboto/300.css";
|
||||
import "@fontsource/roboto/400.css";
|
||||
@@ -22,21 +24,32 @@ import { AuthContextProvider } from "./AuthContext";
|
||||
// React router
|
||||
import Router from "./Router";
|
||||
|
||||
function App() {
|
||||
// Toasts
|
||||
import Toasts from "./components/Toasts";
|
||||
import { ToastContextProvider } from "./ToastContext";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<AuthContextProvider>
|
||||
<HelmetProvider>
|
||||
<Helmet>
|
||||
<title>Todo</title>
|
||||
</Helmet>
|
||||
<ThemeProvider>
|
||||
<Container maxWidth="md">
|
||||
<Router />
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
</HelmetProvider>
|
||||
</AuthContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthContextProvider>
|
||||
<HelmetProvider>
|
||||
<Helmet>
|
||||
<title>Todo</title>
|
||||
</Helmet>
|
||||
<ThemeProvider>
|
||||
<ToastContextProvider>
|
||||
<Container maxWidth="md">
|
||||
<Toasts />
|
||||
<Router />
|
||||
</Container>
|
||||
</ToastContextProvider>
|
||||
</ThemeProvider>
|
||||
</HelmetProvider>
|
||||
</AuthContextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
@@ -1,9 +1,64 @@
|
||||
import { BrowserRouter, Routes } from "react-router-dom";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
const Router = () => {
|
||||
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>
|
||||
<Routes>{}</Routes>
|
||||
</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;
|
||||
|
49
frontend/src/ToastContext.tsx
Normal file
49
frontend/src/ToastContext.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
};
|
67
frontend/src/components/AccountMenu.tsx
Normal file
67
frontend/src/components/AccountMenu.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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;
|
38
frontend/src/components/CheckboxField.tsx
Normal file
38
frontend/src/components/CheckboxField.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
30
frontend/src/components/DateField.tsx
Normal file
30
frontend/src/components/DateField.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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;
|
22
frontend/src/components/EmailField.tsx
Normal file
22
frontend/src/components/EmailField.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
43
frontend/src/components/FormActions.tsx
Normal file
43
frontend/src/components/FormActions.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
19
frontend/src/components/LazyPasswordWithStrengthField.tsx
Normal file
19
frontend/src/components/LazyPasswordWithStrengthField.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
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;
|
39
frontend/src/components/PasswordField.tsx
Normal file
39
frontend/src/components/PasswordField.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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;
|
58
frontend/src/components/PasswordWithStrengthField.tsx
Normal file
58
frontend/src/components/PasswordWithStrengthField.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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,5 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { AuthContext } from "src/AuthContext";
|
||||
|
||||
interface IProps {
|
||||
@@ -8,13 +9,11 @@ interface IProps {
|
||||
|
||||
const RequireAuth = ({ children }: IProps) => {
|
||||
const { authenticated } = useContext(AuthContext);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
if (authenticated) {
|
||||
return <>{children}</>;
|
||||
} else {
|
||||
// re-route user back to login page if not logged in
|
||||
return <Navigate state={{ from: location }} to="/login/" />;
|
||||
}
|
||||
};
|
||||
|
@@ -4,8 +4,6 @@ import { useLocation } from "react-router";
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// pathname changes => scroll to top
|
||||
// scrolling only on navigation
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
21
frontend/src/components/TextField.tsx
Normal file
21
frontend/src/components/TextField.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
47
frontend/src/components/Toasts.tsx
Normal file
47
frontend/src/components/Toasts.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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;
|
61
frontend/src/components/Todo.tsx
Normal file
61
frontend/src/components/Todo.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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;
|
44
frontend/src/components/TodoForm.tsx
Normal file
44
frontend/src/components/TodoForm.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
37
frontend/src/components/TopBar.tsx
Normal file
37
frontend/src/components/TopBar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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;
|
36
frontend/src/models.tsx
Normal file
36
frontend/src/models.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
90
frontend/src/pages/ChangePassword.tsx
Normal file
90
frontend/src/pages/ChangePassword.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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;
|
44
frontend/src/pages/ConfirmEmail.tsx
Normal file
44
frontend/src/pages/ConfirmEmail.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
36
frontend/src/pages/CreateTodo.tsx
Normal file
36
frontend/src/pages/CreateTodo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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;
|
52
frontend/src/pages/EditTodo.tsx
Normal file
52
frontend/src/pages/EditTodo.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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;
|
82
frontend/src/pages/ForgottenPassword.tsx
Normal file
82
frontend/src/pages/ForgottenPassword.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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;
|
99
frontend/src/pages/Login.tsx
Normal file
99
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
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;
|
99
frontend/src/pages/Register.tsx
Normal file
99
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
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;
|
90
frontend/src/pages/ResetPassword.tsx
Normal file
90
frontend/src/pages/ResetPassword.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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;
|
38
frontend/src/pages/Todos.tsx
Normal file
38
frontend/src/pages/Todos.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
72
frontend/src/queries.ts
Normal file
72
frontend/src/queries.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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"]),
|
||||
},
|
||||
);
|
||||
};
|
80
frontend/src/query.ts
Normal file
80
frontend/src/query.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
21
frontend/src/utils.tsx
Normal file
21
frontend/src/utils.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
};
|
4
hypercorn.toml
Normal file
4
hypercorn.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
accesslog = "-"
|
||||
access_log_format = "%(t)s %(h)s %(f)s - %(S)s '%(r)s' %(s)s %(b)s %(D)s"
|
||||
bind = "0.0.0.0:8080"
|
||||
errorlog = "-"
|
Reference in New Issue
Block a user