├── 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 }}
Go to Dashboard

-------------------------------------------------------------------------------- /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:
Reset password
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 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 28 | 30 | 33 | 39 | 43 | 44 | 49 | 50 | 51 | 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 | 30 | } 34 | variant="unstyled" 35 | /> 36 | 37 | } 40 | > 41 | Edit {type} 42 | 43 | } 46 | color="ui.danger" 47 | > 48 | Delete {type} 49 | 50 | 51 | {type === "User" ? ( 52 | 57 | ) : ( 58 | 63 | )} 64 | 70 | 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 | logo 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 | Logo 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 | 32 | } 36 | bg="ui.main" 37 | isRound 38 | /> 39 | 40 | } as={Link} to="settings"> 41 | My profile 42 | 43 | } 45 | onClick={handleLogout} 46 | color="ui.danger" 47 | fontWeight="bold" 48 | > 49 | Log out 50 | 51 | 52 | 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 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | }> 109 | 110 | 111 |
Full nameEmailRoleStatusActions
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 | 75 | 76 | 77 | 78 | 79 | 80 | {isPending ? ( 81 | 82 | {new Array(5).fill(null).map((_, index) => ( 83 | 84 | {new Array(4).fill(null).map((_, index) => ( 85 | 90 | ))} 91 | 92 | ))} 93 | 94 | ) : ( 95 | 96 | {items?.data.map((item) => ( 97 | 98 | 99 | 100 | 103 | 106 | 107 | ))} 108 | 109 | )} 110 |
IDTitleDescriptionActions
86 | 87 | 88 | 89 |
{item.id}{item.title} 101 | {item.description || "N/A"} 102 | 104 | 105 |
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 | FastAPI logo 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 | --------------------------------------------------------------------------------