Compare commits

43 Commits

Author SHA1 Message Date
3a907399bd Feat(backend): update deps 2023-09-09 00:27:42 -04:00
141eac7d17 Feat(frontend): Create/Edit/Delete todos 2023-09-04 17:25:45 -04:00
5e72b2ebc9 Merge branch 'backend' into frontend 2023-09-04 16:48:01 -04:00
056401bb22 Feat(frontend): email, login, password for user 2023-09-04 16:38:53 -04:00
bf31eeb652 Feat(frontend): navigation and registration page 2023-09-04 16:29:32 -04:00
1b9d1ee7d5 Feat(frontend): Toasts 2023-09-04 16:13:20 -04:00
1f7066a2d9 Feat(frontend): React Query for interacting with the backend 2023-09-04 16:08:44 -04:00
36fadc059a Feat(frontend): Model the todo class 2023-09-04 15:52:53 -04:00
4aba3db888 Feat(frontend): Styled Form Components
- Updated README.md
2023-09-04 15:46:17 -04:00
719889e2bc Feat(Backend): Added docs to openAPI 2023-07-21 22:45:28 -04:00
71cb9bdff6 Feat(Frontend): Added Routing 2023-07-21 22:37:28 -04:00
a73afc90d9 Feat(Frontend): Authentication Context 2023-07-21 22:22:19 -04:00
046726de11 Fix(Frontend): Set maxWidth to md
- 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`).
2023-07-21 18:23:34 -04:00
8bc62416ec Feat(Frontend): added ThemeProvider
- ThemeProvider will change the default Material UI looks. CSSBaseline
  in ThemeProvider will reset and normalize the browser's styling,
  making sure our app looks the same in any browser.
- ThemeProvider will be used as the parent of any styled components.
2023-07-21 18:11:24 -04:00
c36993f974 Merge branch 'master' into frontend 2023-07-21 15:51:14 -04:00
d018dfa0c0 Feat(Frontend): Reset 2023-07-21 15:34:16 -04:00
22374728be Feat(Docs): Added docs 2023-07-21 14:58:45 -04:00
1916a2d0da Merge pull request #10 from minhtrannhat/backend
Fix(Doc): Corrected documentation
2023-07-16 23:45:50 -04:00
e2c464671b Fix(Doc): Corrected documentation 2023-07-16 23:45:09 -04:00
c596838489 Merge pull request #9 from minhtrannhat/backend
Fix(testing): Ctxmanager was unable to authenticate users
2023-07-16 23:41:53 -04:00
55437a561c Merge branch 'master' into backend 2023-07-16 23:35:54 -04:00
979e504c0d Fix(backend): Updated authenticated Ctxmanager
- After Quart update, we can't test if an user is authenticated anymore
  with only `quart` but we also have to rely on
  `quart_auth.authenticated_client`. All tests passed after changes
  were made.
2023-07-16 23:25:37 -04:00
62daa16646 Feat(Doc): Improved documentation for backend 2023-07-16 22:07:30 -04:00
eab8670780 Fix(backend): Updated Github CI 2023-07-14 15:58:58 -04:00
a4ea41bef1 Fix(backend): Update deps and postgreSQL version to 15 2023-07-13 10:30:04 -04:00
6859f0c0f2 Fix(backend): Update deps and postgreSQL version to 15 2023-07-12 10:34:17 -04:00
fe23b07e4e Feat(docs): Added more documentation on backend 2023-02-20 16:50:39 -05:00
Minh Tran Nhat
1b65643680 Merge pull request #7 from minhtrannhat/backend_quartz
Todos CRUD functionalities & tests
2022-12-27 14:17:53 -05:00
minhtrannhat
d92982479c Feat(API): CRUD functionalities with todo items
- wrote tests for todo items workflow
2022-12-27 14:14:25 -05:00
minhtrannhat
9564b13879 Merge branch 'master' into backend_quartz 2022-12-26 15:00:49 -05:00
minhtrannhat
3c78fe9133 Feat(API): members CRUD functionalities
- Added tests for members flow (creating/deleting/reset)
- Various fixes to tooling
2022-12-26 14:59:32 -05:00
minhtrannhat
21fd769fa9 Feat(API): members CRUD functionalities
- Added tests for members flow (creating/deleting/reset)
- Various fixes to tooling
2022-12-26 14:56:54 -05:00
minhtrannhat
690ec33ed1 Merge branch 'master' into backend_quartz 2022-12-24 22:05:53 -05:00
Minh Tran Nhat
026be1328e Feat(API + Docs): Handle user sessions (#5)
- Improved documentation
- Wrote tests for user sessions/authentications: including session
  flow(login, status, logout)
2022-12-25 03:05:01 +00:00
minhtrannhat
c9ff07342f Merge remote-tracking branch 'origin/master' into backend_quartz 2022-12-24 22:02:58 -05:00
minhtrannhat
f129c6884e Feat(API + Docs): Handle user sessions
- Improved documentation
- Wrote tests for user sessions/authentications: including session
  flow(login, status, logout)
2022-12-24 22:01:23 -05:00
Minh Tran Nhat
0c42cf5620 Merge pull request #4 from minhtrannhat/backend_quartz
Fix(API): Stop mypy from complaining
2022-12-23 20:19:40 -05:00
minhtrannhat
8a695f2efb Fix(API): Stop mypy from complaining 2022-12-23 20:18:39 -05:00
minhtrannhat
c641ddd47d Feat(API): Database schema and models defined
- Wrote tests for database migrations and populate with test data
2022-12-23 20:07:36 -05:00
minhtrannhat
ad5596c61c Fix(API): Added email template
- Fix the missing logging library
2022-12-21 10:59:46 -05:00
minhtrannhat
8ef0ccf32e Fix(Frontend): Security vulnerability 2022-12-15 10:08:06 -05:00
minhtrannhat
574a9d17dd Fix(Frontend): Update packages
- Audit security vulnerabilities
2022-12-15 10:03:32 -05:00
minhtrannhat
61bac977ff Feat & Fix(API): Sending emails to users
- Send emails to users when they register or when they reset their
  password.
- Fix `.gitignore` not recoginizing changes in `lib` folder
2022-12-15 09:45:16 -05:00
78 changed files with 12740 additions and 34855 deletions

View File

@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [master]
branches: [backend, frontend]
pull_request:
branches: [master]
workflow_dispatch:

View File

@@ -2,37 +2,58 @@
## Frontend
### Development dependencies
### Development Workflow
- `prettier`: Formatter
- Format, lint and test with `npm run {format, lint, test}`.
### 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
Inside the `frontend/` folder
## Backend
### Development workflow
### Development Workflow
- 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.
- 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 tests, lints, formats with `pdm run {test, lint, format}`.
### Dependencies
#### Python dependencies
- `quart`: a micro-webframework, async version of Flask.
- `black`: Code formatter
- `isort`: Import formatter
- `mypy`: Type checking
- `flake8`: General Python bugs
- `vulture`: Find unused code in Python programs
- `pytest`: For testing (turbocharged with `async`)
- `black`: Code formatter.
- `isort`: Import formatter.
- `mypy`: Type checking.
- `flake8`: General Python bugs.
- `vulture`: Find unused code in Python programs.
- `pytest`: For testing (turbocharged with `async`).
- `bcrypt`: Hashing and salting password.
- `zxcvbn`: Test password strength.
- `freezegun`: Check for expired token.
- `quart-rate-limiter`: Rate limiting
- `pydantic` and `quart-schema`: Request/Response validation
- `quart-rate-limiter`: Rate limiting.
- `pydantic` and `quart-schema`: Request/Response validation.
- `httpx`: send HTTP POST requests from our app.
#### SQL Dev-deps
- `bandit`: Check for SQL injection vulnerabilities
- `bandit`: Check for SQL injection vulnerabilities.
#### Miscs Dev-deps
- `djhtml`: Generate jinja templates html for emails
- `djhtml`: Generate jinja templates html for emails.
### Backend Technical Write-up
Inside the `backend/` folder

3
backend/.gitignore vendored
View File

@@ -14,7 +14,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
@@ -107,7 +106,7 @@ ipython_config.py
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
.pdm-python
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

1
backend/.pdm-python Normal file
View File

@@ -0,0 +1 @@
/home/minhradz/Desktop/WebDev/todo/backend/.venv/bin/python

79
backend/README.md Normal file
View File

@@ -0,0 +1,79 @@
<<<<<<< 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
## General Bits of Information
### SameSite setting
The SameSite setting ensures that cookie data is only sent with requests that originate from the given domain. This prevents other websites from initiating requests with the cookie data.
### Pydantic's purpose
Pydantic is to validate the schema/the shape of our input/output (works with JSON responses too).
### Python's dataclass
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`.
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.
>>>>>>> master

