├── .copier ├── .copier-answers.yml.jinja └── update_dotenv.py ├── .env ├── .gitattributes ├── .github ├── DISCUSSION_TEMPLATE │ └── questions.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── privileged.yml ├── dependabot.yml ├── labeler.yml └── workflows │ ├── add-to-project.yml │ ├── deploy-production.yml │ ├── deploy-staging.yml │ ├── generate-client.yml │ ├── issue-manager.yml │ ├── labeler.yml │ ├── latest-changes.yml │ ├── lint-backend.yml │ ├── playwright.yml │ ├── smokeshow.yml │ ├── test-backend.yml │ └── test-docker-compose.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── SECURITY.md ├── backend ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── alembic.ini ├── app │ ├── __init__.py │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── .keep │ │ │ ├── 1a31ce608336_add_cascade_delete_relationships.py │ │ │ ├── 9c0a54914c78_add_max_length_for_string_varchar_.py │ │ │ ├── d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py │ │ │ └── e2412789c190_initialize_models.py │ ├── api │ │ ├── __init__.py │ │ ├── deps.py │ │ ├── main.py │ │ └── routes │ │ │ ├── __init__.py │ │ │ ├── items.py │ │ │ ├── login.py │ │ │ ├── private.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_private.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 │ ├── tests_pre_start.py │ └── utils.py ├── pyproject.toml ├── scripts │ ├── format.sh │ ├── lint.sh │ ├── prestart.sh │ ├── test.sh │ └── tests-start.sh └── uv.lock ├── copier.yml ├── deployment.md ├── development.md ├── docker-compose.override.yml ├── docker-compose.traefik.yml ├── docker-compose.yml ├── frontend ├── .dockerignore ├── .env ├── .gitignore ├── .nvmrc ├── Dockerfile ├── Dockerfile.playwright ├── README.md ├── biome.json ├── index.html ├── nginx-backend-not-found.conf ├── nginx.conf ├── openapi-ts.config.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public │ └── assets │ │ └── images │ │ ├── fastapi-logo.svg │ │ └── favicon.png ├── src │ ├── client │ │ ├── core │ │ │ ├── ApiError.ts │ │ │ ├── ApiRequestOptions.ts │ │ │ ├── ApiResult.ts │ │ │ ├── CancelablePromise.ts │ │ │ ├── OpenAPI.ts │ │ │ └── request.ts │ │ ├── index.ts │ │ ├── schemas.gen.ts │ │ ├── sdk.gen.ts │ │ └── types.gen.ts │ ├── components │ │ ├── Admin │ │ │ ├── AddUser.tsx │ │ │ ├── DeleteUser.tsx │ │ │ └── EditUser.tsx │ │ ├── Common │ │ │ ├── ItemActionsMenu.tsx │ │ │ ├── Navbar.tsx │ │ │ ├── NotFound.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── SidebarItems.tsx │ │ │ ├── UserActionsMenu.tsx │ │ │ └── UserMenu.tsx │ │ ├── Items │ │ │ ├── AddItem.tsx │ │ │ ├── DeleteItem.tsx │ │ │ └── EditItem.tsx │ │ ├── Pending │ │ │ ├── PendingItems.tsx │ │ │ └── PendingUsers.tsx │ │ ├── UserSettings │ │ │ ├── Appearance.tsx │ │ │ ├── ChangePassword.tsx │ │ │ ├── DeleteAccount.tsx │ │ │ ├── DeleteConfirmation.tsx │ │ │ └── UserInformation.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── close-button.tsx │ │ │ ├── color-mode.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── field.tsx │ │ │ ├── input-group.tsx │ │ │ ├── link-button.tsx │ │ │ ├── menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── provider.tsx │ │ │ ├── radio.tsx │ │ │ ├── skeleton.tsx │ │ │ └── toaster.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 │ │ └── signup.tsx │ ├── theme.tsx │ ├── theme │ │ └── button.recipe.ts │ ├── utils.ts │ └── vite-env.d.ts ├── tests │ ├── auth.setup.ts │ ├── config.ts │ ├── login.spec.ts │ ├── reset-password.spec.ts │ ├── sign-up.spec.ts │ ├── user-settings.spec.ts │ └── utils │ │ ├── mailcatcher.ts │ │ ├── privateApi.ts │ │ ├── random.ts │ │ └── user.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── hooks └── post_gen_project.py ├── img ├── dashboard-create.png ├── dashboard-dark.png ├── dashboard-items.png ├── dashboard-user-settings.png ├── dashboard.png ├── docs.png ├── github-social-preview.png ├── github-social-preview.svg └── login.png ├── release-notes.md └── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── generate-client.sh ├── test-local.sh └── test.sh /.copier/.copier-answers.yml.jinja: -------------------------------------------------------------------------------- 1 | {{ _copier_answers|to_json -}} 2 | -------------------------------------------------------------------------------- /.copier/update_dotenv.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import json 3 | 4 | # Update the .env file with the answers from the .copier-answers.yml file 5 | # without using Jinja2 templates in the .env file, this way the code works as is 6 | # without needing Copier, but if Copier is used, the .env file will be updated 7 | root_path = Path(__file__).parent.parent 8 | answers_path = Path(__file__).parent / ".copier-answers.yml" 9 | answers = json.loads(answers_path.read_text()) 10 | env_path = root_path / ".env" 11 | env_content = env_path.read_text() 12 | lines = [] 13 | for line in env_content.splitlines(): 14 | for key, value in answers.items(): 15 | upper_key = key.upper() 16 | if line.startswith(f"{upper_key}="): 17 | if " " in value: 18 | content = f"{upper_key}={value!r}" 19 | else: 20 | content = f"{upper_key}={value}" 21 | new_line = line.replace(line, content) 22 | lines.append(new_line) 23 | break 24 | else: 25 | lines.append(line) 26 | env_path.write_text("\n".join(lines)) 27 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Domain 2 | # This would be set to the production domain with an env var on deployment 3 | # used by Traefik to transmit traffic and aqcuire TLS certificates 4 | DOMAIN=localhost 5 | # To test the local Traefik config 6 | # DOMAIN=localhost.tiangolo.com 7 | 8 | # Used by the backend to generate links in emails to the frontend 9 | FRONTEND_HOST=http://localhost:5173 10 | # In staging and production, set this env var to the frontend host, e.g. 11 | # FRONTEND_HOST=https://dashboard.example.com 12 | 13 | # Environment: local, staging, production 14 | ENVIRONMENT=local 15 | 16 | PROJECT_NAME="Full Stack FastAPI Project" 17 | STACK_NAME=full-stack-fastapi-project 18 | 19 | # Backend 20 | BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" 21 | SECRET_KEY=changethis 22 | FIRST_SUPERUSER=admin@example.com 23 | FIRST_SUPERUSER_PASSWORD=changethis 24 | 25 | # Emails 26 | SMTP_HOST= 27 | SMTP_USER= 28 | SMTP_PASSWORD= 29 | EMAILS_FROM_EMAIL=info@example.com 30 | SMTP_TLS=True 31 | SMTP_SSL=False 32 | SMTP_PORT=587 33 | 34 | # Postgres 35 | POSTGRES_SERVER=localhost 36 | POSTGRES_PORT=5432 37 | POSTGRES_DB=app 38 | POSTGRES_USER=postgres 39 | POSTGRES_PASSWORD=changethis 40 | 41 | SENTRY_DSN= 42 | 43 | # Configure these with your own Docker registry images 44 | DOCKER_IMAGE_BACKEND=backend 45 | DOCKER_IMAGE_FRONTEND=frontend 46 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tiangolo] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Security Contact 4 | about: Please report security vulnerabilities to security@tiangolo.com 5 | - name: Question or Problem 6 | about: Ask a question or ask about a problem in GitHub Discussions. 7 | url: https://github.com/fastapi/full-stack-fastapi-template/discussions/categories/questions 8 | - name: Feature Request 9 | about: To suggest an idea or ask about a feature, please start with a question saying what you would like to achieve. There might be a way to do it already. 10 | url: https://github.com/fastapi/full-stack-fastapi-template/discussions/categories/questions 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/privileged.yml: -------------------------------------------------------------------------------- 1 | name: Privileged 2 | description: You are @tiangolo or he asked you directly to create an issue here. If not, check the other options. 👇 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thanks for your interest in this project! 🚀 8 | 9 | If you are not @tiangolo or he didn't ask you directly to create an issue here, please start the conversation in a [Question in GitHub Discussions](https://github.com/tiangolo/full-stack-fastapi-template/discussions/categories/questions) instead. 10 | - type: checkboxes 11 | id: privileged 12 | attributes: 13 | label: Privileged issue 14 | description: Confirm that you are allowed to create an issue here. 15 | options: 16 | - label: I'm @tiangolo or he asked me directly to create an issue here. 17 | required: true 18 | - type: textarea 19 | id: content 20 | attributes: 21 | label: Issue Content 22 | description: Add the content of the issue here. 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # GitHub Actions 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: daily 8 | commit-message: 9 | prefix: ⬆ 10 | # Python uv 11 | - package-ecosystem: uv 12 | directory: /backend 13 | schedule: 14 | interval: daily 15 | commit-message: 16 | prefix: ⬆ 17 | # npm 18 | - package-ecosystem: npm 19 | directory: /frontend 20 | schedule: 21 | interval: daily 22 | commit-message: 23 | prefix: ⬆ 24 | # Docker 25 | - package-ecosystem: docker 26 | directories: 27 | - /backend 28 | - /frontend 29 | schedule: 30 | interval: weekly 31 | commit-message: 32 | prefix: ⬆ 33 | # Docker Compose 34 | - package-ecosystem: docker-compose 35 | directory: / 36 | schedule: 37 | interval: weekly 38 | commit-message: 39 | prefix: ⬆ 40 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | docs: 2 | - all: 3 | - changed-files: 4 | - any-glob-to-any-file: 5 | - '**/*.md' 6 | - all-globs-to-all-files: 7 | - '!frontend/**' 8 | - '!backend/**' 9 | - '!.github/**' 10 | - '!scripts/**' 11 | - '!.gitignore' 12 | - '!.pre-commit-config.yaml' 13 | 14 | internal: 15 | - all: 16 | - changed-files: 17 | - any-glob-to-any-file: 18 | - .github/** 19 | - scripts/** 20 | - .gitignore 21 | - .pre-commit-config.yaml 22 | - all-globs-to-all-files: 23 | - '!./**/*.md' 24 | - '!frontend/**' 25 | - '!backend/**' 26 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add to Project 2 | 3 | on: 4 | pull_request_target: 5 | issues: 6 | types: 7 | - opened 8 | - reopened 9 | 10 | jobs: 11 | add-to-project: 12 | name: Add to project 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/add-to-project@v1.0.2 16 | with: 17 | project-url: https://github.com/orgs/fastapi/projects/2 18 | github-token: ${{ secrets.PROJECTS_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/deploy-production.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Production 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | deploy: 10 | # Do not deploy in the main repository, only in user projects 11 | if: github.repository_owner != 'fastapi' 12 | runs-on: 13 | - self-hosted 14 | - production 15 | env: 16 | ENVIRONMENT: production 17 | DOMAIN: ${{ secrets.DOMAIN_PRODUCTION }} 18 | STACK_NAME: ${{ secrets.STACK_NAME_PRODUCTION }} 19 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 20 | FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }} 21 | FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }} 22 | SMTP_HOST: ${{ secrets.SMTP_HOST }} 23 | SMTP_USER: ${{ secrets.SMTP_USER }} 24 | SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} 25 | EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }} 26 | POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} 27 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_PRODUCTION }} build 32 | - run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_PRODUCTION }} up -d 33 | -------------------------------------------------------------------------------- /.github/workflows/deploy-staging.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Staging 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | # Do not deploy in the main repository, only in user projects 11 | if: github.repository_owner != 'fastapi' 12 | runs-on: 13 | - self-hosted 14 | - staging 15 | env: 16 | ENVIRONMENT: staging 17 | DOMAIN: ${{ secrets.DOMAIN_STAGING }} 18 | STACK_NAME: ${{ secrets.STACK_NAME_STAGING }} 19 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 20 | FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }} 21 | FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }} 22 | SMTP_HOST: ${{ secrets.SMTP_HOST }} 23 | SMTP_USER: ${{ secrets.SMTP_USER }} 24 | SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} 25 | EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }} 26 | POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} 27 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_STAGING }} build 32 | - run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_STAGING }} up -d 33 | -------------------------------------------------------------------------------- /.github/workflows/generate-client.yml: -------------------------------------------------------------------------------- 1 | name: Generate Client 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | 9 | jobs: 10 | generate-client: 11 | permissions: 12 | contents: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | # For PRs from forks 16 | - uses: actions/checkout@v4 17 | # For PRs from the same repo 18 | - uses: actions/checkout@v4 19 | if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' ) 20 | with: 21 | ref: ${{ github.head_ref }} 22 | token: ${{ secrets.FULL_STACK_FASTAPI_TEMPLATE_REPO_TOKEN }} 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: lts/* 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.10" 29 | - name: Install uv 30 | uses: astral-sh/setup-uv@v6 31 | with: 32 | version: "0.4.15" 33 | enable-cache: true 34 | - name: Install dependencies 35 | run: npm ci 36 | working-directory: frontend 37 | - run: uv sync 38 | working-directory: backend 39 | - run: uv run bash scripts/generate-client.sh 40 | env: 41 | VIRTUAL_ENV: backend/.venv 42 | SECRET_KEY: just-for-generating-client 43 | POSTGRES_PASSWORD: just-for-generating-client 44 | FIRST_SUPERUSER_PASSWORD: just-for-generating-client 45 | - name: Add changes to git 46 | run: | 47 | git config --local user.email "github-actions@github.com" 48 | git config --local user.name "github-actions" 49 | git add frontend/src/client 50 | # Same repo PRs 51 | - name: Push changes 52 | if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' ) 53 | run: | 54 | git diff --staged --quiet || git commit -m "✨ Autogenerate frontend client" 55 | git push 56 | # Fork PRs 57 | - name: Check changes 58 | if: ( github.event_name == 'pull_request' && github.secret_source != 'Actions' ) 59 | run: | 60 | git diff --staged --quiet || (echo "Changes detected in generated client, run scripts/generate-client.sh and commit the changes" && exit 1) 61 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "21 17 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | issues: 10 | types: 11 | - labeled 12 | pull_request_target: 13 | types: 14 | - labeled 15 | workflow_dispatch: 16 | 17 | permissions: 18 | issues: write 19 | pull-requests: write 20 | 21 | jobs: 22 | issue-manager: 23 | if: github.repository_owner == 'fastapi' 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Dump GitHub context 27 | env: 28 | GITHUB_CONTEXT: ${{ toJson(github) }} 29 | run: echo "$GITHUB_CONTEXT" 30 | - uses: tiangolo/issue-manager@0.5.1 31 | with: 32 | token: ${{ secrets.GITHUB_TOKEN }} 33 | config: > 34 | { 35 | "answered": { 36 | "delay": 864000, 37 | "message": "Assuming the original need was handled, this will be automatically closed now. But feel free to add more comments or create new issues or PRs." 38 | }, 39 | "waiting": { 40 | "delay": 2628000, 41 | "message": "As this PR has been waiting for the original user for a while but seems to be inactive, it's now going to be closed. But if there's anyone interested, feel free to create a new PR." 42 | }, 43 | "invalid": { 44 | "delay": 0, 45 | "message": "This was marked as invalid and will be closed now. If this is an error, please provide additional details." 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labels 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - synchronize 7 | - reopened 8 | # For label-checker 9 | - labeled 10 | - unlabeled 11 | 12 | jobs: 13 | labeler: 14 | permissions: 15 | contents: read 16 | pull-requests: write 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/labeler@v5 20 | if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }} 21 | - run: echo "Done adding labels" 22 | # Run this after labeler applied labels 23 | check-labels: 24 | needs: 25 | - labeler 26 | permissions: 27 | pull-requests: read 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: docker://agilepathway/pull-request-label-checker:latest 31 | with: 32 | one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal 33 | repo_token: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/latest-changes.yml: -------------------------------------------------------------------------------- 1 | name: Latest Changes 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - master 7 | types: 8 | - closed 9 | workflow_dispatch: 10 | inputs: 11 | number: 12 | description: PR number 13 | required: true 14 | debug_enabled: 15 | description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)" 16 | required: false 17 | default: "false" 18 | 19 | jobs: 20 | latest-changes: 21 | runs-on: ubuntu-latest 22 | permissions: 23 | pull-requests: read 24 | steps: 25 | - name: Dump GitHub context 26 | env: 27 | GITHUB_CONTEXT: ${{ toJson(github) }} 28 | run: echo "$GITHUB_CONTEXT" 29 | - uses: actions/checkout@v4 30 | with: 31 | # To allow latest-changes to commit to the main branch 32 | token: ${{ secrets.LATEST_CHANGES }} 33 | - uses: tiangolo/latest-changes@0.3.2 34 | with: 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | latest_changes_file: ./release-notes.md 37 | latest_changes_header: "## Latest Changes" 38 | end_regex: "^## " 39 | debug_logs: true 40 | label_header_prefix: "### " 41 | -------------------------------------------------------------------------------- /.github/workflows/lint-backend.yml: -------------------------------------------------------------------------------- 1 | name: Lint Backend 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | lint-backend: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.10" 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | with: 25 | version: "0.4.15" 26 | enable-cache: true 27 | - run: uv run bash scripts/lint.sh 28 | working-directory: backend 29 | -------------------------------------------------------------------------------- /.github/workflows/smokeshow.yml: -------------------------------------------------------------------------------- 1 | name: Smokeshow 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Test Backend] 6 | types: [completed] 7 | 8 | jobs: 9 | smokeshow: 10 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 11 | runs-on: ubuntu-latest 12 | permissions: 13 | actions: read 14 | statuses: write 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.10" 21 | - run: pip install smokeshow 22 | - uses: actions/download-artifact@v4 23 | with: 24 | name: coverage-html 25 | path: backend/htmlcov 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | run-id: ${{ github.event.workflow_run.id }} 28 | - run: smokeshow upload backend/htmlcov 29 | env: 30 | SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage} 31 | SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 90 32 | SMOKESHOW_GITHUB_CONTEXT: coverage 33 | SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 35 | SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} 36 | -------------------------------------------------------------------------------- /.github/workflows/test-backend.yml: -------------------------------------------------------------------------------- 1 | name: Test Backend 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | test-backend: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.10" 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | with: 25 | version: "0.4.15" 26 | enable-cache: true 27 | - run: docker compose down -v --remove-orphans 28 | - run: docker compose up -d db mailcatcher 29 | - name: Migrate DB 30 | run: uv run bash scripts/prestart.sh 31 | working-directory: backend 32 | - name: Run tests 33 | run: uv run bash scripts/tests-start.sh "Coverage for ${{ github.sha }}" 34 | working-directory: backend 35 | - run: docker compose down -v --remove-orphans 36 | - name: Store coverage files 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: coverage-html 40 | path: backend/htmlcov 41 | include-hidden-files: true 42 | -------------------------------------------------------------------------------- /.github/workflows/test-docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: Test Docker Compose 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | 14 | test-docker-compose: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - run: docker compose build 20 | - run: docker compose down -v --remove-orphans 21 | - run: docker compose up -d --wait backend frontend adminer 22 | - name: Test backend is up 23 | run: curl http://localhost:8000/api/v1/utils/health-check 24 | - name: Test frontend is up 25 | run: curl http://localhost:5173 26 | - run: docker compose down -v --remove-orphans 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules/ 3 | /test-results/ 4 | /playwright-report/ 5 | /blob-report/ 6 | /playwright/.cache/ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-toml 9 | - id: check-yaml 10 | args: 11 | - --unsafe 12 | - id: end-of-file-fixer 13 | exclude: | 14 | (?x)^( 15 | frontend/src/client/.*| 16 | backend/app/email-templates/build/.* 17 | )$ 18 | - id: trailing-whitespace 19 | exclude: ^frontend/src/client/.* 20 | - repo: https://github.com/charliermarsh/ruff-pre-commit 21 | rev: v0.2.2 22 | hooks: 23 | - id: ruff 24 | args: 25 | - --fix 26 | - id: ruff-format 27 | 28 | ci: 29 | autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks 30 | autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug FastAPI Project backend: Python Debugger", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "module": "uvicorn", 12 | "args": [ 13 | "app.main:app", 14 | "--reload" 15 | ], 16 | "cwd": "${workspaceFolder}/backend", 17 | "jinja": true, 18 | "envFile": "${workspaceFolder}/.env", 19 | }, 20 | { 21 | "type": "chrome", 22 | "request": "launch", 23 | "name": "Debug Frontend: Launch Chrome against http://localhost:5173", 24 | "url": "http://localhost:5173", 25 | "webRoot": "${workspaceFolder}/frontend" 26 | }, 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sebastián Ramírez 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 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Security is very important for this project and its community. 🔒 4 | 5 | Learn more about it below. 👇 6 | 7 | ## Versions 8 | 9 | The latest version or release is supported. 10 | 11 | You are encouraged to write tests for your application and update your versions frequently after ensuring that your tests are passing. This way you will benefit from the latest features, bug fixes, and **security fixes**. 12 | 13 | ## Reporting a Vulnerability 14 | 15 | If you think you found a vulnerability, and even if you are not sure about it, please report it right away by sending an email to: security@tiangolo.com. Please try to be as explicit as possible, describing all the steps and example code to reproduce the security issue. 16 | 17 | I (the author, [@tiangolo](https://twitter.com/tiangolo)) will review it thoroughly and get back to you. 18 | 19 | ## Public Discussions 20 | 21 | Please restrain from publicly discussing a potential security vulnerability. 🙊 22 | 23 | It's better to discuss privately and try to find a solution first, to limit the potential impact as much as possible. 24 | 25 | --- 26 | 27 | Thanks for your help! 28 | 29 | The community and I thank you for that. 🙇 30 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | app.egg-info 4 | *.pyc 5 | .mypy_cache 6 | .coverage 7 | htmlcov 8 | .venv 9 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | app.egg-info 3 | *.pyc 4 | .mypy_cache 5 | .coverage 6 | htmlcov 7 | .cache 8 | .venv 9 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | 5 | WORKDIR /app/ 6 | 7 | # Install uv 8 | # Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv 9 | COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /bin/ 10 | 11 | # Place executables in the environment at the front of the path 12 | # Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment 13 | ENV PATH="/app/.venv/bin:$PATH" 14 | 15 | # Compile bytecode 16 | # Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode 17 | ENV UV_COMPILE_BYTECODE=1 18 | 19 | # uv Cache 20 | # Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching 21 | ENV UV_LINK_MODE=copy 22 | 23 | # Install dependencies 24 | # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers 25 | RUN --mount=type=cache,target=/root/.cache/uv \ 26 | --mount=type=bind,source=uv.lock,target=uv.lock \ 27 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 28 | uv sync --frozen --no-install-project 29 | 30 | ENV PYTHONPATH=/app 31 | 32 | COPY ./scripts /app/scripts 33 | 34 | COPY ./pyproject.toml ./uv.lock ./alembic.ini /app/ 35 | 36 | COPY ./app /app/app 37 | 38 | # Sync the project 39 | # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers 40 | RUN --mount=type=cache,target=/root/.cache/uv \ 41 | uv sync 42 | 43 | CMD ["fastapi", "run", "--workers", "4", "app/main.py"] 44 | -------------------------------------------------------------------------------- /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/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/backend/app/__init__.py -------------------------------------------------------------------------------- /backend/app/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /backend/app/alembic/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import engine_from_config, pool 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | 15 | # add your model's MetaData object here 16 | # for 'autogenerate' support 17 | # from myapp import mymodel 18 | # target_metadata = mymodel.Base.metadata 19 | # target_metadata = None 20 | 21 | from app.models import SQLModel # noqa 22 | from app.core.config import settings # noqa 23 | 24 | target_metadata = SQLModel.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | 32 | def get_url(): 33 | return str(settings.SQLALCHEMY_DATABASE_URI) 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = get_url() 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | configuration = config.get_section(config.config_ini_section) 65 | configuration["sqlalchemy.url"] = get_url() 66 | connectable = engine_from_config( 67 | configuration, 68 | prefix="sqlalchemy.", 69 | poolclass=pool.NullPool, 70 | ) 71 | 72 | with connectable.connect() as connection: 73 | context.configure( 74 | connection=connection, target_metadata=target_metadata, compare_type=True 75 | ) 76 | 77 | with context.begin_transaction(): 78 | context.run_migrations() 79 | 80 | 81 | if context.is_offline_mode(): 82 | run_migrations_offline() 83 | else: 84 | run_migrations_online() 85 | -------------------------------------------------------------------------------- /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/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/backend/app/alembic/versions/.keep -------------------------------------------------------------------------------- /backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py: -------------------------------------------------------------------------------- 1 | """Add cascade delete relationships 2 | 3 | Revision ID: 1a31ce608336 4 | Revises: d98dd8ec85a3 5 | Create Date: 2024-07-31 22:24:34.447891 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 = '1a31ce608336' 15 | down_revision = 'd98dd8ec85a3' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.alter_column('item', 'owner_id', 23 | existing_type=sa.UUID(), 24 | nullable=False) 25 | op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') 26 | op.create_foreign_key(None, 'item', 'user', ['owner_id'], ['id'], ondelete='CASCADE') 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_constraint(None, 'item', type_='foreignkey') 33 | op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) 34 | op.alter_column('item', 'owner_id', 35 | existing_type=sa.UUID(), 36 | nullable=True) 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /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/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py: -------------------------------------------------------------------------------- 1 | """Edit replace id integers in all models to use UUID instead 2 | 3 | Revision ID: d98dd8ec85a3 4 | Revises: 9c0a54914c78 5 | Create Date: 2024-07-19 04:08:04.000976 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel.sql.sqltypes 11 | from sqlalchemy.dialects import postgresql 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = 'd98dd8ec85a3' 16 | down_revision = '9c0a54914c78' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # Ensure uuid-ossp extension is available 23 | op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') 24 | 25 | # Create a new UUID column with a default UUID value 26 | op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) 27 | op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) 28 | op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True)) 29 | 30 | # Populate the new columns with UUIDs 31 | op.execute('UPDATE "user" SET new_id = uuid_generate_v4()') 32 | op.execute('UPDATE item SET new_id = uuid_generate_v4()') 33 | op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)') 34 | 35 | # Set the new_id as not nullable 36 | op.alter_column('user', 'new_id', nullable=False) 37 | op.alter_column('item', 'new_id', nullable=False) 38 | 39 | # Drop old columns and rename new columns 40 | op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') 41 | op.drop_column('item', 'owner_id') 42 | op.alter_column('item', 'new_owner_id', new_column_name='owner_id') 43 | 44 | op.drop_column('user', 'id') 45 | op.alter_column('user', 'new_id', new_column_name='id') 46 | 47 | op.drop_column('item', 'id') 48 | op.alter_column('item', 'new_id', new_column_name='id') 49 | 50 | # Create primary key constraint 51 | op.create_primary_key('user_pkey', 'user', ['id']) 52 | op.create_primary_key('item_pkey', 'item', ['id']) 53 | 54 | # Recreate foreign key constraint 55 | op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) 56 | 57 | def downgrade(): 58 | # Reverse the upgrade process 59 | op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True)) 60 | op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True)) 61 | op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True)) 62 | 63 | # Populate the old columns with default values 64 | # Generate sequences for the integer IDs if not exist 65 | op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id') 66 | op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id') 67 | 68 | op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)') 69 | op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)') 70 | 71 | op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')') 72 | op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)') 73 | 74 | # Drop new columns and rename old columns back 75 | op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') 76 | op.drop_column('item', 'owner_id') 77 | op.alter_column('item', 'old_owner_id', new_column_name='owner_id') 78 | 79 | op.drop_column('user', 'id') 80 | op.alter_column('user', 'old_id', new_column_name='id') 81 | 82 | op.drop_column('item', 'id') 83 | op.alter_column('item', 'old_id', new_column_name='id') 84 | 85 | # Create primary key constraint 86 | op.create_primary_key('user_pkey', 'user', ['id']) 87 | op.create_primary_key('item_pkey', 'item', ['id']) 88 | 89 | # Recreate foreign key constraint 90 | op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) 91 | -------------------------------------------------------------------------------- /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/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/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, private, users, utils 4 | from app.core.config import settings 5 | 6 | api_router = APIRouter() 7 | api_router.include_router(login.router) 8 | api_router.include_router(users.router) 9 | api_router.include_router(utils.router) 10 | api_router.include_router(items.router) 11 | 12 | 13 | if settings.ENVIRONMENT == "local": 14 | api_router.include_router(private.router) 15 | -------------------------------------------------------------------------------- /backend/app/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/backend/app/api/routes/__init__.py -------------------------------------------------------------------------------- /backend/app/api/routes/items.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any 3 | 4 | from fastapi import APIRouter, HTTPException 5 | from sqlmodel import func, select 6 | 7 | from app.api.deps import CurrentUser, SessionDep 8 | from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message 9 | 10 | router = APIRouter(prefix="/items", tags=["items"]) 11 | 12 | 13 | @router.get("/", response_model=ItemsPublic) 14 | def read_items( 15 | session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 16 | ) -> Any: 17 | """ 18 | Retrieve items. 19 | """ 20 | 21 | if current_user.is_superuser: 22 | count_statement = select(func.count()).select_from(Item) 23 | count = session.exec(count_statement).one() 24 | statement = select(Item).offset(skip).limit(limit) 25 | items = session.exec(statement).all() 26 | else: 27 | count_statement = ( 28 | select(func.count()) 29 | .select_from(Item) 30 | .where(Item.owner_id == current_user.id) 31 | ) 32 | count = session.exec(count_statement).one() 33 | statement = ( 34 | select(Item) 35 | .where(Item.owner_id == current_user.id) 36 | .offset(skip) 37 | .limit(limit) 38 | ) 39 | items = session.exec(statement).all() 40 | 41 | return ItemsPublic(data=items, count=count) 42 | 43 | 44 | @router.get("/{id}", response_model=ItemPublic) 45 | def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: 46 | """ 47 | Get item by ID. 48 | """ 49 | item = session.get(Item, id) 50 | if not item: 51 | raise HTTPException(status_code=404, detail="Item not found") 52 | if not current_user.is_superuser and (item.owner_id != current_user.id): 53 | raise HTTPException(status_code=400, detail="Not enough permissions") 54 | return item 55 | 56 | 57 | @router.post("/", response_model=ItemPublic) 58 | def create_item( 59 | *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate 60 | ) -> Any: 61 | """ 62 | Create new item. 63 | """ 64 | item = Item.model_validate(item_in, update={"owner_id": current_user.id}) 65 | session.add(item) 66 | session.commit() 67 | session.refresh(item) 68 | return item 69 | 70 | 71 | @router.put("/{id}", response_model=ItemPublic) 72 | def update_item( 73 | *, 74 | session: SessionDep, 75 | current_user: CurrentUser, 76 | id: uuid.UUID, 77 | item_in: ItemUpdate, 78 | ) -> Any: 79 | """ 80 | Update an item. 81 | """ 82 | item = session.get(Item, id) 83 | if not item: 84 | raise HTTPException(status_code=404, detail="Item not found") 85 | if not current_user.is_superuser and (item.owner_id != current_user.id): 86 | raise HTTPException(status_code=400, detail="Not enough permissions") 87 | update_dict = item_in.model_dump(exclude_unset=True) 88 | item.sqlmodel_update(update_dict) 89 | session.add(item) 90 | session.commit() 91 | session.refresh(item) 92 | return item 93 | 94 | 95 | @router.delete("/{id}") 96 | def delete_item( 97 | session: SessionDep, current_user: CurrentUser, id: uuid.UUID 98 | ) -> Message: 99 | """ 100 | Delete an item. 101 | """ 102 | item = session.get(Item, id) 103 | if not item: 104 | raise HTTPException(status_code=404, detail="Item not found") 105 | if not current_user.is_superuser and (item.owner_id != current_user.id): 106 | raise HTTPException(status_code=400, detail="Not enough permissions") 107 | session.delete(item) 108 | session.commit() 109 | return Message(message="Item deleted successfully") 110 | -------------------------------------------------------------------------------- /backend/app/api/routes/private.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter 4 | from pydantic import BaseModel 5 | 6 | from app.api.deps import SessionDep 7 | from app.core.security import get_password_hash 8 | from app.models import ( 9 | User, 10 | UserPublic, 11 | ) 12 | 13 | router = APIRouter(tags=["private"], prefix="/private") 14 | 15 | 16 | class PrivateUserCreate(BaseModel): 17 | email: str 18 | password: str 19 | full_name: str 20 | is_verified: bool = False 21 | 22 | 23 | @router.post("/users/", response_model=UserPublic) 24 | def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: 25 | """ 26 | Create a new user. 27 | """ 28 | 29 | user = User( 30 | email=user_in.email, 31 | full_name=user_in.full_name, 32 | hashed_password=get_password_hash(user_in.password), 33 | ) 34 | 35 | session.add(user) 36 | session.commit() 37 | 38 | return user 39 | -------------------------------------------------------------------------------- /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(prefix="/utils", tags=["utils"]) 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 | 28 | 29 | @router.get("/health-check/") 30 | async def health_check() -> bool: 31 | return True 32 | -------------------------------------------------------------------------------- /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/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/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 | EmailStr, 9 | HttpUrl, 10 | PostgresDsn, 11 | computed_field, 12 | model_validator, 13 | ) 14 | from pydantic_core import MultiHostUrl 15 | from pydantic_settings import BaseSettings, SettingsConfigDict 16 | from typing_extensions import Self 17 | 18 | 19 | def parse_cors(v: Any) -> list[str] | str: 20 | if isinstance(v, str) and not v.startswith("["): 21 | return [i.strip() for i in v.split(",")] 22 | elif isinstance(v, list | str): 23 | return v 24 | raise ValueError(v) 25 | 26 | 27 | class Settings(BaseSettings): 28 | model_config = SettingsConfigDict( 29 | # Use top level .env file (one level above ./backend/) 30 | env_file="../.env", 31 | env_ignore_empty=True, 32 | extra="ignore", 33 | ) 34 | API_V1_STR: str = "/api/v1" 35 | SECRET_KEY: str = secrets.token_urlsafe(32) 36 | # 60 minutes * 24 hours * 8 days = 8 days 37 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 38 | FRONTEND_HOST: str = "http://localhost:5173" 39 | ENVIRONMENT: Literal["local", "staging", "production"] = "local" 40 | 41 | BACKEND_CORS_ORIGINS: Annotated[ 42 | list[AnyUrl] | str, BeforeValidator(parse_cors) 43 | ] = [] 44 | 45 | @computed_field # type: ignore[prop-decorator] 46 | @property 47 | def all_cors_origins(self) -> list[str]: 48 | return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ 49 | self.FRONTEND_HOST 50 | ] 51 | 52 | PROJECT_NAME: str 53 | SENTRY_DSN: HttpUrl | None = None 54 | POSTGRES_SERVER: str 55 | POSTGRES_PORT: int = 5432 56 | POSTGRES_USER: str 57 | POSTGRES_PASSWORD: str = "" 58 | POSTGRES_DB: str = "" 59 | 60 | @computed_field # type: ignore[prop-decorator] 61 | @property 62 | def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: 63 | return MultiHostUrl.build( 64 | scheme="postgresql+psycopg", 65 | username=self.POSTGRES_USER, 66 | password=self.POSTGRES_PASSWORD, 67 | host=self.POSTGRES_SERVER, 68 | port=self.POSTGRES_PORT, 69 | path=self.POSTGRES_DB, 70 | ) 71 | 72 | SMTP_TLS: bool = True 73 | SMTP_SSL: bool = False 74 | SMTP_PORT: int = 587 75 | SMTP_HOST: str | None = None 76 | SMTP_USER: str | None = None 77 | SMTP_PASSWORD: str | None = None 78 | EMAILS_FROM_EMAIL: EmailStr | None = None 79 | EMAILS_FROM_NAME: EmailStr | None = None 80 | 81 | @model_validator(mode="after") 82 | def _set_default_emails_from(self) -> Self: 83 | if not self.EMAILS_FROM_NAME: 84 | self.EMAILS_FROM_NAME = self.PROJECT_NAME 85 | return self 86 | 87 | EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 88 | 89 | @computed_field # type: ignore[prop-decorator] 90 | @property 91 | def emails_enabled(self) -> bool: 92 | return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) 93 | 94 | EMAIL_TEST_USER: EmailStr = "test@example.com" 95 | FIRST_SUPERUSER: EmailStr 96 | FIRST_SUPERUSER_PASSWORD: str 97 | 98 | def _check_default_secret(self, var_name: str, value: str | None) -> None: 99 | if value == "changethis": 100 | message = ( 101 | f'The value of {var_name} is "changethis", ' 102 | "for security, please change it, at least for deployments." 103 | ) 104 | if self.ENVIRONMENT == "local": 105 | warnings.warn(message, stacklevel=1) 106 | else: 107 | raise ValueError(message) 108 | 109 | @model_validator(mode="after") 110 | def _enforce_non_default_secrets(self) -> Self: 111 | self._check_default_secret("SECRET_KEY", self.SECRET_KEY) 112 | self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD) 113 | self._check_default_secret( 114 | "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD 115 | ) 116 | 117 | return self 118 | 119 | 120 | settings = Settings() # type: ignore 121 | -------------------------------------------------------------------------------- /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/fastapi/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 | # This works because the models are already imported and registered from app.models 22 | # SQLModel.metadata.create_all(engine) 23 | 24 | user = session.exec( 25 | select(User).where(User.email == settings.FIRST_SUPERUSER) 26 | ).first() 27 | if not user: 28 | user_in = UserCreate( 29 | email=settings.FIRST_SUPERUSER, 30 | password=settings.FIRST_SUPERUSER_PASSWORD, 31 | is_superuser=True, 32 | ) 33 | user = crud.create_user(session=session, user_create=user_in) 34 | -------------------------------------------------------------------------------- /backend/app/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 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.now(timezone.utc) + 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 | import uuid 2 | from typing import Any 3 | 4 | from sqlmodel import Session, select 5 | 6 | from app.core.security import get_password_hash, verify_password 7 | from app.models import Item, ItemCreate, User, UserCreate, UserUpdate 8 | 9 | 10 | def create_user(*, session: Session, user_create: UserCreate) -> User: 11 | db_obj = User.model_validate( 12 | user_create, update={"hashed_password": get_password_hash(user_create.password)} 13 | ) 14 | session.add(db_obj) 15 | session.commit() 16 | session.refresh(db_obj) 17 | return db_obj 18 | 19 | 20 | def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: 21 | user_data = user_in.model_dump(exclude_unset=True) 22 | extra_data = {} 23 | if "password" in user_data: 24 | password = user_data["password"] 25 | hashed_password = get_password_hash(password) 26 | extra_data["hashed_password"] = hashed_password 27 | db_user.sqlmodel_update(user_data, update=extra_data) 28 | session.add(db_user) 29 | session.commit() 30 | session.refresh(db_user) 31 | return db_user 32 | 33 | 34 | def get_user_by_email(*, session: Session, email: str) -> User | None: 35 | statement = select(User).where(User.email == email) 36 | session_user = session.exec(statement).first() 37 | return session_user 38 | 39 | 40 | def authenticate(*, session: Session, email: str, password: str) -> User | None: 41 | db_user = get_user_by_email(session=session, email=email) 42 | if not db_user: 43 | return None 44 | if not verify_password(password, db_user.hashed_password): 45 | return None 46 | return db_user 47 | 48 | 49 | def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: 50 | db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) 51 | session.add(db_item) 52 | session.commit() 53 | session.refresh(db_item) 54 | return db_item 55 | -------------------------------------------------------------------------------- /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.all_cors_origins: 25 | app.add_middleware( 26 | CORSMiddleware, 27 | allow_origins=settings.all_cors_origins, 28 | allow_credentials=True, 29 | allow_methods=["*"], 30 | allow_headers=["*"], 31 | ) 32 | 33 | app.include_router(api_router, prefix=settings.API_V1_STR) 34 | -------------------------------------------------------------------------------- /backend/app/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from pydantic import EmailStr 4 | from sqlmodel import Field, Relationship, SQLModel 5 | 6 | 7 | # Shared properties 8 | class UserBase(SQLModel): 9 | email: EmailStr = Field(unique=True, index=True, max_length=255) 10 | is_active: bool = True 11 | is_superuser: bool = False 12 | full_name: str | None = Field(default=None, max_length=255) 13 | 14 | 15 | # Properties to receive via API on creation 16 | class UserCreate(UserBase): 17 | password: str = Field(min_length=8, max_length=40) 18 | 19 | 20 | class UserRegister(SQLModel): 21 | email: EmailStr = Field(max_length=255) 22 | password: str = Field(min_length=8, max_length=40) 23 | full_name: str | None = Field(default=None, max_length=255) 24 | 25 | 26 | # Properties to receive via API on update, all are optional 27 | class UserUpdate(UserBase): 28 | email: EmailStr | None = Field(default=None, max_length=255) # type: ignore 29 | password: str | None = Field(default=None, min_length=8, max_length=40) 30 | 31 | 32 | class UserUpdateMe(SQLModel): 33 | full_name: str | None = Field(default=None, max_length=255) 34 | email: EmailStr | None = Field(default=None, max_length=255) 35 | 36 | 37 | class UpdatePassword(SQLModel): 38 | current_password: str = Field(min_length=8, max_length=40) 39 | new_password: str = Field(min_length=8, max_length=40) 40 | 41 | 42 | # Database model, database table inferred from class name 43 | class User(UserBase, table=True): 44 | id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) 45 | hashed_password: str 46 | items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) 47 | 48 | 49 | # Properties to return via API, id is always required 50 | class UserPublic(UserBase): 51 | id: uuid.UUID 52 | 53 | 54 | class UsersPublic(SQLModel): 55 | data: list[UserPublic] 56 | count: int 57 | 58 | 59 | # Shared properties 60 | class ItemBase(SQLModel): 61 | title: str = Field(min_length=1, max_length=255) 62 | description: str | None = Field(default=None, max_length=255) 63 | 64 | 65 | # Properties to receive on item creation 66 | class ItemCreate(ItemBase): 67 | pass 68 | 69 | 70 | # Properties to receive on item update 71 | class ItemUpdate(ItemBase): 72 | title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore 73 | 74 | 75 | # Database model, database table inferred from class name 76 | class Item(ItemBase, table=True): 77 | id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) 78 | owner_id: uuid.UUID = Field( 79 | foreign_key="user.id", nullable=False, ondelete="CASCADE" 80 | ) 81 | owner: User | None = Relationship(back_populates="items") 82 | 83 | 84 | # Properties to return via API, id is always required 85 | class ItemPublic(ItemBase): 86 | id: uuid.UUID 87 | owner_id: uuid.UUID 88 | 89 | 90 | class ItemsPublic(SQLModel): 91 | data: list[ItemPublic] 92 | count: int 93 | 94 | 95 | # Generic message 96 | class Message(SQLModel): 97 | message: str 98 | 99 | 100 | # JSON payload containing access token 101 | class Token(SQLModel): 102 | access_token: str 103 | token_type: str = "bearer" 104 | 105 | 106 | # Contents of JWT token 107 | class TokenPayload(SQLModel): 108 | sub: str | None = None 109 | 110 | 111 | class NewPassword(SQLModel): 112 | token: str 113 | new_password: str = Field(min_length=8, max_length=40) 114 | -------------------------------------------------------------------------------- /backend/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/backend/app/tests/__init__.py -------------------------------------------------------------------------------- /backend/app/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/backend/app/tests/api/__init__.py -------------------------------------------------------------------------------- /backend/app/tests/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/backend/app/tests/api/routes/__init__.py -------------------------------------------------------------------------------- /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 5 | 6 | from app.core.config import settings 7 | from app.core.security import verify_password 8 | from app.crud import create_user 9 | from app.models import UserCreate 10 | from app.tests.utils.user import user_authentication_headers 11 | from app.tests.utils.utils import random_email, random_lower_string 12 | from app.utils import generate_password_reset_token 13 | 14 | 15 | def test_get_access_token(client: TestClient) -> None: 16 | login_data = { 17 | "username": settings.FIRST_SUPERUSER, 18 | "password": settings.FIRST_SUPERUSER_PASSWORD, 19 | } 20 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 21 | tokens = r.json() 22 | assert r.status_code == 200 23 | assert "access_token" in tokens 24 | assert tokens["access_token"] 25 | 26 | 27 | def test_get_access_token_incorrect_password(client: TestClient) -> None: 28 | login_data = { 29 | "username": settings.FIRST_SUPERUSER, 30 | "password": "incorrect", 31 | } 32 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 33 | assert r.status_code == 400 34 | 35 | 36 | def test_use_access_token( 37 | client: TestClient, superuser_token_headers: dict[str, str] 38 | ) -> None: 39 | r = client.post( 40 | f"{settings.API_V1_STR}/login/test-token", 41 | headers=superuser_token_headers, 42 | ) 43 | result = r.json() 44 | assert r.status_code == 200 45 | assert "email" in result 46 | 47 | 48 | def test_recovery_password( 49 | client: TestClient, normal_user_token_headers: dict[str, str] 50 | ) -> None: 51 | with ( 52 | patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), 53 | patch("app.core.config.settings.SMTP_USER", "admin@example.com"), 54 | ): 55 | email = "test@example.com" 56 | r = client.post( 57 | f"{settings.API_V1_STR}/password-recovery/{email}", 58 | headers=normal_user_token_headers, 59 | ) 60 | assert r.status_code == 200 61 | assert r.json() == {"message": "Password recovery email sent"} 62 | 63 | 64 | def test_recovery_password_user_not_exits( 65 | client: TestClient, normal_user_token_headers: dict[str, str] 66 | ) -> None: 67 | email = "jVgQr@example.com" 68 | r = client.post( 69 | f"{settings.API_V1_STR}/password-recovery/{email}", 70 | headers=normal_user_token_headers, 71 | ) 72 | assert r.status_code == 404 73 | 74 | 75 | def test_reset_password(client: TestClient, db: Session) -> None: 76 | email = random_email() 77 | password = random_lower_string() 78 | new_password = random_lower_string() 79 | 80 | user_create = UserCreate( 81 | email=email, 82 | full_name="Test User", 83 | password=password, 84 | is_active=True, 85 | is_superuser=False, 86 | ) 87 | user = create_user(session=db, user_create=user_create) 88 | token = generate_password_reset_token(email=email) 89 | headers = user_authentication_headers(client=client, email=email, password=password) 90 | data = {"new_password": new_password, "token": token} 91 | 92 | r = client.post( 93 | f"{settings.API_V1_STR}/reset-password/", 94 | headers=headers, 95 | json=data, 96 | ) 97 | 98 | assert r.status_code == 200 99 | assert r.json() == {"message": "Password updated successfully"} 100 | 101 | db.refresh(user) 102 | assert verify_password(new_password, user.hashed_password) 103 | 104 | 105 | def test_reset_password_invalid_token( 106 | client: TestClient, superuser_token_headers: dict[str, str] 107 | ) -> None: 108 | data = {"new_password": "changethis", "token": "invalid"} 109 | r = client.post( 110 | f"{settings.API_V1_STR}/reset-password/", 111 | headers=superuser_token_headers, 112 | json=data, 113 | ) 114 | response = r.json() 115 | 116 | assert "detail" in response 117 | assert r.status_code == 400 118 | assert response["detail"] == "Invalid token" 119 | -------------------------------------------------------------------------------- /backend/app/tests/api/routes/test_private.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from sqlmodel import Session, select 3 | 4 | from app.core.config import settings 5 | from app.models import User 6 | 7 | 8 | def test_create_user(client: TestClient, db: Session) -> None: 9 | r = client.post( 10 | f"{settings.API_V1_STR}/private/users/", 11 | json={ 12 | "email": "pollo@listo.com", 13 | "password": "password123", 14 | "full_name": "Pollo Listo", 15 | }, 16 | ) 17 | 18 | assert r.status_code == 200 19 | 20 | data = r.json() 21 | 22 | user = db.exec(select(User).where(User.id == data["id"])).first() 23 | 24 | assert user 25 | assert user.email == "pollo@listo.com" 26 | assert user.full_name == "Pollo Listo" 27 | -------------------------------------------------------------------------------- /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/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/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/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/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/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/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/tests_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 | # Try to create session to check if DB is awake 25 | with Session(db_engine) as session: 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/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "" 5 | requires-python = ">=3.10,<4.0" 6 | dependencies = [ 7 | "fastapi[standard]<1.0.0,>=0.114.2", 8 | "python-multipart<1.0.0,>=0.0.7", 9 | "email-validator<3.0.0.0,>=2.1.0.post1", 10 | "passlib[bcrypt]<2.0.0,>=1.7.4", 11 | "tenacity<9.0.0,>=8.2.3", 12 | "pydantic>2.0", 13 | "emails<1.0,>=0.6", 14 | "jinja2<4.0.0,>=3.1.4", 15 | "alembic<2.0.0,>=1.12.1", 16 | "httpx<1.0.0,>=0.25.1", 17 | "psycopg[binary]<4.0.0,>=3.1.13", 18 | "sqlmodel<1.0.0,>=0.0.21", 19 | # Pin bcrypt until passlib supports the latest 20 | "bcrypt==4.0.1", 21 | "pydantic-settings<3.0.0,>=2.2.1", 22 | "sentry-sdk[fastapi]<2.0.0,>=1.40.6", 23 | "pyjwt<3.0.0,>=2.8.0", 24 | ] 25 | 26 | [tool.uv] 27 | dev-dependencies = [ 28 | "pytest<8.0.0,>=7.4.3", 29 | "mypy<2.0.0,>=1.8.0", 30 | "ruff<1.0.0,>=0.2.2", 31 | "pre-commit<4.0.0,>=3.6.2", 32 | "types-passlib<2.0.0.0,>=1.7.7.20240106", 33 | "coverage<8.0.0,>=7.4.3", 34 | ] 35 | 36 | [build-system] 37 | requires = ["hatchling"] 38 | build-backend = "hatchling.build" 39 | 40 | [tool.mypy] 41 | strict = true 42 | exclude = ["venv", ".venv", "alembic"] 43 | 44 | [tool.ruff] 45 | target-version = "py310" 46 | exclude = ["alembic"] 47 | 48 | [tool.ruff.lint] 49 | select = [ 50 | "E", # pycodestyle errors 51 | "W", # pycodestyle warnings 52 | "F", # pyflakes 53 | "I", # isort 54 | "B", # flake8-bugbear 55 | "C4", # flake8-comprehensions 56 | "UP", # pyupgrade 57 | "ARG001", # unused arguments in functions 58 | ] 59 | ignore = [ 60 | "E501", # line too long, handled by black 61 | "B008", # do not perform function calls in argument defaults 62 | "W191", # indentation contains tabs 63 | "B904", # Allow raising exceptions without from e, for HTTPException 64 | ] 65 | 66 | [tool.ruff.lint.pyupgrade] 67 | # Preserve types, even if a file imports `from __future__ import annotations`. 68 | keep-runtime-typing = true 69 | -------------------------------------------------------------------------------- /backend/scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | ruff check app scripts --fix 5 | ruff format app scripts 6 | -------------------------------------------------------------------------------- /backend/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | mypy app 7 | ruff check app 8 | ruff format app --check 9 | -------------------------------------------------------------------------------- /backend/scripts/prestart.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | # Let the DB start 7 | python app/backend_pre_start.py 8 | 9 | # Run migrations 10 | alembic upgrade head 11 | 12 | # Create initial data in DB 13 | python app/initial_data.py 14 | -------------------------------------------------------------------------------- /backend/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | coverage run --source=app -m pytest 7 | coverage report --show-missing 8 | coverage html --title "${@-coverage}" 9 | -------------------------------------------------------------------------------- /backend/scripts/tests-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | set -x 4 | 5 | python app/tests_pre_start.py 6 | 7 | bash scripts/test.sh "$@" 8 | -------------------------------------------------------------------------------- /copier.yml: -------------------------------------------------------------------------------- 1 | project_name: 2 | type: str 3 | help: The name of the project, shown to API users (in .env) 4 | default: FastAPI Project 5 | 6 | stack_name: 7 | type: str 8 | help: The name of the stack used for Docker Compose labels (no spaces) (in .env) 9 | default: fastapi-project 10 | 11 | secret_key: 12 | type: str 13 | help: | 14 | 'The secret key for the project, used for security, 15 | stored in .env, you can generate one with: 16 | python -c "import secrets; print(secrets.token_urlsafe(32))"' 17 | default: changethis 18 | 19 | first_superuser: 20 | type: str 21 | help: The email of the first superuser (in .env) 22 | default: admin@example.com 23 | 24 | first_superuser_password: 25 | type: str 26 | help: The password of the first superuser (in .env) 27 | default: changethis 28 | 29 | smtp_host: 30 | type: str 31 | help: The SMTP server host to send emails, you can set it later in .env 32 | default: "" 33 | 34 | smtp_user: 35 | type: str 36 | help: The SMTP server user to send emails, you can set it later in .env 37 | default: "" 38 | 39 | smtp_password: 40 | type: str 41 | help: The SMTP server password to send emails, you can set it later in .env 42 | default: "" 43 | 44 | emails_from_email: 45 | type: str 46 | help: The email account to send emails from, you can set it later in .env 47 | default: info@example.com 48 | 49 | postgres_password: 50 | type: str 51 | help: | 52 | 'The password for the PostgreSQL database, stored in .env, 53 | you can generate one with: 54 | python -c "import secrets; print(secrets.token_urlsafe(32))"' 55 | default: changethis 56 | 57 | sentry_dsn: 58 | type: str 59 | help: The DSN for Sentry, if you are using it, you can set it later in .env 60 | default: "" 61 | 62 | _exclude: 63 | # Global 64 | - .vscode 65 | - .mypy_cache 66 | # Python 67 | - __pycache__ 68 | - app.egg-info 69 | - "*.pyc" 70 | - .mypy_cache 71 | - .coverage 72 | - htmlcov 73 | - .cache 74 | - .venv 75 | # Frontend 76 | # Logs 77 | - logs 78 | - "*.log" 79 | - npm-debug.log* 80 | - yarn-debug.log* 81 | - yarn-error.log* 82 | - pnpm-debug.log* 83 | - lerna-debug.log* 84 | - node_modules 85 | - dist 86 | - dist-ssr 87 | - "*.local" 88 | # Editor directories and files 89 | - .idea 90 | - .DS_Store 91 | - "*.suo" 92 | - "*.ntvs*" 93 | - "*.njsproj" 94 | - "*.sln" 95 | - "*.sw?" 96 | 97 | _answers_file: .copier/.copier-answers.yml 98 | 99 | _tasks: 100 | - ["{{ _copier_python }}", .copier/update_dotenv.py] 101 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | # Local services are available on their ports, but also available on: 4 | # http://api.localhost.tiangolo.com: backend 5 | # http://dashboard.localhost.tiangolo.com: frontend 6 | # etc. To enable it, update .env, set: 7 | # DOMAIN=localhost.tiangolo.com 8 | proxy: 9 | image: traefik:3.0 10 | volumes: 11 | - /var/run/docker.sock:/var/run/docker.sock 12 | ports: 13 | - "80:80" 14 | - "8090:8080" 15 | # Duplicate the command from docker-compose.yml to add --api.insecure=true 16 | command: 17 | # Enable Docker in Traefik, so that it reads labels from Docker services 18 | - --providers.docker 19 | # Add a constraint to only use services with the label for this stack 20 | - --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`) 21 | # Do not expose all Docker services, only the ones explicitly exposed 22 | - --providers.docker.exposedbydefault=false 23 | # Create an entrypoint "http" listening on port 80 24 | - --entrypoints.http.address=:80 25 | # Create an entrypoint "https" listening on port 443 26 | - --entrypoints.https.address=:443 27 | # Enable the access log, with HTTP requests 28 | - --accesslog 29 | # Enable the Traefik log, for configurations and errors 30 | - --log 31 | # Enable debug logging for local development 32 | - --log.level=DEBUG 33 | # Enable the Dashboard and API 34 | - --api 35 | # Enable the Dashboard and API in insecure mode for local development 36 | - --api.insecure=true 37 | labels: 38 | # Enable Traefik for this service, to make it available in the public network 39 | - traefik.enable=true 40 | - traefik.constraint-label=traefik-public 41 | # Dummy https-redirect middleware that doesn't really redirect, only to 42 | # allow running it locally 43 | - traefik.http.middlewares.https-redirect.contenttype.autodetect=false 44 | networks: 45 | - traefik-public 46 | - default 47 | 48 | db: 49 | restart: "no" 50 | ports: 51 | - "5432:5432" 52 | 53 | adminer: 54 | restart: "no" 55 | ports: 56 | - "8080:8080" 57 | 58 | backend: 59 | restart: "no" 60 | ports: 61 | - "8000:8000" 62 | build: 63 | context: ./backend 64 | # command: sleep infinity # Infinite loop to keep container alive doing nothing 65 | command: 66 | - fastapi 67 | - run 68 | - --reload 69 | - "app/main.py" 70 | develop: 71 | watch: 72 | - path: ./backend 73 | action: sync 74 | target: /app 75 | ignore: 76 | - ./backend/.venv 77 | - .venv 78 | - path: ./backend/pyproject.toml 79 | action: rebuild 80 | # TODO: remove once coverage is done locally 81 | volumes: 82 | - ./backend/htmlcov:/app/htmlcov 83 | environment: 84 | SMTP_HOST: "mailcatcher" 85 | SMTP_PORT: "1025" 86 | SMTP_TLS: "false" 87 | EMAILS_FROM_EMAIL: "noreply@example.com" 88 | 89 | mailcatcher: 90 | image: schickling/mailcatcher 91 | ports: 92 | - "1080:1080" 93 | - "1025:1025" 94 | 95 | frontend: 96 | restart: "no" 97 | ports: 98 | - "5173:80" 99 | build: 100 | context: ./frontend 101 | args: 102 | - VITE_API_URL=http://localhost:8000 103 | - NODE_ENV=development 104 | 105 | playwright: 106 | build: 107 | context: ./frontend 108 | dockerfile: Dockerfile.playwright 109 | args: 110 | - VITE_API_URL=http://backend:8000 111 | - NODE_ENV=production 112 | ipc: host 113 | depends_on: 114 | - backend 115 | - mailcatcher 116 | env_file: 117 | - .env 118 | environment: 119 | - VITE_API_URL=http://backend:8000 120 | - MAILCATCHER_HOST=http://mailcatcher:1080 121 | # For the reports when run locally 122 | - PLAYWRIGHT_HTML_HOST=0.0.0.0 123 | - CI=${CI} 124 | volumes: 125 | - ./frontend/blob-report:/app/blob-report 126 | - ./frontend/test-results:/app/test-results 127 | ports: 128 | - 9323:9323 129 | 130 | networks: 131 | traefik-public: 132 | # For local dev, don't expect an external Traefik network 133 | external: false 134 | -------------------------------------------------------------------------------- /docker-compose.traefik.yml: -------------------------------------------------------------------------------- 1 | services: 2 | traefik: 3 | image: traefik:3.0 4 | ports: 5 | # Listen on port 80, default for HTTP, necessary to redirect to HTTPS 6 | - 80:80 7 | # Listen on port 443, default for HTTPS 8 | - 443:443 9 | restart: always 10 | labels: 11 | # Enable Traefik for this service, to make it available in the public network 12 | - traefik.enable=true 13 | # Use the traefik-public network (declared below) 14 | - traefik.docker.network=traefik-public 15 | # Define the port inside of the Docker service to use 16 | - traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080 17 | # Make Traefik use this domain (from an environment variable) in HTTP 18 | - traefik.http.routers.traefik-dashboard-http.entrypoints=http 19 | - traefik.http.routers.traefik-dashboard-http.rule=Host(`traefik.${DOMAIN?Variable not set}`) 20 | # traefik-https the actual router using HTTPS 21 | - traefik.http.routers.traefik-dashboard-https.entrypoints=https 22 | - traefik.http.routers.traefik-dashboard-https.rule=Host(`traefik.${DOMAIN?Variable not set}`) 23 | - traefik.http.routers.traefik-dashboard-https.tls=true 24 | # Use the "le" (Let's Encrypt) resolver created below 25 | - traefik.http.routers.traefik-dashboard-https.tls.certresolver=le 26 | # Use the special Traefik service api@internal with the web UI/Dashboard 27 | - traefik.http.routers.traefik-dashboard-https.service=api@internal 28 | # https-redirect middleware to redirect HTTP to HTTPS 29 | - traefik.http.middlewares.https-redirect.redirectscheme.scheme=https 30 | - traefik.http.middlewares.https-redirect.redirectscheme.permanent=true 31 | # traefik-http set up only to use the middleware to redirect to https 32 | - traefik.http.routers.traefik-dashboard-http.middlewares=https-redirect 33 | # admin-auth middleware with HTTP Basic auth 34 | # Using the environment variables USERNAME and HASHED_PASSWORD 35 | - traefik.http.middlewares.admin-auth.basicauth.users=${USERNAME?Variable not set}:${HASHED_PASSWORD?Variable not set} 36 | # Enable HTTP Basic auth, using the middleware created above 37 | - traefik.http.routers.traefik-dashboard-https.middlewares=admin-auth 38 | volumes: 39 | # Add Docker as a mounted volume, so that Traefik can read the labels of other services 40 | - /var/run/docker.sock:/var/run/docker.sock:ro 41 | # Mount the volume to store the certificates 42 | - traefik-public-certificates:/certificates 43 | command: 44 | # Enable Docker in Traefik, so that it reads labels from Docker services 45 | - --providers.docker 46 | # Do not expose all Docker services, only the ones explicitly exposed 47 | - --providers.docker.exposedbydefault=false 48 | # Create an entrypoint "http" listening on port 80 49 | - --entrypoints.http.address=:80 50 | # Create an entrypoint "https" listening on port 443 51 | - --entrypoints.https.address=:443 52 | # Create the certificate resolver "le" for Let's Encrypt, uses the environment variable EMAIL 53 | - --certificatesresolvers.le.acme.email=${EMAIL?Variable not set} 54 | # Store the Let's Encrypt certificates in the mounted volume 55 | - --certificatesresolvers.le.acme.storage=/certificates/acme.json 56 | # Use the TLS Challenge for Let's Encrypt 57 | - --certificatesresolvers.le.acme.tlschallenge=true 58 | # Enable the access log, with HTTP requests 59 | - --accesslog 60 | # Enable the Traefik log, for configurations and errors 61 | - --log 62 | # Enable the Dashboard and API 63 | - --api 64 | networks: 65 | # Use the public network created to be shared between Traefik and 66 | # any other service that needs to be publicly available with HTTPS 67 | - traefik-public 68 | 69 | volumes: 70 | # Create a volume to store the certificates, even if the container is recreated 71 | traefik-public-certificates: 72 | 73 | networks: 74 | # Use the previously created public network "traefik-public", shared with other 75 | # services that need to be publicly available via this Traefik 76 | traefik-public: 77 | external: true 78 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:8000 2 | MAILCATCHER_HOST=http://localhost:1080 3 | -------------------------------------------------------------------------------- /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 | /test-results/ 27 | /playwright-report/ 28 | /blob-report/ 29 | /playwright/.cache/ 30 | /playwright/.auth/ -------------------------------------------------------------------------------- /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend 2 | FROM node:20 AS build-stage 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json /app/ 7 | 8 | RUN npm install 9 | 10 | COPY ./ /app/ 11 | 12 | ARG VITE_API_URL=${VITE_API_URL} 13 | 14 | RUN npm run build 15 | 16 | 17 | # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx 18 | FROM nginx:1 19 | 20 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html 21 | 22 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf 23 | COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf 24 | -------------------------------------------------------------------------------- /frontend/Dockerfile.playwright: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json /app/ 6 | 7 | RUN npm install 8 | 9 | RUN npx -y playwright install --with-deps 10 | 11 | COPY ./ /app/ 12 | 13 | ARG VITE_API_URL=${VITE_API_URL} 14 | -------------------------------------------------------------------------------- /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": [ 8 | "node_modules", 9 | "src/routeTree.gen.ts", 10 | "playwright.config.ts", 11 | "playwright-report" 12 | ] 13 | }, 14 | "linter": { 15 | "enabled": true, 16 | "rules": { 17 | "recommended": true, 18 | "suspicious": { 19 | "noExplicitAny": "off", 20 | "noArrayIndexKey": "off" 21 | }, 22 | "style": { 23 | "noNonNullAssertion": "off" 24 | } 25 | } 26 | }, 27 | "formatter": { 28 | "indentStyle": "space" 29 | }, 30 | "javascript": { 31 | "formatter": { 32 | "quoteStyle": "double", 33 | "semicolons": "asNeeded" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Full Stack FastAPI Project 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/nginx-backend-not-found.conf: -------------------------------------------------------------------------------- 1 | location /api { 2 | return 404; 3 | } 4 | location /docs { 5 | return 404; 6 | } 7 | location /redoc { 8 | return 404; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | root /usr/share/nginx/html; 6 | index index.html index.htm; 7 | try_files $uri /index.html =404; 8 | } 9 | 10 | include /etc/nginx/extra-conf.d/*.conf; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/openapi-ts.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@hey-api/openapi-ts" 2 | 3 | export default defineConfig({ 4 | client: "legacy/axios", 5 | input: "./openapi.json", 6 | output: "./src/client", 7 | // exportSchemas: true, 8 | plugins: [ 9 | { 10 | name: "@hey-api/sdk", 11 | // NOTE: this doesn't allow tree-shaking 12 | asClass: true, 13 | operationId: true, 14 | methodNameBuilder: (operation) => { 15 | // @ts-ignore 16 | let name: string = operation.name 17 | // @ts-ignore 18 | const service: string = operation.service 19 | 20 | if (service && name.toLowerCase().startsWith(service.toLowerCase())) { 21 | name = name.slice(service.length) 22 | } 23 | 24 | return name.charAt(0).toLowerCase() + name.slice(1) 25 | }, 26 | }, 27 | ], 28 | }) 29 | -------------------------------------------------------------------------------- /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 -p tsconfig.build.json && 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" 12 | }, 13 | "dependencies": { 14 | "@chakra-ui/react": "^3.8.0", 15 | "@emotion/react": "^11.14.0", 16 | "@tanstack/react-query": "^5.28.14", 17 | "@tanstack/react-query-devtools": "^5.74.9", 18 | "@tanstack/react-router": "1.19.1", 19 | "axios": "1.9.0", 20 | "form-data": "4.0.2", 21 | "next-themes": "^0.4.6", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-error-boundary": "^5.0.0", 25 | "react-hook-form": "7.49.3", 26 | "react-icons": "^5.5.0" 27 | }, 28 | "devDependencies": { 29 | "@biomejs/biome": "1.9.4", 30 | "@hey-api/openapi-ts": "^0.57.0", 31 | "@playwright/test": "^1.52.0", 32 | "@tanstack/router-devtools": "1.19.1", 33 | "@tanstack/router-vite-plugin": "1.19.0", 34 | "@types/node": "^22.15.3", 35 | "@types/react": "^18.2.37", 36 | "@types/react-dom": "^18.2.15", 37 | "@vitejs/plugin-react-swc": "^3.9.0", 38 | "dotenv": "^16.4.5", 39 | "typescript": "^5.2.2", 40 | "vite": "^6.3.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | import 'dotenv/config' 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: process.env.CI ? 'blob' : 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: 'http://localhost:5173', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: 'on-first-retry', 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { name: 'setup', testMatch: /.*\.setup\.ts/ }, 36 | 37 | { 38 | name: 'chromium', 39 | use: { 40 | ...devices['Desktop Chrome'], 41 | storageState: 'playwright/.auth/user.json', 42 | }, 43 | dependencies: ['setup'], 44 | }, 45 | 46 | // { 47 | // name: 'firefox', 48 | // use: { 49 | // ...devices['Desktop Firefox'], 50 | // storageState: 'playwright/.auth/user.json', 51 | // }, 52 | // dependencies: ['setup'], 53 | // }, 54 | 55 | // { 56 | // name: 'webkit', 57 | // use: { 58 | // ...devices['Desktop Safari'], 59 | // storageState: 'playwright/.auth/user.json', 60 | // }, 61 | // dependencies: ['setup'], 62 | // }, 63 | 64 | /* Test against mobile viewports. */ 65 | // { 66 | // name: 'Mobile Chrome', 67 | // use: { ...devices['Pixel 5'] }, 68 | // }, 69 | // { 70 | // name: 'Mobile Safari', 71 | // use: { ...devices['iPhone 12'] }, 72 | // }, 73 | 74 | /* Test against branded browsers. */ 75 | // { 76 | // name: 'Microsoft Edge', 77 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 78 | // }, 79 | // { 80 | // name: 'Google Chrome', 81 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 82 | // }, 83 | ], 84 | 85 | /* Run your local dev server before starting the tests */ 86 | webServer: { 87 | command: 'npm run dev', 88 | url: 'http://localhost:5173', 89 | reuseExistingServer: !process.env.CI, 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /frontend/public/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/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 body?: any 3 | readonly cookies?: Record 4 | readonly errors?: Record 5 | readonly formData?: Record | any[] | Blob | File 6 | readonly headers?: Record 7 | readonly mediaType?: string 8 | readonly method: 9 | | "DELETE" 10 | | "GET" 11 | | "HEAD" 12 | | "OPTIONS" 13 | | "PATCH" 14 | | "POST" 15 | | "PUT" 16 | readonly path?: Record 17 | readonly query?: Record 18 | readonly responseHeader?: string 19 | readonly responseTransformer?: (data: unknown) => Promise 20 | readonly url: string 21 | } 22 | -------------------------------------------------------------------------------- /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 | 4 | type Headers = Record 5 | type Middleware = (value: T) => T | Promise 6 | type Resolver = (options: ApiRequestOptions) => Promise 7 | 8 | export class Interceptors { 9 | _fns: Middleware[] 10 | 11 | constructor() { 12 | this._fns = [] 13 | } 14 | 15 | eject(fn: Middleware): void { 16 | const index = this._fns.indexOf(fn) 17 | if (index !== -1) { 18 | this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)] 19 | } 20 | } 21 | 22 | use(fn: Middleware): void { 23 | this._fns = [...this._fns, fn] 24 | } 25 | } 26 | 27 | export type OpenAPIConfig = { 28 | BASE: string 29 | CREDENTIALS: "include" | "omit" | "same-origin" 30 | ENCODE_PATH?: ((path: string) => string) | undefined 31 | HEADERS?: Headers | Resolver | undefined 32 | PASSWORD?: string | Resolver | undefined 33 | TOKEN?: string | Resolver | undefined 34 | USERNAME?: string | Resolver | undefined 35 | VERSION: string 36 | WITH_CREDENTIALS: boolean 37 | interceptors: { 38 | request: Interceptors 39 | response: Interceptors 40 | } 41 | } 42 | 43 | export const OpenAPI: OpenAPIConfig = { 44 | BASE: "", 45 | CREDENTIALS: "include", 46 | ENCODE_PATH: undefined, 47 | HEADERS: undefined, 48 | PASSWORD: undefined, 49 | TOKEN: undefined, 50 | USERNAME: undefined, 51 | VERSION: "0.1.0", 52 | WITH_CREDENTIALS: false, 53 | interceptors: { 54 | request: new Interceptors(), 55 | response: new Interceptors(), 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/client/index.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | export { ApiError } from "./core/ApiError" 3 | export { CancelablePromise, CancelError } from "./core/CancelablePromise" 4 | export { OpenAPI, type OpenAPIConfig } from "./core/OpenAPI" 5 | export * from "./sdk.gen" 6 | export * from "./types.gen" 7 | -------------------------------------------------------------------------------- /frontend/src/components/Admin/DeleteUser.tsx: -------------------------------------------------------------------------------- 1 | import { Button, DialogTitle, Text } from "@chakra-ui/react" 2 | import { useMutation, useQueryClient } from "@tanstack/react-query" 3 | import { useState } from "react" 4 | import { useForm } from "react-hook-form" 5 | import { FiTrash2 } from "react-icons/fi" 6 | 7 | import { UsersService } from "@/client" 8 | import { 9 | DialogActionTrigger, 10 | DialogBody, 11 | DialogCloseTrigger, 12 | DialogContent, 13 | DialogFooter, 14 | DialogHeader, 15 | DialogRoot, 16 | DialogTrigger, 17 | } from "@/components/ui/dialog" 18 | import useCustomToast from "@/hooks/useCustomToast" 19 | 20 | const DeleteUser = ({ id }: { id: string }) => { 21 | const [isOpen, setIsOpen] = useState(false) 22 | const queryClient = useQueryClient() 23 | const { showSuccessToast, showErrorToast } = useCustomToast() 24 | const { 25 | handleSubmit, 26 | formState: { isSubmitting }, 27 | } = useForm() 28 | 29 | const deleteUser = async (id: string) => { 30 | await UsersService.deleteUser({ userId: id }) 31 | } 32 | 33 | const mutation = useMutation({ 34 | mutationFn: deleteUser, 35 | onSuccess: () => { 36 | showSuccessToast("The user was deleted successfully") 37 | setIsOpen(false) 38 | }, 39 | onError: () => { 40 | showErrorToast("An error occurred while deleting the user") 41 | }, 42 | onSettled: () => { 43 | queryClient.invalidateQueries() 44 | }, 45 | }) 46 | 47 | const onSubmit = async () => { 48 | mutation.mutate(id) 49 | } 50 | 51 | return ( 52 | setIsOpen(open)} 58 | > 59 | 60 | 64 | 65 | 66 |
67 | 68 | Delete User 69 | 70 | 71 | 72 | All items associated with this user will also be{" "} 73 | permanently deleted. Are you sure? You will not 74 | be able to undo this action. 75 | 76 | 77 | 78 | 79 | 80 | 87 | 88 | 96 | 97 | 98 | 99 |
100 |
101 | ) 102 | } 103 | 104 | export default DeleteUser 105 | -------------------------------------------------------------------------------- /frontend/src/components/Common/ItemActionsMenu.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@chakra-ui/react" 2 | import { BsThreeDotsVertical } from "react-icons/bs" 3 | import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu" 4 | 5 | import type { ItemPublic } from "@/client" 6 | import DeleteItem from "../Items/DeleteItem" 7 | import EditItem from "../Items/EditItem" 8 | 9 | interface ItemActionsMenuProps { 10 | item: ItemPublic 11 | } 12 | 13 | export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/Common/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Image, useBreakpointValue } from "@chakra-ui/react" 2 | import { Link } from "@tanstack/react-router" 3 | 4 | import Logo from "/assets/images/fastapi-logo.svg" 5 | import UserMenu from "./UserMenu" 6 | 7 | function Navbar() { 8 | const display = useBreakpointValue({ base: "none", md: "flex" }) 9 | 10 | return ( 11 | 22 | 23 | Logo 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export default Navbar 33 | -------------------------------------------------------------------------------- /frontend/src/components/Common/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Center, Flex, Text } from "@chakra-ui/react" 2 | import { Link } from "@tanstack/react-router" 3 | 4 | const NotFound = () => { 5 | return ( 6 | <> 7 | 15 | 16 | 17 | 23 | 404 24 | 25 | 26 | Oops! 27 | 28 | 29 | 30 | 31 | 38 | The page you are looking for was not found. 39 | 40 |
41 | 42 | 50 | 51 |
52 |
53 | 54 | ) 55 | } 56 | 57 | export default NotFound 58 | -------------------------------------------------------------------------------- /frontend/src/components/Common/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, IconButton, Text } from "@chakra-ui/react" 2 | import { useQueryClient } from "@tanstack/react-query" 3 | import { useState } from "react" 4 | import { FaBars } from "react-icons/fa" 5 | import { FiLogOut } from "react-icons/fi" 6 | 7 | import type { UserPublic } from "@/client" 8 | import useAuth from "@/hooks/useAuth" 9 | import { 10 | DrawerBackdrop, 11 | DrawerBody, 12 | DrawerCloseTrigger, 13 | DrawerContent, 14 | DrawerRoot, 15 | DrawerTrigger, 16 | } from "../ui/drawer" 17 | import SidebarItems from "./SidebarItems" 18 | 19 | const Sidebar = () => { 20 | const queryClient = useQueryClient() 21 | const currentUser = queryClient.getQueryData(["currentUser"]) 22 | const { logout } = useAuth() 23 | const [open, setOpen] = useState(false) 24 | 25 | return ( 26 | <> 27 | {/* Mobile */} 28 | setOpen(e.open)} 32 | > 33 | 34 | 35 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | setOpen(false)} /> 53 | { 56 | logout() 57 | }} 58 | alignItems="center" 59 | gap={4} 60 | px={4} 61 | py={2} 62 | > 63 | 64 | Log Out 65 | 66 | 67 | {currentUser?.email && ( 68 | 69 | Logged in as: {currentUser.email} 70 | 71 | )} 72 | 73 | 74 | 75 | 76 | 77 | 78 | {/* Desktop */} 79 | 80 | 89 | 90 | 91 | 92 | 93 | 94 | ) 95 | } 96 | 97 | export default Sidebar 98 | -------------------------------------------------------------------------------- /frontend/src/components/Common/SidebarItems.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Icon, Text } from "@chakra-ui/react" 2 | import { useQueryClient } from "@tanstack/react-query" 3 | import { Link as RouterLink } from "@tanstack/react-router" 4 | import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" 5 | import type { IconType } from "react-icons/lib" 6 | 7 | import type { UserPublic } from "@/client" 8 | 9 | const items = [ 10 | { icon: FiHome, title: "Dashboard", path: "/" }, 11 | { icon: FiBriefcase, title: "Items", path: "/items" }, 12 | { icon: FiSettings, title: "User Settings", path: "/settings" }, 13 | ] 14 | 15 | interface SidebarItemsProps { 16 | onClose?: () => void 17 | } 18 | 19 | interface Item { 20 | icon: IconType 21 | title: string 22 | path: string 23 | } 24 | 25 | const SidebarItems = ({ onClose }: SidebarItemsProps) => { 26 | const queryClient = useQueryClient() 27 | const currentUser = queryClient.getQueryData(["currentUser"]) 28 | 29 | const finalItems: Item[] = currentUser?.is_superuser 30 | ? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }] 31 | : items 32 | 33 | const listItems = finalItems.map(({ icon, title, path }) => ( 34 | 35 | 45 | 46 | {title} 47 | 48 | 49 | )) 50 | 51 | return ( 52 | <> 53 | 54 | Menu 55 | 56 | {listItems} 57 | 58 | ) 59 | } 60 | 61 | export default SidebarItems 62 | -------------------------------------------------------------------------------- /frontend/src/components/Common/UserActionsMenu.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@chakra-ui/react" 2 | import { BsThreeDotsVertical } from "react-icons/bs" 3 | import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu" 4 | 5 | import type { UserPublic } from "@/client" 6 | import DeleteUser from "../Admin/DeleteUser" 7 | import EditUser from "../Admin/EditUser" 8 | 9 | interface UserActionsMenuProps { 10 | user: UserPublic 11 | disabled?: boolean 12 | } 13 | 14 | export const UserActionsMenu = ({ user, disabled }: UserActionsMenuProps) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/Common/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, Text } from "@chakra-ui/react" 2 | import { Link } from "@tanstack/react-router" 3 | import { FaUserAstronaut } from "react-icons/fa" 4 | import { FiLogOut, FiUser } from "react-icons/fi" 5 | 6 | import useAuth from "@/hooks/useAuth" 7 | import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu" 8 | 9 | const UserMenu = () => { 10 | const { user, logout } = useAuth() 11 | 12 | const handleLogout = async () => { 13 | logout() 14 | } 15 | 16 | return ( 17 | <> 18 | {/* Desktop */} 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 30 | 37 | 38 | My Profile 39 | 40 | 41 | 42 | 49 | 50 | Log Out 51 | 52 | 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export default UserMenu 60 | -------------------------------------------------------------------------------- /frontend/src/components/Items/DeleteItem.tsx: -------------------------------------------------------------------------------- 1 | import { Button, DialogTitle, Text } from "@chakra-ui/react" 2 | import { useMutation, useQueryClient } from "@tanstack/react-query" 3 | import { useState } from "react" 4 | import { useForm } from "react-hook-form" 5 | import { FiTrash2 } from "react-icons/fi" 6 | 7 | import { ItemsService } from "@/client" 8 | import { 9 | DialogActionTrigger, 10 | DialogBody, 11 | DialogCloseTrigger, 12 | DialogContent, 13 | DialogFooter, 14 | DialogHeader, 15 | DialogRoot, 16 | DialogTrigger, 17 | } from "@/components/ui/dialog" 18 | import useCustomToast from "@/hooks/useCustomToast" 19 | 20 | const DeleteItem = ({ id }: { id: string }) => { 21 | const [isOpen, setIsOpen] = useState(false) 22 | const queryClient = useQueryClient() 23 | const { showSuccessToast, showErrorToast } = useCustomToast() 24 | const { 25 | handleSubmit, 26 | formState: { isSubmitting }, 27 | } = useForm() 28 | 29 | const deleteItem = async (id: string) => { 30 | await ItemsService.deleteItem({ id: id }) 31 | } 32 | 33 | const mutation = useMutation({ 34 | mutationFn: deleteItem, 35 | onSuccess: () => { 36 | showSuccessToast("The item was deleted successfully") 37 | setIsOpen(false) 38 | }, 39 | onError: () => { 40 | showErrorToast("An error occurred while deleting the item") 41 | }, 42 | onSettled: () => { 43 | queryClient.invalidateQueries() 44 | }, 45 | }) 46 | 47 | const onSubmit = async () => { 48 | mutation.mutate(id) 49 | } 50 | 51 | return ( 52 | setIsOpen(open)} 58 | > 59 | 60 | 64 | 65 | 66 | 67 |
68 | 69 | 70 | Delete Item 71 | 72 | 73 | 74 | This item will be permanently deleted. Are you sure? You will not 75 | be able to undo this action. 76 | 77 | 78 | 79 | 80 | 81 | 88 | 89 | 97 | 98 | 99 |
100 |
101 | ) 102 | } 103 | 104 | export default DeleteItem 105 | -------------------------------------------------------------------------------- /frontend/src/components/Pending/PendingItems.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from "@chakra-ui/react" 2 | import { SkeletonText } from "../ui/skeleton" 3 | 4 | const PendingItems = () => ( 5 | 6 | 7 | 8 | ID 9 | Title 10 | Description 11 | Actions 12 | 13 | 14 | 15 | {[...Array(5)].map((_, index) => ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ))} 31 | 32 | 33 | ) 34 | 35 | export default PendingItems 36 | -------------------------------------------------------------------------------- /frontend/src/components/Pending/PendingUsers.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from "@chakra-ui/react" 2 | import { SkeletonText } from "../ui/skeleton" 3 | 4 | const PendingUsers = () => ( 5 | 6 | 7 | 8 | Full name 9 | Email 10 | Role 11 | Status 12 | Actions 13 | 14 | 15 | 16 | {[...Array(5)].map((_, index) => ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ))} 35 | 36 | 37 | ) 38 | 39 | export default PendingUsers 40 | -------------------------------------------------------------------------------- /frontend/src/components/UserSettings/Appearance.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading, Stack } from "@chakra-ui/react" 2 | import { useTheme } from "next-themes" 3 | 4 | import { Radio, RadioGroup } from "@/components/ui/radio" 5 | 6 | const Appearance = () => { 7 | const { theme, setTheme } = useTheme() 8 | 9 | return ( 10 | <> 11 | 12 | 13 | Appearance 14 | 15 | 16 | setTheme(e.value)} 18 | value={theme} 19 | colorPalette="teal" 20 | > 21 | 22 | System 23 | Light Mode 24 | Dark Mode 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | export default Appearance 32 | -------------------------------------------------------------------------------- /frontend/src/components/UserSettings/ChangePassword.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Container, Heading, VStack } from "@chakra-ui/react" 2 | import { useMutation } from "@tanstack/react-query" 3 | import { type SubmitHandler, useForm } from "react-hook-form" 4 | import { FiLock } from "react-icons/fi" 5 | 6 | import { type ApiError, type UpdatePassword, UsersService } from "@/client" 7 | import useCustomToast from "@/hooks/useCustomToast" 8 | import { confirmPasswordRules, handleError, passwordRules } from "@/utils" 9 | import { PasswordInput } from "../ui/password-input" 10 | 11 | interface UpdatePasswordForm extends UpdatePassword { 12 | confirm_password: string 13 | } 14 | 15 | const ChangePassword = () => { 16 | const { showSuccessToast } = useCustomToast() 17 | const { 18 | register, 19 | handleSubmit, 20 | reset, 21 | getValues, 22 | formState: { errors, isValid, isSubmitting }, 23 | } = useForm({ 24 | mode: "onBlur", 25 | criteriaMode: "all", 26 | }) 27 | 28 | const mutation = useMutation({ 29 | mutationFn: (data: UpdatePassword) => 30 | UsersService.updatePasswordMe({ requestBody: data }), 31 | onSuccess: () => { 32 | showSuccessToast("Password updated successfully.") 33 | reset() 34 | }, 35 | onError: (err: ApiError) => { 36 | handleError(err) 37 | }, 38 | }) 39 | 40 | const onSubmit: SubmitHandler = async (data) => { 41 | mutation.mutate(data) 42 | } 43 | 44 | return ( 45 | <> 46 | 47 | 48 | Change Password 49 | 50 | 51 | 52 | } 55 | {...register("current_password", passwordRules())} 56 | placeholder="Current Password" 57 | errors={errors} 58 | /> 59 | } 62 | {...register("new_password", passwordRules())} 63 | placeholder="New Password" 64 | errors={errors} 65 | /> 66 | } 69 | {...register("confirm_password", confirmPasswordRules(getValues))} 70 | placeholder="Confirm Password" 71 | errors={errors} 72 | /> 73 | 74 | 83 | 84 | 85 | 86 | ) 87 | } 88 | export default ChangePassword 89 | -------------------------------------------------------------------------------- /frontend/src/components/UserSettings/DeleteAccount.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading, Text } from "@chakra-ui/react" 2 | 3 | import DeleteConfirmation from "./DeleteConfirmation" 4 | 5 | const DeleteAccount = () => { 6 | return ( 7 | 8 | 9 | Delete Account 10 | 11 | 12 | Permanently delete your data and everything associated with your 13 | account. 14 | 15 | 16 | 17 | ) 18 | } 19 | export default DeleteAccount 20 | -------------------------------------------------------------------------------- /frontend/src/components/UserSettings/DeleteConfirmation.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup, Text } from "@chakra-ui/react" 2 | import { useMutation, useQueryClient } from "@tanstack/react-query" 3 | import { useState } from "react" 4 | import { useForm } from "react-hook-form" 5 | 6 | import { type ApiError, UsersService } from "@/client" 7 | import { 8 | DialogActionTrigger, 9 | DialogBody, 10 | DialogCloseTrigger, 11 | DialogContent, 12 | DialogFooter, 13 | DialogHeader, 14 | DialogRoot, 15 | DialogTitle, 16 | DialogTrigger, 17 | } from "@/components/ui/dialog" 18 | import useAuth from "@/hooks/useAuth" 19 | import useCustomToast from "@/hooks/useCustomToast" 20 | import { handleError } from "@/utils" 21 | 22 | const DeleteConfirmation = () => { 23 | const [isOpen, setIsOpen] = useState(false) 24 | const queryClient = useQueryClient() 25 | const { showSuccessToast } = useCustomToast() 26 | const { 27 | handleSubmit, 28 | formState: { isSubmitting }, 29 | } = useForm() 30 | const { logout } = useAuth() 31 | 32 | const mutation = useMutation({ 33 | mutationFn: () => UsersService.deleteUserMe(), 34 | onSuccess: () => { 35 | showSuccessToast("Your account has been successfully deleted") 36 | setIsOpen(false) 37 | logout() 38 | }, 39 | onError: (err: ApiError) => { 40 | handleError(err) 41 | }, 42 | onSettled: () => { 43 | queryClient.invalidateQueries({ queryKey: ["currentUser"] }) 44 | }, 45 | }) 46 | 47 | const onSubmit = async () => { 48 | mutation.mutate() 49 | } 50 | 51 | return ( 52 | <> 53 | setIsOpen(open)} 59 | > 60 | 61 | 64 | 65 | 66 | 67 |
68 | 69 | 70 | Confirmation Required 71 | 72 | 73 | 74 | All your account data will be{" "} 75 | permanently deleted. If you are sure, please 76 | click "Confirm" to proceed. This action cannot 77 | be undone. 78 | 79 | 80 | 81 | 82 | 83 | 84 | 91 | 92 | 100 | 101 | 102 | 103 |
104 |
105 | 106 | ) 107 | } 108 | 109 | export default DeleteConfirmation 110 | -------------------------------------------------------------------------------- /frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react" 2 | import { 3 | AbsoluteCenter, 4 | Button as ChakraButton, 5 | Span, 6 | Spinner, 7 | } from "@chakra-ui/react" 8 | import * as React from "react" 9 | 10 | interface ButtonLoadingProps { 11 | loading?: boolean 12 | loadingText?: React.ReactNode 13 | } 14 | 15 | export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {} 16 | 17 | export const Button = React.forwardRef( 18 | function Button(props, ref) { 19 | const { loading, disabled, loadingText, children, ...rest } = props 20 | return ( 21 | 22 | {loading && !loadingText ? ( 23 | <> 24 | 25 | 26 | 27 | {children} 28 | 29 | ) : loading && loadingText ? ( 30 | <> 31 | 32 | {loadingText} 33 | 34 | ) : ( 35 | children 36 | )} 37 | 38 | ) 39 | }, 40 | ) 41 | -------------------------------------------------------------------------------- /frontend/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox as ChakraCheckbox } from "@chakra-ui/react" 2 | import * as React from "react" 3 | 4 | export interface CheckboxProps extends ChakraCheckbox.RootProps { 5 | icon?: React.ReactNode 6 | inputProps?: React.InputHTMLAttributes 7 | rootRef?: React.Ref 8 | } 9 | 10 | export const Checkbox = React.forwardRef( 11 | function Checkbox(props, ref) { 12 | const { icon, children, inputProps, rootRef, ...rest } = props 13 | return ( 14 | 15 | 16 | 17 | {icon || } 18 | 19 | {children != null && ( 20 | {children} 21 | )} 22 | 23 | ) 24 | }, 25 | ) 26 | -------------------------------------------------------------------------------- /frontend/src/components/ui/close-button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from "@chakra-ui/react" 2 | import { IconButton as ChakraIconButton } from "@chakra-ui/react" 3 | import * as React from "react" 4 | import { LuX } from "react-icons/lu" 5 | 6 | export type CloseButtonProps = ButtonProps 7 | 8 | export const CloseButton = React.forwardRef< 9 | HTMLButtonElement, 10 | CloseButtonProps 11 | >(function CloseButton(props, ref) { 12 | return ( 13 | 14 | {props.children ?? } 15 | 16 | ) 17 | }) 18 | -------------------------------------------------------------------------------- /frontend/src/components/ui/color-mode.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type { IconButtonProps, SpanProps } from "@chakra-ui/react" 4 | import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react" 5 | import { ThemeProvider, useTheme } from "next-themes" 6 | import type { ThemeProviderProps } from "next-themes" 7 | import * as React from "react" 8 | import { LuMoon, LuSun } from "react-icons/lu" 9 | 10 | export interface ColorModeProviderProps extends ThemeProviderProps {} 11 | 12 | export function ColorModeProvider(props: ColorModeProviderProps) { 13 | return ( 14 | 15 | ) 16 | } 17 | 18 | export type ColorMode = "light" | "dark" 19 | 20 | export interface UseColorModeReturn { 21 | colorMode: ColorMode 22 | setColorMode: (colorMode: ColorMode) => void 23 | toggleColorMode: () => void 24 | } 25 | 26 | export function useColorMode(): UseColorModeReturn { 27 | const { resolvedTheme, setTheme } = useTheme() 28 | const toggleColorMode = () => { 29 | setTheme(resolvedTheme === "dark" ? "light" : "dark") 30 | } 31 | return { 32 | colorMode: resolvedTheme as ColorMode, 33 | setColorMode: setTheme, 34 | toggleColorMode, 35 | } 36 | } 37 | 38 | export function useColorModeValue(light: T, dark: T) { 39 | const { colorMode } = useColorMode() 40 | return colorMode === "dark" ? dark : light 41 | } 42 | 43 | export function ColorModeIcon() { 44 | const { colorMode } = useColorMode() 45 | return colorMode === "dark" ? : 46 | } 47 | 48 | interface ColorModeButtonProps extends Omit {} 49 | 50 | export const ColorModeButton = React.forwardRef< 51 | HTMLButtonElement, 52 | ColorModeButtonProps 53 | >(function ColorModeButton(props, ref) { 54 | const { toggleColorMode } = useColorMode() 55 | return ( 56 | }> 57 | 71 | 72 | 73 | 74 | ) 75 | }) 76 | 77 | export const LightMode = React.forwardRef( 78 | function LightMode(props, ref) { 79 | return ( 80 | 89 | ) 90 | }, 91 | ) 92 | 93 | export const DarkMode = React.forwardRef( 94 | function DarkMode(props, ref) { 95 | return ( 96 | 105 | ) 106 | }, 107 | ) 108 | -------------------------------------------------------------------------------- /frontend/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react" 2 | import * as React from "react" 3 | import { CloseButton } from "./close-button" 4 | 5 | interface DialogContentProps extends ChakraDialog.ContentProps { 6 | portalled?: boolean 7 | portalRef?: React.RefObject 8 | backdrop?: boolean 9 | } 10 | 11 | export const DialogContent = React.forwardRef< 12 | HTMLDivElement, 13 | DialogContentProps 14 | >(function DialogContent(props, ref) { 15 | const { 16 | children, 17 | portalled = true, 18 | portalRef, 19 | backdrop = true, 20 | ...rest 21 | } = props 22 | 23 | return ( 24 | 25 | {backdrop && } 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | ) 33 | }) 34 | 35 | export const DialogCloseTrigger = React.forwardRef< 36 | HTMLButtonElement, 37 | ChakraDialog.CloseTriggerProps 38 | >(function DialogCloseTrigger(props, ref) { 39 | return ( 40 | 47 | 48 | {props.children} 49 | 50 | 51 | ) 52 | }) 53 | 54 | export const DialogRoot = ChakraDialog.Root 55 | export const DialogFooter = ChakraDialog.Footer 56 | export const DialogHeader = ChakraDialog.Header 57 | export const DialogBody = ChakraDialog.Body 58 | export const DialogBackdrop = ChakraDialog.Backdrop 59 | export const DialogTitle = ChakraDialog.Title 60 | export const DialogDescription = ChakraDialog.Description 61 | export const DialogTrigger = ChakraDialog.Trigger 62 | export const DialogActionTrigger = ChakraDialog.ActionTrigger 63 | -------------------------------------------------------------------------------- /frontend/src/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | import { Drawer as ChakraDrawer, Portal } from "@chakra-ui/react" 2 | import * as React from "react" 3 | import { CloseButton } from "./close-button" 4 | 5 | interface DrawerContentProps extends ChakraDrawer.ContentProps { 6 | portalled?: boolean 7 | portalRef?: React.RefObject 8 | offset?: ChakraDrawer.ContentProps["padding"] 9 | } 10 | 11 | export const DrawerContent = React.forwardRef< 12 | HTMLDivElement, 13 | DrawerContentProps 14 | >(function DrawerContent(props, ref) { 15 | const { children, portalled = true, portalRef, offset, ...rest } = props 16 | return ( 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | 24 | ) 25 | }) 26 | 27 | export const DrawerCloseTrigger = React.forwardRef< 28 | HTMLButtonElement, 29 | ChakraDrawer.CloseTriggerProps 30 | >(function DrawerCloseTrigger(props, ref) { 31 | return ( 32 | 39 | 40 | 41 | ) 42 | }) 43 | 44 | export const DrawerTrigger = ChakraDrawer.Trigger 45 | export const DrawerRoot = ChakraDrawer.Root 46 | export const DrawerFooter = ChakraDrawer.Footer 47 | export const DrawerHeader = ChakraDrawer.Header 48 | export const DrawerBody = ChakraDrawer.Body 49 | export const DrawerBackdrop = ChakraDrawer.Backdrop 50 | export const DrawerDescription = ChakraDrawer.Description 51 | export const DrawerTitle = ChakraDrawer.Title 52 | export const DrawerActionTrigger = ChakraDrawer.ActionTrigger 53 | -------------------------------------------------------------------------------- /frontend/src/components/ui/field.tsx: -------------------------------------------------------------------------------- 1 | import { Field as ChakraField } from "@chakra-ui/react" 2 | import * as React from "react" 3 | 4 | export interface FieldProps extends Omit { 5 | label?: React.ReactNode 6 | helperText?: React.ReactNode 7 | errorText?: React.ReactNode 8 | optionalText?: React.ReactNode 9 | } 10 | 11 | export const Field = React.forwardRef( 12 | function Field(props, ref) { 13 | const { label, children, helperText, errorText, optionalText, ...rest } = 14 | props 15 | return ( 16 | 17 | {label && ( 18 | 19 | {label} 20 | 21 | 22 | )} 23 | {children} 24 | {helperText && ( 25 | {helperText} 26 | )} 27 | {errorText && ( 28 | {errorText} 29 | )} 30 | 31 | ) 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /frontend/src/components/ui/input-group.tsx: -------------------------------------------------------------------------------- 1 | import type { BoxProps, InputElementProps } from "@chakra-ui/react" 2 | import { Group, InputElement } from "@chakra-ui/react" 3 | import * as React from "react" 4 | 5 | export interface InputGroupProps extends BoxProps { 6 | startElementProps?: InputElementProps 7 | endElementProps?: InputElementProps 8 | startElement?: React.ReactNode 9 | endElement?: React.ReactNode 10 | children: React.ReactElement 11 | startOffset?: InputElementProps["paddingStart"] 12 | endOffset?: InputElementProps["paddingEnd"] 13 | } 14 | 15 | export const InputGroup = React.forwardRef( 16 | function InputGroup(props, ref) { 17 | const { 18 | startElement, 19 | startElementProps, 20 | endElement, 21 | endElementProps, 22 | children, 23 | startOffset = "6px", 24 | endOffset = "6px", 25 | ...rest 26 | } = props 27 | 28 | const child = 29 | React.Children.only>(children) 30 | 31 | return ( 32 | 33 | {startElement && ( 34 | 35 | {startElement} 36 | 37 | )} 38 | {React.cloneElement(child, { 39 | ...(startElement && { 40 | ps: `calc(var(--input-height) - ${startOffset})`, 41 | }), 42 | ...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }), 43 | ...children.props, 44 | })} 45 | {endElement && ( 46 | 47 | {endElement} 48 | 49 | )} 50 | 51 | ) 52 | }, 53 | ) 54 | -------------------------------------------------------------------------------- /frontend/src/components/ui/link-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react" 4 | import { createRecipeContext } from "@chakra-ui/react" 5 | 6 | export interface LinkButtonProps 7 | extends HTMLChakraProps<"a", RecipeProps<"button">> {} 8 | 9 | const { withContext } = createRecipeContext({ key: "button" }) 10 | 11 | // Replace "a" with your framework's link component 12 | export const LinkButton = withContext("a") 13 | -------------------------------------------------------------------------------- /frontend/src/components/ui/menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react" 4 | import * as React from "react" 5 | import { LuCheck, LuChevronRight } from "react-icons/lu" 6 | 7 | interface MenuContentProps extends ChakraMenu.ContentProps { 8 | portalled?: boolean 9 | portalRef?: React.RefObject 10 | } 11 | 12 | export const MenuContent = React.forwardRef( 13 | function MenuContent(props, ref) { 14 | const { portalled = true, portalRef, ...rest } = props 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | }, 23 | ) 24 | 25 | export const MenuArrow = React.forwardRef< 26 | HTMLDivElement, 27 | ChakraMenu.ArrowProps 28 | >(function MenuArrow(props, ref) { 29 | return ( 30 | 31 | 32 | 33 | ) 34 | }) 35 | 36 | export const MenuCheckboxItem = React.forwardRef< 37 | HTMLDivElement, 38 | ChakraMenu.CheckboxItemProps 39 | >(function MenuCheckboxItem(props, ref) { 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | {props.children} 48 | 49 | ) 50 | }) 51 | 52 | export const MenuRadioItem = React.forwardRef< 53 | HTMLDivElement, 54 | ChakraMenu.RadioItemProps 55 | >(function MenuRadioItem(props, ref) { 56 | const { children, ...rest } = props 57 | return ( 58 | 59 | 60 | 61 | 62 | 63 | 64 | {children} 65 | 66 | ) 67 | }) 68 | 69 | export const MenuItemGroup = React.forwardRef< 70 | HTMLDivElement, 71 | ChakraMenu.ItemGroupProps 72 | >(function MenuItemGroup(props, ref) { 73 | const { title, children, ...rest } = props 74 | return ( 75 | 76 | {title && ( 77 | 78 | {title} 79 | 80 | )} 81 | {children} 82 | 83 | ) 84 | }) 85 | 86 | export interface MenuTriggerItemProps extends ChakraMenu.ItemProps { 87 | startIcon?: React.ReactNode 88 | } 89 | 90 | export const MenuTriggerItem = React.forwardRef< 91 | HTMLDivElement, 92 | MenuTriggerItemProps 93 | >(function MenuTriggerItem(props, ref) { 94 | const { startIcon, children, ...rest } = props 95 | return ( 96 | 97 | {startIcon} 98 | {children} 99 | 100 | 101 | ) 102 | }) 103 | 104 | export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup 105 | export const MenuContextTrigger = ChakraMenu.ContextTrigger 106 | export const MenuRoot = ChakraMenu.Root 107 | export const MenuSeparator = ChakraMenu.Separator 108 | 109 | export const MenuItem = ChakraMenu.Item 110 | export const MenuItemText = ChakraMenu.ItemText 111 | export const MenuItemCommand = ChakraMenu.ItemCommand 112 | export const MenuTrigger = ChakraMenu.Trigger 113 | -------------------------------------------------------------------------------- /frontend/src/components/ui/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ChakraProvider } from "@chakra-ui/react" 4 | import React, { type PropsWithChildren } from "react" 5 | import { system } from "../../theme" 6 | import { ColorModeProvider } from "./color-mode" 7 | import { Toaster } from "./toaster" 8 | 9 | export function CustomProvider(props: PropsWithChildren) { 10 | return ( 11 | 12 | 13 | {props.children} 14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/components/ui/radio.tsx: -------------------------------------------------------------------------------- 1 | import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react" 2 | import * as React from "react" 3 | 4 | export interface RadioProps extends ChakraRadioGroup.ItemProps { 5 | rootRef?: React.Ref 6 | inputProps?: React.InputHTMLAttributes 7 | } 8 | 9 | export const Radio = React.forwardRef( 10 | function Radio(props, ref) { 11 | const { children, inputProps, rootRef, ...rest } = props 12 | return ( 13 | 14 | 15 | 16 | {children && ( 17 | {children} 18 | )} 19 | 20 | ) 21 | }, 22 | ) 23 | 24 | export const RadioGroup = ChakraRadioGroup.Root 25 | -------------------------------------------------------------------------------- /frontend/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | SkeletonProps as ChakraSkeletonProps, 3 | CircleProps, 4 | } from "@chakra-ui/react" 5 | import { Skeleton as ChakraSkeleton, Circle, Stack } from "@chakra-ui/react" 6 | import * as React from "react" 7 | 8 | export interface SkeletonCircleProps extends ChakraSkeletonProps { 9 | size?: CircleProps["size"] 10 | } 11 | 12 | export const SkeletonCircle = React.forwardRef< 13 | HTMLDivElement, 14 | SkeletonCircleProps 15 | >(function SkeletonCircle(props, ref) { 16 | const { size, ...rest } = props 17 | return ( 18 | 19 | 20 | 21 | ) 22 | }) 23 | 24 | export interface SkeletonTextProps extends ChakraSkeletonProps { 25 | noOfLines?: number 26 | } 27 | 28 | export const SkeletonText = React.forwardRef( 29 | function SkeletonText(props, ref) { 30 | const { noOfLines = 3, gap, ...rest } = props 31 | return ( 32 | 33 | {Array.from({ length: noOfLines }).map((_, index) => ( 34 | 41 | ))} 42 | 43 | ) 44 | }, 45 | ) 46 | 47 | export const Skeleton = ChakraSkeleton 48 | -------------------------------------------------------------------------------- /frontend/src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Toaster as ChakraToaster, 5 | Portal, 6 | Spinner, 7 | Stack, 8 | Toast, 9 | createToaster, 10 | } from "@chakra-ui/react" 11 | 12 | export const toaster = createToaster({ 13 | placement: "top-end", 14 | pauseOnPageIdle: true, 15 | }) 16 | 17 | export const Toaster = () => { 18 | return ( 19 | 20 | 21 | {(toast) => ( 22 | 23 | {toast.type === "loading" ? ( 24 | 25 | ) : ( 26 | 27 | )} 28 | 29 | {toast.title && {toast.title}} 30 | {toast.description && ( 31 | {toast.description} 32 | )} 33 | 34 | {toast.action && ( 35 | {toast.action.label} 36 | )} 37 | {toast.meta?.closable && } 38 | 39 | )} 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" 2 | import { useNavigate } from "@tanstack/react-router" 3 | import { useState } from "react" 4 | 5 | import { 6 | type Body_login_login_access_token as AccessToken, 7 | type ApiError, 8 | LoginService, 9 | type UserPublic, 10 | type UserRegister, 11 | UsersService, 12 | } from "@/client" 13 | import { handleError } from "@/utils" 14 | 15 | const isLoggedIn = () => { 16 | return localStorage.getItem("access_token") !== null 17 | } 18 | 19 | const useAuth = () => { 20 | const [error, setError] = useState(null) 21 | const navigate = useNavigate() 22 | const queryClient = useQueryClient() 23 | const { data: user } = useQuery({ 24 | queryKey: ["currentUser"], 25 | queryFn: UsersService.readUserMe, 26 | enabled: isLoggedIn(), 27 | }) 28 | 29 | const signUpMutation = useMutation({ 30 | mutationFn: (data: UserRegister) => 31 | UsersService.registerUser({ requestBody: data }), 32 | 33 | onSuccess: () => { 34 | navigate({ to: "/login" }) 35 | }, 36 | onError: (err: ApiError) => { 37 | handleError(err) 38 | }, 39 | onSettled: () => { 40 | queryClient.invalidateQueries({ queryKey: ["users"] }) 41 | }, 42 | }) 43 | 44 | const login = async (data: AccessToken) => { 45 | const response = await LoginService.loginAccessToken({ 46 | formData: data, 47 | }) 48 | localStorage.setItem("access_token", response.access_token) 49 | } 50 | 51 | const loginMutation = useMutation({ 52 | mutationFn: login, 53 | onSuccess: () => { 54 | navigate({ to: "/" }) 55 | }, 56 | onError: (err: ApiError) => { 57 | handleError(err) 58 | }, 59 | }) 60 | 61 | const logout = () => { 62 | localStorage.removeItem("access_token") 63 | navigate({ to: "/login" }) 64 | } 65 | 66 | return { 67 | signUpMutation, 68 | loginMutation, 69 | logout, 70 | user, 71 | error, 72 | resetError: () => setError(null), 73 | } 74 | } 75 | 76 | export { isLoggedIn } 77 | export default useAuth 78 | -------------------------------------------------------------------------------- /frontend/src/hooks/useCustomToast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { toaster } from "@/components/ui/toaster" 4 | 5 | const useCustomToast = () => { 6 | const showSuccessToast = (description: string) => { 7 | toaster.create({ 8 | title: "Success!", 9 | description, 10 | type: "success", 11 | }) 12 | } 13 | 14 | const showErrorToast = (description: string) => { 15 | toaster.create({ 16 | title: "Something went wrong!", 17 | description, 18 | type: "error", 19 | }) 20 | } 21 | 22 | return { showSuccessToast, showErrorToast } 23 | } 24 | 25 | export default useCustomToast 26 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MutationCache, 3 | QueryCache, 4 | QueryClient, 5 | QueryClientProvider, 6 | } from "@tanstack/react-query" 7 | import { RouterProvider, createRouter } from "@tanstack/react-router" 8 | import React, { StrictMode } from "react" 9 | import ReactDOM from "react-dom/client" 10 | import { routeTree } from "./routeTree.gen" 11 | 12 | import { ApiError, OpenAPI } from "./client" 13 | import { CustomProvider } from "./components/ui/provider" 14 | 15 | OpenAPI.BASE = import.meta.env.VITE_API_URL 16 | OpenAPI.TOKEN = async () => { 17 | return localStorage.getItem("access_token") || "" 18 | } 19 | 20 | const handleApiError = (error: Error) => { 21 | if (error instanceof ApiError && [401, 403].includes(error.status)) { 22 | localStorage.removeItem("access_token") 23 | window.location.href = "/login" 24 | } 25 | } 26 | const queryClient = new QueryClient({ 27 | queryCache: new QueryCache({ 28 | onError: handleApiError, 29 | }), 30 | mutationCache: new MutationCache({ 31 | onError: handleApiError, 32 | }), 33 | }) 34 | 35 | const router = createRouter({ routeTree }) 36 | declare module "@tanstack/react-router" { 37 | interface Register { 38 | router: typeof router 39 | } 40 | } 41 | 42 | ReactDOM.createRoot(document.getElementById("root")!).render( 43 | 44 | 45 | 46 | 47 | 48 | 49 | , 50 | ) 51 | -------------------------------------------------------------------------------- /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 SignupImport } from './routes/signup' 15 | import { Route as ResetPasswordImport } from './routes/reset-password' 16 | import { Route as RecoverPasswordImport } from './routes/recover-password' 17 | import { Route as LoginImport } from './routes/login' 18 | import { Route as LayoutImport } from './routes/_layout' 19 | import { Route as LayoutIndexImport } from './routes/_layout/index' 20 | import { Route as LayoutSettingsImport } from './routes/_layout/settings' 21 | import { Route as LayoutItemsImport } from './routes/_layout/items' 22 | import { Route as LayoutAdminImport } from './routes/_layout/admin' 23 | 24 | // Create/Update Routes 25 | 26 | const SignupRoute = SignupImport.update({ 27 | path: '/signup', 28 | getParentRoute: () => rootRoute, 29 | } as any) 30 | 31 | const ResetPasswordRoute = ResetPasswordImport.update({ 32 | path: '/reset-password', 33 | getParentRoute: () => rootRoute, 34 | } as any) 35 | 36 | const RecoverPasswordRoute = RecoverPasswordImport.update({ 37 | path: '/recover-password', 38 | getParentRoute: () => rootRoute, 39 | } as any) 40 | 41 | const LoginRoute = LoginImport.update({ 42 | path: '/login', 43 | getParentRoute: () => rootRoute, 44 | } as any) 45 | 46 | const LayoutRoute = LayoutImport.update({ 47 | id: '/_layout', 48 | getParentRoute: () => rootRoute, 49 | } as any) 50 | 51 | const LayoutIndexRoute = LayoutIndexImport.update({ 52 | path: '/', 53 | getParentRoute: () => LayoutRoute, 54 | } as any) 55 | 56 | const LayoutSettingsRoute = LayoutSettingsImport.update({ 57 | path: '/settings', 58 | getParentRoute: () => LayoutRoute, 59 | } as any) 60 | 61 | const LayoutItemsRoute = LayoutItemsImport.update({ 62 | path: '/items', 63 | getParentRoute: () => LayoutRoute, 64 | } as any) 65 | 66 | const LayoutAdminRoute = LayoutAdminImport.update({ 67 | path: '/admin', 68 | getParentRoute: () => LayoutRoute, 69 | } as any) 70 | 71 | // Populate the FileRoutesByPath interface 72 | 73 | declare module '@tanstack/react-router' { 74 | interface FileRoutesByPath { 75 | '/_layout': { 76 | preLoaderRoute: typeof LayoutImport 77 | parentRoute: typeof rootRoute 78 | } 79 | '/login': { 80 | preLoaderRoute: typeof LoginImport 81 | parentRoute: typeof rootRoute 82 | } 83 | '/recover-password': { 84 | preLoaderRoute: typeof RecoverPasswordImport 85 | parentRoute: typeof rootRoute 86 | } 87 | '/reset-password': { 88 | preLoaderRoute: typeof ResetPasswordImport 89 | parentRoute: typeof rootRoute 90 | } 91 | '/signup': { 92 | preLoaderRoute: typeof SignupImport 93 | parentRoute: typeof rootRoute 94 | } 95 | '/_layout/admin': { 96 | preLoaderRoute: typeof LayoutAdminImport 97 | parentRoute: typeof LayoutImport 98 | } 99 | '/_layout/items': { 100 | preLoaderRoute: typeof LayoutItemsImport 101 | parentRoute: typeof LayoutImport 102 | } 103 | '/_layout/settings': { 104 | preLoaderRoute: typeof LayoutSettingsImport 105 | parentRoute: typeof LayoutImport 106 | } 107 | '/_layout/': { 108 | preLoaderRoute: typeof LayoutIndexImport 109 | parentRoute: typeof LayoutImport 110 | } 111 | } 112 | } 113 | 114 | // Create and export the route tree 115 | 116 | export const routeTree = rootRoute.addChildren([ 117 | LayoutRoute.addChildren([ 118 | LayoutAdminRoute, 119 | LayoutItemsRoute, 120 | LayoutSettingsRoute, 121 | LayoutIndexRoute, 122 | ]), 123 | LoginRoute, 124 | RecoverPasswordRoute, 125 | ResetPasswordRoute, 126 | SignupRoute, 127 | ]) 128 | 129 | /* prettier-ignore-end */ 130 | -------------------------------------------------------------------------------- /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 } from "@chakra-ui/react" 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router" 3 | 4 | import Navbar from "@/components/Common/Navbar" 5 | import Sidebar from "@/components/Common/Sidebar" 6 | import { 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 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | export default Layout 34 | -------------------------------------------------------------------------------- /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/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading, Tabs } from "@chakra-ui/react" 2 | import { createFileRoute } from "@tanstack/react-router" 3 | 4 | import Appearance from "@/components/UserSettings/Appearance" 5 | import ChangePassword from "@/components/UserSettings/ChangePassword" 6 | import DeleteAccount from "@/components/UserSettings/DeleteAccount" 7 | import UserInformation from "@/components/UserSettings/UserInformation" 8 | import useAuth from "@/hooks/useAuth" 9 | 10 | const tabsConfig = [ 11 | { value: "my-profile", title: "My profile", component: UserInformation }, 12 | { value: "password", title: "Password", component: ChangePassword }, 13 | { value: "appearance", title: "Appearance", component: Appearance }, 14 | { value: "danger-zone", title: "Danger zone", component: DeleteAccount }, 15 | ] 16 | 17 | export const Route = createFileRoute("/_layout/settings")({ 18 | component: UserSettings, 19 | }) 20 | 21 | function UserSettings() { 22 | const { user: currentUser } = useAuth() 23 | const finalTabs = currentUser?.is_superuser 24 | ? tabsConfig.slice(0, 3) 25 | : tabsConfig 26 | 27 | if (!currentUser) { 28 | return null 29 | } 30 | 31 | return ( 32 | 33 | 34 | User Settings 35 | 36 | 37 | 38 | 39 | {finalTabs.map((tab) => ( 40 | 41 | {tab.title} 42 | 43 | ))} 44 | 45 | {finalTabs.map((tab) => ( 46 | 47 | 48 | 49 | ))} 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Image, Input, Text } from "@chakra-ui/react" 2 | import { 3 | Link as RouterLink, 4 | createFileRoute, 5 | redirect, 6 | } from "@tanstack/react-router" 7 | import { type SubmitHandler, useForm } from "react-hook-form" 8 | import { FiLock, FiMail } from "react-icons/fi" 9 | 10 | import type { Body_login_login_access_token as AccessToken } from "@/client" 11 | import { Button } from "@/components/ui/button" 12 | import { Field } from "@/components/ui/field" 13 | import { InputGroup } from "@/components/ui/input-group" 14 | import { PasswordInput } from "@/components/ui/password-input" 15 | import useAuth, { isLoggedIn } from "@/hooks/useAuth" 16 | import Logo from "/assets/images/fastapi-logo.svg" 17 | import { emailPattern, passwordRules } from "../utils" 18 | 19 | export const Route = createFileRoute("/login")({ 20 | component: Login, 21 | beforeLoad: async () => { 22 | if (isLoggedIn()) { 23 | throw redirect({ 24 | to: "/", 25 | }) 26 | } 27 | }, 28 | }) 29 | 30 | function Login() { 31 | const { loginMutation, error, resetError } = useAuth() 32 | const { 33 | register, 34 | handleSubmit, 35 | formState: { errors, isSubmitting }, 36 | } = useForm({ 37 | mode: "onBlur", 38 | criteriaMode: "all", 39 | defaultValues: { 40 | username: "", 41 | password: "", 42 | }, 43 | }) 44 | 45 | const onSubmit: SubmitHandler = async (data) => { 46 | if (isSubmitting) return 47 | 48 | resetError() 49 | 50 | try { 51 | await loginMutation.mutateAsync(data) 52 | } catch { 53 | // error is handled by useAuth hook 54 | } 55 | } 56 | 57 | return ( 58 | <> 59 | 69 | FastAPI logo 77 | 81 | }> 82 | 91 | 92 | 93 | } 96 | {...register("password", passwordRules())} 97 | placeholder="Password" 98 | errors={errors} 99 | /> 100 | 101 | Forgot Password? 102 | 103 | 106 | 107 | Don't have an account?{" "} 108 | 109 | Sign Up 110 | 111 | 112 | 113 | 114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /frontend/src/routes/recover-password.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading, Input, Text } from "@chakra-ui/react" 2 | import { useMutation } from "@tanstack/react-query" 3 | import { createFileRoute, redirect } from "@tanstack/react-router" 4 | import { type SubmitHandler, useForm } from "react-hook-form" 5 | import { FiMail } from "react-icons/fi" 6 | 7 | import { type ApiError, LoginService } from "@/client" 8 | import { Button } from "@/components/ui/button" 9 | import { Field } from "@/components/ui/field" 10 | import { InputGroup } from "@/components/ui/input-group" 11 | import { isLoggedIn } from "@/hooks/useAuth" 12 | import useCustomToast from "@/hooks/useCustomToast" 13 | import { emailPattern, handleError } from "@/utils" 14 | 15 | interface FormData { 16 | email: string 17 | } 18 | 19 | export const Route = createFileRoute("/recover-password")({ 20 | component: RecoverPassword, 21 | beforeLoad: async () => { 22 | if (isLoggedIn()) { 23 | throw redirect({ 24 | to: "/", 25 | }) 26 | } 27 | }, 28 | }) 29 | 30 | function RecoverPassword() { 31 | const { 32 | register, 33 | handleSubmit, 34 | reset, 35 | formState: { errors, isSubmitting }, 36 | } = useForm() 37 | const { showSuccessToast } = useCustomToast() 38 | 39 | const recoverPassword = async (data: FormData) => { 40 | await LoginService.recoverPassword({ 41 | email: data.email, 42 | }) 43 | } 44 | 45 | const mutation = useMutation({ 46 | mutationFn: recoverPassword, 47 | onSuccess: () => { 48 | showSuccessToast("Password recovery email sent successfully.") 49 | reset() 50 | }, 51 | onError: (err: ApiError) => { 52 | handleError(err) 53 | }, 54 | }) 55 | 56 | const onSubmit: SubmitHandler = async (data) => { 57 | mutation.mutate(data) 58 | } 59 | 60 | return ( 61 | 71 | 72 | Password Recovery 73 | 74 | 75 | A password recovery email will be sent to the registered account. 76 | 77 | 78 | }> 79 | 88 | 89 | 90 | 93 | 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /frontend/src/routes/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading, Text } from "@chakra-ui/react" 2 | import { useMutation } from "@tanstack/react-query" 3 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" 4 | import { type SubmitHandler, useForm } from "react-hook-form" 5 | import { FiLock } from "react-icons/fi" 6 | 7 | import { type ApiError, LoginService, type NewPassword } from "@/client" 8 | import { Button } from "@/components/ui/button" 9 | import { PasswordInput } from "@/components/ui/password-input" 10 | import { isLoggedIn } from "@/hooks/useAuth" 11 | import useCustomToast from "@/hooks/useCustomToast" 12 | import { confirmPasswordRules, handleError, passwordRules } from "@/utils" 13 | 14 | interface NewPasswordForm extends NewPassword { 15 | confirm_password: string 16 | } 17 | 18 | export const Route = createFileRoute("/reset-password")({ 19 | component: ResetPassword, 20 | beforeLoad: async () => { 21 | if (isLoggedIn()) { 22 | throw redirect({ 23 | to: "/", 24 | }) 25 | } 26 | }, 27 | }) 28 | 29 | function ResetPassword() { 30 | const { 31 | register, 32 | handleSubmit, 33 | getValues, 34 | reset, 35 | formState: { errors }, 36 | } = useForm({ 37 | mode: "onBlur", 38 | criteriaMode: "all", 39 | defaultValues: { 40 | new_password: "", 41 | }, 42 | }) 43 | const { showSuccessToast } = useCustomToast() 44 | const navigate = useNavigate() 45 | 46 | const resetPassword = async (data: NewPassword) => { 47 | const token = new URLSearchParams(window.location.search).get("token") 48 | if (!token) return 49 | await LoginService.resetPassword({ 50 | requestBody: { new_password: data.new_password, token: token }, 51 | }) 52 | } 53 | 54 | const mutation = useMutation({ 55 | mutationFn: resetPassword, 56 | onSuccess: () => { 57 | showSuccessToast("Password updated successfully.") 58 | reset() 59 | navigate({ to: "/login" }) 60 | }, 61 | onError: (err: ApiError) => { 62 | handleError(err) 63 | }, 64 | }) 65 | 66 | const onSubmit: SubmitHandler = async (data) => { 67 | mutation.mutate(data) 68 | } 69 | 70 | return ( 71 | 81 | 82 | Reset Password 83 | 84 | 85 | Please enter your new password and confirm it to reset your password. 86 | 87 | } 89 | type="new_password" 90 | errors={errors} 91 | {...register("new_password", passwordRules())} 92 | placeholder="New Password" 93 | /> 94 | } 96 | type="confirm_password" 97 | errors={errors} 98 | {...register("confirm_password", confirmPasswordRules(getValues))} 99 | placeholder="Confirm Password" 100 | /> 101 | 104 | 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /frontend/src/theme.tsx: -------------------------------------------------------------------------------- 1 | import { createSystem, defaultConfig } from "@chakra-ui/react" 2 | import { buttonRecipe } from "./theme/button.recipe" 3 | 4 | export const system = createSystem(defaultConfig, { 5 | globalCss: { 6 | html: { 7 | fontSize: "16px", 8 | }, 9 | body: { 10 | fontSize: "0.875rem", 11 | margin: 0, 12 | padding: 0, 13 | }, 14 | ".main-link": { 15 | color: "ui.main", 16 | fontWeight: "bold", 17 | }, 18 | }, 19 | theme: { 20 | tokens: { 21 | colors: { 22 | ui: { 23 | main: { value: "#009688" }, 24 | }, 25 | }, 26 | }, 27 | recipes: { 28 | button: buttonRecipe, 29 | }, 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /frontend/src/theme/button.recipe.ts: -------------------------------------------------------------------------------- 1 | import { defineRecipe } from "@chakra-ui/react" 2 | 3 | export const buttonRecipe = defineRecipe({ 4 | base: { 5 | fontWeight: "bold", 6 | display: "flex", 7 | alignItems: "center", 8 | justifyContent: "center", 9 | colorPalette: "teal", 10 | }, 11 | variants: { 12 | variant: { 13 | ghost: { 14 | bg: "transparent", 15 | _hover: { 16 | bg: "gray.100", 17 | }, 18 | }, 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /frontend/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ApiError } from "./client" 2 | import useCustomToast from "./hooks/useCustomToast" 3 | 4 | export const emailPattern = { 5 | value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, 6 | message: "Invalid email address", 7 | } 8 | 9 | export const namePattern = { 10 | value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/, 11 | message: "Invalid name", 12 | } 13 | 14 | export const passwordRules = (isRequired = true) => { 15 | const rules: any = { 16 | minLength: { 17 | value: 8, 18 | message: "Password must be at least 8 characters", 19 | }, 20 | } 21 | 22 | if (isRequired) { 23 | rules.required = "Password is required" 24 | } 25 | 26 | return rules 27 | } 28 | 29 | export const confirmPasswordRules = ( 30 | getValues: () => any, 31 | isRequired = true, 32 | ) => { 33 | const rules: any = { 34 | validate: (value: string) => { 35 | const password = getValues().password || getValues().new_password 36 | return value === password ? true : "The passwords do not match" 37 | }, 38 | } 39 | 40 | if (isRequired) { 41 | rules.required = "Password confirmation is required" 42 | } 43 | 44 | return rules 45 | } 46 | 47 | export const handleError = (err: ApiError) => { 48 | const { showErrorToast } = useCustomToast() 49 | const errDetail = (err.body as any)?.detail 50 | let errorMessage = errDetail || "Something went wrong." 51 | if (Array.isArray(errDetail) && errDetail.length > 0) { 52 | errorMessage = errDetail[0].msg 53 | } 54 | showErrorToast(errorMessage) 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tests/auth.setup.ts: -------------------------------------------------------------------------------- 1 | import { test as setup } from "@playwright/test" 2 | import { firstSuperuser, firstSuperuserPassword } from "./config.ts" 3 | 4 | const authFile = "playwright/.auth/user.json" 5 | 6 | setup("authenticate", async ({ page }) => { 7 | await page.goto("/login") 8 | await page.getByPlaceholder("Email").fill(firstSuperuser) 9 | await page.getByPlaceholder("Password").fill(firstSuperuserPassword) 10 | await page.getByRole("button", { name: "Log In" }).click() 11 | await page.waitForURL("/") 12 | await page.context().storageState({ path: authFile }) 13 | }) 14 | -------------------------------------------------------------------------------- /frontend/tests/config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import { fileURLToPath } from "node:url" 3 | import dotenv from "dotenv" 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = path.dirname(__filename) 7 | 8 | dotenv.config({ path: path.join(__dirname, "../../.env") }) 9 | 10 | const { FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD } = process.env 11 | 12 | if (typeof FIRST_SUPERUSER !== "string") { 13 | throw new Error("Environment variable FIRST_SUPERUSER is undefined") 14 | } 15 | 16 | if (typeof FIRST_SUPERUSER_PASSWORD !== "string") { 17 | throw new Error("Environment variable FIRST_SUPERUSER_PASSWORD is undefined") 18 | } 19 | 20 | export const firstSuperuser = FIRST_SUPERUSER as string 21 | export const firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string 22 | -------------------------------------------------------------------------------- /frontend/tests/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { type Page, expect, test } from "@playwright/test" 2 | import { firstSuperuser, firstSuperuserPassword } from "./config.ts" 3 | import { randomPassword } from "./utils/random.ts" 4 | 5 | test.use({ storageState: { cookies: [], origins: [] } }) 6 | 7 | type OptionsType = { 8 | exact?: boolean 9 | } 10 | 11 | const fillForm = async (page: Page, email: string, password: string) => { 12 | await page.getByPlaceholder("Email").fill(email) 13 | await page.getByPlaceholder("Password", { exact: true }).fill(password) 14 | } 15 | 16 | const verifyInput = async ( 17 | page: Page, 18 | placeholder: string, 19 | options?: OptionsType, 20 | ) => { 21 | const input = page.getByPlaceholder(placeholder, options) 22 | await expect(input).toBeVisible() 23 | await expect(input).toHaveText("") 24 | await expect(input).toBeEditable() 25 | } 26 | 27 | test("Inputs are visible, empty and editable", async ({ page }) => { 28 | await page.goto("/login") 29 | 30 | await verifyInput(page, "Email") 31 | await verifyInput(page, "Password", { exact: true }) 32 | }) 33 | 34 | test("Log In button is visible", async ({ page }) => { 35 | await page.goto("/login") 36 | 37 | await expect(page.getByRole("button", { name: "Log In" })).toBeVisible() 38 | }) 39 | 40 | test("Forgot Password link is visible", async ({ page }) => { 41 | await page.goto("/login") 42 | 43 | await expect( 44 | page.getByRole("link", { name: "Forgot password?" }), 45 | ).toBeVisible() 46 | }) 47 | 48 | test("Log in with valid email and password ", async ({ page }) => { 49 | await page.goto("/login") 50 | 51 | await fillForm(page, firstSuperuser, firstSuperuserPassword) 52 | await page.getByRole("button", { name: "Log In" }).click() 53 | 54 | await page.waitForURL("/") 55 | 56 | await expect( 57 | page.getByText("Welcome back, nice to see you again!"), 58 | ).toBeVisible() 59 | }) 60 | 61 | test("Log in with invalid email", async ({ page }) => { 62 | await page.goto("/login") 63 | 64 | await fillForm(page, "invalidemail", firstSuperuserPassword) 65 | await page.getByRole("button", { name: "Log In" }).click() 66 | 67 | await expect(page.getByText("Invalid email address")).toBeVisible() 68 | }) 69 | 70 | test("Log in with invalid password", async ({ page }) => { 71 | const password = randomPassword() 72 | 73 | await page.goto("/login") 74 | await fillForm(page, firstSuperuser, password) 75 | await page.getByRole("button", { name: "Log In" }).click() 76 | 77 | await expect(page.getByText("Incorrect email or password")).toBeVisible() 78 | }) 79 | 80 | // Log out 81 | 82 | test("Successful log out", async ({ page }) => { 83 | await page.goto("/login") 84 | 85 | await fillForm(page, firstSuperuser, firstSuperuserPassword) 86 | await page.getByRole("button", { name: "Log In" }).click() 87 | 88 | await page.waitForURL("/") 89 | 90 | await expect( 91 | page.getByText("Welcome back, nice to see you again!"), 92 | ).toBeVisible() 93 | 94 | await page.getByTestId("user-menu").click() 95 | await page.getByRole("menuitem", { name: "Log out" }).click() 96 | await page.waitForURL("/login") 97 | }) 98 | 99 | test("Logged-out user cannot access protected routes", async ({ page }) => { 100 | await page.goto("/login") 101 | 102 | await fillForm(page, firstSuperuser, firstSuperuserPassword) 103 | await page.getByRole("button", { name: "Log In" }).click() 104 | 105 | await page.waitForURL("/") 106 | 107 | await expect( 108 | page.getByText("Welcome back, nice to see you again!"), 109 | ).toBeVisible() 110 | 111 | await page.getByTestId("user-menu").click() 112 | await page.getByRole("menuitem", { name: "Log out" }).click() 113 | await page.waitForURL("/login") 114 | 115 | await page.goto("/settings") 116 | await page.waitForURL("/login") 117 | }) 118 | 119 | test("Redirects to /login when token is wrong", async ({ page }) => { 120 | await page.goto("/settings") 121 | await page.evaluate(() => { 122 | localStorage.setItem("access_token", "invalid_token") 123 | }) 124 | await page.goto("/settings") 125 | await page.waitForURL("/login") 126 | await expect(page).toHaveURL("/login") 127 | }) 128 | -------------------------------------------------------------------------------- /frontend/tests/utils/mailcatcher.ts: -------------------------------------------------------------------------------- 1 | import type { APIRequestContext } from "@playwright/test" 2 | 3 | type Email = { 4 | id: number 5 | recipients: string[] 6 | subject: string 7 | } 8 | 9 | async function findEmail({ 10 | request, 11 | filter, 12 | }: { request: APIRequestContext; filter?: (email: Email) => boolean }) { 13 | const response = await request.get(`${process.env.MAILCATCHER_HOST}/messages`) 14 | 15 | let emails = await response.json() 16 | 17 | if (filter) { 18 | emails = emails.filter(filter) 19 | } 20 | 21 | const email = emails[emails.length - 1] 22 | 23 | if (email) { 24 | return email as Email 25 | } 26 | 27 | return null 28 | } 29 | 30 | export function findLastEmail({ 31 | request, 32 | filter, 33 | timeout = 5000, 34 | }: { 35 | request: APIRequestContext 36 | filter?: (email: Email) => boolean 37 | timeout?: number 38 | }) { 39 | const timeoutPromise = new Promise((_, reject) => 40 | setTimeout( 41 | () => reject(new Error("Timeout while trying to get latest email")), 42 | timeout, 43 | ), 44 | ) 45 | 46 | const checkEmails = async () => { 47 | while (true) { 48 | const emailData = await findEmail({ request, filter }) 49 | 50 | if (emailData) { 51 | return emailData 52 | } 53 | // Wait for 100ms before checking again 54 | await new Promise((resolve) => setTimeout(resolve, 100)) 55 | } 56 | } 57 | 58 | return Promise.race([timeoutPromise, checkEmails()]) 59 | } 60 | -------------------------------------------------------------------------------- /frontend/tests/utils/privateApi.ts: -------------------------------------------------------------------------------- 1 | // Note: the `PrivateService` is only available when generating the client 2 | // for local environments 3 | import { OpenAPI, PrivateService } from "../../src/client" 4 | 5 | OpenAPI.BASE = `${process.env.VITE_API_URL}` 6 | 7 | export const createUser = async ({ 8 | email, 9 | password, 10 | }: { 11 | email: string 12 | password: string 13 | }) => { 14 | return await PrivateService.createUser({ 15 | requestBody: { 16 | email, 17 | password, 18 | is_verified: true, 19 | full_name: "Test User", 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /frontend/tests/utils/random.ts: -------------------------------------------------------------------------------- 1 | export const randomEmail = () => 2 | `test_${Math.random().toString(36).substring(7)}@example.com` 3 | 4 | export const randomTeamName = () => 5 | `Team ${Math.random().toString(36).substring(7)}` 6 | 7 | export const randomPassword = () => `${Math.random().toString(36).substring(2)}` 8 | 9 | export const slugify = (text: string) => 10 | text 11 | .toLowerCase() 12 | .replace(/\s+/g, "-") 13 | .replace(/[^\w-]+/g, "") 14 | -------------------------------------------------------------------------------- /frontend/tests/utils/user.ts: -------------------------------------------------------------------------------- 1 | import { type Page, expect } from "@playwright/test" 2 | 3 | export async function signUpNewUser( 4 | page: Page, 5 | name: string, 6 | email: string, 7 | password: string, 8 | ) { 9 | await page.goto("/signup") 10 | 11 | await page.getByPlaceholder("Full Name").fill(name) 12 | await page.getByPlaceholder("Email").fill(email) 13 | await page.getByPlaceholder("Password", { exact: true }).fill(password) 14 | await page.getByPlaceholder("Confirm Password").fill(password) 15 | await page.getByRole("button", { name: "Sign Up" }).click() 16 | await page.goto("/login") 17 | } 18 | 19 | export async function logInUser(page: Page, email: string, password: string) { 20 | await page.goto("/login") 21 | 22 | await page.getByPlaceholder("Email").fill(email) 23 | await page.getByPlaceholder("Password", { exact: true }).fill(password) 24 | await page.getByRole("button", { name: "Log In" }).click() 25 | await page.waitForURL("/") 26 | await expect( 27 | page.getByText("Welcome back, nice to see you again!"), 28 | ).toBeVisible() 29 | } 30 | 31 | export async function logOutUser(page: Page) { 32 | await page.getByTestId("user-menu").click() 33 | await page.getByRole("menuitem", { name: "Log out" }).click() 34 | await page.goto("/login") 35 | } 36 | -------------------------------------------------------------------------------- /frontend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["tests/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /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 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["src/**/*.ts", "tests/**/*.ts", "playwright.config.ts"], 25 | "references": [ 26 | { 27 | "path": "./tsconfig.node.json" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /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 path from "node:path" 2 | import { TanStackRouterVite } from "@tanstack/router-vite-plugin" 3 | import react from "@vitejs/plugin-react-swc" 4 | import { defineConfig } from "vite" 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | plugins: [react(), TanStackRouterVite()], 14 | }) 15 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | path: Path 5 | for path in Path(".").glob("**/*.sh"): 6 | data = path.read_bytes() 7 | lf_data = data.replace(b"\r\n", b"\n") 8 | path.write_bytes(lf_data) 9 | -------------------------------------------------------------------------------- /img/dashboard-create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/dashboard-create.png -------------------------------------------------------------------------------- /img/dashboard-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/dashboard-dark.png -------------------------------------------------------------------------------- /img/dashboard-items.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/dashboard-items.png -------------------------------------------------------------------------------- /img/dashboard-user-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/dashboard-user-settings.png -------------------------------------------------------------------------------- /img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/dashboard.png -------------------------------------------------------------------------------- /img/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/docs.png -------------------------------------------------------------------------------- /img/github-social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/github-social-preview.png -------------------------------------------------------------------------------- /img/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi/full-stack-fastapi-template/6c9b1fa2ce4e046201236d41c71ac15b415717aa/img/login.png -------------------------------------------------------------------------------- /scripts/build-push.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG?Variable not set} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | sh ./scripts/build.sh 9 | 10 | docker-compose -f docker-compose.yml push 11 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG?Variable not set} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | docker-compose \ 9 | -f docker-compose.yml \ 10 | build 11 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | DOMAIN=${DOMAIN?Variable not set} \ 7 | STACK_NAME=${STACK_NAME?Variable not set} \ 8 | TAG=${TAG?Variable not set} \ 9 | docker-compose \ 10 | -f docker-compose.yml \ 11 | config > docker-stack.yml 12 | 13 | docker-auto-labels docker-stack.yml 14 | 15 | docker stack deploy -c docker-stack.yml --with-registry-auth "${STACK_NAME?Variable not set}" 16 | -------------------------------------------------------------------------------- /scripts/generate-client.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | cd backend 7 | python -c "import app.main; import json; print(json.dumps(app.main.app.openapi()))" > ../openapi.json 8 | cd .. 9 | mv openapi.json frontend/ 10 | cd frontend 11 | npm run generate-client 12 | npx biome format --write ./src/client 13 | -------------------------------------------------------------------------------- /scripts/test-local.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | docker-compose down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 7 | 8 | if [ $(uname -s) = "Linux" ]; then 9 | echo "Remove __pycache__ files" 10 | sudo find . -type d -name __pycache__ -exec rm -r {} \+ 11 | fi 12 | 13 | docker-compose build 14 | docker-compose up -d 15 | docker-compose exec -T backend bash scripts/tests-start.sh "$@" 16 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | set -x 6 | 7 | docker compose build 8 | docker compose down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 9 | docker compose up -d 10 | docker compose exec -T backend bash scripts/tests-start.sh "$@" 11 | docker compose down -v --remove-orphans 12 | --------------------------------------------------------------------------------