├── .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 |
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 |
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 |
40 |
41 |
42 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------