View File

@@ -2,6 +2,7 @@ TODO_BASE_URL="localhost:5050"
TODO_DEBUG=true
TODO_SECRET_KEY="secret key"
TODO_QUART_DB_DATABASE_URL="postgresql://todo:todo@0.0.0.0:5432/todo"
TODO_QUART_DB_DATA_PATH="migrations/data.py"
# disable for when we not using HTTPS
TODO_QUART_AUTH_COOKIE_SECURE=false

1184
backend/pdm.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,22 +3,22 @@ name = "Todo_api"
version = ""
description = ""
authors = [
{name = "Minh Tran Nhat", email = "minhtrannhat@minhtrannhat.com"},
{name = "Minh Tran Nhat", email = "minh@minhtrannhat.com"},
]
requires-python = ">=3.10"
license = {text = "Private"}
dependencies = [
"quart>=0.18.3",
"quart>=0.18.4",
"quart-auth>=0.7.0",
"bcrypt>=4.0.1",
"itsdangerous>=2.1.2",
"quart-rate-limiter>=0.7.0",
"pydantic[email]>=1.10.2",
"pydantic[email]",
"quart-schema>=0.14.3",
"quart-db[postgresql]>=0.4.1",
"httpx>=0.23.1",
"zxcvbn>=4.4.28",
]
[project.optional-dependencies]
[build-system]
requires = ["pdm-pep517>=1.0.0"]
build-backend = "pdm.pep517.api"
@@ -36,6 +36,7 @@ dev = [
"pytest-asyncio>=0.19.0",
"djhtml>=1.5.2",
"freezegun>=1.2.2",
"bump-pydantic>=0.6.0",
]
[tool.black]
@@ -52,9 +53,13 @@ addopts = "--showlocals"
asyncio_mode = "auto"
pythonpath = ["src"]
[[tool.mypy.overrides]]
module =["h11"]
ignore_missing_imports = true
[tool.pdm.scripts]
format-black = "black src/ tests/"
format-djhtml = "djhtml src/backend/templates -t 2 --in-place"
format-djhtml = "djhtml src/backend/templates -t 2"
format-isort = "isort src tests"
format = {composite = ["format-black", "format-djhtml", "format-isort"]}
@@ -72,3 +77,5 @@ start = {cmd = "quart --app src/backend/run.py run --port 5050", env_file = "dev
recreate-db-base = "quart --app src/backend/run.py recreate_db"
recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"}
all = {composite = ["format", "lint", "test"]}

View File

@@ -1,11 +1,13 @@
from quart import Blueprint, ResponseReturnValue
from quart_rate_limiter import rate_exempt
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"}

View File

@@ -0,0 +1,217 @@
from dataclasses import dataclass
from datetime import timedelta
from typing import cast
import asyncpg # type: ignore
import bcrypt
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from pydantic import EmailStr
from quart import Blueprint, ResponseReturnValue, current_app, g
from quart_auth import current_user, login_required
from quart_rate_limiter import rate_limit
from quart_schema import validate_request
from zxcvbn import zxcvbn # type: ignore
from backend.lib.api_error import APIError
from backend.lib.email import send_email
from backend.models.member import (
insert_member,
select_member_by_email,
select_member_by_id,
update_member_email_verified,
update_member_password,
)
blueprint = Blueprint("members", __name__)
MINIMUM_STRENGTH = 3
EMAIL_VERIFICATION_SALT = "email verify"
ONE_MONTH = int(timedelta(days=30).total_seconds())
FORGOTTEN_PASSWORD_SALT = "forgotten password" # nosec
ONE_DAY = int(timedelta(hours=24).total_seconds())
@dataclass
class MemberData:
email: str
password: str
@blueprint.post("/members/")
@rate_limit(10, timedelta(seconds=10))
@validate_request(MemberData)
async def register(data: MemberData) -> ResponseReturnValue:
"""Create a new Member.
This allows a Member to be created.
"""
strength = zxcvbn(data.password)
if strength["score"] < MINIMUM_STRENGTH:
raise APIError(400, "WEAK_PASSWORD")
hashed_password = bcrypt.hashpw(
data.password.encode("utf-8"),
bcrypt.gensalt(14),
)
try:
member = await insert_member(
g.connection,
data.email,
hashed_password.decode(),
)
except asyncpg.exceptions.UniqueViolationError:
pass
else:
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
)
token = serializer.dumps(member.id)
await send_email(
member.email,
"Welcome",
"welcome.html",
{"token": token},
)
return {}, 201
@dataclass
class TokenData:
token: str
@blueprint.put("/members/email/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(TokenData)
async def verify_email(data: TokenData) -> ResponseReturnValue:
"""Call to verify an email.
This requires the user to supply a valid token.
"""
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
)
try:
member_id = serializer.loads(data.token, max_age=ONE_MONTH)
except SignatureExpired:
raise APIError(403, "TOKEN_EXPIRED")
except BadSignature:
raise APIError(400, "TOKEN_INVALID")
else:
await update_member_email_verified(g.connection, member_id)
return {}
@dataclass
class PasswordData:
current_password: str
new_password: str
@blueprint.put("/members/password/")
@rate_limit(5, timedelta(minutes=1))
@login_required
@validate_request(PasswordData)
async def change_password(data: PasswordData) -> ResponseReturnValue:
"""Update the members password.
This allows the user to update their password.
"""
strength = zxcvbn(data.new_password)
if strength["score"] < MINIMUM_STRENGTH:
raise APIError(400, "WEAK_PASSWORD")
member_id = int(cast(str, current_user.auth_id))
member = await select_member_by_id(g.connection, member_id)
assert member is not None # nosec
passwords_match = bcrypt.checkpw(
data.current_password.encode("utf-8"),
member.password_hash.encode("utf-8"),
)
if not passwords_match:
raise APIError(401, "INVALID_PASSWORD")
hashed_password = bcrypt.hashpw(
data.new_password.encode("utf-8"),
bcrypt.gensalt(14),
)
await update_member_password(g.connection, member_id, hashed_password.decode())
await send_email(
member.email,
"Password changed",
"password_changed.html",
{},
)
return {}
@dataclass
class ForgottenPasswordData:
email: EmailStr
@blueprint.put("/members/forgotten-password/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(ForgottenPasswordData)
async def forgotten_password(data: ForgottenPasswordData) -> ResponseReturnValue:
"""Call to trigger a forgotten password email.
This requires a valid member email.
"""
member = await select_member_by_email(g.connection, data.email)
if member is not None:
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
)
token = serializer.dumps(member.id)
await send_email(
member.email,
"Forgotten password",
"forgotten_password.html",
{"token": token},
)
return {}
@dataclass
class ResetPasswordData:
password: str
token: str
@blueprint.put("/members/reset-password/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(ResetPasswordData)
async def reset_password(data: ResetPasswordData) -> ResponseReturnValue:
"""Call to reset a password using a token.
This requires the user to supply a valid token and a
new password.
"""
serializer = URLSafeTimedSerializer(
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
)
try:
member_id = serializer.loads(data.token, max_age=ONE_DAY)
except SignatureExpired:
raise APIError(403, "TOKEN_EXPIRED")
except BadSignature:
raise APIError(400, "TOKEN_INVALID")
else:
strength = zxcvbn(data.password)
if strength["score"] < MINIMUM_STRENGTH:
raise APIError(400, "WEAK_PASSWORD")
hashed_password = bcrypt.hashpw(
data.password.encode("utf-8"),
bcrypt.gensalt(14),
)
await update_member_password(g.connection, member_id, hashed_password.decode())
member = await select_member_by_id(
g.connection, int(cast(str, current_user.auth_id))
)
assert member is not None # nosec
await send_email(
member.email,
"Password changed",
"password_changed.html",
{},
)
return {}

View File

@@ -0,0 +1,73 @@
from dataclasses import dataclass
from datetime import timedelta
from bcrypt import checkpw
from pydantic import EmailStr
from quart import Blueprint, ResponseReturnValue, g
from quart_auth import AuthUser, current_user, login_required, login_user, logout_user
from quart_rate_limiter import rate_exempt, rate_limit
from quart_schema import validate_request, validate_response
from backend.lib.api_error import APIError
from backend.models.member import select_member_by_email
# Blueprint for user session (authentication) route handlers
blueprint = Blueprint("sessions", __name__)
@dataclass
class LoginData:
email: EmailStr
password: str
remember: bool = False
@dataclass
class Status:
member_id: int
@blueprint.post("/sessions/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(LoginData)
async def login(data: LoginData) -> ResponseReturnValue:
"""
Login to the app.
By providing credentials and then saving the returned cookie.
"""
result = await select_member_by_email(g.connection, data.email)
if result is None:
raise APIError(401, "INVALID_CREDENTIALS")
passwords_match = checkpw(
data.password.encode("utf_8"),
result.password_hash.encode("utf_8"),
)
if passwords_match:
login_user(AuthUser(str(result.id)), data.remember)
return {}, 200
else:
raise APIError(401, "INVALID_CREDENTIALS")
@blueprint.delete("/sessions/")
@rate_exempt
async def logout() -> ResponseReturnValue:
"""Logout from the app.
Deletes the session cookie.
"""
logout_user()
return {}
@blueprint.get("/sessions/")
@rate_limit(10, timedelta(minutes=1))
@login_required
@validate_response(Status)
async def status() -> ResponseReturnValue:
assert current_user.auth_id is not None # nosec
return Status(member_id=int(current_user.auth_id))

View File

@@ -0,0 +1,122 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import cast
from quart import Blueprint, ResponseReturnValue, g
from quart_auth import current_user, login_required
from quart_rate_limiter import rate_limit
from quart_schema import validate_querystring, validate_request, validate_response
from backend.lib.api_error import APIError
from backend.models.todo import (
Todo,
delete_todo,
insert_todo,
select_todo,
select_todos,
update_todo,
)
blueprint = Blueprint("todos", __name__)
@dataclass
class TodoData:
complete: bool
due: datetime | None
task: str
@blueprint.post("/todos/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_request(TodoData)
@validate_response(Todo, 201)
async def post_todo(data: TodoData) -> tuple[Todo, int]:
"""Create a new Todo.
This allows todos to be created and stored.
"""
todo = await insert_todo(
g.connection,
int(cast(str, current_user.auth_id)),
data.task,
data.complete,
data.due,
)
return todo, 201
@blueprint.get("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(Todo)
async def get_todo(id: int) -> Todo:
"""Get a todo.
Fetch a Todo by its ID.
"""
todo = await select_todo(g.connection, id, int(cast(str, current_user.auth_id)))
if todo is None:
raise APIError(404, "NOT_FOUND")
else:
return todo
@dataclass
class Todos:
todos: list[Todo]
@dataclass
class TodoFilter:
complete: bool | None = None
@blueprint.get("/todos/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(Todos)
@validate_querystring(TodoFilter)
async def get_todos(query_args: TodoFilter) -> Todos:
"""Get the todos.
Fetch all the Todos optionally based on the complete status.
"""
todos = await select_todos(
g.connection,
int(cast(str, current_user.auth_id)),
query_args.complete,
)
return Todos(todos=todos)
@blueprint.put("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_request(TodoData)
@validate_response(Todo)
async def put_todo(id: int, data: TodoData) -> Todo:
"""Update the identified todo
This allows the todo to be replaced with the request data.
"""
todo = await update_todo(
g.connection,
id,
int(cast(str, current_user.auth_id)),
data.task,
data.complete,
data.due,
)
if todo is None:
raise APIError(404, "NOT_FOUND")
else:
return todo
@blueprint.delete("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
async def todo_delete(id: int) -> ResponseReturnValue:
"""Delete the identified todo
This will delete the todo.
"""
await delete_todo(g.connection, id, int(cast(str, current_user.auth_id)))
return "", 202

View File

View File

@@ -0,0 +1,4 @@
class APIError(Exception):
def __init__(self, status_code: int, code: str) -> None:
self.status_code = status_code
self.code = code

View File

@@ -0,0 +1,40 @@
import logging
from typing import Any, cast
import httpx
from quart import current_app, render_template
log = logging.getLogger(__name__)
class PostmarkError(Exception):
def __init__(self, error_code: int, message: str) -> None:
self.error_code = error_code
self.message = message
async def send_email(
to: str,
subject: str,
template: str,
ctx: dict[str, Any],
) -> None:
content = await render_template(template, **ctx)
log.info("Sending %s to %s\n%s", template, to, content)
token = current_app.config.get("POSTMARK_TOKEN")
if token is not None:
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.postmarkapp.com/email",
json={
"From": "Todo <todo@minhtrannhat.com>",
"To": to,
"Subject": subject,
"Tag": template,
"HtmlBody": content,
},
headers={"X-Postmark-Server-Token": token},
)
data = cast(dict, response.json())
if response.status_code != 200:
raise PostmarkError(data["ErrorCode"], data["Message"])

View File

@@ -0,0 +1,31 @@
from quart_db import Connection
async def migrate(connection: Connection) -> None:
await connection.execute(
"""CREATE TABLE members (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created TIMESTAMP NOT NULL DEFAULT now(),
email TEXT NOT NULL,
email_verified TIMESTAMP,
password_hash TEXT NOT NULL
)""",
)
await connection.execute(
"""CREATE UNIQUE INDEX members_unique_email_idx
ON members (LOWER(email)
)"""
)
await connection.execute(
"""CREATE TABLE todos (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
complete BOOLEAN NOT NULL DEFAULT FALSE,
due TIMESTAMPTZ,
member_id INT NOT NULL REFERENCES members(id),
task TEXT NOT NULL
)""",
)
async def valid_migration(connection: Connection) -> bool:
return True

View File

@@ -0,0 +1,14 @@
from quart_db import Connection
async def execute(connection: Connection) -> None:
await connection.execute(
"""INSERT INTO members (email, password_hash)
VALUES ('member@todo.minhtrannhat.com',
'$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe'
)"""
)
await connection.execute(
"""INSERT INTO todos (member_id, task)
VALUES (1, 'Test Task')"""
)

View File

View File

@@ -0,0 +1,60 @@
from dataclasses import dataclass
from datetime import datetime
from quart_db import Connection
@dataclass
class Member:
id: int
email: str
password_hash: str
created: datetime
email_verified: datetime | None
async def select_member_by_email(db: Connection, email: str) -> Member | None:
result = await db.fetch_one(
"""SELECT id, email, password_hash, created, email_verified
FROM members
WHERE LOWER(email) = LOWER(:email)""",
{"email": email},
)
return None if result is None else Member(**result)
async def select_member_by_id(db: Connection, id: int) -> Member | None:
result = await db.fetch_one(
"""SELECT id, email, password_hash, created, email_verified
FROM members
WHERE id = :id""",
{"id": id},
)
return None if result is None else Member(**result)
async def insert_member(db: Connection, email: str, password_hash: str) -> Member:
result = await db.fetch_one(
"""INSERT INTO members (email, password_hash)
VALUES (:email, :password_hash)
RETURNING id, email, password_hash, created,
email_verified""",
{"email": email, "password_hash": password_hash},
)
return Member(**result) # type: ignore
async def update_member_password(db: Connection, id: int, password_hash: str) -> None:
await db.execute(
"""UPDATE members
SET password_hash = :password_hash
WHERE id = :id""",
{"id": id, "password_hash": password_hash},
)
async def update_member_email_verified(db: Connection, id: int) -> None:
await db.execute(
"UPDATE members SET email_verified = now() WHERE id = :id",
{"id": id},
)

View File

@@ -0,0 +1,102 @@
from dataclasses import dataclass
from datetime import datetime
from pydantic import constr
from quart_db import Connection
@dataclass
class Todo:
complete: bool
due: datetime | None
id: int
task: constr(strip_whitespace=True, min_length=1) # type: ignore
async def select_todos(
connection: Connection,
member_id: int,
complete: bool | None = None,
) -> list[Todo]:
if complete is None:
query = """SELECT id, complete, due, task
FROM todos
WHERE member_id = :member_id"""
values = {"member_id": member_id}
else:
query = """SELECT id, complete, due, task
FROM todos
WHERE member_id = :member_id
AND complete = :complete"""
values = {"member_id": member_id, "complete": complete}
return [Todo(**row) async for row in connection.iterate(query, values)]
async def select_todo(
connection: Connection,
id: int,
member_id: int,
) -> Todo | None:
result = await connection.fetch_one(
"""SELECT id, complete, due, task
FROM todos
WHERE id = :id AND member_id = :member_id""",
{"id": id, "member_id": member_id},
)
return None if result is None else Todo(**result)
async def insert_todo(
connection: Connection,
member_id: int,
task: str,
complete: bool,
due: datetime | None,
) -> Todo:
result = await connection.fetch_one(
"""INSERT INTO todos (complete, due, member_id, task)
VALUES (:complete, :due, :member_id, :task)
RETURNING id, complete, due, task""",
{
"member_id": member_id,
"task": task,
"complete": complete,
"due": due,
},
)
return Todo(**result) # type: ignore
async def update_todo(
connection: Connection,
id: int,
member_id: int,
task: str,
complete: bool,
due: datetime | None,
) -> Todo | None:
result = await connection.fetch_one(
"""UPDATE todos
SET complete = :complete, due = :due, task = :task
WHERE id = :id AND member_id = :member_id
RETURNING id, complete, due, task""",
{
"id": id,
"member_id": member_id,
"task": task,
"complete": complete,
"due": due,
},
)
return None if result is None else Todo(**result)
async def delete_todo(
connection: Connection,
id: int,
member_id: int,
) -> None:
await connection.execute(
"DELETE FROM todos WHERE id = :id AND member_id = :member_id",
{"id": id, "member_id": member_id},
)

View File

@@ -1,28 +1,31 @@
from quart import Quart, ResponseReturnValue
# logging
import logging
import os
from subprocess import call # no sec
from subprocess import call # nosec
from urllib.parse import urlparse
# Each blueprint is a logical collection of features in our web app
from backend.blueprints.control import blueprint as control_blueprint
# For making sure error responses are in JSON format
from backend.lib.api_error import APIError
# Rate limiting
from quart_rate_limiter import RateLimiter
from quart_rate_limiter import RateLimitExceeded
from quart import Quart, ResponseReturnValue
# Authentication
from quart_auth import AuthManager
# Request/Response validation
from quart_schema import QuartSchema, RequestSchemaValidationError
from quart_auth import QuartAuth
# PostgreSQL database driver
from quart_db import QuartDB
# Rate limiting
from quart_rate_limiter import RateLimiter, RateLimitExceeded
# Request/Response validation
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.sessions import blueprint as sessions_blueprint
from backend.blueprints.todos import blueprint as todos_blueprint
# For making sure error responses are in JSON format
from backend.lib.api_error import APIError
app: Quart = Quart(__name__)
@@ -30,16 +33,24 @@ app: Quart = Quart(__name__)
# Either in DEV/DEBUG mode or TEST mode
app.config.from_prefixed_env(prefix="TODO")
auth_manager: AuthManager = AuthManager(app)
# Initializes auth, database, rate limiter and schema
# for the web app
auth_manager: QuartAuth = QuartAuth(app)
quart_db = QuartDB(app)
rate_limiter: RateLimiter = RateLimiter(app)
schema = QuartSchema(app, convert_casing=True)
# Set up logging
logging.basicConfig(level=logging.INFO)
# Registers these groups of routes handlers
app.register_blueprint(control_blueprint)
app.register_blueprint(sessions_blueprint)
app.register_blueprint(members_blueprint)
app.register_blueprint(todos_blueprint)
# rate limiting
# Rate limiting
@app.errorhandler(RateLimitExceeded) # type: ignore
async def handle_rate_limit_exceeded_error(
error: RateLimitExceeded,
@@ -47,7 +58,7 @@ async def handle_rate_limit_exceeded_error(
return {}, error.get_headers(), 429
# handles errors
# Handles errors
@app.errorhandler(APIError) # type: ignore
async def handle_api_error(error: APIError) -> ResponseReturnValue:
return {"code": error.code}, error.status_code
@@ -102,3 +113,14 @@ def recreate_db() -> None:
f"CREATE DATABASE {db_url.path.removeprefix('/')}",
],
)
call( # nosec
[
"psql",
"-U",
"postgres",
"-c",
f"ALTER DATABASE \
{db_url.path.removeprefix('/')} OWNER TO {db_url.username}",
]
)

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<title>Todo - email</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="
font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;
font-size: 14px;
font-style: normal;
margin: 0;
">
<table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center">
<table height="100%" cellpadding="20" cellspacing="0" border="0" style="max-width: 540px">
<tr>
<td align="left" width="540">
{% block welcome %} Hello, {% endblock %}
</td>
</tr>
<tr>
<td align="left" width="540">
{% block content %} Example content {% endblock %}
</td>
</tr>
<tr>
<td align="center" width="540">The Todo team</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,9 @@
{% extends "email.html" %}
{% block content %}
You can use this
<a href="{{ config['BASE_URL'] }}/reset-password/{{ token }}/">
link
</a>
to reset your password.
{% endblock %}

View File

@@ -0,0 +1,6 @@
{% extends "email.html" %}
{% block content %}
Your Todo password has been successfully changed.
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "email.html" %}
{% block welcome %}
Hello and welcome to tozo!
{% endblock %}
{% block content %}
Please confirm you signed up by following this
<a href="{{ config['BASE_URL'] }}/confirm-email/{{ token }}/">
link
</a>.
{% endblock %}

View File

@@ -3,6 +3,7 @@ TODO_DEBUG=true
TODO_SECRET_KEY="secret key"
TODO_TESTING=true
TODO_QUART_DB_DATABASE_URL="postgresql://todo_test:todo_test@0.0.0.0:5432/todo_test"
TODO_QUART_DB_DATA_PATH="migrations/data.py"
# disable for when we not using HTTPS
TODO_QUART_AUTH_COOKIE_SECURE=false

View File

View File

@@ -0,0 +1,67 @@
import pytest
from freezegun import freeze_time
from itsdangerous import URLSafeTimedSerializer
from quart import Quart
from quart_auth import authenticated_client
from backend.blueprints.members import EMAIL_VERIFICATION_SALT
async def test_register(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
test_client = app.test_client()
data = {
"email": "new@todo.minhtrannhat.com",
"password": "testPassword2$",
}
await test_client.post("/members/", json=data)
response = await test_client.post("/sessions/", json=data)
assert response.status_code == 200
assert "Sending welcome.html to new@todo.minhtrannhat.com" in caplog.text
@pytest.mark.parametrize(
"time, expected",
[("2022-01-01", 403), (None, 200)],
)
async def test_verify_email(app: Quart, time: str | None, expected: int) -> None:
with freeze_time(time):
signer = URLSafeTimedSerializer(app.secret_key, salt=EMAIL_VERIFICATION_SALT)
token = signer.dumps(1)
test_client = app.test_client()
response = await test_client.put("/members/email/", json={"token": token})
assert response.status_code == expected
async def test_verify_email_invalid_token(app: Quart) -> None:
test_client = app.test_client()
response = await test_client.put("/members/email/", json={"token": "invalid"})
assert response.status_code == 400
async def test_change_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
test_client = app.test_client()
data = {
"email": "new_password@todo.minhtrannhat.com",
"password": "testPassword2$",
}
response = await test_client.post("/members/", json=data)
async with authenticated_client(test_client, auth_id=2): # type: ignore
response = await test_client.put(
"/members/password/",
json={
"currentPassword": data["password"],
"newPassword": "testPassword3$",
},
)
assert response.status_code == 200
assert "Sending password_changed.html to new@todo.minhtrannhat.com" in caplog.text
async def test_forgotten_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
test_client = app.test_client()
data = {"email": "member@todo.minhtrannhat.com"}
response = await test_client.put("/members/forgotten-password/", json=data)
assert response.status_code == 200
assert (
"Sending forgotten_password.html to member@todo.minhtrannhat.com" in caplog.text
)

View File

@@ -0,0 +1,24 @@
from quart import Quart
async def test_session_flow(app: Quart) -> None:
test_client = app.test_client()
await test_client.post(
"/sessions/",
json={"email": "member@todo.minhtrannhat.com", "password": "password"},
)
response = await test_client.get("/sessions/")
assert (await response.get_json())["memberId"] == 1
await test_client.delete("/sessions/")
response = await test_client.get("/sessions/")
assert response.status_code == 401
async def test_login_invalid_password(app: Quart) -> None:
test_client = app.test_client()
await test_client.post(
"/sessions/",
json={"email": "member@todo.minhtrannhat.com", "password": "incorrect"},
)
response = await test_client.get("/sessions/")
assert response.status_code == 401

View File

@@ -0,0 +1,55 @@
from quart import Quart
from quart_auth import authenticated_client
async def test_post_todo(app: Quart) -> None:
test_client = app.test_client()
async with authenticated_client(test_client, auth_id=1): # type: ignore
response = await test_client.post(
"/todos/",
json={"complete": False, "due": None, "task": "Test task"},
)
assert response.status_code == 201
assert (await response.get_json())["id"] > 0
async def test_get_todo(app: Quart) -> None:
test_client = app.test_client()
async with authenticated_client(test_client, auth_id=1): # type: ignore
response = await test_client.get("/todos/1/")
assert response.status_code == 200
assert (await response.get_json())["task"] == "Test Task"
async def test_put_todo(app: Quart) -> None:
test_client = app.test_client()
async with authenticated_client(test_client, auth_id=1): # type: ignore
response = await test_client.post(
"/todos/",
json={"complete": False, "due": None, "task": "Test task"},
)
todo_id = (await response.get_json())["id"]
response = await test_client.put(
f"/todos/{todo_id}/",
json={"complete": False, "due": None, "task": "Updated"},
)
assert (await response.get_json())["task"] == "Updated"
response = await test_client.get(f"/todos/{todo_id}/")
assert (await response.get_json())["task"] == "Updated"
async def test_delete_todo(app: Quart) -> None:
test_client = app.test_client()
async with authenticated_client(test_client, auth_id=1): # type: ignore
response = await test_client.post(
"/todos/",
json={"complete": False, "due": None, "task": "Test task"},
)
todo_id = (await response.get_json())["id"]
await test_client.delete(f"/todos/{todo_id}/")
response = await test_client.get(f"/todos/{todo_id}/")
assert response.status_code == 404

View File

View File

@@ -0,0 +1,10 @@
from pytest import LogCaptureFixture
from quart import Quart
from backend.lib.email import send_email
async def test_send_email(app: Quart, caplog: LogCaptureFixture) -> None:
async with app.app_context():
await send_email("member@minhtrannhat.com", "Welcome", "email.html", {})
assert "Sending email.html to member@minhtrannhat.com" in caplog.text

View File

View File

@@ -0,0 +1,17 @@
import pytest
from asyncpg.exceptions import UniqueViolationError # type: ignore
from quart_db import Connection
from backend.models.member import insert_member, select_member_by_email
async def test_insert_member(connection: Connection) -> None:
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
with pytest.raises(UniqueViolationError):
await insert_member(connection, "Casing@todo.minhtrannhat.com", "")
async def test_select_member_by_email(connection: Connection) -> None:
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
member = await select_member_by_email(connection, "Casing@todo.minhtrannhat.com")
assert member is not None

View File

@@ -0,0 +1,31 @@
import pytest
from quart_db import Connection
from backend.models.todo import delete_todo, insert_todo, select_todo, update_todo
@pytest.mark.parametrize(
"member_id, deleted",
[(1, True), (2, False)],
)
async def test_delete_todo(
connection: Connection, member_id: int, deleted: bool
) -> None:
todo = await insert_todo(connection, 1, "Task", False, None)
await delete_todo(connection, todo.id, member_id)
new_todo = await select_todo(connection, todo.id, 1)
assert (new_todo is None) is deleted
@pytest.mark.parametrize(
"member_id, complete",
[(1, True), (2, False)],
)
async def test_update_todo(
connection: Connection, member_id: int, complete: bool
) -> None:
todo = await insert_todo(connection, 1, "Task", False, None)
await update_todo(connection, todo.id, member_id, "Task", True, None)
new_todo = await select_todo(connection, todo.id, 1)
assert new_todo is not None
assert new_todo.complete is complete

View File

@@ -1,46 +1,38 @@
# Getting Started with Create React App
# Neo Todo App Frontend
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Dev Log
## Available Scripts
### Styling the frontend
In the project directory, you can run:
- Used the [MUI](mui.com) React component library using Material Design by Google.
- Font used is `Roboto`. Font is imported to `src/App.tsx`.
### `npm start`
#### Use ThemeProvider
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
To override the default MUI looks, we create a ThemeProvider at `src/ThemeProvider.tsx`.
The page will reload if you make edits.\
You will also see any lint errors in the console.
#### React Hook useMemo
### `npm test`
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.
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.
#### MUI (Material UI)
### `npm run build`
##### Palette Mode
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
Change default component colors to suit one's needs.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
##### CSSBaseline
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
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.
### `npm run eject`
##### useMediaQuery
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
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.
If you arent 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.
##### Container's maxWidth
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 youre on your own.
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`).
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
### Configure the page's title (Browser Tab Text)
## 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/).
Use `react-helmet-async`, wrap it around the `App` in `App.tsx`.

43476
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,14 @@
"version": "0.1.0",
"private": true,
"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/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
@@ -10,11 +18,18 @@
"@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-scripts": "^2.1.3",
"react-helmet-async": "^1.3.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"typescript": "^4.7.4",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"yup": "^0.32.11",
"zxcvbn": "^4.4.2"
},
"scripts": {
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
@@ -33,7 +48,12 @@
"plugin:import/warnings",
"plugin:import/typescript",
"prettier"
]
],
"settings": {
"import/resolver": {
"typescript": {}
}
}
},
"browserslist": {
"production": [
@@ -48,13 +68,16 @@
]
},
"devDependencies": {
"@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://127.0.0.1:5050"
}

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
test('renders learn react link', () => {
test("renders the app", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -1,26 +1,55 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
import React from "react";
import "./App.css";
function App() {
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// 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 (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<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;

View File

@@ -0,0 +1,25 @@
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>
);
};

64
frontend/src/Router.tsx Normal file
View File

@@ -0,0 +1,64 @@
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;

View File

@@ -0,0 +1,29 @@
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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,21 @@
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;

View File

@@ -0,0 +1,14 @@
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;

View 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;

View File

@@ -0,0 +1,19 @@
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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -1,16 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);
// If you want to start measuring performance in your app, pass a function

36
frontend/src/models.tsx Normal file
View 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,
};
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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,
},
);
}

View File

@@ -1,8 +1,8 @@
import { ReportHandler } from 'web-vitals';
import { ReportHandler } from "web-vitals";
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
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);
getFID(onPerfEntry);
getFCP(onPerfEntry);

View File

@@ -2,4 +2,8 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// 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;
};

21
frontend/src/utils.tsx Normal file
View 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;
}
};

View File

@@ -1,11 +1,8 @@
{
"compilerOptions": {
"baseUrl": "./",
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -20,7 +17,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"include": ["src"]
}