├── LICENSE
├── README.md
├── backend
├── .env
├── .gitignore
├── README.md
├── alembic.ini
├── app
│ ├── __init__.py
│ ├── alembic
│ │ ├── README
│ │ ├── env.py
│ │ ├── script.py.mako
│ │ └── versions
│ │ │ ├── .keep
│ │ │ ├── 9c0a54914c78_add_max_length_for_string_varchar_.py
│ │ │ └── e2412789c190_initialize_models.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── deps.py
│ │ ├── main.py
│ │ └── routes
│ │ │ ├── __init__.py
│ │ │ ├── items.py
│ │ │ ├── login.py
│ │ │ ├── users.py
│ │ │ └── utils.py
│ ├── backend_pre_start.py
│ ├── core
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── db.py
│ │ └── security.py
│ ├── crud.py
│ ├── email-templates
│ │ ├── build
│ │ │ ├── new_account.html
│ │ │ ├── reset_password.html
│ │ │ └── test_email.html
│ │ └── src
│ │ │ ├── new_account.mjml
│ │ │ ├── reset_password.mjml
│ │ │ └── test_email.mjml
│ ├── initial_data.py
│ ├── main.py
│ ├── models.py
│ ├── tests
│ │ ├── __init__.py
│ │ ├── api
│ │ │ ├── __init__.py
│ │ │ └── routes
│ │ │ │ ├── __init__.py
│ │ │ │ ├── test_items.py
│ │ │ │ ├── test_login.py
│ │ │ │ └── test_users.py
│ │ ├── conftest.py
│ │ ├── crud
│ │ │ ├── __init__.py
│ │ │ └── test_user.py
│ │ ├── scripts
│ │ │ ├── __init__.py
│ │ │ ├── test_backend_pre_start.py
│ │ │ └── test_test_pre_start.py
│ │ └── utils
│ │ │ ├── __init__.py
│ │ │ ├── item.py
│ │ │ ├── user.py
│ │ │ └── utils.py
│ └── utils.py
├── poetry.lock
├── prestart.sh
└── pyproject.toml
└── frontend
├── .env
├── .gitignore
├── README.md
├── biome.json
├── index.html
├── modify-openapi-operationids.js
├── package-lock.json
├── package.json
├── public
└── assets
│ └── images
│ ├── fastapi-logo.svg
│ └── favicon.png
├── src
├── client
│ ├── core
│ │ ├── ApiError.ts
│ │ ├── ApiRequestOptions.ts
│ │ ├── ApiResult.ts
│ │ ├── CancelablePromise.ts
│ │ ├── OpenAPI.ts
│ │ ├── request.ts
│ │ └── types.ts
│ ├── index.ts
│ ├── models.ts
│ ├── schemas.ts
│ └── services.ts
├── components
│ ├── Admin
│ │ ├── AddUser.tsx
│ │ └── EditUser.tsx
│ ├── Common
│ │ ├── ActionsMenu.tsx
│ │ ├── DeleteAlert.tsx
│ │ ├── Navbar.tsx
│ │ ├── NotFound.tsx
│ │ ├── Sidebar.tsx
│ │ ├── SidebarItems.tsx
│ │ └── UserMenu.tsx
│ ├── Items
│ │ ├── AddItem.tsx
│ │ └── EditItem.tsx
│ └── UserSettings
│ │ ├── Appearance.tsx
│ │ ├── ChangePassword.tsx
│ │ ├── DeleteAccount.tsx
│ │ ├── DeleteConfirmation.tsx
│ │ └── UserInformation.tsx
├── hooks
│ ├── useAuth.ts
│ └── useCustomToast.ts
├── main.tsx
├── routeTree.gen.ts
├── routes
│ ├── __root.tsx
│ ├── _layout.tsx
│ ├── _layout
│ │ ├── admin.tsx
│ │ ├── index.tsx
│ │ ├── items.tsx
│ │ └── settings.tsx
│ ├── login.tsx
│ ├── recover-password.tsx
│ └── reset-password.tsx
├── theme.tsx
├── utils.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 HNG Tech
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Full-Stack FastAPI and React Template
2 |
3 | Welcome to the Full-Stack FastAPI and React template repository. This repository serves as a demo application for interns, showcasing how to set up and run a full-stack application with a FastAPI backend and a ReactJS frontend using ChakraUI.
4 |
5 | ## Project Structure
6 |
7 | The repository is organized into two main directories:
8 |
9 | - **frontend**: Contains the ReactJS application.
10 | - **backend**: Contains the FastAPI application and PostgreSQL database integration.
11 |
12 | Each directory has its own README file with detailed instructions specific to that part of the application.
13 |
14 | ## Getting Started
15 |
16 | To get started with this template, please follow the instructions in the respective directories:
17 |
18 | - [Frontend README](./frontend/README.md)
19 | - [Backend README](./backend/README.md)
20 |
21 |
--------------------------------------------------------------------------------
/backend/.env:
--------------------------------------------------------------------------------
1 | # Domain
2 | # This would be set to the production domain with an env var on deployment
3 | DOMAIN=localhost
4 |
5 | # Environment: local, staging, production
6 | ENVIRONMENT=local
7 |
8 | PROJECT_NAME="Full Stack FastAPI Project"
9 | STACK_NAME=full-stack-fastapi-project
10 |
11 | # Backend
12 | BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173"
13 | SECRET_KEY=changethis123
14 | FIRST_SUPERUSER=devops@hng.tech
15 | FIRST_SUPERUSER_PASSWORD=devops#HNG11
16 | USERS_OPEN_REGISTRATION=True
17 |
18 | # Emails
19 | SMTP_HOST=
20 | SMTP_USER=
21 | SMTP_PASSWORD=
22 | EMAILS_FROM_EMAIL=info@example.com
23 | SMTP_TLS=True
24 | SMTP_SSL=False
25 | SMTP_PORT=587
26 |
27 | # Postgres
28 | POSTGRES_SERVER=localhost
29 | POSTGRES_PORT=5432
30 | POSTGRES_DB=app
31 | POSTGRES_USER=app
32 | POSTGRES_PASSWORD=changethis123
33 |
34 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | app.egg-info
3 | *.pyc
4 | .mypy_cache
5 | .coverage
6 | htmlcov
7 | .cache
8 | .venv
9 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # Backend - FastAPI with PostgreSQL
2 |
3 | This directory contains the backend of the application built with FastAPI and a PostgreSQL database.
4 |
5 | ## Prerequisites
6 |
7 | - Python 3.8 or higher
8 | - Poetry (for dependency management)
9 | - PostgreSQL (ensure the database server is running)
10 |
11 | ### Installing Poetry
12 |
13 | To install Poetry, follow these steps:
14 |
15 | ```sh
16 | curl -sSL https://install.python-poetry.org | python3 -
17 | ```
18 |
19 | Add Poetry to your PATH (if not automatically added):
20 |
21 | ## Setup Instructions
22 |
23 | 1. **Navigate to the backend directory**:
24 | ```sh
25 | cd backend
26 | ```
27 |
28 | 2. **Install dependencies using Poetry**:
29 | ```sh
30 | poetry install
31 | ```
32 |
33 | 3. **Set up the database with the necessary tables**:
34 | ```sh
35 | poetry run bash ./prestart.sh
36 | ```
37 |
38 | 4. **Run the backend server**:
39 | ```sh
40 | poetry run uvicorn app.main:app --reload
41 | ```
42 |
43 | 5. **Update configuration**:
44 | Ensure you update the necessary configurations in the `.env` file, particularly the database configuration.
45 |
--------------------------------------------------------------------------------
/backend/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = app/alembic
6 |
7 | # template used to generate migration files
8 | # file_template = %%(rev)s_%%(slug)s
9 |
10 | # timezone to use when rendering the date
11 | # within the migration file as well as the filename.
12 | # string value is passed to dateutil.tz.gettz()
13 | # leave blank for localtime
14 | # timezone =
15 |
16 | # max length of characters to apply to the
17 | # "slug" field
18 | #truncate_slug_length = 40
19 |
20 | # set to 'true' to run the environment during
21 | # the 'revision' command, regardless of autogenerate
22 | # revision_environment = false
23 |
24 | # set to 'true' to allow .pyc and .pyo files without
25 | # a source .py file to be detected as revisions in the
26 | # versions/ directory
27 | # sourceless = false
28 |
29 | # version location specification; this defaults
30 | # to alembic/versions. When using multiple version
31 | # directories, initial revisions must be specified with --version-path
32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions
33 |
34 | # the output encoding used when revision files
35 | # are written from script.py.mako
36 | # output_encoding = utf-8
37 |
38 | # Logging configuration
39 | [loggers]
40 | keys = root,sqlalchemy,alembic
41 |
42 | [handlers]
43 | keys = console
44 |
45 | [formatters]
46 | keys = generic
47 |
48 | [logger_root]
49 | level = WARN
50 | handlers = console
51 | qualname =
52 |
53 | [logger_sqlalchemy]
54 | level = WARN
55 | handlers =
56 | qualname = sqlalchemy.engine
57 |
58 | [logger_alembic]
59 | level = INFO
60 | handlers =
61 | qualname = alembic
62 |
63 | [handler_console]
64 | class = StreamHandler
65 | args = (sys.stderr,)
66 | level = NOTSET
67 | formatter = generic
68 |
69 | [formatter_generic]
70 | format = %(levelname)-5.5s [%(name)s] %(message)s
71 | datefmt = %H:%M:%S
72 |
--------------------------------------------------------------------------------
/backend/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/__init__.py
--------------------------------------------------------------------------------
/backend/app/alembic/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
2 |
--------------------------------------------------------------------------------
/backend/app/alembic/env.py:
--------------------------------------------------------------------------------
1 | import os
2 | from dotenv import load_dotenv
3 | from logging.config import fileConfig
4 |
5 | from alembic import context
6 | from sqlalchemy import engine_from_config, pool
7 |
8 | # this is the Alembic Config object, which provides
9 | # access to the values within the .ini file in use.
10 | config = context.config
11 |
12 | # Interpret the config file for Python logging.
13 | # This line sets up loggers basically.
14 | fileConfig(config.config_file_name)
15 |
16 | # add your model's MetaData object here
17 | # for 'autogenerate' support
18 | # from myapp import mymodel
19 | # target_metadata = mymodel.Base.metadata
20 | # target_metadata = None
21 |
22 | from app.models import SQLModel # noqa
23 |
24 | target_metadata = SQLModel.metadata
25 |
26 | load_dotenv()
27 |
28 | def get_url():
29 | user = os.getenv("POSTGRES_USER", "postgres")
30 | password = os.getenv("POSTGRES_PASSWORD", "")
31 | server = os.getenv("POSTGRES_SERVER", "db")
32 | port = os.getenv("POSTGRES_PORT", "5432")
33 | db = os.getenv("POSTGRES_DB", "app")
34 |
35 | return f"postgresql+psycopg://{user}:{password}@{server}:{port}/{db}"
36 |
37 |
38 | def run_migrations_offline():
39 | """Run migrations in 'offline' mode.
40 |
41 | This configures the context with just a URL
42 | and not an Engine, though an Engine is acceptable
43 | here as well. By skipping the Engine creation
44 | we don't even need a DBAPI to be available.
45 |
46 | Calls to context.execute() here emit the given string to the
47 | script output.
48 |
49 | """
50 | url = get_url()
51 | context.configure(
52 | url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True
53 | )
54 |
55 | with context.begin_transaction():
56 | context.run_migrations()
57 |
58 |
59 | def run_migrations_online():
60 | """Run migrations in 'online' mode.
61 |
62 | In this scenario we need to create an Engine
63 | and associate a connection with the context.
64 |
65 | """
66 | configuration = config.get_section(config.config_ini_section)
67 | configuration["sqlalchemy.url"] = get_url()
68 | connectable = engine_from_config(
69 | configuration,
70 | prefix="sqlalchemy.",
71 | poolclass=pool.NullPool,
72 | )
73 |
74 | with connectable.connect() as connection:
75 | context.configure(
76 | connection=connection, target_metadata=target_metadata, compare_type=True
77 | )
78 |
79 | with context.begin_transaction():
80 | context.run_migrations()
81 |
82 |
83 | if context.is_offline_mode():
84 | run_migrations_offline()
85 | else:
86 | run_migrations_online()
87 |
--------------------------------------------------------------------------------
/backend/app/alembic/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlmodel.sql.sqltypes
11 | ${imports if imports else ""}
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = ${repr(up_revision)}
15 | down_revision = ${repr(down_revision)}
16 | branch_labels = ${repr(branch_labels)}
17 | depends_on = ${repr(depends_on)}
18 |
19 |
20 | def upgrade():
21 | ${upgrades if upgrades else "pass"}
22 |
23 |
24 | def downgrade():
25 | ${downgrades if downgrades else "pass"}
26 |
--------------------------------------------------------------------------------
/backend/app/alembic/versions/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/alembic/versions/.keep
--------------------------------------------------------------------------------
/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py:
--------------------------------------------------------------------------------
1 | """Add max length for string(varchar) fields in User and Items models
2 |
3 | Revision ID: 9c0a54914c78
4 | Revises: e2412789c190
5 | Create Date: 2024-06-17 14:42:44.639457
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlmodel.sql.sqltypes
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = '9c0a54914c78'
15 | down_revision = 'e2412789c190'
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade():
21 | # Adjust the length of the email field in the User table
22 | op.alter_column('user', 'email',
23 | existing_type=sa.String(),
24 | type_=sa.String(length=255),
25 | existing_nullable=False)
26 |
27 | # Adjust the length of the full_name field in the User table
28 | op.alter_column('user', 'full_name',
29 | existing_type=sa.String(),
30 | type_=sa.String(length=255),
31 | existing_nullable=True)
32 |
33 | # Adjust the length of the title field in the Item table
34 | op.alter_column('item', 'title',
35 | existing_type=sa.String(),
36 | type_=sa.String(length=255),
37 | existing_nullable=False)
38 |
39 | # Adjust the length of the description field in the Item table
40 | op.alter_column('item', 'description',
41 | existing_type=sa.String(),
42 | type_=sa.String(length=255),
43 | existing_nullable=True)
44 |
45 |
46 | def downgrade():
47 | # Revert the length of the email field in the User table
48 | op.alter_column('user', 'email',
49 | existing_type=sa.String(length=255),
50 | type_=sa.String(),
51 | existing_nullable=False)
52 |
53 | # Revert the length of the full_name field in the User table
54 | op.alter_column('user', 'full_name',
55 | existing_type=sa.String(length=255),
56 | type_=sa.String(),
57 | existing_nullable=True)
58 |
59 | # Revert the length of the title field in the Item table
60 | op.alter_column('item', 'title',
61 | existing_type=sa.String(length=255),
62 | type_=sa.String(),
63 | existing_nullable=False)
64 |
65 | # Revert the length of the description field in the Item table
66 | op.alter_column('item', 'description',
67 | existing_type=sa.String(length=255),
68 | type_=sa.String(),
69 | existing_nullable=True)
70 |
--------------------------------------------------------------------------------
/backend/app/alembic/versions/e2412789c190_initialize_models.py:
--------------------------------------------------------------------------------
1 | """Initialize models
2 |
3 | Revision ID: e2412789c190
4 | Revises:
5 | Create Date: 2023-11-24 22:55:43.195942
6 |
7 | """
8 | import sqlalchemy as sa
9 | import sqlmodel.sql.sqltypes
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "e2412789c190"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "user",
23 | sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
24 | sa.Column("is_active", sa.Boolean(), nullable=False),
25 | sa.Column("is_superuser", sa.Boolean(), nullable=False),
26 | sa.Column("full_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
27 | sa.Column("id", sa.Integer(), nullable=False),
28 | sa.Column(
29 | "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False
30 | ),
31 | sa.PrimaryKeyConstraint("id"),
32 | )
33 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True)
34 | op.create_table(
35 | "item",
36 | sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
37 | sa.Column("id", sa.Integer(), nullable=False),
38 | sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
39 | sa.Column("owner_id", sa.Integer(), nullable=False),
40 | sa.ForeignKeyConstraint(
41 | ["owner_id"],
42 | ["user.id"],
43 | ),
44 | sa.PrimaryKeyConstraint("id"),
45 | )
46 | # ### end Alembic commands ###
47 |
48 |
49 | def downgrade():
50 | # ### commands auto generated by Alembic - please adjust! ###
51 | op.drop_table("item")
52 | op.drop_index(op.f("ix_user_email"), table_name="user")
53 | op.drop_table("user")
54 | # ### end Alembic commands ###
55 |
--------------------------------------------------------------------------------
/backend/app/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/api/__init__.py
--------------------------------------------------------------------------------
/backend/app/api/deps.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Generator
2 | from typing import Annotated
3 |
4 | import jwt
5 | from fastapi import Depends, HTTPException, status
6 | from fastapi.security import OAuth2PasswordBearer
7 | from jwt.exceptions import InvalidTokenError
8 | from pydantic import ValidationError
9 | from sqlmodel import Session
10 |
11 | from app.core import security
12 | from app.core.config import settings
13 | from app.core.db import engine
14 | from app.models import TokenPayload, User
15 |
16 | reusable_oauth2 = OAuth2PasswordBearer(
17 | tokenUrl=f"{settings.API_V1_STR}/login/access-token"
18 | )
19 |
20 |
21 | def get_db() -> Generator[Session, None, None]:
22 | with Session(engine) as session:
23 | yield session
24 |
25 |
26 | SessionDep = Annotated[Session, Depends(get_db)]
27 | TokenDep = Annotated[str, Depends(reusable_oauth2)]
28 |
29 |
30 | def get_current_user(session: SessionDep, token: TokenDep) -> User:
31 | try:
32 | payload = jwt.decode(
33 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
34 | )
35 | token_data = TokenPayload(**payload)
36 | except (InvalidTokenError, ValidationError):
37 | raise HTTPException(
38 | status_code=status.HTTP_403_FORBIDDEN,
39 | detail="Could not validate credentials",
40 | )
41 | user = session.get(User, token_data.sub)
42 | if not user:
43 | raise HTTPException(status_code=404, detail="User not found")
44 | if not user.is_active:
45 | raise HTTPException(status_code=400, detail="Inactive user")
46 | return user
47 |
48 |
49 | CurrentUser = Annotated[User, Depends(get_current_user)]
50 |
51 |
52 | def get_current_active_superuser(current_user: CurrentUser) -> User:
53 | if not current_user.is_superuser:
54 | raise HTTPException(
55 | status_code=403, detail="The user doesn't have enough privileges"
56 | )
57 | return current_user
58 |
--------------------------------------------------------------------------------
/backend/app/api/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 |
3 | from app.api.routes import items, login, users, utils
4 |
5 | api_router = APIRouter()
6 | api_router.include_router(login.router, tags=["login"])
7 | api_router.include_router(users.router, prefix="/users", tags=["users"])
8 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
9 | api_router.include_router(items.router, prefix="/items", tags=["items"])
10 |
--------------------------------------------------------------------------------
/backend/app/api/routes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/api/routes/__init__.py
--------------------------------------------------------------------------------
/backend/app/api/routes/items.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from fastapi import APIRouter, HTTPException
4 | from sqlmodel import func, select
5 |
6 | from app.api.deps import CurrentUser, SessionDep
7 | from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
8 |
9 | router = APIRouter()
10 |
11 |
12 | @router.get("/", response_model=ItemsPublic)
13 | def read_items(
14 | session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
15 | ) -> Any:
16 | """
17 | Retrieve items.
18 | """
19 |
20 | if current_user.is_superuser:
21 | count_statement = select(func.count()).select_from(Item)
22 | count = session.exec(count_statement).one()
23 | statement = select(Item).offset(skip).limit(limit)
24 | items = session.exec(statement).all()
25 | else:
26 | count_statement = (
27 | select(func.count())
28 | .select_from(Item)
29 | .where(Item.owner_id == current_user.id)
30 | )
31 | count = session.exec(count_statement).one()
32 | statement = (
33 | select(Item)
34 | .where(Item.owner_id == current_user.id)
35 | .offset(skip)
36 | .limit(limit)
37 | )
38 | items = session.exec(statement).all()
39 |
40 | return ItemsPublic(data=items, count=count)
41 |
42 |
43 | @router.get("/{id}", response_model=ItemPublic)
44 | def read_item(session: SessionDep, current_user: CurrentUser, id: int) -> Any:
45 | """
46 | Get item by ID.
47 | """
48 | item = session.get(Item, id)
49 | if not item:
50 | raise HTTPException(status_code=404, detail="Item not found")
51 | if not current_user.is_superuser and (item.owner_id != current_user.id):
52 | raise HTTPException(status_code=400, detail="Not enough permissions")
53 | return item
54 |
55 |
56 | @router.post("/", response_model=ItemPublic)
57 | def create_item(
58 | *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
59 | ) -> Any:
60 | """
61 | Create new item.
62 | """
63 | item = Item.model_validate(item_in, update={"owner_id": current_user.id})
64 | session.add(item)
65 | session.commit()
66 | session.refresh(item)
67 | return item
68 |
69 |
70 | @router.put("/{id}", response_model=ItemPublic)
71 | def update_item(
72 | *, session: SessionDep, current_user: CurrentUser, id: int, item_in: ItemUpdate
73 | ) -> Any:
74 | """
75 | Update an item.
76 | """
77 | item = session.get(Item, id)
78 | if not item:
79 | raise HTTPException(status_code=404, detail="Item not found")
80 | if not current_user.is_superuser and (item.owner_id != current_user.id):
81 | raise HTTPException(status_code=400, detail="Not enough permissions")
82 | update_dict = item_in.model_dump(exclude_unset=True)
83 | item.sqlmodel_update(update_dict)
84 | session.add(item)
85 | session.commit()
86 | session.refresh(item)
87 | return item
88 |
89 |
90 | @router.delete("/{id}")
91 | def delete_item(session: SessionDep, current_user: CurrentUser, id: int) -> Message:
92 | """
93 | Delete an item.
94 | """
95 | item = session.get(Item, id)
96 | if not item:
97 | raise HTTPException(status_code=404, detail="Item not found")
98 | if not current_user.is_superuser and (item.owner_id != current_user.id):
99 | raise HTTPException(status_code=400, detail="Not enough permissions")
100 | session.delete(item)
101 | session.commit()
102 | return Message(message="Item deleted successfully")
103 |
--------------------------------------------------------------------------------
/backend/app/api/routes/login.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 | from typing import Annotated, Any
3 |
4 | from fastapi import APIRouter, Depends, HTTPException
5 | from fastapi.responses import HTMLResponse
6 | from fastapi.security import OAuth2PasswordRequestForm
7 |
8 | from app import crud
9 | from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
10 | from app.core import security
11 | from app.core.config import settings
12 | from app.core.security import get_password_hash
13 | from app.models import Message, NewPassword, Token, UserPublic
14 | from app.utils import (
15 | generate_password_reset_token,
16 | generate_reset_password_email,
17 | send_email,
18 | verify_password_reset_token,
19 | )
20 |
21 | router = APIRouter()
22 |
23 |
24 | @router.post("/login/access-token")
25 | def login_access_token(
26 | session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
27 | ) -> Token:
28 | """
29 | OAuth2 compatible token login, get an access token for future requests
30 | """
31 | user = crud.authenticate(
32 | session=session, email=form_data.username, password=form_data.password
33 | )
34 | if not user:
35 | raise HTTPException(status_code=400, detail="Incorrect email or password")
36 | elif not user.is_active:
37 | raise HTTPException(status_code=400, detail="Inactive user")
38 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
39 | return Token(
40 | access_token=security.create_access_token(
41 | user.id, expires_delta=access_token_expires
42 | )
43 | )
44 |
45 |
46 | @router.post("/login/test-token", response_model=UserPublic)
47 | def test_token(current_user: CurrentUser) -> Any:
48 | """
49 | Test access token
50 | """
51 | return current_user
52 |
53 |
54 | @router.post("/password-recovery/{email}")
55 | def recover_password(email: str, session: SessionDep) -> Message:
56 | """
57 | Password Recovery
58 | """
59 | user = crud.get_user_by_email(session=session, email=email)
60 |
61 | if not user:
62 | raise HTTPException(
63 | status_code=404,
64 | detail="The user with this email does not exist in the system.",
65 | )
66 | password_reset_token = generate_password_reset_token(email=email)
67 | email_data = generate_reset_password_email(
68 | email_to=user.email, email=email, token=password_reset_token
69 | )
70 | send_email(
71 | email_to=user.email,
72 | subject=email_data.subject,
73 | html_content=email_data.html_content,
74 | )
75 | return Message(message="Password recovery email sent")
76 |
77 |
78 | @router.post("/reset-password/")
79 | def reset_password(session: SessionDep, body: NewPassword) -> Message:
80 | """
81 | Reset password
82 | """
83 | email = verify_password_reset_token(token=body.token)
84 | if not email:
85 | raise HTTPException(status_code=400, detail="Invalid token")
86 | user = crud.get_user_by_email(session=session, email=email)
87 | if not user:
88 | raise HTTPException(
89 | status_code=404,
90 | detail="The user with this email does not exist in the system.",
91 | )
92 | elif not user.is_active:
93 | raise HTTPException(status_code=400, detail="Inactive user")
94 | hashed_password = get_password_hash(password=body.new_password)
95 | user.hashed_password = hashed_password
96 | session.add(user)
97 | session.commit()
98 | return Message(message="Password updated successfully")
99 |
100 |
101 | @router.post(
102 | "/password-recovery-html-content/{email}",
103 | dependencies=[Depends(get_current_active_superuser)],
104 | response_class=HTMLResponse,
105 | )
106 | def recover_password_html_content(email: str, session: SessionDep) -> Any:
107 | """
108 | HTML Content for Password Recovery
109 | """
110 | user = crud.get_user_by_email(session=session, email=email)
111 |
112 | if not user:
113 | raise HTTPException(
114 | status_code=404,
115 | detail="The user with this username does not exist in the system.",
116 | )
117 | password_reset_token = generate_password_reset_token(email=email)
118 | email_data = generate_reset_password_email(
119 | email_to=user.email, email=email, token=password_reset_token
120 | )
121 |
122 | return HTMLResponse(
123 | content=email_data.html_content, headers={"subject:": email_data.subject}
124 | )
125 |
--------------------------------------------------------------------------------
/backend/app/api/routes/users.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from fastapi import APIRouter, Depends, HTTPException
4 | from sqlmodel import col, delete, func, select
5 |
6 | from app import crud
7 | from app.api.deps import (
8 | CurrentUser,
9 | SessionDep,
10 | get_current_active_superuser,
11 | )
12 | from app.core.config import settings
13 | from app.core.security import get_password_hash, verify_password
14 | from app.models import (
15 | Item,
16 | Message,
17 | UpdatePassword,
18 | User,
19 | UserCreate,
20 | UserPublic,
21 | UserRegister,
22 | UsersPublic,
23 | UserUpdate,
24 | UserUpdateMe,
25 | )
26 | from app.utils import generate_new_account_email, send_email
27 |
28 | router = APIRouter()
29 |
30 |
31 | @router.get(
32 | "/",
33 | dependencies=[Depends(get_current_active_superuser)],
34 | response_model=UsersPublic,
35 | )
36 | def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
37 | """
38 | Retrieve users.
39 | """
40 |
41 | count_statement = select(func.count()).select_from(User)
42 | count = session.exec(count_statement).one()
43 |
44 | statement = select(User).offset(skip).limit(limit)
45 | users = session.exec(statement).all()
46 |
47 | return UsersPublic(data=users, count=count)
48 |
49 |
50 | @router.post(
51 | "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic
52 | )
53 | def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
54 | """
55 | Create new user.
56 | """
57 | user = crud.get_user_by_email(session=session, email=user_in.email)
58 | if user:
59 | raise HTTPException(
60 | status_code=400,
61 | detail="The user with this email already exists in the system.",
62 | )
63 |
64 | user = crud.create_user(session=session, user_create=user_in)
65 | if settings.emails_enabled and user_in.email:
66 | email_data = generate_new_account_email(
67 | email_to=user_in.email, username=user_in.email, password=user_in.password
68 | )
69 | send_email(
70 | email_to=user_in.email,
71 | subject=email_data.subject,
72 | html_content=email_data.html_content,
73 | )
74 | return user
75 |
76 |
77 | @router.patch("/me", response_model=UserPublic)
78 | def update_user_me(
79 | *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser
80 | ) -> Any:
81 | """
82 | Update own user.
83 | """
84 |
85 | if user_in.email:
86 | existing_user = crud.get_user_by_email(session=session, email=user_in.email)
87 | if existing_user and existing_user.id != current_user.id:
88 | raise HTTPException(
89 | status_code=409, detail="User with this email already exists"
90 | )
91 | user_data = user_in.model_dump(exclude_unset=True)
92 | current_user.sqlmodel_update(user_data)
93 | session.add(current_user)
94 | session.commit()
95 | session.refresh(current_user)
96 | return current_user
97 |
98 |
99 | @router.patch("/me/password", response_model=Message)
100 | def update_password_me(
101 | *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser
102 | ) -> Any:
103 | """
104 | Update own password.
105 | """
106 | if not verify_password(body.current_password, current_user.hashed_password):
107 | raise HTTPException(status_code=400, detail="Incorrect password")
108 | if body.current_password == body.new_password:
109 | raise HTTPException(
110 | status_code=400, detail="New password cannot be the same as the current one"
111 | )
112 | hashed_password = get_password_hash(body.new_password)
113 | current_user.hashed_password = hashed_password
114 | session.add(current_user)
115 | session.commit()
116 | return Message(message="Password updated successfully")
117 |
118 |
119 | @router.get("/me", response_model=UserPublic)
120 | def read_user_me(current_user: CurrentUser) -> Any:
121 | """
122 | Get current user.
123 | """
124 | return current_user
125 |
126 |
127 | @router.delete("/me", response_model=Message)
128 | def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
129 | """
130 | Delete own user.
131 | """
132 | if current_user.is_superuser:
133 | raise HTTPException(
134 | status_code=403, detail="Super users are not allowed to delete themselves"
135 | )
136 | statement = delete(Item).where(col(Item.owner_id) == current_user.id)
137 | session.exec(statement) # type: ignore
138 | session.delete(current_user)
139 | session.commit()
140 | return Message(message="User deleted successfully")
141 |
142 |
143 | @router.post("/signup", response_model=UserPublic)
144 | def register_user(session: SessionDep, user_in: UserRegister) -> Any:
145 | """
146 | Create new user without the need to be logged in.
147 | """
148 | if not settings.USERS_OPEN_REGISTRATION:
149 | raise HTTPException(
150 | status_code=403,
151 | detail="Open user registration is forbidden on this server",
152 | )
153 | user = crud.get_user_by_email(session=session, email=user_in.email)
154 | if user:
155 | raise HTTPException(
156 | status_code=400,
157 | detail="The user with this email already exists in the system",
158 | )
159 | user_create = UserCreate.model_validate(user_in)
160 | user = crud.create_user(session=session, user_create=user_create)
161 | return user
162 |
163 |
164 | @router.get("/{user_id}", response_model=UserPublic)
165 | def read_user_by_id(
166 | user_id: int, session: SessionDep, current_user: CurrentUser
167 | ) -> Any:
168 | """
169 | Get a specific user by id.
170 | """
171 | user = session.get(User, user_id)
172 | if user == current_user:
173 | return user
174 | if not current_user.is_superuser:
175 | raise HTTPException(
176 | status_code=403,
177 | detail="The user doesn't have enough privileges",
178 | )
179 | return user
180 |
181 |
182 | @router.patch(
183 | "/{user_id}",
184 | dependencies=[Depends(get_current_active_superuser)],
185 | response_model=UserPublic,
186 | )
187 | def update_user(
188 | *,
189 | session: SessionDep,
190 | user_id: int,
191 | user_in: UserUpdate,
192 | ) -> Any:
193 | """
194 | Update a user.
195 | """
196 |
197 | db_user = session.get(User, user_id)
198 | if not db_user:
199 | raise HTTPException(
200 | status_code=404,
201 | detail="The user with this id does not exist in the system",
202 | )
203 | if user_in.email:
204 | existing_user = crud.get_user_by_email(session=session, email=user_in.email)
205 | if existing_user and existing_user.id != user_id:
206 | raise HTTPException(
207 | status_code=409, detail="User with this email already exists"
208 | )
209 |
210 | db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in)
211 | return db_user
212 |
213 |
214 | @router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
215 | def delete_user(
216 | session: SessionDep, current_user: CurrentUser, user_id: int
217 | ) -> Message:
218 | """
219 | Delete a user.
220 | """
221 | user = session.get(User, user_id)
222 | if not user:
223 | raise HTTPException(status_code=404, detail="User not found")
224 | if user == current_user:
225 | raise HTTPException(
226 | status_code=403, detail="Super users are not allowed to delete themselves"
227 | )
228 | statement = delete(Item).where(col(Item.owner_id) == user_id)
229 | session.exec(statement) # type: ignore
230 | session.delete(user)
231 | session.commit()
232 | return Message(message="User deleted successfully")
233 |
--------------------------------------------------------------------------------
/backend/app/api/routes/utils.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 | from pydantic.networks import EmailStr
3 |
4 | from app.api.deps import get_current_active_superuser
5 | from app.models import Message
6 | from app.utils import generate_test_email, send_email
7 |
8 | router = APIRouter()
9 |
10 |
11 | @router.post(
12 | "/test-email/",
13 | dependencies=[Depends(get_current_active_superuser)],
14 | status_code=201,
15 | )
16 | def test_email(email_to: EmailStr) -> Message:
17 | """
18 | Test emails.
19 | """
20 | email_data = generate_test_email(email_to=email_to)
21 | send_email(
22 | email_to=email_to,
23 | subject=email_data.subject,
24 | html_content=email_data.html_content,
25 | )
26 | return Message(message="Test email sent")
27 |
--------------------------------------------------------------------------------
/backend/app/backend_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from sqlalchemy import Engine
4 | from sqlmodel import Session, select
5 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
6 |
7 | from app.core.db import engine
8 |
9 | logging.basicConfig(level=logging.INFO)
10 | logger = logging.getLogger(__name__)
11 |
12 | max_tries = 60 * 5 # 5 minutes
13 | wait_seconds = 1
14 |
15 |
16 | @retry(
17 | stop=stop_after_attempt(max_tries),
18 | wait=wait_fixed(wait_seconds),
19 | before=before_log(logger, logging.INFO),
20 | after=after_log(logger, logging.WARN),
21 | )
22 | def init(db_engine: Engine) -> None:
23 | try:
24 | with Session(db_engine) as session:
25 | # Try to create session to check if DB is awake
26 | session.exec(select(1))
27 | except Exception as e:
28 | logger.error(e)
29 | raise e
30 |
31 |
32 | def main() -> None:
33 | logger.info("Initializing service")
34 | init(engine)
35 | logger.info("Service finished initializing")
36 |
37 |
38 | if __name__ == "__main__":
39 | main()
40 |
--------------------------------------------------------------------------------
/backend/app/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/core/__init__.py
--------------------------------------------------------------------------------
/backend/app/core/config.py:
--------------------------------------------------------------------------------
1 | import secrets
2 | import warnings
3 | from typing import Annotated, Any, Literal
4 |
5 | from pydantic import (
6 | AnyUrl,
7 | BeforeValidator,
8 | HttpUrl,
9 | PostgresDsn,
10 | computed_field,
11 | model_validator,
12 | )
13 | from pydantic_core import MultiHostUrl
14 | from pydantic_settings import BaseSettings, SettingsConfigDict
15 | from typing_extensions import Self
16 |
17 |
18 | def parse_cors(v: Any) -> list[str] | str:
19 | if isinstance(v, str) and not v.startswith("["):
20 | return [i.strip() for i in v.split(",")]
21 | elif isinstance(v, list | str):
22 | return v
23 | raise ValueError(v)
24 |
25 |
26 | class Settings(BaseSettings):
27 | model_config = SettingsConfigDict(
28 | env_file=".env", env_ignore_empty=True, extra="ignore"
29 | )
30 | API_V1_STR: str = "/api/v1"
31 | SECRET_KEY: str = secrets.token_urlsafe(32)
32 | # 60 minutes * 24 hours * 8 days = 8 days
33 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
34 | DOMAIN: str = "localhost"
35 | ENVIRONMENT: Literal["local", "staging", "production"] = "local"
36 |
37 | @computed_field # type: ignore[misc]
38 | @property
39 | def server_host(self) -> str:
40 | # Use HTTPS for anything other than local development
41 | if self.ENVIRONMENT == "local":
42 | return f"http://{self.DOMAIN}"
43 | return f"https://{self.DOMAIN}"
44 |
45 | BACKEND_CORS_ORIGINS: Annotated[
46 | list[AnyUrl] | str, BeforeValidator(parse_cors)
47 | ] = []
48 |
49 | PROJECT_NAME: str
50 | SENTRY_DSN: HttpUrl | None = None
51 | POSTGRES_SERVER: str
52 | POSTGRES_PORT: int = 5432
53 | POSTGRES_USER: str
54 | POSTGRES_PASSWORD: str
55 | POSTGRES_DB: str = ""
56 |
57 | @computed_field # type: ignore[misc]
58 | @property
59 | def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
60 | return MultiHostUrl.build(
61 | scheme="postgresql+psycopg",
62 | username=self.POSTGRES_USER,
63 | password=self.POSTGRES_PASSWORD,
64 | host=self.POSTGRES_SERVER,
65 | port=self.POSTGRES_PORT,
66 | path=self.POSTGRES_DB,
67 | )
68 |
69 | SMTP_TLS: bool = True
70 | SMTP_SSL: bool = False
71 | SMTP_PORT: int = 587
72 | SMTP_HOST: str | None = None
73 | SMTP_USER: str | None = None
74 | SMTP_PASSWORD: str | None = None
75 | # TODO: update type to EmailStr when sqlmodel supports it
76 | EMAILS_FROM_EMAIL: str | None = None
77 | EMAILS_FROM_NAME: str | None = None
78 |
79 | @model_validator(mode="after")
80 | def _set_default_emails_from(self) -> Self:
81 | if not self.EMAILS_FROM_NAME:
82 | self.EMAILS_FROM_NAME = self.PROJECT_NAME
83 | return self
84 |
85 | EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
86 |
87 | @computed_field # type: ignore[misc]
88 | @property
89 | def emails_enabled(self) -> bool:
90 | return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
91 |
92 | # TODO: update type to EmailStr when sqlmodel supports it
93 | EMAIL_TEST_USER: str = "test@example.com"
94 | # TODO: update type to EmailStr when sqlmodel supports it
95 | FIRST_SUPERUSER: str
96 | FIRST_SUPERUSER_PASSWORD: str
97 | USERS_OPEN_REGISTRATION: bool = False
98 |
99 | def _check_default_secret(self, var_name: str, value: str | None) -> None:
100 | if value == "changethis":
101 | message = (
102 | f'The value of {var_name} is "changethis", '
103 | "for security, please change it, at least for deployments."
104 | )
105 | if self.ENVIRONMENT == "local":
106 | warnings.warn(message, stacklevel=1)
107 | else:
108 | raise ValueError(message)
109 |
110 | @model_validator(mode="after")
111 | def _enforce_non_default_secrets(self) -> Self:
112 | self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
113 | self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
114 | self._check_default_secret(
115 | "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
116 | )
117 |
118 | return self
119 |
120 |
121 | settings = Settings() # type: ignore
122 |
--------------------------------------------------------------------------------
/backend/app/core/db.py:
--------------------------------------------------------------------------------
1 | from sqlmodel import Session, create_engine, select
2 |
3 | from app import crud
4 | from app.core.config import settings
5 | from app.models import User, UserCreate
6 |
7 | engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
8 |
9 |
10 | # make sure all SQLModel models are imported (app.models) before initializing DB
11 | # otherwise, SQLModel might fail to initialize relationships properly
12 | # for more details: https://github.com/tiangolo/full-stack-fastapi-template/issues/28
13 |
14 |
15 | def init_db(session: Session) -> None:
16 | # Tables should be created with Alembic migrations
17 | # But if you don't want to use migrations, create
18 | # the tables un-commenting the next lines
19 | # from sqlmodel import SQLModel
20 |
21 | # from app.core.engine import engine
22 | # This works because the models are already imported and registered from app.models
23 | # SQLModel.metadata.create_all(engine)
24 |
25 | user = session.exec(
26 | select(User).where(User.email == settings.FIRST_SUPERUSER)
27 | ).first()
28 | if not user:
29 | user_in = UserCreate(
30 | email=settings.FIRST_SUPERUSER,
31 | password=settings.FIRST_SUPERUSER_PASSWORD,
32 | is_superuser=True,
33 | )
34 | user = crud.create_user(session=session, user_create=user_in)
35 |
--------------------------------------------------------------------------------
/backend/app/core/security.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | from typing import Any
3 |
4 | import jwt
5 | from passlib.context import CryptContext
6 |
7 | from app.core.config import settings
8 |
9 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
10 |
11 |
12 | ALGORITHM = "HS256"
13 |
14 |
15 | def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
16 | expire = datetime.utcnow() + expires_delta
17 | to_encode = {"exp": expire, "sub": str(subject)}
18 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
19 | return encoded_jwt
20 |
21 |
22 | def verify_password(plain_password: str, hashed_password: str) -> bool:
23 | return pwd_context.verify(plain_password, hashed_password)
24 |
25 |
26 | def get_password_hash(password: str) -> str:
27 | return pwd_context.hash(password)
28 |
--------------------------------------------------------------------------------
/backend/app/crud.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from sqlmodel import Session, select
4 |
5 | from app.core.security import get_password_hash, verify_password
6 | from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
7 |
8 |
9 | def create_user(*, session: Session, user_create: UserCreate) -> User:
10 | db_obj = User.model_validate(
11 | user_create, update={"hashed_password": get_password_hash(user_create.password)}
12 | )
13 | session.add(db_obj)
14 | session.commit()
15 | session.refresh(db_obj)
16 | return db_obj
17 |
18 |
19 | def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any:
20 | user_data = user_in.model_dump(exclude_unset=True)
21 | extra_data = {}
22 | if "password" in user_data:
23 | password = user_data["password"]
24 | hashed_password = get_password_hash(password)
25 | extra_data["hashed_password"] = hashed_password
26 | db_user.sqlmodel_update(user_data, update=extra_data)
27 | session.add(db_user)
28 | session.commit()
29 | session.refresh(db_user)
30 | return db_user
31 |
32 |
33 | def get_user_by_email(*, session: Session, email: str) -> User | None:
34 | statement = select(User).where(User.email == email)
35 | session_user = session.exec(statement).first()
36 | return session_user
37 |
38 |
39 | def authenticate(*, session: Session, email: str, password: str) -> User | None:
40 | db_user = get_user_by_email(session=session, email=email)
41 | if not db_user:
42 | return None
43 | if not verify_password(password, db_user.hashed_password):
44 | return None
45 | return db_user
46 |
47 |
48 | def create_item(*, session: Session, item_in: ItemCreate, owner_id: int) -> Item:
49 | db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
50 | session.add(db_item)
51 | session.commit()
52 | session.refresh(db_item)
53 | return db_item
54 |
--------------------------------------------------------------------------------
/backend/app/email-templates/build/new_account.html:
--------------------------------------------------------------------------------
1 |
{{ project_name }} - New Account | Welcome to your new account! | Here are your account details: | Username: {{ username }} | Password: {{ password }} | | |
|
--------------------------------------------------------------------------------
/backend/app/email-templates/build/reset_password.html:
--------------------------------------------------------------------------------
1 | {{ project_name }} - Password Recovery | Hello {{ username }} | We've received a request to reset your password. You can do it by clicking the button below: | | Or copy and paste the following link into your browser: | | This password will expire in {{ valid_hours }} hours. | | If you didn't request a password recovery you can disregard this email. |
|
--------------------------------------------------------------------------------
/backend/app/email-templates/build/test_email.html:
--------------------------------------------------------------------------------
1 | {{ project_name }} | Test email for: {{ email }} | |
|
--------------------------------------------------------------------------------
/backend/app/email-templates/src/new_account.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ project_name }} - New Account
6 | Welcome to your new account!
7 | Here are your account details:
8 | Username: {{ username }}
9 | Password: {{ password }}
10 | Go to Dashboard
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/backend/app/email-templates/src/reset_password.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ project_name }} - Password Recovery
6 | Hello {{ username }}
7 | We've received a request to reset your password. You can do it by clicking the button below:
8 | Reset password
9 | Or copy and paste the following link into your browser:
10 | {{ link }}
11 | This password will expire in {{ valid_hours }} hours.
12 |
13 | If you didn't request a password recovery you can disregard this email.
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/backend/app/email-templates/src/test_email.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ project_name }}
6 | Test email for: {{ email }}
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/backend/app/initial_data.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from sqlmodel import Session
4 |
5 | from app.core.db import engine, init_db
6 |
7 | logging.basicConfig(level=logging.INFO)
8 | logger = logging.getLogger(__name__)
9 |
10 |
11 | def init() -> None:
12 | with Session(engine) as session:
13 | init_db(session)
14 |
15 |
16 | def main() -> None:
17 | logger.info("Creating initial data")
18 | init()
19 | logger.info("Initial data created")
20 |
21 |
22 | if __name__ == "__main__":
23 | main()
24 |
--------------------------------------------------------------------------------
/backend/app/main.py:
--------------------------------------------------------------------------------
1 | import sentry_sdk
2 | from fastapi import FastAPI
3 | from fastapi.routing import APIRoute
4 | from starlette.middleware.cors import CORSMiddleware
5 |
6 | from app.api.main import api_router
7 | from app.core.config import settings
8 |
9 |
10 | def custom_generate_unique_id(route: APIRoute) -> str:
11 | return f"{route.tags[0]}-{route.name}"
12 |
13 |
14 | if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
15 | sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
16 |
17 | app = FastAPI(
18 | title=settings.PROJECT_NAME,
19 | openapi_url=f"{settings.API_V1_STR}/openapi.json",
20 | generate_unique_id_function=custom_generate_unique_id,
21 | )
22 |
23 | # Set all CORS enabled origins
24 | if settings.BACKEND_CORS_ORIGINS:
25 | app.add_middleware(
26 | CORSMiddleware,
27 | allow_origins=[
28 | str(origin).strip("/") for origin in settings.BACKEND_CORS_ORIGINS
29 | ],
30 | allow_credentials=True,
31 | allow_methods=["*"],
32 | allow_headers=["*"],
33 | )
34 |
35 | app.include_router(api_router, prefix=settings.API_V1_STR)
36 |
--------------------------------------------------------------------------------
/backend/app/models.py:
--------------------------------------------------------------------------------
1 | from pydantic import EmailStr
2 | from sqlmodel import Field, Relationship, SQLModel
3 |
4 |
5 | # Shared properties
6 | class UserBase(SQLModel):
7 | email: EmailStr = Field(unique=True, index=True, max_length=255)
8 | is_active: bool = True
9 | is_superuser: bool = False
10 | full_name: str | None = Field(default=None, max_length=255)
11 |
12 |
13 | # Properties to receive via API on creation
14 | class UserCreate(UserBase):
15 | password: str = Field(min_length=8, max_length=40)
16 |
17 |
18 | class UserRegister(SQLModel):
19 | email: EmailStr = Field(max_length=255)
20 | password: str = Field(min_length=8, max_length=40)
21 | full_name: str | None = Field(default=None, max_length=255)
22 |
23 |
24 | # Properties to receive via API on update, all are optional
25 | class UserUpdate(UserBase):
26 | email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
27 | password: str | None = Field(default=None, min_length=8, max_length=40)
28 |
29 |
30 | class UserUpdateMe(SQLModel):
31 | full_name: str | None = Field(default=None, max_length=255)
32 | email: EmailStr | None = Field(default=None, max_length=255)
33 |
34 |
35 | class UpdatePassword(SQLModel):
36 | current_password: str = Field(min_length=8, max_length=40)
37 | new_password: str = Field(min_length=8, max_length=40)
38 |
39 |
40 | # Database model, database table inferred from class name
41 | class User(UserBase, table=True):
42 | id: int | None = Field(default=None, primary_key=True)
43 | hashed_password: str
44 | items: list["Item"] = Relationship(back_populates="owner")
45 |
46 |
47 | # Properties to return via API, id is always required
48 | class UserPublic(UserBase):
49 | id: int
50 |
51 |
52 | class UsersPublic(SQLModel):
53 | data: list[UserPublic]
54 | count: int
55 |
56 |
57 | # Shared properties
58 | class ItemBase(SQLModel):
59 | title: str = Field(min_length=1, max_length=255)
60 | description: str | None = Field(default=None, max_length=255)
61 |
62 |
63 | # Properties to receive on item creation
64 | class ItemCreate(ItemBase):
65 | title: str = Field(min_length=1, max_length=255)
66 |
67 |
68 | # Properties to receive on item update
69 | class ItemUpdate(ItemBase):
70 | title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
71 |
72 |
73 | # Database model, database table inferred from class name
74 | class Item(ItemBase, table=True):
75 | id: int | None = Field(default=None, primary_key=True)
76 | title: str = Field(max_length=255)
77 | owner_id: int | None = Field(default=None, foreign_key="user.id", nullable=False)
78 | owner: User | None = Relationship(back_populates="items")
79 |
80 |
81 | # Properties to return via API, id is always required
82 | class ItemPublic(ItemBase):
83 | id: int
84 | owner_id: int
85 |
86 |
87 | class ItemsPublic(SQLModel):
88 | data: list[ItemPublic]
89 | count: int
90 |
91 |
92 | # Generic message
93 | class Message(SQLModel):
94 | message: str
95 |
96 |
97 | # JSON payload containing access token
98 | class Token(SQLModel):
99 | access_token: str
100 | token_type: str = "bearer"
101 |
102 |
103 | # Contents of JWT token
104 | class TokenPayload(SQLModel):
105 | sub: int | None = None
106 |
107 |
108 | class NewPassword(SQLModel):
109 | token: str
110 | new_password: str = Field(min_length=8, max_length=40)
111 |
--------------------------------------------------------------------------------
/backend/app/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/tests/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/tests/api/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/api/routes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/tests/api/routes/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/api/routes/test_items.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient
2 | from sqlmodel import Session
3 |
4 | from app.core.config import settings
5 | from app.tests.utils.item import create_random_item
6 |
7 |
8 | def test_create_item(
9 | client: TestClient, superuser_token_headers: dict[str, str]
10 | ) -> None:
11 | data = {"title": "Foo", "description": "Fighters"}
12 | response = client.post(
13 | f"{settings.API_V1_STR}/items/",
14 | headers=superuser_token_headers,
15 | json=data,
16 | )
17 | assert response.status_code == 200
18 | content = response.json()
19 | assert content["title"] == data["title"]
20 | assert content["description"] == data["description"]
21 | assert "id" in content
22 | assert "owner_id" in content
23 |
24 |
25 | def test_read_item(
26 | client: TestClient, superuser_token_headers: dict[str, str], db: Session
27 | ) -> None:
28 | item = create_random_item(db)
29 | response = client.get(
30 | f"{settings.API_V1_STR}/items/{item.id}",
31 | headers=superuser_token_headers,
32 | )
33 | assert response.status_code == 200
34 | content = response.json()
35 | assert content["title"] == item.title
36 | assert content["description"] == item.description
37 | assert content["id"] == item.id
38 | assert content["owner_id"] == item.owner_id
39 |
40 |
41 | def test_read_item_not_found(
42 | client: TestClient, superuser_token_headers: dict[str, str]
43 | ) -> None:
44 | response = client.get(
45 | f"{settings.API_V1_STR}/items/999",
46 | headers=superuser_token_headers,
47 | )
48 | assert response.status_code == 404
49 | content = response.json()
50 | assert content["detail"] == "Item not found"
51 |
52 |
53 | def test_read_item_not_enough_permissions(
54 | client: TestClient, normal_user_token_headers: dict[str, str], db: Session
55 | ) -> None:
56 | item = create_random_item(db)
57 | response = client.get(
58 | f"{settings.API_V1_STR}/items/{item.id}",
59 | headers=normal_user_token_headers,
60 | )
61 | assert response.status_code == 400
62 | content = response.json()
63 | assert content["detail"] == "Not enough permissions"
64 |
65 |
66 | def test_read_items(
67 | client: TestClient, superuser_token_headers: dict[str, str], db: Session
68 | ) -> None:
69 | create_random_item(db)
70 | create_random_item(db)
71 | response = client.get(
72 | f"{settings.API_V1_STR}/items/",
73 | headers=superuser_token_headers,
74 | )
75 | assert response.status_code == 200
76 | content = response.json()
77 | assert len(content["data"]) >= 2
78 |
79 |
80 | def test_update_item(
81 | client: TestClient, superuser_token_headers: dict[str, str], db: Session
82 | ) -> None:
83 | item = create_random_item(db)
84 | data = {"title": "Updated title", "description": "Updated description"}
85 | response = client.put(
86 | f"{settings.API_V1_STR}/items/{item.id}",
87 | headers=superuser_token_headers,
88 | json=data,
89 | )
90 | assert response.status_code == 200
91 | content = response.json()
92 | assert content["title"] == data["title"]
93 | assert content["description"] == data["description"]
94 | assert content["id"] == item.id
95 | assert content["owner_id"] == item.owner_id
96 |
97 |
98 | def test_update_item_not_found(
99 | client: TestClient, superuser_token_headers: dict[str, str]
100 | ) -> None:
101 | data = {"title": "Updated title", "description": "Updated description"}
102 | response = client.put(
103 | f"{settings.API_V1_STR}/items/999",
104 | headers=superuser_token_headers,
105 | json=data,
106 | )
107 | assert response.status_code == 404
108 | content = response.json()
109 | assert content["detail"] == "Item not found"
110 |
111 |
112 | def test_update_item_not_enough_permissions(
113 | client: TestClient, normal_user_token_headers: dict[str, str], db: Session
114 | ) -> None:
115 | item = create_random_item(db)
116 | data = {"title": "Updated title", "description": "Updated description"}
117 | response = client.put(
118 | f"{settings.API_V1_STR}/items/{item.id}",
119 | headers=normal_user_token_headers,
120 | json=data,
121 | )
122 | assert response.status_code == 400
123 | content = response.json()
124 | assert content["detail"] == "Not enough permissions"
125 |
126 |
127 | def test_delete_item(
128 | client: TestClient, superuser_token_headers: dict[str, str], db: Session
129 | ) -> None:
130 | item = create_random_item(db)
131 | response = client.delete(
132 | f"{settings.API_V1_STR}/items/{item.id}",
133 | headers=superuser_token_headers,
134 | )
135 | assert response.status_code == 200
136 | content = response.json()
137 | assert content["message"] == "Item deleted successfully"
138 |
139 |
140 | def test_delete_item_not_found(
141 | client: TestClient, superuser_token_headers: dict[str, str]
142 | ) -> None:
143 | response = client.delete(
144 | f"{settings.API_V1_STR}/items/999",
145 | headers=superuser_token_headers,
146 | )
147 | assert response.status_code == 404
148 | content = response.json()
149 | assert content["detail"] == "Item not found"
150 |
151 |
152 | def test_delete_item_not_enough_permissions(
153 | client: TestClient, normal_user_token_headers: dict[str, str], db: Session
154 | ) -> None:
155 | item = create_random_item(db)
156 | response = client.delete(
157 | f"{settings.API_V1_STR}/items/{item.id}",
158 | headers=normal_user_token_headers,
159 | )
160 | assert response.status_code == 400
161 | content = response.json()
162 | assert content["detail"] == "Not enough permissions"
163 |
--------------------------------------------------------------------------------
/backend/app/tests/api/routes/test_login.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | from fastapi.testclient import TestClient
4 | from sqlmodel import Session, select
5 |
6 | from app.core.config import settings
7 | from app.core.security import verify_password
8 | from app.models import User
9 | from app.utils import generate_password_reset_token
10 |
11 |
12 | def test_get_access_token(client: TestClient) -> None:
13 | login_data = {
14 | "username": settings.FIRST_SUPERUSER,
15 | "password": settings.FIRST_SUPERUSER_PASSWORD,
16 | }
17 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
18 | tokens = r.json()
19 | assert r.status_code == 200
20 | assert "access_token" in tokens
21 | assert tokens["access_token"]
22 |
23 |
24 | def test_get_access_token_incorrect_password(client: TestClient) -> None:
25 | login_data = {
26 | "username": settings.FIRST_SUPERUSER,
27 | "password": "incorrect",
28 | }
29 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
30 | assert r.status_code == 400
31 |
32 |
33 | def test_use_access_token(
34 | client: TestClient, superuser_token_headers: dict[str, str]
35 | ) -> None:
36 | r = client.post(
37 | f"{settings.API_V1_STR}/login/test-token",
38 | headers=superuser_token_headers,
39 | )
40 | result = r.json()
41 | assert r.status_code == 200
42 | assert "email" in result
43 |
44 |
45 | def test_recovery_password(
46 | client: TestClient, normal_user_token_headers: dict[str, str]
47 | ) -> None:
48 | with (
49 | patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"),
50 | patch("app.core.config.settings.SMTP_USER", "admin@example.com"),
51 | ):
52 | email = "test@example.com"
53 | r = client.post(
54 | f"{settings.API_V1_STR}/password-recovery/{email}",
55 | headers=normal_user_token_headers,
56 | )
57 | assert r.status_code == 200
58 | assert r.json() == {"message": "Password recovery email sent"}
59 |
60 |
61 | def test_recovery_password_user_not_exits(
62 | client: TestClient, normal_user_token_headers: dict[str, str]
63 | ) -> None:
64 | email = "jVgQr@example.com"
65 | r = client.post(
66 | f"{settings.API_V1_STR}/password-recovery/{email}",
67 | headers=normal_user_token_headers,
68 | )
69 | assert r.status_code == 404
70 |
71 |
72 | def test_reset_password(
73 | client: TestClient, superuser_token_headers: dict[str, str], db: Session
74 | ) -> None:
75 | token = generate_password_reset_token(email=settings.FIRST_SUPERUSER)
76 | data = {"new_password": "changethis", "token": token}
77 | r = client.post(
78 | f"{settings.API_V1_STR}/reset-password/",
79 | headers=superuser_token_headers,
80 | json=data,
81 | )
82 | assert r.status_code == 200
83 | assert r.json() == {"message": "Password updated successfully"}
84 |
85 | user_query = select(User).where(User.email == settings.FIRST_SUPERUSER)
86 | user = db.exec(user_query).first()
87 | assert user
88 | assert verify_password(data["new_password"], user.hashed_password)
89 |
90 |
91 | def test_reset_password_invalid_token(
92 | client: TestClient, superuser_token_headers: dict[str, str]
93 | ) -> None:
94 | data = {"new_password": "changethis", "token": "invalid"}
95 | r = client.post(
96 | f"{settings.API_V1_STR}/reset-password/",
97 | headers=superuser_token_headers,
98 | json=data,
99 | )
100 | response = r.json()
101 |
102 | assert "detail" in response
103 | assert r.status_code == 400
104 | assert response["detail"] == "Invalid token"
105 |
--------------------------------------------------------------------------------
/backend/app/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Generator
2 |
3 | import pytest
4 | from fastapi.testclient import TestClient
5 | from sqlmodel import Session, delete
6 |
7 | from app.core.config import settings
8 | from app.core.db import engine, init_db
9 | from app.main import app
10 | from app.models import Item, User
11 | from app.tests.utils.user import authentication_token_from_email
12 | from app.tests.utils.utils import get_superuser_token_headers
13 |
14 |
15 | @pytest.fixture(scope="session", autouse=True)
16 | def db() -> Generator[Session, None, None]:
17 | with Session(engine) as session:
18 | init_db(session)
19 | yield session
20 | statement = delete(Item)
21 | session.execute(statement)
22 | statement = delete(User)
23 | session.execute(statement)
24 | session.commit()
25 |
26 |
27 | @pytest.fixture(scope="module")
28 | def client() -> Generator[TestClient, None, None]:
29 | with TestClient(app) as c:
30 | yield c
31 |
32 |
33 | @pytest.fixture(scope="module")
34 | def superuser_token_headers(client: TestClient) -> dict[str, str]:
35 | return get_superuser_token_headers(client)
36 |
37 |
38 | @pytest.fixture(scope="module")
39 | def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]:
40 | return authentication_token_from_email(
41 | client=client, email=settings.EMAIL_TEST_USER, db=db
42 | )
43 |
--------------------------------------------------------------------------------
/backend/app/tests/crud/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/tests/crud/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/crud/test_user.py:
--------------------------------------------------------------------------------
1 | from fastapi.encoders import jsonable_encoder
2 | from sqlmodel import Session
3 |
4 | from app import crud
5 | from app.core.security import verify_password
6 | from app.models import User, UserCreate, UserUpdate
7 | from app.tests.utils.utils import random_email, random_lower_string
8 |
9 |
10 | def test_create_user(db: Session) -> None:
11 | email = random_email()
12 | password = random_lower_string()
13 | user_in = UserCreate(email=email, password=password)
14 | user = crud.create_user(session=db, user_create=user_in)
15 | assert user.email == email
16 | assert hasattr(user, "hashed_password")
17 |
18 |
19 | def test_authenticate_user(db: Session) -> None:
20 | email = random_email()
21 | password = random_lower_string()
22 | user_in = UserCreate(email=email, password=password)
23 | user = crud.create_user(session=db, user_create=user_in)
24 | authenticated_user = crud.authenticate(session=db, email=email, password=password)
25 | assert authenticated_user
26 | assert user.email == authenticated_user.email
27 |
28 |
29 | def test_not_authenticate_user(db: Session) -> None:
30 | email = random_email()
31 | password = random_lower_string()
32 | user = crud.authenticate(session=db, email=email, password=password)
33 | assert user is None
34 |
35 |
36 | def test_check_if_user_is_active(db: Session) -> None:
37 | email = random_email()
38 | password = random_lower_string()
39 | user_in = UserCreate(email=email, password=password)
40 | user = crud.create_user(session=db, user_create=user_in)
41 | assert user.is_active is True
42 |
43 |
44 | def test_check_if_user_is_active_inactive(db: Session) -> None:
45 | email = random_email()
46 | password = random_lower_string()
47 | user_in = UserCreate(email=email, password=password, disabled=True)
48 | user = crud.create_user(session=db, user_create=user_in)
49 | assert user.is_active
50 |
51 |
52 | def test_check_if_user_is_superuser(db: Session) -> None:
53 | email = random_email()
54 | password = random_lower_string()
55 | user_in = UserCreate(email=email, password=password, is_superuser=True)
56 | user = crud.create_user(session=db, user_create=user_in)
57 | assert user.is_superuser is True
58 |
59 |
60 | def test_check_if_user_is_superuser_normal_user(db: Session) -> None:
61 | username = random_email()
62 | password = random_lower_string()
63 | user_in = UserCreate(email=username, password=password)
64 | user = crud.create_user(session=db, user_create=user_in)
65 | assert user.is_superuser is False
66 |
67 |
68 | def test_get_user(db: Session) -> None:
69 | password = random_lower_string()
70 | username = random_email()
71 | user_in = UserCreate(email=username, password=password, is_superuser=True)
72 | user = crud.create_user(session=db, user_create=user_in)
73 | user_2 = db.get(User, user.id)
74 | assert user_2
75 | assert user.email == user_2.email
76 | assert jsonable_encoder(user) == jsonable_encoder(user_2)
77 |
78 |
79 | def test_update_user(db: Session) -> None:
80 | password = random_lower_string()
81 | email = random_email()
82 | user_in = UserCreate(email=email, password=password, is_superuser=True)
83 | user = crud.create_user(session=db, user_create=user_in)
84 | new_password = random_lower_string()
85 | user_in_update = UserUpdate(password=new_password, is_superuser=True)
86 | if user.id is not None:
87 | crud.update_user(session=db, db_user=user, user_in=user_in_update)
88 | user_2 = db.get(User, user.id)
89 | assert user_2
90 | assert user.email == user_2.email
91 | assert verify_password(new_password, user_2.hashed_password)
92 |
--------------------------------------------------------------------------------
/backend/app/tests/scripts/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/tests/scripts/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/scripts/test_backend_pre_start.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock, patch
2 |
3 | from sqlmodel import select
4 |
5 | from app.backend_pre_start import init, logger
6 |
7 |
8 | def test_init_successful_connection() -> None:
9 | engine_mock = MagicMock()
10 |
11 | session_mock = MagicMock()
12 | exec_mock = MagicMock(return_value=True)
13 | session_mock.configure_mock(**{"exec.return_value": exec_mock})
14 |
15 | with (
16 | patch("sqlmodel.Session", return_value=session_mock),
17 | patch.object(logger, "info"),
18 | patch.object(logger, "error"),
19 | patch.object(logger, "warn"),
20 | ):
21 | try:
22 | init(engine_mock)
23 | connection_successful = True
24 | except Exception:
25 | connection_successful = False
26 |
27 | assert (
28 | connection_successful
29 | ), "The database connection should be successful and not raise an exception."
30 |
31 | assert session_mock.exec.called_once_with(
32 | select(1)
33 | ), "The session should execute a select statement once."
34 |
--------------------------------------------------------------------------------
/backend/app/tests/scripts/test_test_pre_start.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock, patch
2 |
3 | from sqlmodel import select
4 |
5 | from app.tests_pre_start import init, logger
6 |
7 |
8 | def test_init_successful_connection() -> None:
9 | engine_mock = MagicMock()
10 |
11 | session_mock = MagicMock()
12 | exec_mock = MagicMock(return_value=True)
13 | session_mock.configure_mock(**{"exec.return_value": exec_mock})
14 |
15 | with (
16 | patch("sqlmodel.Session", return_value=session_mock),
17 | patch.object(logger, "info"),
18 | patch.object(logger, "error"),
19 | patch.object(logger, "warn"),
20 | ):
21 | try:
22 | init(engine_mock)
23 | connection_successful = True
24 | except Exception:
25 | connection_successful = False
26 |
27 | assert (
28 | connection_successful
29 | ), "The database connection should be successful and not raise an exception."
30 |
31 | assert session_mock.exec.called_once_with(
32 | select(1)
33 | ), "The session should execute a select statement once."
34 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/backend/app/tests/utils/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/utils/item.py:
--------------------------------------------------------------------------------
1 | from sqlmodel import Session
2 |
3 | from app import crud
4 | from app.models import Item, ItemCreate
5 | from app.tests.utils.user import create_random_user
6 | from app.tests.utils.utils import random_lower_string
7 |
8 |
9 | def create_random_item(db: Session) -> Item:
10 | user = create_random_user(db)
11 | owner_id = user.id
12 | assert owner_id is not None
13 | title = random_lower_string()
14 | description = random_lower_string()
15 | item_in = ItemCreate(title=title, description=description)
16 | return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)
17 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/user.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient
2 | from sqlmodel import Session
3 |
4 | from app import crud
5 | from app.core.config import settings
6 | from app.models import User, UserCreate, UserUpdate
7 | from app.tests.utils.utils import random_email, random_lower_string
8 |
9 |
10 | def user_authentication_headers(
11 | *, client: TestClient, email: str, password: str
12 | ) -> dict[str, str]:
13 | data = {"username": email, "password": password}
14 |
15 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data)
16 | response = r.json()
17 | auth_token = response["access_token"]
18 | headers = {"Authorization": f"Bearer {auth_token}"}
19 | return headers
20 |
21 |
22 | def create_random_user(db: Session) -> User:
23 | email = random_email()
24 | password = random_lower_string()
25 | user_in = UserCreate(email=email, password=password)
26 | user = crud.create_user(session=db, user_create=user_in)
27 | return user
28 |
29 |
30 | def authentication_token_from_email(
31 | *, client: TestClient, email: str, db: Session
32 | ) -> dict[str, str]:
33 | """
34 | Return a valid token for the user with given email.
35 |
36 | If the user doesn't exist it is created first.
37 | """
38 | password = random_lower_string()
39 | user = crud.get_user_by_email(session=db, email=email)
40 | if not user:
41 | user_in_create = UserCreate(email=email, password=password)
42 | user = crud.create_user(session=db, user_create=user_in_create)
43 | else:
44 | user_in_update = UserUpdate(password=password)
45 | if not user.id:
46 | raise Exception("User id not set")
47 | user = crud.update_user(session=db, db_user=user, user_in=user_in_update)
48 |
49 | return user_authentication_headers(client=client, email=email, password=password)
50 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/utils.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 | from fastapi.testclient import TestClient
5 |
6 | from app.core.config import settings
7 |
8 |
9 | def random_lower_string() -> str:
10 | return "".join(random.choices(string.ascii_lowercase, k=32))
11 |
12 |
13 | def random_email() -> str:
14 | return f"{random_lower_string()}@{random_lower_string()}.com"
15 |
16 |
17 | def get_superuser_token_headers(client: TestClient) -> dict[str, str]:
18 | login_data = {
19 | "username": settings.FIRST_SUPERUSER,
20 | "password": settings.FIRST_SUPERUSER_PASSWORD,
21 | }
22 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
23 | tokens = r.json()
24 | a_token = tokens["access_token"]
25 | headers = {"Authorization": f"Bearer {a_token}"}
26 | return headers
27 |
--------------------------------------------------------------------------------
/backend/app/utils.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dataclasses import dataclass
3 | from datetime import datetime, timedelta
4 | from pathlib import Path
5 | from typing import Any
6 |
7 | import emails # type: ignore
8 | import jwt
9 | from jinja2 import Template
10 | from jwt.exceptions import InvalidTokenError
11 |
12 | from app.core.config import settings
13 |
14 |
15 | @dataclass
16 | class EmailData:
17 | html_content: str
18 | subject: str
19 |
20 |
21 | def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
22 | template_str = (
23 | Path(__file__).parent / "email-templates" / "build" / template_name
24 | ).read_text()
25 | html_content = Template(template_str).render(context)
26 | return html_content
27 |
28 |
29 | def send_email(
30 | *,
31 | email_to: str,
32 | subject: str = "",
33 | html_content: str = "",
34 | ) -> None:
35 | assert settings.emails_enabled, "no provided configuration for email variables"
36 | message = emails.Message(
37 | subject=subject,
38 | html=html_content,
39 | mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
40 | )
41 | smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
42 | if settings.SMTP_TLS:
43 | smtp_options["tls"] = True
44 | elif settings.SMTP_SSL:
45 | smtp_options["ssl"] = True
46 | if settings.SMTP_USER:
47 | smtp_options["user"] = settings.SMTP_USER
48 | if settings.SMTP_PASSWORD:
49 | smtp_options["password"] = settings.SMTP_PASSWORD
50 | response = message.send(to=email_to, smtp=smtp_options)
51 | logging.info(f"send email result: {response}")
52 |
53 |
54 | def generate_test_email(email_to: str) -> EmailData:
55 | project_name = settings.PROJECT_NAME
56 | subject = f"{project_name} - Test email"
57 | html_content = render_email_template(
58 | template_name="test_email.html",
59 | context={"project_name": settings.PROJECT_NAME, "email": email_to},
60 | )
61 | return EmailData(html_content=html_content, subject=subject)
62 |
63 |
64 | def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData:
65 | project_name = settings.PROJECT_NAME
66 | subject = f"{project_name} - Password recovery for user {email}"
67 | link = f"{settings.server_host}/reset-password?token={token}"
68 | html_content = render_email_template(
69 | template_name="reset_password.html",
70 | context={
71 | "project_name": settings.PROJECT_NAME,
72 | "username": email,
73 | "email": email_to,
74 | "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS,
75 | "link": link,
76 | },
77 | )
78 | return EmailData(html_content=html_content, subject=subject)
79 |
80 |
81 | def generate_new_account_email(
82 | email_to: str, username: str, password: str
83 | ) -> EmailData:
84 | project_name = settings.PROJECT_NAME
85 | subject = f"{project_name} - New account for user {username}"
86 | html_content = render_email_template(
87 | template_name="new_account.html",
88 | context={
89 | "project_name": settings.PROJECT_NAME,
90 | "username": username,
91 | "password": password,
92 | "email": email_to,
93 | "link": settings.server_host,
94 | },
95 | )
96 | return EmailData(html_content=html_content, subject=subject)
97 |
98 |
99 | def generate_password_reset_token(email: str) -> str:
100 | delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
101 | now = datetime.utcnow()
102 | expires = now + delta
103 | exp = expires.timestamp()
104 | encoded_jwt = jwt.encode(
105 | {"exp": exp, "nbf": now, "sub": email},
106 | settings.SECRET_KEY,
107 | algorithm="HS256",
108 | )
109 | return encoded_jwt
110 |
111 |
112 | def verify_password_reset_token(token: str) -> str | None:
113 | try:
114 | decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
115 | return str(decoded_token["sub"])
116 | except InvalidTokenError:
117 | return None
118 |
--------------------------------------------------------------------------------
/backend/prestart.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env bash
2 |
3 | # Let the DB start
4 | python ./app/backend_pre_start.py
5 |
6 | # Run migrations
7 | alembic upgrade head
8 |
9 | # Create initial data in DB
10 | python ./app/initial_data.py
11 |
--------------------------------------------------------------------------------
/backend/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "app"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["Admin "]
6 |
7 | [tool.poetry.dependencies]
8 | python = "^3.10"
9 | uvicorn = {extras = ["standard"], version = "^0.24.0.post1"}
10 | fastapi = "^0.109.1"
11 | python-multipart = "^0.0.7"
12 | email-validator = "^2.1.0.post1"
13 | passlib = {extras = ["bcrypt"], version = "^1.7.4"}
14 | tenacity = "^8.2.3"
15 | pydantic = ">2.0"
16 | emails = "^0.6"
17 |
18 | gunicorn = "^22.0.0"
19 | jinja2 = "^3.1.4"
20 | alembic = "^1.12.1"
21 | httpx = "^0.25.1"
22 | psycopg = {extras = ["binary"], version = "^3.1.13"}
23 | sqlmodel = "^0.0.19"
24 | # Pin bcrypt until passlib supports the latest
25 | bcrypt = "4.0.1"
26 | pydantic-settings = "^2.2.1"
27 | sentry-sdk = {extras = ["fastapi"], version = "^1.40.6"}
28 | pyjwt = "^2.8.0"
29 | python-dotenv = "^1.0.1"
30 |
31 | [tool.poetry.group.dev.dependencies]
32 | pytest = "^7.4.3"
33 | mypy = "^1.8.0"
34 | ruff = "^0.2.2"
35 | pre-commit = "^3.6.2"
36 | types-passlib = "^1.7.7.20240106"
37 | coverage = "^7.4.3"
38 |
39 | [build-system]
40 | requires = ["poetry>=0.12"]
41 | build-backend = "poetry.masonry.api"
42 |
43 | [tool.mypy]
44 | strict = true
45 | exclude = ["venv", ".venv", "alembic"]
46 |
47 | [tool.ruff]
48 | target-version = "py310"
49 | exclude = ["alembic"]
50 |
51 | [tool.ruff.lint]
52 | select = [
53 | "E", # pycodestyle errors
54 | "W", # pycodestyle warnings
55 | "F", # pyflakes
56 | "I", # isort
57 | "B", # flake8-bugbear
58 | "C4", # flake8-comprehensions
59 | "UP", # pyupgrade
60 | "ARG001", # unused arguments in functions
61 | ]
62 | ignore = [
63 | "E501", # line too long, handled by black
64 | "B008", # do not perform function calls in argument defaults
65 | "W191", # indentation contains tabs
66 | "B904", # Allow raising exceptions without from e, for HTTPException
67 | ]
68 |
69 | [tool.ruff.lint.pyupgrade]
70 | # Preserve types, even if a file imports `from __future__ import annotations`.
71 | keep-runtime-typing = true
72 |
--------------------------------------------------------------------------------
/frontend/.env:
--------------------------------------------------------------------------------
1 | VITE_API_URL=http://localhost:8000
2 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | openapi.json
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Frontend - ReactJS with ChakraUI
2 |
3 | This directory contains the frontend of the application built with ReactJS and ChakraUI.
4 |
5 | ## Prerequisites
6 |
7 | - Node.js (version 14.x or higher)
8 | - npm (version 6.x or higher)
9 |
10 | ## Setup Instructions
11 |
12 | 1. **Navigate to the frontend directory**:
13 | ```sh
14 | cd frontend
15 | ```
16 |
17 | 2. **Install dependencies**:
18 | ```sh
19 | npm install
20 | ```
21 |
22 | 3. **Run the development server**:
23 | ```sh
24 | npm run dev
25 | ```
26 |
27 | 4. **Configure API URL**:
28 | Ensure the API URL is correctly set in the `.env` file.
29 |
30 |
--------------------------------------------------------------------------------
/frontend/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.6.1/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "files": {
7 | "ignore": ["node_modules", "src/routeTree.gen.ts"]
8 | },
9 | "linter": {
10 | "enabled": true,
11 | "rules": {
12 | "recommended": true,
13 | "suspicious": {
14 | "noExplicitAny": "off",
15 | "noArrayIndexKey": "off"
16 | },
17 | "style": {
18 | "noNonNullAssertion": "off"
19 | }
20 | }
21 | },
22 | "formatter": {
23 | "indentStyle": "space"
24 | },
25 | "javascript": {
26 | "formatter": {
27 | "quoteStyle": "double",
28 | "semicolons": "asNeeded"
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Full Stack FastAPI Project
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/frontend/modify-openapi-operationids.js:
--------------------------------------------------------------------------------
1 | import * as fs from "node:fs"
2 |
3 | async function modifyOpenAPIFile(filePath) {
4 | try {
5 | const data = await fs.promises.readFile(filePath)
6 | const openapiContent = JSON.parse(data)
7 |
8 | const paths = openapiContent.paths
9 | for (const pathKey of Object.keys(paths)) {
10 | const pathData = paths[pathKey]
11 | for (const method of Object.keys(pathData)) {
12 | const operation = pathData[method]
13 | if (operation.tags && operation.tags.length > 0) {
14 | const tag = operation.tags[0]
15 | const operationId = operation.operationId
16 | const toRemove = `${tag}-`
17 | if (operationId.startsWith(toRemove)) {
18 | const newOperationId = operationId.substring(toRemove.length)
19 | operation.operationId = newOperationId
20 | }
21 | }
22 | }
23 | }
24 |
25 | await fs.promises.writeFile(
26 | filePath,
27 | JSON.stringify(openapiContent, null, 2),
28 | )
29 | console.log("File successfully modified")
30 | } catch (err) {
31 | console.error("Error:", err)
32 | }
33 | }
34 |
35 | const filePath = "./openapi.json"
36 | modifyOpenAPIFile(filePath)
37 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "biome check --apply-unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./",
10 | "preview": "vite preview",
11 | "generate-client": "openapi-ts --input ./openapi.json --output ./src/client --client axios --exportSchemas true && biome format --write ./src/client"
12 | },
13 | "dependencies": {
14 | "@chakra-ui/icons": "2.1.1",
15 | "@chakra-ui/react": "2.8.2",
16 | "@emotion/react": "11.11.3",
17 | "@emotion/styled": "11.11.0",
18 | "@tanstack/react-query": "^5.28.14",
19 | "@tanstack/react-query-devtools": "^5.28.14",
20 | "@tanstack/react-router": "1.19.1",
21 | "axios": "1.6.2",
22 | "form-data": "4.0.0",
23 | "framer-motion": "10.16.16",
24 | "react": "^18.2.0",
25 | "react-dom": "^18.2.0",
26 | "react-error-boundary": "^4.0.13",
27 | "react-hook-form": "7.49.3",
28 | "react-icons": "5.0.1",
29 | "verbose": "^0.2.3"
30 | },
31 | "devDependencies": {
32 | "@biomejs/biome": "1.6.1",
33 | "@hey-api/openapi-ts": "^0.34.1",
34 | "@tanstack/router-devtools": "1.19.1",
35 | "@tanstack/router-vite-plugin": "1.19.0",
36 | "@types/node": "20.10.5",
37 | "@types/react": "^18.2.37",
38 | "@types/react-dom": "^18.2.15",
39 | "@vitejs/plugin-react-swc": "^3.5.0",
40 | "typescript": "^5.2.2",
41 | "vite": "^5.0.13"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/public/assets/images/fastapi-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
52 |
--------------------------------------------------------------------------------
/frontend/public/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hngprojects/devops-stage-2/e0840988066817cb540a2eb3201a110f78af0942/frontend/public/assets/images/favicon.png
--------------------------------------------------------------------------------
/frontend/src/client/core/ApiError.ts:
--------------------------------------------------------------------------------
1 | import type { ApiRequestOptions } from "./ApiRequestOptions"
2 | import type { ApiResult } from "./ApiResult"
3 |
4 | export class ApiError extends Error {
5 | public readonly url: string
6 | public readonly status: number
7 | public readonly statusText: string
8 | public readonly body: unknown
9 | public readonly request: ApiRequestOptions
10 |
11 | constructor(
12 | request: ApiRequestOptions,
13 | response: ApiResult,
14 | message: string,
15 | ) {
16 | super(message)
17 |
18 | this.name = "ApiError"
19 | this.url = response.url
20 | this.status = response.status
21 | this.statusText = response.statusText
22 | this.body = response.body
23 | this.request = request
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/client/core/ApiRequestOptions.ts:
--------------------------------------------------------------------------------
1 | export type ApiRequestOptions = {
2 | readonly method:
3 | | "GET"
4 | | "PUT"
5 | | "POST"
6 | | "DELETE"
7 | | "OPTIONS"
8 | | "HEAD"
9 | | "PATCH"
10 | readonly url: string
11 | readonly path?: Record
12 | readonly cookies?: Record
13 | readonly headers?: Record
14 | readonly query?: Record
15 | readonly formData?: Record
16 | readonly body?: any
17 | readonly mediaType?: string
18 | readonly responseHeader?: string
19 | readonly errors?: Record
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/client/core/ApiResult.ts:
--------------------------------------------------------------------------------
1 | export type ApiResult = {
2 | readonly body: TData
3 | readonly ok: boolean
4 | readonly status: number
5 | readonly statusText: string
6 | readonly url: string
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/client/core/CancelablePromise.ts:
--------------------------------------------------------------------------------
1 | export class CancelError extends Error {
2 | constructor(message: string) {
3 | super(message)
4 | this.name = "CancelError"
5 | }
6 |
7 | public get isCancelled(): boolean {
8 | return true
9 | }
10 | }
11 |
12 | export interface OnCancel {
13 | readonly isResolved: boolean
14 | readonly isRejected: boolean
15 | readonly isCancelled: boolean
16 |
17 | (cancelHandler: () => void): void
18 | }
19 |
20 | export class CancelablePromise implements Promise {
21 | private _isResolved: boolean
22 | private _isRejected: boolean
23 | private _isCancelled: boolean
24 | readonly cancelHandlers: (() => void)[]
25 | readonly promise: Promise
26 | private _resolve?: (value: T | PromiseLike) => void
27 | private _reject?: (reason?: unknown) => void
28 |
29 | constructor(
30 | executor: (
31 | resolve: (value: T | PromiseLike) => void,
32 | reject: (reason?: unknown) => void,
33 | onCancel: OnCancel,
34 | ) => void,
35 | ) {
36 | this._isResolved = false
37 | this._isRejected = false
38 | this._isCancelled = false
39 | this.cancelHandlers = []
40 | this.promise = new Promise((resolve, reject) => {
41 | this._resolve = resolve
42 | this._reject = reject
43 |
44 | const onResolve = (value: T | PromiseLike): void => {
45 | if (this._isResolved || this._isRejected || this._isCancelled) {
46 | return
47 | }
48 | this._isResolved = true
49 | if (this._resolve) this._resolve(value)
50 | }
51 |
52 | const onReject = (reason?: unknown): void => {
53 | if (this._isResolved || this._isRejected || this._isCancelled) {
54 | return
55 | }
56 | this._isRejected = true
57 | if (this._reject) this._reject(reason)
58 | }
59 |
60 | const onCancel = (cancelHandler: () => void): void => {
61 | if (this._isResolved || this._isRejected || this._isCancelled) {
62 | return
63 | }
64 | this.cancelHandlers.push(cancelHandler)
65 | }
66 |
67 | Object.defineProperty(onCancel, "isResolved", {
68 | get: (): boolean => this._isResolved,
69 | })
70 |
71 | Object.defineProperty(onCancel, "isRejected", {
72 | get: (): boolean => this._isRejected,
73 | })
74 |
75 | Object.defineProperty(onCancel, "isCancelled", {
76 | get: (): boolean => this._isCancelled,
77 | })
78 |
79 | return executor(onResolve, onReject, onCancel as OnCancel)
80 | })
81 | }
82 |
83 | get [Symbol.toStringTag]() {
84 | return "Cancellable Promise"
85 | }
86 |
87 | public then(
88 | onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null,
89 | onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null,
90 | ): Promise {
91 | return this.promise.then(onFulfilled, onRejected)
92 | }
93 |
94 | public catch(
95 | onRejected?: ((reason: unknown) => TResult | PromiseLike) | null,
96 | ): Promise {
97 | return this.promise.catch(onRejected)
98 | }
99 |
100 | public finally(onFinally?: (() => void) | null): Promise {
101 | return this.promise.finally(onFinally)
102 | }
103 |
104 | public cancel(): void {
105 | if (this._isResolved || this._isRejected || this._isCancelled) {
106 | return
107 | }
108 | this._isCancelled = true
109 | if (this.cancelHandlers.length) {
110 | try {
111 | for (const cancelHandler of this.cancelHandlers) {
112 | cancelHandler()
113 | }
114 | } catch (error) {
115 | console.warn("Cancellation threw an error", error)
116 | return
117 | }
118 | }
119 | this.cancelHandlers.length = 0
120 | if (this._reject) this._reject(new CancelError("Request aborted"))
121 | }
122 |
123 | public get isCancelled(): boolean {
124 | return this._isCancelled
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/frontend/src/client/core/OpenAPI.ts:
--------------------------------------------------------------------------------
1 | import type { AxiosRequestConfig, AxiosResponse } from "axios"
2 | import type { ApiRequestOptions } from "./ApiRequestOptions"
3 | import type { TResult } from "./types"
4 |
5 | type Headers = Record
6 | type Middleware = (value: T) => T | Promise
7 | type Resolver = (options: ApiRequestOptions) => Promise
8 |
9 | export class Interceptors {
10 | _fns: Middleware[]
11 |
12 | constructor() {
13 | this._fns = []
14 | }
15 |
16 | eject(fn: Middleware) {
17 | const index = this._fns.indexOf(fn)
18 | if (index !== -1) {
19 | this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]
20 | }
21 | }
22 |
23 | use(fn: Middleware) {
24 | this._fns = [...this._fns, fn]
25 | }
26 | }
27 |
28 | export type OpenAPIConfig = {
29 | BASE: string
30 | CREDENTIALS: "include" | "omit" | "same-origin"
31 | ENCODE_PATH?: ((path: string) => string) | undefined
32 | HEADERS?: Headers | Resolver | undefined
33 | PASSWORD?: string | Resolver | undefined
34 | RESULT?: TResult
35 | TOKEN?: string | Resolver | undefined
36 | USERNAME?: string | Resolver | undefined
37 | VERSION: string
38 | WITH_CREDENTIALS: boolean
39 | interceptors: {
40 | request: Interceptors
41 | response: Interceptors
42 | }
43 | }
44 |
45 | export const OpenAPI: OpenAPIConfig = {
46 | BASE: "",
47 | CREDENTIALS: "include",
48 | ENCODE_PATH: undefined,
49 | HEADERS: undefined,
50 | PASSWORD: undefined,
51 | RESULT: "body",
52 | TOKEN: undefined,
53 | USERNAME: undefined,
54 | VERSION: "0.1.0",
55 | WITH_CREDENTIALS: false,
56 | interceptors: { request: new Interceptors(), response: new Interceptors() },
57 | }
58 |
--------------------------------------------------------------------------------
/frontend/src/client/core/types.ts:
--------------------------------------------------------------------------------
1 | import type { ApiResult } from "./ApiResult"
2 |
3 | export type TResult = "body" | "raw"
4 |
5 | export type TApiResponse = Exclude<
6 | T,
7 | "raw"
8 | > extends never
9 | ? ApiResult
10 | : ApiResult["body"]
11 |
12 | export type TConfig = {
13 | _result?: T
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/client/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiError } from "./core/ApiError"
2 | export { CancelablePromise, CancelError } from "./core/CancelablePromise"
3 | export { OpenAPI } from "./core/OpenAPI"
4 | export type { OpenAPIConfig } from "./core/OpenAPI"
5 |
6 | export * from "./models"
7 | export * from "./schemas"
8 | export * from "./services"
9 |
--------------------------------------------------------------------------------
/frontend/src/client/models.ts:
--------------------------------------------------------------------------------
1 | export type Body_login_login_access_token = {
2 | grant_type?: string | null
3 | username: string
4 | password: string
5 | scope?: string
6 | client_id?: string | null
7 | client_secret?: string | null
8 | }
9 |
10 | export type HTTPValidationError = {
11 | detail?: Array
12 | }
13 |
14 | export type ItemCreate = {
15 | title: string
16 | description?: string | null
17 | }
18 |
19 | export type ItemPublic = {
20 | title: string
21 | description?: string | null
22 | id: number
23 | owner_id: number
24 | }
25 |
26 | export type ItemUpdate = {
27 | title?: string | null
28 | description?: string | null
29 | }
30 |
31 | export type ItemsPublic = {
32 | data: Array
33 | count: number
34 | }
35 |
36 | export type Message = {
37 | message: string
38 | }
39 |
40 | export type NewPassword = {
41 | token: string
42 | new_password: string
43 | }
44 |
45 | export type Token = {
46 | access_token: string
47 | token_type?: string
48 | }
49 |
50 | export type UpdatePassword = {
51 | current_password: string
52 | new_password: string
53 | }
54 |
55 | export type UserCreate = {
56 | email: string
57 | is_active?: boolean
58 | is_superuser?: boolean
59 | full_name?: string | null
60 | password: string
61 | }
62 |
63 | export type UserPublic = {
64 | email: string
65 | is_active?: boolean
66 | is_superuser?: boolean
67 | full_name?: string | null
68 | id: number
69 | }
70 |
71 | export type UserRegister = {
72 | email: string
73 | password: string
74 | full_name?: string | null
75 | }
76 |
77 | export type UserUpdate = {
78 | email?: string | null
79 | is_active?: boolean
80 | is_superuser?: boolean
81 | full_name?: string | null
82 | password?: string | null
83 | }
84 |
85 | export type UserUpdateMe = {
86 | full_name?: string | null
87 | email?: string | null
88 | }
89 |
90 | export type UsersPublic = {
91 | data: Array
92 | count: number
93 | }
94 |
95 | export type ValidationError = {
96 | loc: Array
97 | msg: string
98 | type: string
99 | }
100 |
--------------------------------------------------------------------------------
/frontend/src/client/schemas.ts:
--------------------------------------------------------------------------------
1 | export const $Body_login_login_access_token = {
2 | properties: {
3 | grant_type: {
4 | type: "any-of",
5 | contains: [
6 | {
7 | type: "string",
8 | pattern: "password",
9 | },
10 | {
11 | type: "null",
12 | },
13 | ],
14 | },
15 | username: {
16 | type: "string",
17 | isRequired: true,
18 | },
19 | password: {
20 | type: "string",
21 | isRequired: true,
22 | },
23 | scope: {
24 | type: "string",
25 | default: "",
26 | },
27 | client_id: {
28 | type: "any-of",
29 | contains: [
30 | {
31 | type: "string",
32 | },
33 | {
34 | type: "null",
35 | },
36 | ],
37 | },
38 | client_secret: {
39 | type: "any-of",
40 | contains: [
41 | {
42 | type: "string",
43 | },
44 | {
45 | type: "null",
46 | },
47 | ],
48 | },
49 | },
50 | } as const
51 |
52 | export const $HTTPValidationError = {
53 | properties: {
54 | detail: {
55 | type: "array",
56 | contains: {
57 | type: "ValidationError",
58 | },
59 | },
60 | },
61 | } as const
62 |
63 | export const $ItemCreate = {
64 | properties: {
65 | title: {
66 | type: "string",
67 | isRequired: true,
68 | },
69 | description: {
70 | type: "any-of",
71 | contains: [
72 | {
73 | type: "string",
74 | },
75 | {
76 | type: "null",
77 | },
78 | ],
79 | },
80 | },
81 | } as const
82 |
83 | export const $ItemPublic = {
84 | properties: {
85 | title: {
86 | type: "string",
87 | isRequired: true,
88 | },
89 | description: {
90 | type: "any-of",
91 | contains: [
92 | {
93 | type: "string",
94 | },
95 | {
96 | type: "null",
97 | },
98 | ],
99 | },
100 | id: {
101 | type: "number",
102 | isRequired: true,
103 | },
104 | owner_id: {
105 | type: "number",
106 | isRequired: true,
107 | },
108 | },
109 | } as const
110 |
111 | export const $ItemUpdate = {
112 | properties: {
113 | title: {
114 | type: "any-of",
115 | contains: [
116 | {
117 | type: "string",
118 | },
119 | {
120 | type: "null",
121 | },
122 | ],
123 | },
124 | description: {
125 | type: "any-of",
126 | contains: [
127 | {
128 | type: "string",
129 | },
130 | {
131 | type: "null",
132 | },
133 | ],
134 | },
135 | },
136 | } as const
137 |
138 | export const $ItemsPublic = {
139 | properties: {
140 | data: {
141 | type: "array",
142 | contains: {
143 | type: "ItemPublic",
144 | },
145 | isRequired: true,
146 | },
147 | count: {
148 | type: "number",
149 | isRequired: true,
150 | },
151 | },
152 | } as const
153 |
154 | export const $Message = {
155 | properties: {
156 | message: {
157 | type: "string",
158 | isRequired: true,
159 | },
160 | },
161 | } as const
162 |
163 | export const $NewPassword = {
164 | properties: {
165 | token: {
166 | type: "string",
167 | isRequired: true,
168 | },
169 | new_password: {
170 | type: "string",
171 | isRequired: true,
172 | },
173 | },
174 | } as const
175 |
176 | export const $Token = {
177 | properties: {
178 | access_token: {
179 | type: "string",
180 | isRequired: true,
181 | },
182 | token_type: {
183 | type: "string",
184 | default: "bearer",
185 | },
186 | },
187 | } as const
188 |
189 | export const $UpdatePassword = {
190 | properties: {
191 | current_password: {
192 | type: "string",
193 | isRequired: true,
194 | },
195 | new_password: {
196 | type: "string",
197 | isRequired: true,
198 | },
199 | },
200 | } as const
201 |
202 | export const $UserCreate = {
203 | properties: {
204 | email: {
205 | type: "string",
206 | isRequired: true,
207 | },
208 | is_active: {
209 | type: "boolean",
210 | default: true,
211 | },
212 | is_superuser: {
213 | type: "boolean",
214 | default: false,
215 | },
216 | full_name: {
217 | type: "any-of",
218 | contains: [
219 | {
220 | type: "string",
221 | },
222 | {
223 | type: "null",
224 | },
225 | ],
226 | },
227 | password: {
228 | type: "string",
229 | isRequired: true,
230 | },
231 | },
232 | } as const
233 |
234 | export const $UserPublic = {
235 | properties: {
236 | email: {
237 | type: "string",
238 | isRequired: true,
239 | },
240 | is_active: {
241 | type: "boolean",
242 | default: true,
243 | },
244 | is_superuser: {
245 | type: "boolean",
246 | default: false,
247 | },
248 | full_name: {
249 | type: "any-of",
250 | contains: [
251 | {
252 | type: "string",
253 | },
254 | {
255 | type: "null",
256 | },
257 | ],
258 | },
259 | id: {
260 | type: "number",
261 | isRequired: true,
262 | },
263 | },
264 | } as const
265 |
266 | export const $UserRegister = {
267 | properties: {
268 | email: {
269 | type: "string",
270 | isRequired: true,
271 | },
272 | password: {
273 | type: "string",
274 | isRequired: true,
275 | },
276 | full_name: {
277 | type: "any-of",
278 | contains: [
279 | {
280 | type: "string",
281 | },
282 | {
283 | type: "null",
284 | },
285 | ],
286 | },
287 | },
288 | } as const
289 |
290 | export const $UserUpdate = {
291 | properties: {
292 | email: {
293 | type: "any-of",
294 | contains: [
295 | {
296 | type: "string",
297 | },
298 | {
299 | type: "null",
300 | },
301 | ],
302 | },
303 | is_active: {
304 | type: "boolean",
305 | default: true,
306 | },
307 | is_superuser: {
308 | type: "boolean",
309 | default: false,
310 | },
311 | full_name: {
312 | type: "any-of",
313 | contains: [
314 | {
315 | type: "string",
316 | },
317 | {
318 | type: "null",
319 | },
320 | ],
321 | },
322 | password: {
323 | type: "any-of",
324 | contains: [
325 | {
326 | type: "string",
327 | },
328 | {
329 | type: "null",
330 | },
331 | ],
332 | },
333 | },
334 | } as const
335 |
336 | export const $UserUpdateMe = {
337 | properties: {
338 | full_name: {
339 | type: "any-of",
340 | contains: [
341 | {
342 | type: "string",
343 | },
344 | {
345 | type: "null",
346 | },
347 | ],
348 | },
349 | email: {
350 | type: "any-of",
351 | contains: [
352 | {
353 | type: "string",
354 | },
355 | {
356 | type: "null",
357 | },
358 | ],
359 | },
360 | },
361 | } as const
362 |
363 | export const $UsersPublic = {
364 | properties: {
365 | data: {
366 | type: "array",
367 | contains: {
368 | type: "UserPublic",
369 | },
370 | isRequired: true,
371 | },
372 | count: {
373 | type: "number",
374 | isRequired: true,
375 | },
376 | },
377 | } as const
378 |
379 | export const $ValidationError = {
380 | properties: {
381 | loc: {
382 | type: "array",
383 | contains: {
384 | type: "any-of",
385 | contains: [
386 | {
387 | type: "string",
388 | },
389 | {
390 | type: "number",
391 | },
392 | ],
393 | },
394 | isRequired: true,
395 | },
396 | msg: {
397 | type: "string",
398 | isRequired: true,
399 | },
400 | type: {
401 | type: "string",
402 | isRequired: true,
403 | },
404 | },
405 | } as const
406 |
--------------------------------------------------------------------------------
/frontend/src/components/Admin/AddUser.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Checkbox,
4 | Flex,
5 | FormControl,
6 | FormErrorMessage,
7 | FormLabel,
8 | Input,
9 | Modal,
10 | ModalBody,
11 | ModalCloseButton,
12 | ModalContent,
13 | ModalFooter,
14 | ModalHeader,
15 | ModalOverlay,
16 | } from "@chakra-ui/react"
17 | import { useMutation, useQueryClient } from "@tanstack/react-query"
18 | import { type SubmitHandler, useForm } from "react-hook-form"
19 |
20 | import { type UserCreate, UsersService } from "../../client"
21 | import type { ApiError } from "../../client/core/ApiError"
22 | import useCustomToast from "../../hooks/useCustomToast"
23 | import { emailPattern } from "../../utils"
24 |
25 | interface AddUserProps {
26 | isOpen: boolean
27 | onClose: () => void
28 | }
29 |
30 | interface UserCreateForm extends UserCreate {
31 | confirm_password: string
32 | }
33 |
34 | const AddUser = ({ isOpen, onClose }: AddUserProps) => {
35 | const queryClient = useQueryClient()
36 | const showToast = useCustomToast()
37 | const {
38 | register,
39 | handleSubmit,
40 | reset,
41 | getValues,
42 | formState: { errors, isSubmitting },
43 | } = useForm({
44 | mode: "onBlur",
45 | criteriaMode: "all",
46 | defaultValues: {
47 | email: "",
48 | full_name: "",
49 | password: "",
50 | confirm_password: "",
51 | is_superuser: false,
52 | is_active: false,
53 | },
54 | })
55 |
56 | const mutation = useMutation({
57 | mutationFn: (data: UserCreate) =>
58 | UsersService.createUser({ requestBody: data }),
59 | onSuccess: () => {
60 | showToast("Success!", "User created successfully.", "success")
61 | reset()
62 | onClose()
63 | },
64 | onError: (err: ApiError) => {
65 | const errDetail = (err.body as any)?.detail
66 | showToast("Something went wrong.", `${errDetail}`, "error")
67 | },
68 | onSettled: () => {
69 | queryClient.invalidateQueries({ queryKey: ["users"] })
70 | },
71 | })
72 |
73 | const onSubmit: SubmitHandler = (data) => {
74 | mutation.mutate(data)
75 | }
76 |
77 | return (
78 | <>
79 |
85 |
86 |
87 | Add User
88 |
89 |
90 |
91 | Email
92 |
101 | {errors.email && (
102 | {errors.email.message}
103 | )}
104 |
105 |
106 | Full name
107 |
113 | {errors.full_name && (
114 | {errors.full_name.message}
115 | )}
116 |
117 |
118 | Set Password
119 |
131 | {errors.password && (
132 | {errors.password.message}
133 | )}
134 |
135 |
140 | Confirm Password
141 |
146 | value === getValues().password ||
147 | "The passwords do not match",
148 | })}
149 | placeholder="Password"
150 | type="password"
151 | />
152 | {errors.confirm_password && (
153 |
154 | {errors.confirm_password.message}
155 |
156 | )}
157 |
158 |
159 |
160 |
161 | Is superuser?
162 |
163 |
164 |
165 |
166 | Is active?
167 |
168 |
169 |
170 |
171 |
172 |
175 |
176 |
177 |
178 |
179 | >
180 | )
181 | }
182 |
183 | export default AddUser
184 |
--------------------------------------------------------------------------------
/frontend/src/components/Admin/EditUser.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Checkbox,
4 | Flex,
5 | FormControl,
6 | FormErrorMessage,
7 | FormLabel,
8 | Input,
9 | Modal,
10 | ModalBody,
11 | ModalCloseButton,
12 | ModalContent,
13 | ModalFooter,
14 | ModalHeader,
15 | ModalOverlay,
16 | } from "@chakra-ui/react"
17 | import { useMutation, useQueryClient } from "@tanstack/react-query"
18 | import { type SubmitHandler, useForm } from "react-hook-form"
19 |
20 | import {
21 | type ApiError,
22 | type UserPublic,
23 | type UserUpdate,
24 | UsersService,
25 | } from "../../client"
26 | import useCustomToast from "../../hooks/useCustomToast"
27 | import { emailPattern } from "../../utils"
28 |
29 | interface EditUserProps {
30 | user: UserPublic
31 | isOpen: boolean
32 | onClose: () => void
33 | }
34 |
35 | interface UserUpdateForm extends UserUpdate {
36 | confirm_password: string
37 | }
38 |
39 | const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
40 | const queryClient = useQueryClient()
41 | const showToast = useCustomToast()
42 |
43 | const {
44 | register,
45 | handleSubmit,
46 | reset,
47 | getValues,
48 | formState: { errors, isSubmitting, isDirty },
49 | } = useForm({
50 | mode: "onBlur",
51 | criteriaMode: "all",
52 | defaultValues: user,
53 | })
54 |
55 | const mutation = useMutation({
56 | mutationFn: (data: UserUpdateForm) =>
57 | UsersService.updateUser({ userId: user.id, requestBody: data }),
58 | onSuccess: () => {
59 | showToast("Success!", "User updated successfully.", "success")
60 | onClose()
61 | },
62 | onError: (err: ApiError) => {
63 | const errDetail = (err.body as any)?.detail
64 | showToast("Something went wrong.", `${errDetail}`, "error")
65 | },
66 | onSettled: () => {
67 | queryClient.invalidateQueries({ queryKey: ["users"] })
68 | },
69 | })
70 |
71 | const onSubmit: SubmitHandler = async (data) => {
72 | if (data.password === "") {
73 | data.password = undefined
74 | }
75 | mutation.mutate(data)
76 | }
77 |
78 | const onCancel = () => {
79 | reset()
80 | onClose()
81 | }
82 |
83 | return (
84 | <>
85 |
91 |
92 |
93 | Edit User
94 |
95 |
96 |
97 | Email
98 |
107 | {errors.email && (
108 | {errors.email.message}
109 | )}
110 |
111 |
112 | Full name
113 |
114 |
115 |
116 | Set Password
117 |
128 | {errors.password && (
129 | {errors.password.message}
130 | )}
131 |
132 |
133 | Confirm Password
134 |
138 | value === getValues().password ||
139 | "The passwords do not match",
140 | })}
141 | placeholder="Password"
142 | type="password"
143 | />
144 | {errors.confirm_password && (
145 |
146 | {errors.confirm_password.message}
147 |
148 | )}
149 |
150 |
151 |
152 |
153 | Is superuser?
154 |
155 |
156 |
157 |
158 | Is active?
159 |
160 |
161 |
162 |
163 |
164 |
165 |
173 |
174 |
175 |
176 |
177 | >
178 | )
179 | }
180 |
181 | export default EditUser
182 |
--------------------------------------------------------------------------------
/frontend/src/components/Common/ActionsMenu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Menu,
4 | MenuButton,
5 | MenuItem,
6 | MenuList,
7 | useDisclosure,
8 | } from "@chakra-ui/react"
9 | import { BsThreeDotsVertical } from "react-icons/bs"
10 | import { FiEdit, FiTrash } from "react-icons/fi"
11 |
12 | import type { ItemPublic, UserPublic } from "../../client"
13 | import EditUser from "../Admin/EditUser"
14 | import EditItem from "../Items/EditItem"
15 | import Delete from "./DeleteAlert"
16 |
17 | interface ActionsMenuProps {
18 | type: string
19 | value: ItemPublic | UserPublic
20 | disabled?: boolean
21 | }
22 |
23 | const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => {
24 | const editUserModal = useDisclosure()
25 | const deleteModal = useDisclosure()
26 |
27 | return (
28 | <>
29 |
71 | >
72 | )
73 | }
74 |
75 | export default ActionsMenu
76 |
--------------------------------------------------------------------------------
/frontend/src/components/Common/DeleteAlert.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogBody,
4 | AlertDialogContent,
5 | AlertDialogFooter,
6 | AlertDialogHeader,
7 | AlertDialogOverlay,
8 | Button,
9 | } from "@chakra-ui/react"
10 | import { useMutation, useQueryClient } from "@tanstack/react-query"
11 | import React from "react"
12 | import { useForm } from "react-hook-form"
13 |
14 | import { ItemsService, UsersService } from "../../client"
15 | import useCustomToast from "../../hooks/useCustomToast"
16 |
17 | interface DeleteProps {
18 | type: string
19 | id: number
20 | isOpen: boolean
21 | onClose: () => void
22 | }
23 |
24 | const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => {
25 | const queryClient = useQueryClient()
26 | const showToast = useCustomToast()
27 | const cancelRef = React.useRef(null)
28 | const {
29 | handleSubmit,
30 | formState: { isSubmitting },
31 | } = useForm()
32 |
33 | const deleteEntity = async (id: number) => {
34 | if (type === "Item") {
35 | await ItemsService.deleteItem({ id: id })
36 | } else if (type === "User") {
37 | await UsersService.deleteUser({ userId: id })
38 | } else {
39 | throw new Error(`Unexpected type: ${type}`)
40 | }
41 | }
42 |
43 | const mutation = useMutation({
44 | mutationFn: deleteEntity,
45 | onSuccess: () => {
46 | showToast(
47 | "Success",
48 | `The ${type.toLowerCase()} was deleted successfully.`,
49 | "success",
50 | )
51 | onClose()
52 | },
53 | onError: () => {
54 | showToast(
55 | "An error occurred.",
56 | `An error occurred while deleting the ${type.toLowerCase()}.`,
57 | "error",
58 | )
59 | },
60 | onSettled: () => {
61 | queryClient.invalidateQueries({
62 | queryKey: [type === "Item" ? "items" : "users"],
63 | })
64 | },
65 | })
66 |
67 | const onSubmit = async () => {
68 | mutation.mutate(id)
69 | }
70 |
71 | return (
72 | <>
73 |
80 |
81 |
82 | Delete {type}
83 |
84 |
85 | {type === "User" && (
86 |
87 | All items associated with this user will also be{" "}
88 | permantly deleted.
89 |
90 | )}
91 | Are you sure? You will not be able to undo this action.
92 |
93 |
94 |
95 |
98 |
105 |
106 |
107 |
108 |
109 | >
110 | )
111 | }
112 |
113 | export default Delete
114 |
--------------------------------------------------------------------------------
/frontend/src/components/Common/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react"
2 | import { FaPlus } from "react-icons/fa"
3 |
4 | import AddUser from "../Admin/AddUser"
5 | import AddItem from "../Items/AddItem"
6 |
7 | interface NavbarProps {
8 | type: string
9 | }
10 |
11 | const Navbar = ({ type }: NavbarProps) => {
12 | const addUserModal = useDisclosure()
13 | const addItemModal = useDisclosure()
14 |
15 | return (
16 | <>
17 |
18 | {/* TODO: Complete search functionality */}
19 | {/*
20 |
21 |
22 |
23 |
24 | */}
25 |
33 |
34 |
35 |
36 | >
37 | )
38 | }
39 |
40 | export default Navbar
41 |
--------------------------------------------------------------------------------
/frontend/src/components/Common/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Container, Text } from "@chakra-ui/react"
2 | import { Link } from "@tanstack/react-router"
3 |
4 | const NotFound = () => {
5 | return (
6 | <>
7 |
15 |
22 | 404
23 |
24 | Oops!
25 | Page not found.
26 |
36 |
37 | >
38 | )
39 | }
40 |
41 | export default NotFound
42 |
--------------------------------------------------------------------------------
/frontend/src/components/Common/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Drawer,
4 | DrawerBody,
5 | DrawerCloseButton,
6 | DrawerContent,
7 | DrawerOverlay,
8 | Flex,
9 | IconButton,
10 | Image,
11 | Text,
12 | useColorModeValue,
13 | useDisclosure,
14 | } from "@chakra-ui/react"
15 | import { useQueryClient } from "@tanstack/react-query"
16 | import { FiLogOut, FiMenu } from "react-icons/fi"
17 |
18 | import Logo from "/assets/images/fastapi-logo.svg"
19 | import type { UserPublic } from "../../client"
20 | import useAuth from "../../hooks/useAuth"
21 | import SidebarItems from "./SidebarItems"
22 |
23 | const Sidebar = () => {
24 | const queryClient = useQueryClient()
25 | const bgColor = useColorModeValue("ui.light", "ui.dark")
26 | const textColor = useColorModeValue("ui.dark", "ui.light")
27 | const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate")
28 | const currentUser = queryClient.getQueryData(["currentUser"])
29 | const { isOpen, onOpen, onClose } = useDisclosure()
30 | const { logout } = useAuth()
31 |
32 | const handleLogout = async () => {
33 | logout()
34 | }
35 |
36 | return (
37 | <>
38 | {/* Mobile */}
39 | }
47 | />
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
65 |
66 | Log out
67 |
68 |
69 | {currentUser?.email && (
70 |
71 | Logged in as: {currentUser.email}
72 |
73 | )}
74 |
75 |
76 |
77 |
78 |
79 | {/* Desktop */}
80 |
88 |
95 |
96 |
97 |
98 |
99 | {currentUser?.email && (
100 |
107 | Logged in as: {currentUser.email}
108 |
109 | )}
110 |
111 |
112 | >
113 | )
114 | }
115 |
116 | export default Sidebar
117 |
--------------------------------------------------------------------------------
/frontend/src/components/Common/SidebarItems.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react"
2 | import { useQueryClient } from "@tanstack/react-query"
3 | import { Link } from "@tanstack/react-router"
4 | import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
5 |
6 | import type { UserPublic } from "../../client"
7 |
8 | const items = [
9 | { icon: FiHome, title: "Dashboard", path: "/" },
10 | { icon: FiBriefcase, title: "Items", path: "/items" },
11 | { icon: FiSettings, title: "User Settings", path: "/settings" },
12 | ]
13 |
14 | interface SidebarItemsProps {
15 | onClose?: () => void
16 | }
17 |
18 | const SidebarItems = ({ onClose }: SidebarItemsProps) => {
19 | const queryClient = useQueryClient()
20 | const textColor = useColorModeValue("ui.main", "ui.light")
21 | const bgActive = useColorModeValue("#E2E8F0", "#4A5568")
22 | const currentUser = queryClient.getQueryData(["currentUser"])
23 |
24 | const finalItems = currentUser?.is_superuser
25 | ? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }]
26 | : items
27 |
28 | const listItems = finalItems.map(({ icon, title, path }) => (
29 |
44 |
45 | {title}
46 |
47 | ))
48 |
49 | return (
50 | <>
51 | {listItems}
52 | >
53 | )
54 | }
55 |
56 | export default SidebarItems
57 |
--------------------------------------------------------------------------------
/frontend/src/components/Common/UserMenu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | IconButton,
4 | Menu,
5 | MenuButton,
6 | MenuItem,
7 | MenuList,
8 | } from "@chakra-ui/react"
9 | import { Link } from "@tanstack/react-router"
10 | import { FaUserAstronaut } from "react-icons/fa"
11 | import { FiLogOut, FiUser } from "react-icons/fi"
12 |
13 | import useAuth from "../../hooks/useAuth"
14 |
15 | const UserMenu = () => {
16 | const { logout } = useAuth()
17 |
18 | const handleLogout = async () => {
19 | logout()
20 | }
21 |
22 | return (
23 | <>
24 | {/* Desktop */}
25 |
31 |
53 |
54 | >
55 | )
56 | }
57 |
58 | export default UserMenu
59 |
--------------------------------------------------------------------------------
/frontend/src/components/Items/AddItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | FormControl,
4 | FormErrorMessage,
5 | FormLabel,
6 | Input,
7 | Modal,
8 | ModalBody,
9 | ModalCloseButton,
10 | ModalContent,
11 | ModalFooter,
12 | ModalHeader,
13 | ModalOverlay,
14 | } from "@chakra-ui/react"
15 | import { useMutation, useQueryClient } from "@tanstack/react-query"
16 | import { type SubmitHandler, useForm } from "react-hook-form"
17 |
18 | import { type ApiError, type ItemCreate, ItemsService } from "../../client"
19 | import useCustomToast from "../../hooks/useCustomToast"
20 |
21 | interface AddItemProps {
22 | isOpen: boolean
23 | onClose: () => void
24 | }
25 |
26 | const AddItem = ({ isOpen, onClose }: AddItemProps) => {
27 | const queryClient = useQueryClient()
28 | const showToast = useCustomToast()
29 | const {
30 | register,
31 | handleSubmit,
32 | reset,
33 | formState: { errors, isSubmitting },
34 | } = useForm({
35 | mode: "onBlur",
36 | criteriaMode: "all",
37 | defaultValues: {
38 | title: "",
39 | description: "",
40 | },
41 | })
42 |
43 | const mutation = useMutation({
44 | mutationFn: (data: ItemCreate) =>
45 | ItemsService.createItem({ requestBody: data }),
46 | onSuccess: () => {
47 | showToast("Success!", "Item created successfully.", "success")
48 | reset()
49 | onClose()
50 | },
51 | onError: (err: ApiError) => {
52 | const errDetail = (err.body as any)?.detail
53 | showToast("Something went wrong.", `${errDetail}`, "error")
54 | },
55 | onSettled: () => {
56 | queryClient.invalidateQueries({ queryKey: ["items"] })
57 | },
58 | })
59 |
60 | const onSubmit: SubmitHandler = (data) => {
61 | mutation.mutate(data)
62 | }
63 |
64 | return (
65 | <>
66 |
72 |
73 |
74 | Add Item
75 |
76 |
77 |
78 | Title
79 |
87 | {errors.title && (
88 | {errors.title.message}
89 | )}
90 |
91 |
92 | Description
93 |
99 |
100 |
101 |
102 |
103 |
106 |
107 |
108 |
109 |
110 | >
111 | )
112 | }
113 |
114 | export default AddItem
115 |
--------------------------------------------------------------------------------
/frontend/src/components/Items/EditItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | FormControl,
4 | FormErrorMessage,
5 | FormLabel,
6 | Input,
7 | Modal,
8 | ModalBody,
9 | ModalCloseButton,
10 | ModalContent,
11 | ModalFooter,
12 | ModalHeader,
13 | ModalOverlay,
14 | } from "@chakra-ui/react"
15 | import { useMutation, useQueryClient } from "@tanstack/react-query"
16 | import { type SubmitHandler, useForm } from "react-hook-form"
17 |
18 | import {
19 | type ApiError,
20 | type ItemPublic,
21 | type ItemUpdate,
22 | ItemsService,
23 | } from "../../client"
24 | import useCustomToast from "../../hooks/useCustomToast"
25 |
26 | interface EditItemProps {
27 | item: ItemPublic
28 | isOpen: boolean
29 | onClose: () => void
30 | }
31 |
32 | const EditItem = ({ item, isOpen, onClose }: EditItemProps) => {
33 | const queryClient = useQueryClient()
34 | const showToast = useCustomToast()
35 | const {
36 | register,
37 | handleSubmit,
38 | reset,
39 | formState: { isSubmitting, errors, isDirty },
40 | } = useForm({
41 | mode: "onBlur",
42 | criteriaMode: "all",
43 | defaultValues: item,
44 | })
45 |
46 | const mutation = useMutation({
47 | mutationFn: (data: ItemUpdate) =>
48 | ItemsService.updateItem({ id: item.id, requestBody: data }),
49 | onSuccess: () => {
50 | showToast("Success!", "Item updated successfully.", "success")
51 | onClose()
52 | },
53 | onError: (err: ApiError) => {
54 | const errDetail = (err.body as any)?.detail
55 | showToast("Something went wrong.", `${errDetail}`, "error")
56 | },
57 | onSettled: () => {
58 | queryClient.invalidateQueries({ queryKey: ["items"] })
59 | },
60 | })
61 |
62 | const onSubmit: SubmitHandler = async (data) => {
63 | mutation.mutate(data)
64 | }
65 |
66 | const onCancel = () => {
67 | reset()
68 | onClose()
69 | }
70 |
71 | return (
72 | <>
73 |
79 |
80 |
81 | Edit Item
82 |
83 |
84 |
85 | Title
86 |
93 | {errors.title && (
94 | {errors.title.message}
95 | )}
96 |
97 |
98 | Description
99 |
105 |
106 |
107 |
108 |
116 |
117 |
118 |
119 |
120 | >
121 | )
122 | }
123 |
124 | export default EditItem
125 |
--------------------------------------------------------------------------------
/frontend/src/components/UserSettings/Appearance.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge,
3 | Container,
4 | Heading,
5 | Radio,
6 | RadioGroup,
7 | Stack,
8 | useColorMode,
9 | } from "@chakra-ui/react"
10 |
11 | const Appearance = () => {
12 | const { colorMode, toggleColorMode } = useColorMode()
13 |
14 | return (
15 | <>
16 |
17 |
18 | Appearance
19 |
20 |
21 |
22 | {/* TODO: Add system default option */}
23 |
24 | Light Mode
25 |
26 | Default
27 |
28 |
29 |
30 | Dark Mode
31 |
32 |
33 |
34 |
35 | >
36 | )
37 | }
38 | export default Appearance
39 |
--------------------------------------------------------------------------------
/frontend/src/components/UserSettings/ChangePassword.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Container,
5 | FormControl,
6 | FormErrorMessage,
7 | FormLabel,
8 | Heading,
9 | Input,
10 | useColorModeValue,
11 | } from "@chakra-ui/react"
12 | import { useMutation } from "@tanstack/react-query"
13 | import { type SubmitHandler, useForm } from "react-hook-form"
14 |
15 | import { type ApiError, type UpdatePassword, UsersService } from "../../client"
16 | import useCustomToast from "../../hooks/useCustomToast"
17 | import { confirmPasswordRules, passwordRules } from "../../utils"
18 |
19 | interface UpdatePasswordForm extends UpdatePassword {
20 | confirm_password: string
21 | }
22 |
23 | const ChangePassword = () => {
24 | const color = useColorModeValue("inherit", "ui.light")
25 | const showToast = useCustomToast()
26 | const {
27 | register,
28 | handleSubmit,
29 | reset,
30 | getValues,
31 | formState: { errors, isSubmitting },
32 | } = useForm({
33 | mode: "onBlur",
34 | criteriaMode: "all",
35 | })
36 |
37 | const mutation = useMutation({
38 | mutationFn: (data: UpdatePassword) =>
39 | UsersService.updatePasswordMe({ requestBody: data }),
40 | onSuccess: () => {
41 | showToast("Success!", "Password updated.", "success")
42 | reset()
43 | },
44 | onError: (err: ApiError) => {
45 | const errDetail = (err.body as any)?.detail
46 | showToast("Something went wrong.", `${errDetail}`, "error")
47 | },
48 | })
49 |
50 | const onSubmit: SubmitHandler = async (data) => {
51 | mutation.mutate(data)
52 | }
53 |
54 | return (
55 | <>
56 |
57 |
58 | Change Password
59 |
60 |
65 |
66 |
67 | Current Password
68 |
69 |
75 | {errors.current_password && (
76 |
77 | {errors.current_password.message}
78 |
79 | )}
80 |
81 |
82 | Set Password
83 |
89 | {errors.new_password && (
90 | {errors.new_password.message}
91 | )}
92 |
93 |
94 | Confirm Password
95 |
101 | {errors.confirm_password && (
102 |
103 | {errors.confirm_password.message}
104 |
105 | )}
106 |
107 |
115 |
116 |
117 | >
118 | )
119 | }
120 | export default ChangePassword
121 |
--------------------------------------------------------------------------------
/frontend/src/components/UserSettings/DeleteAccount.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Container,
4 | Heading,
5 | Text,
6 | useDisclosure,
7 | } from "@chakra-ui/react"
8 |
9 | import DeleteConfirmation from "./DeleteConfirmation"
10 |
11 | const DeleteAccount = () => {
12 | const confirmationModal = useDisclosure()
13 |
14 | return (
15 | <>
16 |
17 |
18 | Delete Account
19 |
20 |
21 | Permanently delete your data and everything associated with your
22 | account.
23 |
24 |
27 |
31 |
32 | >
33 | )
34 | }
35 | export default DeleteAccount
36 |
--------------------------------------------------------------------------------
/frontend/src/components/UserSettings/DeleteConfirmation.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogBody,
4 | AlertDialogContent,
5 | AlertDialogFooter,
6 | AlertDialogHeader,
7 | AlertDialogOverlay,
8 | Button,
9 | } from "@chakra-ui/react"
10 | import { useMutation, useQueryClient } from "@tanstack/react-query"
11 | import React from "react"
12 | import { useForm } from "react-hook-form"
13 |
14 | import { type ApiError, UsersService } from "../../client"
15 | import useAuth from "../../hooks/useAuth"
16 | import useCustomToast from "../../hooks/useCustomToast"
17 |
18 | interface DeleteProps {
19 | isOpen: boolean
20 | onClose: () => void
21 | }
22 |
23 | const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
24 | const queryClient = useQueryClient()
25 | const showToast = useCustomToast()
26 | const cancelRef = React.useRef(null)
27 | const {
28 | handleSubmit,
29 | formState: { isSubmitting },
30 | } = useForm()
31 | const { logout } = useAuth()
32 |
33 | const mutation = useMutation({
34 | mutationFn: () => UsersService.deleteUserMe(),
35 | onSuccess: () => {
36 | showToast(
37 | "Success",
38 | "Your account has been successfully deleted.",
39 | "success",
40 | )
41 | logout()
42 | onClose()
43 | },
44 | onError: (err: ApiError) => {
45 | const errDetail = (err.body as any)?.detail
46 | showToast("Something went wrong.", `${errDetail}`, "error")
47 | },
48 | onSettled: () => {
49 | queryClient.invalidateQueries({ queryKey: ["currentUser"] })
50 | },
51 | })
52 |
53 | const onSubmit = async () => {
54 | mutation.mutate()
55 | }
56 |
57 | return (
58 | <>
59 |
66 |
67 |
68 | Confirmation Required
69 |
70 |
71 | All your account data will be{" "}
72 | permanently deleted. If you are sure, please
73 | click "Confirm" to proceed. This action cannot be
74 | undone.
75 |
76 |
77 |
78 |
81 |
88 |
89 |
90 |
91 |
92 | >
93 | )
94 | }
95 |
96 | export default DeleteConfirmation
97 |
--------------------------------------------------------------------------------
/frontend/src/components/UserSettings/UserInformation.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Container,
5 | Flex,
6 | FormControl,
7 | FormErrorMessage,
8 | FormLabel,
9 | Heading,
10 | Input,
11 | Text,
12 | useColorModeValue,
13 | } from "@chakra-ui/react"
14 | import { useMutation, useQueryClient } from "@tanstack/react-query"
15 | import { useState } from "react"
16 | import { type SubmitHandler, useForm } from "react-hook-form"
17 |
18 | import {
19 | type ApiError,
20 | type UserPublic,
21 | type UserUpdateMe,
22 | UsersService,
23 | } from "../../client"
24 | import useAuth from "../../hooks/useAuth"
25 | import useCustomToast from "../../hooks/useCustomToast"
26 | import { emailPattern } from "../../utils"
27 |
28 | const UserInformation = () => {
29 | const queryClient = useQueryClient()
30 | const color = useColorModeValue("inherit", "ui.light")
31 | const showToast = useCustomToast()
32 | const [editMode, setEditMode] = useState(false)
33 | const { user: currentUser } = useAuth()
34 | const {
35 | register,
36 | handleSubmit,
37 | reset,
38 | getValues,
39 | formState: { isSubmitting, errors, isDirty },
40 | } = useForm({
41 | mode: "onBlur",
42 | criteriaMode: "all",
43 | defaultValues: {
44 | full_name: currentUser?.full_name,
45 | email: currentUser?.email,
46 | },
47 | })
48 |
49 | const toggleEditMode = () => {
50 | setEditMode(!editMode)
51 | }
52 |
53 | const mutation = useMutation({
54 | mutationFn: (data: UserUpdateMe) =>
55 | UsersService.updateUserMe({ requestBody: data }),
56 | onSuccess: () => {
57 | showToast("Success!", "User updated successfully.", "success")
58 | },
59 | onError: (err: ApiError) => {
60 | const errDetail = (err.body as any)?.detail
61 | showToast("Something went wrong.", `${errDetail}`, "error")
62 | },
63 | onSettled: () => {
64 | // TODO: can we do just one call now?
65 | queryClient.invalidateQueries({ queryKey: ["users"] })
66 | queryClient.invalidateQueries({ queryKey: ["currentUser"] })
67 | },
68 | })
69 |
70 | const onSubmit: SubmitHandler = async (data) => {
71 | mutation.mutate(data)
72 | }
73 |
74 | const onCancel = () => {
75 | reset()
76 | toggleEditMode()
77 | }
78 |
79 | return (
80 | <>
81 |
82 |
83 | User Information
84 |
85 |
90 |
91 |
92 | Full name
93 |
94 | {editMode ? (
95 |
101 | ) : (
102 |
107 | {currentUser?.full_name || "N/A"}
108 |
109 | )}
110 |
111 |
112 |
113 | Email
114 |
115 | {editMode ? (
116 |
125 | ) : (
126 |
127 | {currentUser?.email}
128 |
129 | )}
130 | {errors.email && (
131 | {errors.email.message}
132 | )}
133 |
134 |
135 |
144 | {editMode && (
145 |
148 | )}
149 |
150 |
151 |
152 | >
153 | )
154 | }
155 |
156 | export default UserInformation
157 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useAuth.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery } from "@tanstack/react-query"
2 | import { useNavigate } from "@tanstack/react-router"
3 | import { useState } from "react"
4 |
5 | import { AxiosError } from "axios"
6 | import {
7 | type Body_login_login_access_token as AccessToken,
8 | type ApiError,
9 | LoginService,
10 | type UserPublic,
11 | UsersService,
12 | } from "../client"
13 |
14 | const isLoggedIn = () => {
15 | return localStorage.getItem("access_token") !== null
16 | }
17 |
18 | const useAuth = () => {
19 | const [error, setError] = useState(null)
20 | const navigate = useNavigate()
21 | const { data: user, isLoading } = useQuery({
22 | queryKey: ["currentUser"],
23 | queryFn: UsersService.readUserMe,
24 | enabled: isLoggedIn(),
25 | })
26 |
27 | const login = async (data: AccessToken) => {
28 | const response = await LoginService.loginAccessToken({
29 | formData: data,
30 | })
31 | localStorage.setItem("access_token", response.access_token)
32 | }
33 |
34 | const loginMutation = useMutation({
35 | mutationFn: login,
36 | onSuccess: () => {
37 | navigate({ to: "/" })
38 | },
39 | onError: (err: ApiError) => {
40 | let errDetail = (err.body as any)?.detail
41 |
42 | if (err instanceof AxiosError) {
43 | errDetail = err.message
44 | }
45 |
46 | if (Array.isArray(errDetail)) {
47 | errDetail = "Something went wrong"
48 | }
49 |
50 | setError(errDetail)
51 | },
52 | })
53 |
54 | const logout = () => {
55 | localStorage.removeItem("access_token")
56 | navigate({ to: "/login" })
57 | }
58 |
59 | return {
60 | loginMutation,
61 | logout,
62 | user,
63 | isLoading,
64 | error,
65 | resetError: () => setError(null),
66 | }
67 | }
68 |
69 | export { isLoggedIn }
70 | export default useAuth
71 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useCustomToast.ts:
--------------------------------------------------------------------------------
1 | import { useToast } from "@chakra-ui/react"
2 | import { useCallback } from "react"
3 |
4 | const useCustomToast = () => {
5 | const toast = useToast()
6 |
7 | const showToast = useCallback(
8 | (title: string, description: string, status: "success" | "error") => {
9 | toast({
10 | title,
11 | description,
12 | status,
13 | isClosable: true,
14 | position: "bottom-right",
15 | })
16 | },
17 | [toast],
18 | )
19 |
20 | return showToast
21 | }
22 |
23 | export default useCustomToast
24 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider } from "@chakra-ui/react"
2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
3 | import { RouterProvider, createRouter } from "@tanstack/react-router"
4 | import ReactDOM from "react-dom/client"
5 | import { routeTree } from "./routeTree.gen"
6 |
7 | import { StrictMode } from "react"
8 | import { OpenAPI } from "./client"
9 | import theme from "./theme"
10 |
11 | OpenAPI.BASE = import.meta.env.VITE_API_URL
12 | OpenAPI.TOKEN = async () => {
13 | return localStorage.getItem("access_token") || ""
14 | }
15 |
16 | const queryClient = new QueryClient()
17 |
18 | const router = createRouter({ routeTree })
19 | declare module "@tanstack/react-router" {
20 | interface Register {
21 | router: typeof router
22 | }
23 | }
24 |
25 | ReactDOM.createRoot(document.getElementById("root")!).render(
26 |
27 |
28 |
29 |
30 |
31 |
32 | ,
33 | )
34 |
--------------------------------------------------------------------------------
/frontend/src/routeTree.gen.ts:
--------------------------------------------------------------------------------
1 | /* prettier-ignore-start */
2 |
3 | /* eslint-disable */
4 |
5 | // @ts-nocheck
6 |
7 | // noinspection JSUnusedGlobalSymbols
8 |
9 | // This file is auto-generated by TanStack Router
10 |
11 | // Import Routes
12 |
13 | import { Route as rootRoute } from './routes/__root'
14 | import { Route as ResetPasswordImport } from './routes/reset-password'
15 | import { Route as RecoverPasswordImport } from './routes/recover-password'
16 | import { Route as LoginImport } from './routes/login'
17 | import { Route as LayoutImport } from './routes/_layout'
18 | import { Route as LayoutIndexImport } from './routes/_layout/index'
19 | import { Route as LayoutSettingsImport } from './routes/_layout/settings'
20 | import { Route as LayoutItemsImport } from './routes/_layout/items'
21 | import { Route as LayoutAdminImport } from './routes/_layout/admin'
22 |
23 | // Create/Update Routes
24 |
25 | const ResetPasswordRoute = ResetPasswordImport.update({
26 | path: '/reset-password',
27 | getParentRoute: () => rootRoute,
28 | } as any)
29 |
30 | const RecoverPasswordRoute = RecoverPasswordImport.update({
31 | path: '/recover-password',
32 | getParentRoute: () => rootRoute,
33 | } as any)
34 |
35 | const LoginRoute = LoginImport.update({
36 | path: '/login',
37 | getParentRoute: () => rootRoute,
38 | } as any)
39 |
40 | const LayoutRoute = LayoutImport.update({
41 | id: '/_layout',
42 | getParentRoute: () => rootRoute,
43 | } as any)
44 |
45 | const LayoutIndexRoute = LayoutIndexImport.update({
46 | path: '/',
47 | getParentRoute: () => LayoutRoute,
48 | } as any)
49 |
50 | const LayoutSettingsRoute = LayoutSettingsImport.update({
51 | path: '/settings',
52 | getParentRoute: () => LayoutRoute,
53 | } as any)
54 |
55 | const LayoutItemsRoute = LayoutItemsImport.update({
56 | path: '/items',
57 | getParentRoute: () => LayoutRoute,
58 | } as any)
59 |
60 | const LayoutAdminRoute = LayoutAdminImport.update({
61 | path: '/admin',
62 | getParentRoute: () => LayoutRoute,
63 | } as any)
64 |
65 | // Populate the FileRoutesByPath interface
66 |
67 | declare module '@tanstack/react-router' {
68 | interface FileRoutesByPath {
69 | '/_layout': {
70 | preLoaderRoute: typeof LayoutImport
71 | parentRoute: typeof rootRoute
72 | }
73 | '/login': {
74 | preLoaderRoute: typeof LoginImport
75 | parentRoute: typeof rootRoute
76 | }
77 | '/recover-password': {
78 | preLoaderRoute: typeof RecoverPasswordImport
79 | parentRoute: typeof rootRoute
80 | }
81 | '/reset-password': {
82 | preLoaderRoute: typeof ResetPasswordImport
83 | parentRoute: typeof rootRoute
84 | }
85 | '/_layout/admin': {
86 | preLoaderRoute: typeof LayoutAdminImport
87 | parentRoute: typeof LayoutImport
88 | }
89 | '/_layout/items': {
90 | preLoaderRoute: typeof LayoutItemsImport
91 | parentRoute: typeof LayoutImport
92 | }
93 | '/_layout/settings': {
94 | preLoaderRoute: typeof LayoutSettingsImport
95 | parentRoute: typeof LayoutImport
96 | }
97 | '/_layout/': {
98 | preLoaderRoute: typeof LayoutIndexImport
99 | parentRoute: typeof LayoutImport
100 | }
101 | }
102 | }
103 |
104 | // Create and export the route tree
105 |
106 | export const routeTree = rootRoute.addChildren([
107 | LayoutRoute.addChildren([
108 | LayoutAdminRoute,
109 | LayoutItemsRoute,
110 | LayoutSettingsRoute,
111 | LayoutIndexRoute,
112 | ]),
113 | LoginRoute,
114 | RecoverPasswordRoute,
115 | ResetPasswordRoute,
116 | ])
117 |
118 | /* prettier-ignore-end */
119 |
--------------------------------------------------------------------------------
/frontend/src/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet, createRootRoute } from "@tanstack/react-router"
2 | import React, { Suspense } from "react"
3 |
4 | import NotFound from "../components/Common/NotFound"
5 |
6 | const loadDevtools = () =>
7 | Promise.all([
8 | import("@tanstack/router-devtools"),
9 | import("@tanstack/react-query-devtools"),
10 | ]).then(([routerDevtools, reactQueryDevtools]) => {
11 | return {
12 | default: () => (
13 | <>
14 |
15 |
16 | >
17 | ),
18 | }
19 | })
20 |
21 | const TanStackDevtools =
22 | process.env.NODE_ENV === "production" ? () => null : React.lazy(loadDevtools)
23 |
24 | export const Route = createRootRoute({
25 | component: () => (
26 | <>
27 |
28 |
29 |
30 |
31 | >
32 | ),
33 | notFoundComponent: () => ,
34 | })
35 |
--------------------------------------------------------------------------------
/frontend/src/routes/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Spinner } from "@chakra-ui/react"
2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
3 |
4 | import Sidebar from "../components/Common/Sidebar"
5 | import UserMenu from "../components/Common/UserMenu"
6 | import useAuth, { isLoggedIn } from "../hooks/useAuth"
7 |
8 | export const Route = createFileRoute("/_layout")({
9 | component: Layout,
10 | beforeLoad: async () => {
11 | if (!isLoggedIn()) {
12 | throw redirect({
13 | to: "/login",
14 | })
15 | }
16 | },
17 | })
18 |
19 | function Layout() {
20 | const { isLoading } = useAuth()
21 |
22 | return (
23 |
24 |
25 | {isLoading ? (
26 |
27 |
28 |
29 | ) : (
30 |
31 | )}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/src/routes/_layout/admin.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge,
3 | Box,
4 | Container,
5 | Flex,
6 | Heading,
7 | SkeletonText,
8 | Table,
9 | TableContainer,
10 | Tbody,
11 | Td,
12 | Th,
13 | Thead,
14 | Tr,
15 | } from "@chakra-ui/react"
16 | import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"
17 | import { createFileRoute } from "@tanstack/react-router"
18 |
19 | import { Suspense } from "react"
20 | import { type UserPublic, UsersService } from "../../client"
21 | import ActionsMenu from "../../components/Common/ActionsMenu"
22 | import Navbar from "../../components/Common/Navbar"
23 |
24 | export const Route = createFileRoute("/_layout/admin")({
25 | component: Admin,
26 | })
27 |
28 | const MembersTableBody = () => {
29 | const queryClient = useQueryClient()
30 | const currentUser = queryClient.getQueryData(["currentUser"])
31 |
32 | const { data: users } = useSuspenseQuery({
33 | queryKey: ["users"],
34 | queryFn: () => UsersService.readUsers({}),
35 | })
36 |
37 | return (
38 |
39 | {users.data.map((user) => (
40 |
41 |
42 | {user.full_name || "N/A"}
43 | {currentUser?.id === user.id && (
44 |
45 | You
46 |
47 | )}
48 | |
49 | {user.email} |
50 | {user.is_superuser ? "Superuser" : "User"} |
51 |
52 |
53 |
60 | {user.is_active ? "Active" : "Inactive"}
61 |
62 | |
63 |
64 |
69 | |
70 |
71 | ))}
72 |
73 | )
74 | }
75 |
76 | const MembersBodySkeleton = () => {
77 | return (
78 |
79 |
80 | {new Array(5).fill(null).map((_, index) => (
81 |
82 |
83 | |
84 | ))}
85 |
86 |
87 | )
88 | }
89 |
90 | function Admin() {
91 | return (
92 |
93 |
94 | User Management
95 |
96 |
97 |
98 |
99 |
100 |
101 | Full name |
102 | Email |
103 | Role |
104 | Status |
105 | Actions |
106 |
107 |
108 | }>
109 |
110 |
111 |
112 |
113 |
114 | )
115 | }
116 |
--------------------------------------------------------------------------------
/frontend/src/routes/_layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Container, Text } from "@chakra-ui/react"
2 | import { createFileRoute } from "@tanstack/react-router"
3 |
4 | import useAuth from "../../hooks/useAuth"
5 |
6 | export const Route = createFileRoute("/_layout/")({
7 | component: Dashboard,
8 | })
9 |
10 | function Dashboard() {
11 | const { user: currentUser } = useAuth()
12 |
13 | return (
14 | <>
15 |
16 |
17 |
18 | Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
19 |
20 | Welcome back, nice to see you again!
21 |
22 |
23 | >
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/routes/_layout/items.tsx:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 | import {
3 | Button,
4 | Container,
5 | Flex,
6 | Heading,
7 | Skeleton,
8 | Table,
9 | TableContainer,
10 | Tbody,
11 | Td,
12 | Th,
13 | Thead,
14 | Tr,
15 | } from "@chakra-ui/react"
16 | import { useQuery, useQueryClient } from "@tanstack/react-query"
17 | import { createFileRoute, useNavigate } from "@tanstack/react-router"
18 |
19 | import { useEffect } from "react"
20 | import { ItemsService } from "../../client"
21 | import ActionsMenu from "../../components/Common/ActionsMenu"
22 | import Navbar from "../../components/Common/Navbar"
23 |
24 | const itemsSearchSchema = z.object({
25 | page: z.number().catch(1),
26 | })
27 |
28 | export const Route = createFileRoute("/_layout/items")({
29 | component: Items,
30 | validateSearch: (search) => itemsSearchSchema.parse(search),
31 | })
32 |
33 | const PER_PAGE = 5
34 |
35 | function getItemsQueryOptions({ page }: { page: number }) {
36 | return {
37 | queryFn: () =>
38 | ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
39 | queryKey: ["items", { page }],
40 | }
41 | }
42 |
43 | function ItemsTable() {
44 | const queryClient = useQueryClient()
45 | const { page } = Route.useSearch()
46 | const navigate = useNavigate({ from: Route.fullPath })
47 | const setPage = (page: number) =>
48 | navigate({ search: (prev) => ({ ...prev, page }) })
49 |
50 | const {
51 | data: items,
52 | isPending,
53 | isPlaceholderData,
54 | } = useQuery({
55 | ...getItemsQueryOptions({ page }),
56 | placeholderData: (prevData) => prevData,
57 | })
58 |
59 | const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE
60 | const hasPreviousPage = page > 1
61 |
62 | useEffect(() => {
63 | if (hasNextPage) {
64 | queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
65 | }
66 | }, [page, queryClient])
67 |
68 | return (
69 | <>
70 |
71 |
72 |
73 |
74 | ID |
75 | Title |
76 | Description |
77 | Actions |
78 |
79 |
80 | {isPending ? (
81 |
82 | {new Array(5).fill(null).map((_, index) => (
83 |
84 | {new Array(4).fill(null).map((_, index) => (
85 |
86 |
87 |
88 |
89 | |
90 | ))}
91 |
92 | ))}
93 |
94 | ) : (
95 |
96 | {items?.data.map((item) => (
97 |
98 | {item.id} |
99 | {item.title} |
100 |
101 | {item.description || "N/A"}
102 | |
103 |
104 |
105 | |
106 |
107 | ))}
108 |
109 | )}
110 |
111 |
112 |
119 |
122 | Page {page}
123 |
126 |
127 | >
128 | )
129 | }
130 |
131 | function Items() {
132 | return (
133 |
134 |
135 | Items Management
136 |
137 |
138 |
139 |
140 |
141 | )
142 | }
143 |
--------------------------------------------------------------------------------
/frontend/src/routes/_layout/settings.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Container,
3 | Heading,
4 | Tab,
5 | TabList,
6 | TabPanel,
7 | TabPanels,
8 | Tabs,
9 | } from "@chakra-ui/react"
10 | import { useQueryClient } from "@tanstack/react-query"
11 | import { createFileRoute } from "@tanstack/react-router"
12 |
13 | import type { UserPublic } from "../../client"
14 | import Appearance from "../../components/UserSettings/Appearance"
15 | import ChangePassword from "../../components/UserSettings/ChangePassword"
16 | import DeleteAccount from "../../components/UserSettings/DeleteAccount"
17 | import UserInformation from "../../components/UserSettings/UserInformation"
18 |
19 | const tabsConfig = [
20 | { title: "My profile", component: UserInformation },
21 | { title: "Password", component: ChangePassword },
22 | { title: "Appearance", component: Appearance },
23 | { title: "Danger zone", component: DeleteAccount },
24 | ]
25 |
26 | export const Route = createFileRoute("/_layout/settings")({
27 | component: UserSettings,
28 | })
29 |
30 | function UserSettings() {
31 | const queryClient = useQueryClient()
32 | const currentUser = queryClient.getQueryData(["currentUser"])
33 | const finalTabs = currentUser?.is_superuser
34 | ? tabsConfig.slice(0, 3)
35 | : tabsConfig
36 |
37 | return (
38 |
39 |
40 | User Settings
41 |
42 |
43 |
44 | {finalTabs.map((tab, index) => (
45 | {tab.title}
46 | ))}
47 |
48 |
49 | {finalTabs.map((tab, index) => (
50 |
51 |
52 |
53 | ))}
54 |
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/frontend/src/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons"
2 | import {
3 | Button,
4 | Center,
5 | Container,
6 | FormControl,
7 | FormErrorMessage,
8 | Icon,
9 | Image,
10 | Input,
11 | InputGroup,
12 | InputRightElement,
13 | Link,
14 | useBoolean,
15 | } from "@chakra-ui/react"
16 | import {
17 | Link as RouterLink,
18 | createFileRoute,
19 | redirect,
20 | } from "@tanstack/react-router"
21 | import { type SubmitHandler, useForm } from "react-hook-form"
22 |
23 | import Logo from "/assets/images/fastapi-logo.svg"
24 | import type { Body_login_login_access_token as AccessToken } from "../client"
25 | import useAuth, { isLoggedIn } from "../hooks/useAuth"
26 | import { emailPattern } from "../utils"
27 |
28 | export const Route = createFileRoute("/login")({
29 | component: Login,
30 | beforeLoad: async () => {
31 | if (isLoggedIn()) {
32 | throw redirect({
33 | to: "/",
34 | })
35 | }
36 | },
37 | })
38 |
39 | function Login() {
40 | const [show, setShow] = useBoolean()
41 | const { loginMutation, error, resetError } = useAuth()
42 | const {
43 | register,
44 | handleSubmit,
45 | formState: { errors, isSubmitting },
46 | } = useForm({
47 | mode: "onBlur",
48 | criteriaMode: "all",
49 | defaultValues: {
50 | username: "",
51 | password: "",
52 | },
53 | })
54 |
55 | const onSubmit: SubmitHandler = async (data) => {
56 | if (isSubmitting) return
57 |
58 | resetError()
59 |
60 | try {
61 | await loginMutation.mutateAsync(data)
62 | } catch {
63 | // error is handled by useAuth hook
64 | }
65 | }
66 |
67 | return (
68 | <>
69 |
79 |
87 |
88 |
97 | {errors.username && (
98 | {errors.username.message}
99 | )}
100 |
101 |
102 |
103 |
109 |
115 |
119 | {show ? : }
120 |
121 |
122 |
123 | {error && {error}}
124 |
125 |
126 |
127 | Forgot password?
128 |
129 |
130 |
133 |
134 | >
135 | )
136 | }
137 |
--------------------------------------------------------------------------------
/frontend/src/routes/recover-password.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Container,
4 | FormControl,
5 | FormErrorMessage,
6 | Heading,
7 | Input,
8 | Text,
9 | } from "@chakra-ui/react"
10 | import { useMutation } from "@tanstack/react-query"
11 | import { createFileRoute, redirect } from "@tanstack/react-router"
12 | import { type SubmitHandler, useForm } from "react-hook-form"
13 |
14 | import { type ApiError, LoginService } from "../client"
15 | import { isLoggedIn } from "../hooks/useAuth"
16 | import useCustomToast from "../hooks/useCustomToast"
17 | import { emailPattern } from "../utils"
18 |
19 | interface FormData {
20 | email: string
21 | }
22 |
23 | export const Route = createFileRoute("/recover-password")({
24 | component: RecoverPassword,
25 | beforeLoad: async () => {
26 | if (isLoggedIn()) {
27 | throw redirect({
28 | to: "/",
29 | })
30 | }
31 | },
32 | })
33 |
34 | function RecoverPassword() {
35 | const {
36 | register,
37 | handleSubmit,
38 | reset,
39 | formState: { errors, isSubmitting },
40 | } = useForm()
41 | const showToast = useCustomToast()
42 |
43 | const recoverPassword = async (data: FormData) => {
44 | await LoginService.recoverPassword({
45 | email: data.email,
46 | })
47 | }
48 |
49 | const mutation = useMutation({
50 | mutationFn: recoverPassword,
51 | onSuccess: () => {
52 | showToast(
53 | "Email sent.",
54 | "We sent an email with a link to get back into your account.",
55 | "success",
56 | )
57 | reset()
58 | },
59 | onError: (err: ApiError) => {
60 | const errDetail = (err.body as any)?.detail
61 | showToast("Something went wrong.", `${errDetail}`, "error")
62 | },
63 | })
64 |
65 | const onSubmit: SubmitHandler = async (data) => {
66 | mutation.mutate(data)
67 | }
68 |
69 | return (
70 |
80 |
81 | Password Recovery
82 |
83 |
84 | A password recovery email will be sent to the registered account.
85 |
86 |
87 |
96 | {errors.email && (
97 | {errors.email.message}
98 | )}
99 |
100 |
103 |
104 | )
105 | }
106 |
--------------------------------------------------------------------------------
/frontend/src/routes/reset-password.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Container,
4 | FormControl,
5 | FormErrorMessage,
6 | FormLabel,
7 | Heading,
8 | Input,
9 | Text,
10 | } from "@chakra-ui/react"
11 | import { useMutation } from "@tanstack/react-query"
12 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
13 | import { type SubmitHandler, useForm } from "react-hook-form"
14 |
15 | import { type ApiError, LoginService, type NewPassword } from "../client"
16 | import { isLoggedIn } from "../hooks/useAuth"
17 | import useCustomToast from "../hooks/useCustomToast"
18 | import { confirmPasswordRules, passwordRules } from "../utils"
19 |
20 | interface NewPasswordForm extends NewPassword {
21 | confirm_password: string
22 | }
23 |
24 | export const Route = createFileRoute("/reset-password")({
25 | component: ResetPassword,
26 | beforeLoad: async () => {
27 | if (isLoggedIn()) {
28 | throw redirect({
29 | to: "/",
30 | })
31 | }
32 | },
33 | })
34 |
35 | function ResetPassword() {
36 | const {
37 | register,
38 | handleSubmit,
39 | getValues,
40 | reset,
41 | formState: { errors },
42 | } = useForm({
43 | mode: "onBlur",
44 | criteriaMode: "all",
45 | defaultValues: {
46 | new_password: "",
47 | },
48 | })
49 | const showToast = useCustomToast()
50 | const navigate = useNavigate()
51 |
52 | const resetPassword = async (data: NewPassword) => {
53 | const token = new URLSearchParams(window.location.search).get("token")
54 | if (!token) return
55 | await LoginService.resetPassword({
56 | requestBody: { new_password: data.new_password, token: token },
57 | })
58 | }
59 |
60 | const mutation = useMutation({
61 | mutationFn: resetPassword,
62 | onSuccess: () => {
63 | showToast("Success!", "Password updated.", "success")
64 | reset()
65 | navigate({ to: "/login" })
66 | },
67 | onError: (err: ApiError) => {
68 | const errDetail = (err.body as any)?.detail
69 | showToast("Something went wrong.", `${errDetail}`, "error")
70 | },
71 | })
72 |
73 | const onSubmit: SubmitHandler = async (data) => {
74 | mutation.mutate(data)
75 | }
76 |
77 | return (
78 |
88 |
89 | Reset Password
90 |
91 |
92 | Please enter your new password and confirm it to reset your password.
93 |
94 |
95 | Set Password
96 |
102 | {errors.new_password && (
103 | {errors.new_password.message}
104 | )}
105 |
106 |
107 | Confirm Password
108 |
114 | {errors.confirm_password && (
115 | {errors.confirm_password.message}
116 | )}
117 |
118 |
121 |
122 | )
123 | }
124 |
--------------------------------------------------------------------------------
/frontend/src/theme.tsx:
--------------------------------------------------------------------------------
1 | import { extendTheme } from "@chakra-ui/react"
2 |
3 | const disabledStyles = {
4 | _disabled: {
5 | backgroundColor: "ui.main",
6 | },
7 | }
8 |
9 | const theme = extendTheme({
10 | colors: {
11 | ui: {
12 | main: "#009688",
13 | secondary: "#EDF2F7",
14 | success: "#48BB78",
15 | danger: "#E53E3E",
16 | light: "#FAFAFA",
17 | dark: "#1A202C",
18 | darkSlate: "#252D3D",
19 | dim: "#A0AEC0",
20 | },
21 | },
22 | components: {
23 | Button: {
24 | variants: {
25 | primary: {
26 | backgroundColor: "ui.main",
27 | color: "ui.light",
28 | _hover: {
29 | backgroundColor: "#00766C",
30 | },
31 | _disabled: {
32 | ...disabledStyles,
33 | _hover: {
34 | ...disabledStyles,
35 | },
36 | },
37 | },
38 | danger: {
39 | backgroundColor: "ui.danger",
40 | color: "ui.light",
41 | _hover: {
42 | backgroundColor: "#E32727",
43 | },
44 | },
45 | },
46 | },
47 | Tabs: {
48 | variants: {
49 | enclosed: {
50 | tab: {
51 | _selected: {
52 | color: "ui.main",
53 | },
54 | },
55 | },
56 | },
57 | },
58 | },
59 | })
60 |
61 | export default theme
62 |
--------------------------------------------------------------------------------
/frontend/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const emailPattern = {
2 | value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
3 | message: "Invalid email address",
4 | }
5 |
6 | export const namePattern = {
7 | value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/,
8 | message: "Invalid name",
9 | }
10 |
11 | export const passwordRules = (isRequired = true) => {
12 | const rules: any = {
13 | minLength: {
14 | value: 8,
15 | message: "Password must be at least 8 characters",
16 | },
17 | }
18 |
19 | if (isRequired) {
20 | rules.required = "Password is required"
21 | }
22 |
23 | return rules
24 | }
25 |
26 | export const confirmPasswordRules = (
27 | getValues: () => any,
28 | isRequired = true,
29 | ) => {
30 | const rules: any = {
31 | validate: (value: string) => {
32 | const password = getValues().password || getValues().new_password
33 | return value === password ? true : "The passwords do not match"
34 | },
35 | }
36 |
37 | if (isRequired) {
38 | rules.required = "Password confirmation is required"
39 | }
40 |
41 | return rules
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { TanStackRouterVite } from "@tanstack/router-vite-plugin"
2 | import react from "@vitejs/plugin-react-swc"
3 | import { defineConfig } from "vite"
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react(), TanStackRouterVite()],
8 | })
9 |
--------------------------------------------------------------------------------