mirror of
https://github.com/fastapi/full-stack-fastapi-template.git
synced 2026-11-03 20:36:27 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eb8f5b624 | |||
| 558b466d43 | |||
| 496c7090b3 | |||
| 50d2a3bfe1 | |||
| 1523c20cae | |||
| f8e35a5249 | |||
| 9f6f8f088b | |||
| ff5079079b | |||
| 7b05fc1f6d | |||
| 86ef743226 | |||
| ed34e5e170 | |||
| 322975dd6e | |||
| 55df823739 | |||
| 74e2604faf | |||
| 631980a876 | |||
| f178e58bfe | |||
| aee08490e5 | |||
| 42fd54e06b | |||
| 16997dabf0 | |||
| 7cffa83649 | |||
| 15f41f755b | |||
| f2eb1b5c5f | |||
| 6042eda4bc | |||
| ba1706b489 | |||
| c754a0058b | |||
| 2d553dda53 | |||
| 846dd9e81a | |||
| d4df197c59 | |||
| 87afb074a3 | |||
| 98a2d7ddcf | |||
| b95dea6d26 | |||
| 88c83cc06c | |||
| 3e6ed0157e | |||
| b0f6195827 | |||
| 30832076f5 | |||
| aa71abedcc | |||
| 14129f7a50 | |||
| 414864d848 | |||
| 953e9e5416 | |||
| b7d2df2f81 | |||
| a21c57ab2b | |||
| e34527325b | |||
| 19764d5307 | |||
| 3aad3fb877 | |||
| 61fa9431b7 | |||
| 24c4f13629 | |||
| e684f3c8d6 | |||
| d3d370cad0 | |||
| c23c91bd4e | |||
| 6768359244 | |||
| a2e3a8ae11 | |||
| f5612ed7cd | |||
| 12a13b4108 | |||
| db7ea2cbbe | |||
| 86d2fcc2e8 | |||
| 67e3f35491 | |||
| 1c3e464acd | |||
| 372c9f8f91 | |||
| 8d52d018b4 | |||
| 88e1a607b0 | |||
| 6047623ad1 | |||
| 0a5f1025d6 | |||
| 172bfd9349 | |||
| 79883c879f | |||
| 721a44ab2f | |||
| ddb4235bfe | |||
| 2939b7723b | |||
| 4d5b0a065c | |||
| 3793150fe4 | |||
| ab0d1bb12f | |||
| 3136437bcb | |||
| b9ec1943eb | |||
| fe7f4f7949 | |||
| b67fb44e5b | |||
| 945fbc7699 | |||
| 109a7454c7 | |||
| 38843cbe28 | |||
| 1030118247 | |||
| 08dd87d0f2 | |||
| 6a8fc240b3 | |||
| c297e9ee9e |
@@ -12,7 +12,11 @@ jobs:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# For PRs from forks
|
||||
- uses: actions/checkout@v4
|
||||
# For PRs from the same repo
|
||||
- uses: actions/checkout@v4
|
||||
if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' )
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.FULL_STACK_FASTAPI_TEMPLATE_REPO_TOKEN }}
|
||||
@@ -23,7 +27,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v2
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "0.4.15"
|
||||
enable-cache: true
|
||||
@@ -35,10 +39,23 @@ jobs:
|
||||
- run: uv run bash scripts/generate-client.sh
|
||||
env:
|
||||
VIRTUAL_ENV: backend/.venv
|
||||
- name: Commit changes
|
||||
ENVIRONMENT: production
|
||||
SECRET_KEY: just-for-generating-client
|
||||
POSTGRES_PASSWORD: just-for-generating-client
|
||||
FIRST_SUPERUSER_PASSWORD: just-for-generating-client
|
||||
- name: Add changes to git
|
||||
run: |
|
||||
git config --local user.email "github-actions@github.com"
|
||||
git config --local user.name "github-actions"
|
||||
git add frontend/src/client
|
||||
# Same repo PRs
|
||||
- name: Push changes
|
||||
if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' )
|
||||
run: |
|
||||
git diff --staged --quiet || git commit -m "✨ Autogenerate frontend client"
|
||||
git push
|
||||
# Fork PRs
|
||||
- name: Check changes
|
||||
if: ( github.event_name == 'pull_request' && github.secret_source != 'Actions' )
|
||||
run: |
|
||||
git diff --staged --quiet || (echo "Changes detected in generated client, run scripts/generate-client.sh and commit the changes" && exit 1)
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||
run: echo "$GITHUB_CONTEXT"
|
||||
- uses: tiangolo/issue-manager@0.5.0
|
||||
- uses: tiangolo/issue-manager@0.5.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config: >
|
||||
@@ -39,5 +39,9 @@ jobs:
|
||||
"waiting": {
|
||||
"delay": 2628000,
|
||||
"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."
|
||||
},
|
||||
"invalid": {
|
||||
"delay": 0,
|
||||
"message": "This was marked as invalid and will be closed now. If this is an error, please provide additional details."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
|
||||
- run: echo "Done adding labels"
|
||||
# Run this after labeler applied labels
|
||||
check-labels:
|
||||
needs:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
# To allow latest-changes to commit to the main branch
|
||||
token: ${{ secrets.LATEST_CHANGES }}
|
||||
- uses: tiangolo/latest-changes@0.3.1
|
||||
- uses: tiangolo/latest-changes@0.3.2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
latest_changes_file: ./release-notes.md
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v2
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "0.4.15"
|
||||
enable-cache: true
|
||||
|
||||
@@ -16,10 +16,36 @@ on:
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
changed: ${{ steps.filter.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# For pull requests it's not necessary to checkout the code but for the main branch it is
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
changed:
|
||||
- backend/**
|
||||
- frontend/**
|
||||
- .env
|
||||
- docker-compose*.yml
|
||||
- .github/workflows/playwright.yml
|
||||
|
||||
test:
|
||||
test-playwright:
|
||||
needs:
|
||||
- changes
|
||||
if: ${{ needs.changes.outputs.changed == 'true' }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -33,35 +59,73 @@ jobs:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "0.4.15"
|
||||
enable-cache: true
|
||||
- run: uv sync
|
||||
working-directory: backend
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: uv run bash scripts/generate-client.sh
|
||||
env:
|
||||
VIRTUAL_ENV: backend/.venv
|
||||
- run: docker compose build
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- name: Run Playwright tests
|
||||
run: docker compose run --rm playwright npx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: frontend/blob-report
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
merge-playwright-reports:
|
||||
needs:
|
||||
- test-playwright
|
||||
- changes
|
||||
# Merge reports after playwright-tests, even if some shards have failed
|
||||
if: ${{ !cancelled() && needs.changes.outputs.changed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
working-directory: frontend
|
||||
- run: docker compose build
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- run: docker compose up -d --wait backend mailcatcher
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test --fail-on-flaky-tests --trace=retain-on-failure
|
||||
working-directory: frontend
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
path: frontend/all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
- name: Merge into HTML Report
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
working-directory: frontend
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
path: frontend/playwright-report
|
||||
retention-days: 30
|
||||
include-hidden-files: true
|
||||
|
||||
# https://github.com/marketplace/actions/alls-green#why
|
||||
e2e-alls-green: # This job does nothing and is only used for the branch protection
|
||||
alls-green-playwright: # This job does nothing and is only used for the branch protection
|
||||
if: always()
|
||||
needs:
|
||||
- test
|
||||
- test-playwright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Decide whether the needed jobs succeeded or failed
|
||||
uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
allowed-skips: test-playwright
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
statuses: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v2
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "0.4.15"
|
||||
enable-cache: true
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- run: docker compose build
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- run: docker compose up -d --wait
|
||||
- run: docker compose up -d --wait backend frontend adminer
|
||||
- name: Test backend is up
|
||||
run: curl http://localhost:8000/api/v1/utils/health-check
|
||||
- name: Test frontend is up
|
||||
|
||||
@@ -10,7 +10,11 @@ repos:
|
||||
args:
|
||||
- --unsafe
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^frontend/src/client/.*
|
||||
exclude: |
|
||||
(?x)^(
|
||||
frontend/src/client/.*|
|
||||
backend/app/email-templates/build/.*
|
||||
)$
|
||||
- id: trailing-whitespace
|
||||
exclude: ^frontend/src/client/.*
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
FROM python:3.10
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app/
|
||||
|
||||
# Install uv
|
||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.4.15 /uv /bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /bin/
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment
|
||||
|
||||
+10
-5
@@ -1,9 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import items, login, users, utils
|
||||
from app.api.routes import items, login, private, users, utils
|
||||
from app.core.config import settings
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, tags=["login"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
|
||||
api_router.include_router(items.router, prefix="/items", tags=["items"])
|
||||
api_router.include_router(login.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(utils.router)
|
||||
api_router.include_router(items.router)
|
||||
|
||||
|
||||
if settings.ENVIRONMENT == "local":
|
||||
api_router.include_router(private.router)
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlmodel import func, select
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/items", tags=["items"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ItemsPublic)
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.utils import (
|
||||
verify_password_reset_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["login"])
|
||||
|
||||
|
||||
@router.post("/login/access-token")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import SessionDep
|
||||
from app.core.security import get_password_hash
|
||||
from app.models import (
|
||||
User,
|
||||
UserPublic,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["private"], prefix="/private")
|
||||
|
||||
|
||||
class PrivateUserCreate(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
full_name: str
|
||||
is_verified: bool = False
|
||||
|
||||
|
||||
@router.post("/users/", response_model=UserPublic)
|
||||
def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
|
||||
"""
|
||||
Create a new user.
|
||||
"""
|
||||
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
full_name=user_in.full_name,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
return user
|
||||
@@ -26,7 +26,7 @@ from app.models import (
|
||||
)
|
||||
from app.utils import generate_new_account_email, send_email
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -134,8 +134,6 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
statement = delete(Item).where(col(Item.owner_id) == current_user.id)
|
||||
session.exec(statement) # type: ignore
|
||||
session.delete(current_user)
|
||||
session.commit()
|
||||
return Message(message="User deleted successfully")
|
||||
|
||||
@@ -5,7 +5,7 @@ from app.api.deps import get_current_active_superuser
|
||||
from app.models import Message
|
||||
from app.utils import generate_test_email, send_email
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/utils", tags=["utils"])
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Annotated, Any, Literal
|
||||
from pydantic import (
|
||||
AnyUrl,
|
||||
BeforeValidator,
|
||||
EmailStr,
|
||||
HttpUrl,
|
||||
PostgresDsn,
|
||||
computed_field,
|
||||
@@ -74,9 +75,8 @@ class Settings(BaseSettings):
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_USER: str | None = None
|
||||
SMTP_PASSWORD: str | None = None
|
||||
# TODO: update type to EmailStr when sqlmodel supports it
|
||||
EMAILS_FROM_EMAIL: str | None = None
|
||||
EMAILS_FROM_NAME: str | None = None
|
||||
EMAILS_FROM_EMAIL: EmailStr | None = None
|
||||
EMAILS_FROM_NAME: EmailStr | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _set_default_emails_from(self) -> Self:
|
||||
@@ -91,10 +91,8 @@ class Settings(BaseSettings):
|
||||
def emails_enabled(self) -> bool:
|
||||
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
|
||||
|
||||
# TODO: update type to EmailStr when sqlmodel supports it
|
||||
EMAIL_TEST_USER: str = "test@example.com"
|
||||
# TODO: update type to EmailStr when sqlmodel supports it
|
||||
FIRST_SUPERUSER: str
|
||||
EMAIL_TEST_USER: EmailStr = "test@example.com"
|
||||
FIRST_SUPERUSER: EmailStr
|
||||
FIRST_SUPERUSER_PASSWORD: str
|
||||
|
||||
def _check_default_secret(self, var_name: str, value: str | None) -> None:
|
||||
|
||||
@@ -18,7 +18,6 @@ def init_db(session: Session) -> None:
|
||||
# the tables un-commenting the next lines
|
||||
# from sqlmodel import SQLModel
|
||||
|
||||
# from app.core.engine import engine
|
||||
# This works because the models are already imported and registered from app.models
|
||||
# SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_password
|
||||
from app.models import User
|
||||
from app.crud import create_user
|
||||
from app.models import UserCreate
|
||||
from app.tests.utils.user import user_authentication_headers
|
||||
from app.tests.utils.utils import random_email, random_lower_string
|
||||
from app.utils import generate_password_reset_token
|
||||
|
||||
|
||||
@@ -69,23 +72,34 @@ def test_recovery_password_user_not_exits(
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_reset_password(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
token = generate_password_reset_token(email=settings.FIRST_SUPERUSER)
|
||||
data = {"new_password": "changethis", "token": token}
|
||||
def test_reset_password(client: TestClient, db: Session) -> None:
|
||||
email = random_email()
|
||||
password = random_lower_string()
|
||||
new_password = random_lower_string()
|
||||
|
||||
user_create = UserCreate(
|
||||
email=email,
|
||||
full_name="Test User",
|
||||
password=password,
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
user = create_user(session=db, user_create=user_create)
|
||||
token = generate_password_reset_token(email=email)
|
||||
headers = user_authentication_headers(client=client, email=email, password=password)
|
||||
data = {"new_password": new_password, "token": token}
|
||||
|
||||
r = client.post(
|
||||
f"{settings.API_V1_STR}/reset-password/",
|
||||
headers=superuser_token_headers,
|
||||
headers=headers,
|
||||
json=data,
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"message": "Password updated successfully"}
|
||||
|
||||
user_query = select(User).where(User.email == settings.FIRST_SUPERUSER)
|
||||
user = db.exec(user_query).first()
|
||||
assert user
|
||||
assert verify_password(data["new_password"], user.hashed_password)
|
||||
db.refresh(user)
|
||||
assert verify_password(new_password, user.hashed_password)
|
||||
|
||||
|
||||
def test_reset_password_invalid_token(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models import User
|
||||
|
||||
|
||||
def test_create_user(client: TestClient, db: Session) -> None:
|
||||
r = client.post(
|
||||
f"{settings.API_V1_STR}/private/users/",
|
||||
json={
|
||||
"email": "pollo@listo.com",
|
||||
"password": "password123",
|
||||
"full_name": "Pollo Listo",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
|
||||
data = r.json()
|
||||
|
||||
user = db.exec(select(User).where(User.id == data["id"])).first()
|
||||
|
||||
assert user
|
||||
assert user.email == "pollo@listo.com"
|
||||
assert user.full_name == "Pollo Listo"
|
||||
@@ -9,8 +9,12 @@ import jwt
|
||||
from jinja2 import Template
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailData:
|
||||
@@ -48,7 +52,7 @@ def send_email(
|
||||
if settings.SMTP_PASSWORD:
|
||||
smtp_options["password"] = settings.SMTP_PASSWORD
|
||||
response = message.send(to=email_to, smtp=smtp_options)
|
||||
logging.info(f"send email result: {response}")
|
||||
logger.info(f"send email result: {response}")
|
||||
|
||||
|
||||
def generate_test_email(email_to: str) -> EmailData:
|
||||
@@ -104,14 +108,16 @@ def generate_password_reset_token(email: str) -> str:
|
||||
encoded_jwt = jwt.encode(
|
||||
{"exp": exp, "nbf": now, "sub": email},
|
||||
settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
algorithm=security.ALGORITHM,
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password_reset_token(token: str) -> str | None:
|
||||
try:
|
||||
decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
decoded_token = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||
)
|
||||
return str(decoded_token["sub"])
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
+1
-1
@@ -97,4 +97,4 @@ _exclude:
|
||||
_answers_file: .copier/.copier-answers.yml
|
||||
|
||||
_tasks:
|
||||
- "python .copier/update_dotenv.py"
|
||||
- ["{{ _copier_python }}", .copier/update_dotenv.py]
|
||||
|
||||
+12
-6
@@ -183,22 +183,22 @@ There are already two environments configured, `staging` and `production`. 🚀
|
||||
|
||||
### Install GitHub Actions Runner
|
||||
|
||||
* On your remote server, if you are running as the `root` user, create a user for your GitHub Actions:
|
||||
* On your remote server, create a user for your GitHub Actions:
|
||||
|
||||
```bash
|
||||
adduser github
|
||||
sudo adduser github
|
||||
```
|
||||
|
||||
* Add Docker permissions to the `github` user:
|
||||
|
||||
```bash
|
||||
usermod -aG docker github
|
||||
sudo usermod -aG docker github
|
||||
```
|
||||
|
||||
* Temporarily switch to the `github` user:
|
||||
|
||||
```bash
|
||||
su - github
|
||||
sudo su - github
|
||||
```
|
||||
|
||||
* Go to the `github` user's home directory:
|
||||
@@ -219,9 +219,15 @@ To make sure it runs on startup and continues running, you can install it as a s
|
||||
exit
|
||||
```
|
||||
|
||||
After you do it, you would be on the `root` user again. And you will be on the previous directory, belonging to the `root` user.
|
||||
After you do it, you will be on the previous user again. And you will be on the previous directory, belonging to that user.
|
||||
|
||||
* Go to the `actions-runner` directory inside of the `github` user's home directory:
|
||||
Before being able to go the `github` user directory, you need to become the `root` user (you might already be):
|
||||
|
||||
```bash
|
||||
sudo su
|
||||
```
|
||||
|
||||
* As the `root` user, go to the `actions-runner` directory inside of the `github` user's home directory:
|
||||
|
||||
```bash
|
||||
cd /home/github/actions-runner
|
||||
|
||||
+6
-2
@@ -186,6 +186,8 @@ Adminer: http://localhost:8080
|
||||
|
||||
Traefik UI: http://localhost:8090
|
||||
|
||||
MailCatcher: http://localhost:1080
|
||||
|
||||
### Development URLs with `localhost.tiangolo.com` Configured
|
||||
|
||||
Development URLs, for local development.
|
||||
@@ -194,10 +196,12 @@ Frontend: http://dashboard.localhost.tiangolo.com
|
||||
|
||||
Backend: http://api.localhost.tiangolo.com
|
||||
|
||||
Automatic Interactive Docs (Swagger UI): http://api.localhost.tiangolo.comdocs
|
||||
Automatic Interactive Docs (Swagger UI): http://api.localhost.tiangolo.com/docs
|
||||
|
||||
Automatic Alternative Docs (ReDoc): http://api.localhost.tiangolo.comredoc
|
||||
Automatic Alternative Docs (ReDoc): http://api.localhost.tiangolo.com/redoc
|
||||
|
||||
Adminer: http://localhost.tiangolo.com:8080
|
||||
|
||||
Traefik UI: http://localhost.tiangolo.com:8090
|
||||
|
||||
MailCatcher: http://localhost.tiangolo.com:1080
|
||||
@@ -102,6 +102,31 @@ services:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
- NODE_ENV=development
|
||||
|
||||
playwright:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.playwright
|
||||
args:
|
||||
- VITE_API_URL=http://backend:8000
|
||||
- NODE_ENV=production
|
||||
ipc: host
|
||||
depends_on:
|
||||
- backend
|
||||
- mailcatcher
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- VITE_API_URL=http://backend:8000
|
||||
- MAILCATCHER_HOST=http://mailcatcher:1080
|
||||
# For the reports when run locally
|
||||
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
||||
- CI=${CI}
|
||||
volumes:
|
||||
- ./frontend/blob-report:/app/blob-report
|
||||
- ./frontend/test-results:/app/test-results
|
||||
ports:
|
||||
- 9323:9323
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
# For local dev, don't expect an external Traefik network
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
MAILCATCHER_HOST=http://localhost:1080
|
||||
|
||||
@@ -27,3 +27,4 @@ openapi.json
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json /app/
|
||||
|
||||
RUN npm install
|
||||
|
||||
RUN npx -y playwright install --with-deps
|
||||
|
||||
COPY ./ /app/
|
||||
|
||||
ARG VITE_API_URL=${VITE_API_URL}
|
||||
+2
-8
@@ -59,7 +59,7 @@ If you are developing an API-only app and want to remove the frontend, you can d
|
||||
|
||||
* In the `docker-compose.yml` file, remove the whole service / section `frontend`.
|
||||
|
||||
* In the `docker-compose.override.yml` file, remove the whole service / section `frontend`.
|
||||
* In the `docker-compose.override.yml` file, remove the whole service / section `frontend` and `playwright`.
|
||||
|
||||
Done, you have a frontend-less (api-only) app. 🤓
|
||||
|
||||
@@ -80,7 +80,7 @@ But it would be only to clean them up, leaving them won't really have any effect
|
||||
* From the top level project directory, run the script:
|
||||
|
||||
```bash
|
||||
./scripts/generate-frontend-client.sh
|
||||
./scripts/generate-client.sh
|
||||
```
|
||||
|
||||
* Commit the changes.
|
||||
@@ -91,12 +91,6 @@ But it would be only to clean them up, leaving them won't really have any effect
|
||||
|
||||
* Download the OpenAPI JSON file from `http://localhost/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
||||
|
||||
* To simplify the names in the generated frontend client code, modify the `openapi.json` file by running the following script:
|
||||
|
||||
```bash
|
||||
node modify-openapi-operationids.js
|
||||
```
|
||||
|
||||
* To generate the frontend client, run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as fs from "node:fs"
|
||||
|
||||
async function modifyOpenAPIFile(filePath) {
|
||||
try {
|
||||
const data = await fs.promises.readFile(filePath)
|
||||
const openapiContent = JSON.parse(data)
|
||||
|
||||
const paths = openapiContent.paths
|
||||
for (const pathKey of Object.keys(paths)) {
|
||||
const pathData = paths[pathKey]
|
||||
for (const method of Object.keys(pathData)) {
|
||||
const operation = pathData[method]
|
||||
if (operation.tags && operation.tags.length > 0) {
|
||||
const tag = operation.tags[0]
|
||||
const operationId = operation.operationId
|
||||
const toRemove = `${tag}-`
|
||||
if (operationId.startsWith(toRemove)) {
|
||||
const newOperationId = operationId.substring(toRemove.length)
|
||||
operation.operationId = newOperationId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(openapiContent, null, 2),
|
||||
)
|
||||
console.log("File successfully modified")
|
||||
} catch (err) {
|
||||
console.error("Error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = "./openapi.json"
|
||||
modifyOpenAPIFile(filePath)
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "@hey-api/openapi-ts"
|
||||
|
||||
export default defineConfig({
|
||||
client: "legacy/axios",
|
||||
input: "./openapi.json",
|
||||
output: "./src/client",
|
||||
// exportSchemas: true,
|
||||
plugins: [
|
||||
{
|
||||
name: "@hey-api/sdk",
|
||||
// NOTE: this doesn't allow tree-shaking
|
||||
asClass: true,
|
||||
operationId: true,
|
||||
methodNameBuilder: (operation) => {
|
||||
// @ts-ignore
|
||||
let name: string = operation.name
|
||||
// @ts-ignore
|
||||
const service: string = operation.service
|
||||
|
||||
if (service && name.toLowerCase().startsWith(service.toLowerCase())) {
|
||||
name = name.slice(service.length)
|
||||
}
|
||||
|
||||
return name.charAt(0).toLowerCase() + name.slice(1)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
Generated
+3395
-3141
File diff suppressed because it is too large
Load Diff
+8
-10
@@ -5,31 +5,29 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc -p tsconfig.build.json && vite build",
|
||||
"lint": "biome check --apply-unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./",
|
||||
"preview": "vite preview",
|
||||
"generate-client": "openapi-ts --input ./openapi.json --output ./src/client --client axios --exportSchemas true"
|
||||
"generate-client": "openapi-ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "2.1.1",
|
||||
"@chakra-ui/react": "2.8.2",
|
||||
"@emotion/react": "11.11.3",
|
||||
"@emotion/styled": "11.11.0",
|
||||
"@chakra-ui/react": "^3.8.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tanstack/react-query": "^5.28.14",
|
||||
"@tanstack/react-query-devtools": "^5.28.14",
|
||||
"@tanstack/react-router": "1.19.1",
|
||||
"axios": "1.7.4",
|
||||
"form-data": "4.0.0",
|
||||
"framer-motion": "10.16.16",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-hook-form": "7.49.3",
|
||||
"react-icons": "5.0.1"
|
||||
"react-icons": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.6.1",
|
||||
"@hey-api/openapi-ts": "^0.34.1",
|
||||
"@hey-api/openapi-ts": "^0.57.0",
|
||||
"@playwright/test": "^1.45.2",
|
||||
"@tanstack/router-devtools": "1.19.1",
|
||||
"@tanstack/router-vite-plugin": "1.19.0",
|
||||
@@ -39,6 +37,6 @@
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.13"
|
||||
"vite": "^5.4.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
import 'dotenv/config'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
@@ -21,7 +20,7 @@ export default defineConfig({
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
reporter: process.env.CI ? 'blob' : 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
export type ApiRequestOptions = {
|
||||
readonly method:
|
||||
| "GET"
|
||||
| "PUT"
|
||||
| "POST"
|
||||
| "DELETE"
|
||||
| "OPTIONS"
|
||||
| "HEAD"
|
||||
| "PATCH"
|
||||
readonly url: string
|
||||
readonly path?: Record<string, unknown>
|
||||
readonly cookies?: Record<string, unknown>
|
||||
readonly headers?: Record<string, unknown>
|
||||
readonly query?: Record<string, unknown>
|
||||
readonly formData?: Record<string, unknown>
|
||||
export type ApiRequestOptions<T = unknown> = {
|
||||
readonly body?: any
|
||||
readonly cookies?: Record<string, unknown>
|
||||
readonly errors?: Record<number | string, string>
|
||||
readonly formData?: Record<string, unknown> | any[] | Blob | File
|
||||
readonly headers?: Record<string, unknown>
|
||||
readonly mediaType?: string
|
||||
readonly method:
|
||||
| "DELETE"
|
||||
| "GET"
|
||||
| "HEAD"
|
||||
| "OPTIONS"
|
||||
| "PATCH"
|
||||
| "POST"
|
||||
| "PUT"
|
||||
readonly path?: Record<string, unknown>
|
||||
readonly query?: Record<string, unknown>
|
||||
readonly responseHeader?: string
|
||||
readonly errors?: Record<number, string>
|
||||
readonly responseTransformer?: (data: unknown) => Promise<T>
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import type { ApiRequestOptions } from "./ApiRequestOptions"
|
||||
import type { TResult } from "./types"
|
||||
|
||||
type Headers = Record<string, string>
|
||||
type Middleware<T> = (value: T) => T | Promise<T>
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>
|
||||
type Resolver<T> = (options: ApiRequestOptions<T>) => Promise<T>
|
||||
|
||||
export class Interceptors<T> {
|
||||
_fns: Middleware<T>[]
|
||||
@@ -13,14 +12,14 @@ export class Interceptors<T> {
|
||||
this._fns = []
|
||||
}
|
||||
|
||||
eject(fn: Middleware<T>) {
|
||||
eject(fn: Middleware<T>): void {
|
||||
const index = this._fns.indexOf(fn)
|
||||
if (index !== -1) {
|
||||
this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]
|
||||
}
|
||||
}
|
||||
|
||||
use(fn: Middleware<T>) {
|
||||
use(fn: Middleware<T>): void {
|
||||
this._fns = [...this._fns, fn]
|
||||
}
|
||||
}
|
||||
@@ -31,7 +30,6 @@ export type OpenAPIConfig = {
|
||||
ENCODE_PATH?: ((path: string) => string) | undefined
|
||||
HEADERS?: Headers | Resolver<Headers> | undefined
|
||||
PASSWORD?: string | Resolver<string> | undefined
|
||||
RESULT?: TResult
|
||||
TOKEN?: string | Resolver<string> | undefined
|
||||
USERNAME?: string | Resolver<string> | undefined
|
||||
VERSION: string
|
||||
@@ -48,10 +46,12 @@ export const OpenAPI: OpenAPIConfig = {
|
||||
ENCODE_PATH: undefined,
|
||||
HEADERS: undefined,
|
||||
PASSWORD: undefined,
|
||||
RESULT: "body",
|
||||
TOKEN: undefined,
|
||||
USERNAME: undefined,
|
||||
VERSION: "0.1.0",
|
||||
WITH_CREDENTIALS: false,
|
||||
interceptors: { request: new Interceptors(), response: new Interceptors() },
|
||||
interceptors: {
|
||||
request: new Interceptors(),
|
||||
response: new Interceptors(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@ export const getQueryString = (params: Record<string, unknown>): string => {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value instanceof Date) {
|
||||
append(key, value.toISOString())
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((v) => encodePair(key, v))
|
||||
} else if (typeof value === "object") {
|
||||
Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v))
|
||||
@@ -113,10 +115,10 @@ export const getFormData = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>
|
||||
type Resolver<T> = (options: ApiRequestOptions<T>) => Promise<T>
|
||||
|
||||
export const resolve = async <T>(
|
||||
options: ApiRequestOptions,
|
||||
options: ApiRequestOptions<T>,
|
||||
resolver?: T | Resolver<T>,
|
||||
): Promise<T | undefined> => {
|
||||
if (typeof resolver === "function") {
|
||||
@@ -125,14 +127,18 @@ export const resolve = async <T>(
|
||||
return resolver
|
||||
}
|
||||
|
||||
export const getHeaders = async (
|
||||
export const getHeaders = async <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
options: ApiRequestOptions<T>,
|
||||
): Promise<Record<string, string>> => {
|
||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
||||
// @ts-ignore
|
||||
resolve(options, config.TOKEN),
|
||||
// @ts-ignore
|
||||
resolve(options, config.USERNAME),
|
||||
// @ts-ignore
|
||||
resolve(options, config.PASSWORD),
|
||||
// @ts-ignore
|
||||
resolve(options, config.HEADERS),
|
||||
])
|
||||
|
||||
@@ -187,7 +193,7 @@ export const getRequestBody = (options: ApiRequestOptions): unknown => {
|
||||
|
||||
export const sendRequest = async <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
options: ApiRequestOptions<T>,
|
||||
url: string,
|
||||
body: unknown,
|
||||
formData: FormData | undefined,
|
||||
@@ -325,7 +331,7 @@ export const catchErrorCodes = (
|
||||
*/
|
||||
export const request = <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
options: ApiRequestOptions<T>,
|
||||
axiosClient: AxiosInstance = axios,
|
||||
): CancelablePromise<T> => {
|
||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||
@@ -357,12 +363,17 @@ export const request = <T>(
|
||||
options.responseHeader,
|
||||
)
|
||||
|
||||
let transformedBody = responseBody
|
||||
if (options.responseTransformer && isSuccess(response.status)) {
|
||||
transformedBody = await options.responseTransformer(responseBody)
|
||||
}
|
||||
|
||||
const result: ApiResult = {
|
||||
url,
|
||||
ok: isSuccess(response.status),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: responseHeader ?? responseBody,
|
||||
body: responseHeader ?? transformedBody,
|
||||
}
|
||||
|
||||
catchErrorCodes(options, result)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ApiResult } from "./ApiResult"
|
||||
|
||||
export type TResult = "body" | "raw"
|
||||
|
||||
export type TApiResponse<T extends TResult, TData> = Exclude<
|
||||
T,
|
||||
"raw"
|
||||
> extends never
|
||||
? ApiResult<TData>
|
||||
: ApiResult<TData>["body"]
|
||||
|
||||
export type TConfig<T extends TResult> = {
|
||||
_result?: T
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
export { ApiError } from "./core/ApiError"
|
||||
export { CancelablePromise, CancelError } from "./core/CancelablePromise"
|
||||
export { OpenAPI } from "./core/OpenAPI"
|
||||
export type { OpenAPIConfig } from "./core/OpenAPI"
|
||||
|
||||
export * from "./models"
|
||||
export * from "./schemas"
|
||||
export * from "./services"
|
||||
export { OpenAPI, type OpenAPIConfig } from "./core/OpenAPI"
|
||||
export * from "./sdk.gen"
|
||||
export * from "./types.gen"
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
export type Body_login_login_access_token = {
|
||||
grant_type?: string | null
|
||||
username: string
|
||||
password: string
|
||||
scope?: string
|
||||
client_id?: string | null
|
||||
client_secret?: string | null
|
||||
}
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: Array<ValidationError>
|
||||
}
|
||||
|
||||
export type ItemCreate = {
|
||||
title: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ItemPublic = {
|
||||
title: string
|
||||
description?: string | null
|
||||
id: string
|
||||
owner_id: string
|
||||
}
|
||||
|
||||
export type ItemUpdate = {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ItemsPublic = {
|
||||
data: Array<ItemPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type Message = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NewPassword = {
|
||||
token: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type Token = {
|
||||
access_token: string
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export type UpdatePassword = {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type UserCreate = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
export type UserPublic = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
id: string
|
||||
}
|
||||
|
||||
export type UserRegister = {
|
||||
email: string
|
||||
password: string
|
||||
full_name?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdate = {
|
||||
email?: string | null
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdateMe = {
|
||||
full_name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export type UsersPublic = {
|
||||
data: Array<UserPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type ValidationError = {
|
||||
loc: Array<string | number>
|
||||
msg: string
|
||||
type: string
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
export const $Body_login_login_access_token = {
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export const Body_login_login_access_tokenSchema = {
|
||||
properties: {
|
||||
grant_type: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
pattern: "password",
|
||||
@@ -11,22 +12,23 @@ export const $Body_login_login_access_token = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Grant Type",
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
title: "Username",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
title: "Password",
|
||||
},
|
||||
scope: {
|
||||
type: "string",
|
||||
title: "Scope",
|
||||
default: "",
|
||||
},
|
||||
client_id: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
@@ -34,10 +36,10 @@ export const $Body_login_login_access_token = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Client Id",
|
||||
},
|
||||
client_secret: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
@@ -45,32 +47,38 @@ export const $Body_login_login_access_token = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Client Secret",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["username", "password"],
|
||||
title: "Body_login-login_access_token",
|
||||
} as const
|
||||
|
||||
export const $HTTPValidationError = {
|
||||
export const HTTPValidationErrorSchema = {
|
||||
properties: {
|
||||
detail: {
|
||||
items: {
|
||||
$ref: "#/components/schemas/ValidationError",
|
||||
},
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "ValidationError",
|
||||
},
|
||||
title: "Detail",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
title: "HTTPValidationError",
|
||||
} as const
|
||||
|
||||
export const $ItemCreate = {
|
||||
export const ItemCreateSchema = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
title: "Title",
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -79,21 +87,24 @@ export const $ItemCreate = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Description",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["title"],
|
||||
title: "ItemCreate",
|
||||
} as const
|
||||
|
||||
export const $ItemPublic = {
|
||||
export const ItemPublicSchema = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
title: "Title",
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -102,25 +113,28 @@ export const $ItemPublic = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Description",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
title: "Id",
|
||||
},
|
||||
owner_id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
title: "Owner Id",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["title", "id", "owner_id"],
|
||||
title: "ItemPublic",
|
||||
} as const
|
||||
|
||||
export const $ItemUpdate = {
|
||||
export const ItemUpdateSchema = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -130,10 +144,10 @@ export const $ItemUpdate = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Title",
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -142,99 +156,119 @@ export const $ItemUpdate = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Description",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
title: "ItemUpdate",
|
||||
} as const
|
||||
|
||||
export const $ItemsPublic = {
|
||||
export const ItemsPublicSchema = {
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "ItemPublic",
|
||||
items: {
|
||||
$ref: "#/components/schemas/ItemPublic",
|
||||
},
|
||||
isRequired: true,
|
||||
type: "array",
|
||||
title: "Data",
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
isRequired: true,
|
||||
type: "integer",
|
||||
title: "Count",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["data", "count"],
|
||||
title: "ItemsPublic",
|
||||
} as const
|
||||
|
||||
export const $Message = {
|
||||
export const MessageSchema = {
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
title: "Message",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["message"],
|
||||
title: "Message",
|
||||
} as const
|
||||
|
||||
export const $NewPassword = {
|
||||
export const NewPasswordSchema = {
|
||||
properties: {
|
||||
token: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
title: "Token",
|
||||
},
|
||||
new_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
title: "New Password",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["token", "new_password"],
|
||||
title: "NewPassword",
|
||||
} as const
|
||||
|
||||
export const $Token = {
|
||||
export const TokenSchema = {
|
||||
properties: {
|
||||
access_token: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
title: "Access Token",
|
||||
},
|
||||
token_type: {
|
||||
type: "string",
|
||||
title: "Token Type",
|
||||
default: "bearer",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["access_token"],
|
||||
title: "Token",
|
||||
} as const
|
||||
|
||||
export const $UpdatePassword = {
|
||||
export const UpdatePasswordSchema = {
|
||||
properties: {
|
||||
current_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
title: "Current Password",
|
||||
},
|
||||
new_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
title: "New Password",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["current_password", "new_password"],
|
||||
title: "UpdatePassword",
|
||||
} as const
|
||||
|
||||
export const $UserCreate = {
|
||||
export const UserCreateSchema = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
format: "email",
|
||||
title: "Email",
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
title: "Is Active",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
title: "Is Superuser",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -243,35 +277,40 @@ export const $UserCreate = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Full Name",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
title: "Password",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["email", "password"],
|
||||
title: "UserCreate",
|
||||
} as const
|
||||
|
||||
export const $UserPublic = {
|
||||
export const UserPublicSchema = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
format: "email",
|
||||
title: "Email",
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
title: "Is Active",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
title: "Is Superuser",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -280,32 +319,35 @@ export const $UserPublic = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Full Name",
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
title: "Id",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["email", "id"],
|
||||
title: "UserPublic",
|
||||
} as const
|
||||
|
||||
export const $UserRegister = {
|
||||
export const UserRegisterSchema = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
format: "email",
|
||||
title: "Email",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
title: "Password",
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -314,36 +356,41 @@ export const $UserRegister = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Full Name",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["email", "password"],
|
||||
title: "UserRegister",
|
||||
} as const
|
||||
|
||||
export const $UserUpdate = {
|
||||
export const UserUpdateSchema = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
format: "email",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Email",
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
title: "Is Active",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
title: "Is Superuser",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -352,10 +399,10 @@ export const $UserUpdate = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Full Name",
|
||||
},
|
||||
password: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 40,
|
||||
@@ -365,15 +412,17 @@ export const $UserUpdate = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Password",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
title: "UserUpdate",
|
||||
} as const
|
||||
|
||||
export const $UserUpdateMe = {
|
||||
export const UserUpdateMeSchema = {
|
||||
properties: {
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
@@ -382,63 +431,71 @@ export const $UserUpdateMe = {
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Full Name",
|
||||
},
|
||||
email: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
format: "email",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Email",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
title: "UserUpdateMe",
|
||||
} as const
|
||||
|
||||
export const $UsersPublic = {
|
||||
export const UsersPublicSchema = {
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "UserPublic",
|
||||
items: {
|
||||
$ref: "#/components/schemas/UserPublic",
|
||||
},
|
||||
isRequired: true,
|
||||
type: "array",
|
||||
title: "Data",
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
isRequired: true,
|
||||
type: "integer",
|
||||
title: "Count",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["data", "count"],
|
||||
title: "UsersPublic",
|
||||
} as const
|
||||
|
||||
export const $ValidationError = {
|
||||
export const ValidationErrorSchema = {
|
||||
properties: {
|
||||
loc: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
type: "integer",
|
||||
},
|
||||
],
|
||||
},
|
||||
isRequired: true,
|
||||
type: "array",
|
||||
title: "Location",
|
||||
},
|
||||
msg: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
title: "Message",
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
title: "Error Type",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["loc", "msg", "type"],
|
||||
title: "ValidationError",
|
||||
} as const
|
||||
@@ -1,56 +1,191 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { CancelablePromise } from "./core/CancelablePromise"
|
||||
import { OpenAPI } from "./core/OpenAPI"
|
||||
import { request as __request } from "./core/request"
|
||||
|
||||
import type {
|
||||
Body_login_login_access_token,
|
||||
Message,
|
||||
NewPassword,
|
||||
Token,
|
||||
UserPublic,
|
||||
UpdatePassword,
|
||||
UserCreate,
|
||||
UserRegister,
|
||||
UsersPublic,
|
||||
UserUpdate,
|
||||
UserUpdateMe,
|
||||
ItemCreate,
|
||||
ItemPublic,
|
||||
ItemsPublic,
|
||||
ItemUpdate,
|
||||
} from "./models"
|
||||
ItemsReadItemsData,
|
||||
ItemsReadItemsResponse,
|
||||
ItemsCreateItemData,
|
||||
ItemsCreateItemResponse,
|
||||
ItemsReadItemData,
|
||||
ItemsReadItemResponse,
|
||||
ItemsUpdateItemData,
|
||||
ItemsUpdateItemResponse,
|
||||
ItemsDeleteItemData,
|
||||
ItemsDeleteItemResponse,
|
||||
LoginLoginAccessTokenData,
|
||||
LoginLoginAccessTokenResponse,
|
||||
LoginTestTokenResponse,
|
||||
LoginRecoverPasswordData,
|
||||
LoginRecoverPasswordResponse,
|
||||
LoginResetPasswordData,
|
||||
LoginResetPasswordResponse,
|
||||
LoginRecoverPasswordHtmlContentData,
|
||||
LoginRecoverPasswordHtmlContentResponse,
|
||||
UsersReadUsersData,
|
||||
UsersReadUsersResponse,
|
||||
UsersCreateUserData,
|
||||
UsersCreateUserResponse,
|
||||
UsersReadUserMeResponse,
|
||||
UsersDeleteUserMeResponse,
|
||||
UsersUpdateUserMeData,
|
||||
UsersUpdateUserMeResponse,
|
||||
UsersUpdatePasswordMeData,
|
||||
UsersUpdatePasswordMeResponse,
|
||||
UsersRegisterUserData,
|
||||
UsersRegisterUserResponse,
|
||||
UsersReadUserByIdData,
|
||||
UsersReadUserByIdResponse,
|
||||
UsersUpdateUserData,
|
||||
UsersUpdateUserResponse,
|
||||
UsersDeleteUserData,
|
||||
UsersDeleteUserResponse,
|
||||
UtilsTestEmailData,
|
||||
UtilsTestEmailResponse,
|
||||
UtilsHealthCheckResponse,
|
||||
} from "./types.gen"
|
||||
|
||||
export type TDataLoginAccessToken = {
|
||||
formData: Body_login_login_access_token
|
||||
}
|
||||
export type TDataRecoverPassword = {
|
||||
email: string
|
||||
}
|
||||
export type TDataResetPassword = {
|
||||
requestBody: NewPassword
|
||||
}
|
||||
export type TDataRecoverPasswordHtmlContent = {
|
||||
email: string
|
||||
export class ItemsService {
|
||||
/**
|
||||
* Read Items
|
||||
* Retrieve items.
|
||||
* @param data The data for the request.
|
||||
* @param data.skip
|
||||
* @param data.limit
|
||||
* @returns ItemsPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readItems(
|
||||
data: ItemsReadItemsData = {},
|
||||
): CancelablePromise<ItemsReadItemsResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/items/",
|
||||
query: {
|
||||
skip: data.skip,
|
||||
limit: data.limit,
|
||||
},
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Item
|
||||
* Create new item.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns ItemPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static createItem(
|
||||
data: ItemsCreateItemData,
|
||||
): CancelablePromise<ItemsCreateItemResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/items/",
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Item
|
||||
* Get item by ID.
|
||||
* @param data The data for the request.
|
||||
* @param data.id
|
||||
* @returns ItemPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readItem(
|
||||
data: ItemsReadItemData,
|
||||
): CancelablePromise<ItemsReadItemResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/items/{id}",
|
||||
path: {
|
||||
id: data.id,
|
||||
},
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Item
|
||||
* Update an item.
|
||||
* @param data The data for the request.
|
||||
* @param data.id
|
||||
* @param data.requestBody
|
||||
* @returns ItemPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updateItem(
|
||||
data: ItemsUpdateItemData,
|
||||
): CancelablePromise<ItemsUpdateItemResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "PUT",
|
||||
url: "/api/v1/items/{id}",
|
||||
path: {
|
||||
id: data.id,
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Item
|
||||
* Delete an item.
|
||||
* @param data The data for the request.
|
||||
* @param data.id
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteItem(
|
||||
data: ItemsDeleteItemData,
|
||||
): CancelablePromise<ItemsDeleteItemResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "DELETE",
|
||||
url: "/api/v1/items/{id}",
|
||||
path: {
|
||||
id: data.id,
|
||||
},
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class LoginService {
|
||||
/**
|
||||
* Login Access Token
|
||||
* OAuth2 compatible token login, get an access token for future requests
|
||||
* @param data The data for the request.
|
||||
* @param data.formData
|
||||
* @returns Token Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static loginAccessToken(
|
||||
data: TDataLoginAccessToken,
|
||||
): CancelablePromise<Token> {
|
||||
const { formData } = data
|
||||
data: LoginLoginAccessTokenData,
|
||||
): CancelablePromise<LoginLoginAccessTokenResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/login/access-token",
|
||||
formData: formData,
|
||||
formData: data.formData,
|
||||
mediaType: "application/x-www-form-urlencoded",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -61,7 +196,7 @@ export class LoginService {
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static testToken(): CancelablePromise<UserPublic> {
|
||||
public static testToken(): CancelablePromise<LoginTestTokenResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/login/test-token",
|
||||
@@ -71,21 +206,22 @@ export class LoginService {
|
||||
/**
|
||||
* Recover Password
|
||||
* Password Recovery
|
||||
* @param data The data for the request.
|
||||
* @param data.email
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static recoverPassword(
|
||||
data: TDataRecoverPassword,
|
||||
): CancelablePromise<Message> {
|
||||
const { email } = data
|
||||
data: LoginRecoverPasswordData,
|
||||
): CancelablePromise<LoginRecoverPasswordResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/password-recovery/{email}",
|
||||
path: {
|
||||
email,
|
||||
email: data.email,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -93,20 +229,21 @@ export class LoginService {
|
||||
/**
|
||||
* Reset Password
|
||||
* Reset password
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static resetPassword(
|
||||
data: TDataResetPassword,
|
||||
): CancelablePromise<Message> {
|
||||
const { requestBody } = data
|
||||
data: LoginResetPasswordData,
|
||||
): CancelablePromise<LoginResetPasswordResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/reset-password/",
|
||||
body: requestBody,
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -114,73 +251,49 @@ export class LoginService {
|
||||
/**
|
||||
* Recover Password Html Content
|
||||
* HTML Content for Password Recovery
|
||||
* @param data The data for the request.
|
||||
* @param data.email
|
||||
* @returns string Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static recoverPasswordHtmlContent(
|
||||
data: TDataRecoverPasswordHtmlContent,
|
||||
): CancelablePromise<string> {
|
||||
const { email } = data
|
||||
data: LoginRecoverPasswordHtmlContentData,
|
||||
): CancelablePromise<LoginRecoverPasswordHtmlContentResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/password-recovery-html-content/{email}",
|
||||
path: {
|
||||
email,
|
||||
email: data.email,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type TDataReadUsers = {
|
||||
limit?: number
|
||||
skip?: number
|
||||
}
|
||||
export type TDataCreateUser = {
|
||||
requestBody: UserCreate
|
||||
}
|
||||
export type TDataUpdateUserMe = {
|
||||
requestBody: UserUpdateMe
|
||||
}
|
||||
export type TDataUpdatePasswordMe = {
|
||||
requestBody: UpdatePassword
|
||||
}
|
||||
export type TDataRegisterUser = {
|
||||
requestBody: UserRegister
|
||||
}
|
||||
export type TDataReadUserById = {
|
||||
userId: string
|
||||
}
|
||||
export type TDataUpdateUser = {
|
||||
requestBody: UserUpdate
|
||||
userId: string
|
||||
}
|
||||
export type TDataDeleteUser = {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export class UsersService {
|
||||
/**
|
||||
* Read Users
|
||||
* Retrieve users.
|
||||
* @param data The data for the request.
|
||||
* @param data.skip
|
||||
* @param data.limit
|
||||
* @returns UsersPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readUsers(
|
||||
data: TDataReadUsers = {},
|
||||
): CancelablePromise<UsersPublic> {
|
||||
const { limit = 100, skip = 0 } = data
|
||||
data: UsersReadUsersData = {},
|
||||
): CancelablePromise<UsersReadUsersResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/users/",
|
||||
query: {
|
||||
skip,
|
||||
limit,
|
||||
skip: data.skip,
|
||||
limit: data.limit,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -188,20 +301,21 @@ export class UsersService {
|
||||
/**
|
||||
* Create User
|
||||
* Create new user.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static createUser(
|
||||
data: TDataCreateUser,
|
||||
): CancelablePromise<UserPublic> {
|
||||
const { requestBody } = data
|
||||
data: UsersCreateUserData,
|
||||
): CancelablePromise<UsersCreateUserResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/users/",
|
||||
body: requestBody,
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -212,7 +326,7 @@ export class UsersService {
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readUserMe(): CancelablePromise<UserPublic> {
|
||||
public static readUserMe(): CancelablePromise<UsersReadUserMeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/users/me",
|
||||
@@ -225,7 +339,7 @@ export class UsersService {
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteUserMe(): CancelablePromise<Message> {
|
||||
public static deleteUserMe(): CancelablePromise<UsersDeleteUserMeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "DELETE",
|
||||
url: "/api/v1/users/me",
|
||||
@@ -235,20 +349,21 @@ export class UsersService {
|
||||
/**
|
||||
* Update User Me
|
||||
* Update own user.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updateUserMe(
|
||||
data: TDataUpdateUserMe,
|
||||
): CancelablePromise<UserPublic> {
|
||||
const { requestBody } = data
|
||||
data: UsersUpdateUserMeData,
|
||||
): CancelablePromise<UsersUpdateUserMeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "PATCH",
|
||||
url: "/api/v1/users/me",
|
||||
body: requestBody,
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -256,20 +371,21 @@ export class UsersService {
|
||||
/**
|
||||
* Update Password Me
|
||||
* Update own password.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updatePasswordMe(
|
||||
data: TDataUpdatePasswordMe,
|
||||
): CancelablePromise<Message> {
|
||||
const { requestBody } = data
|
||||
data: UsersUpdatePasswordMeData,
|
||||
): CancelablePromise<UsersUpdatePasswordMeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "PATCH",
|
||||
url: "/api/v1/users/me/password",
|
||||
body: requestBody,
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -277,20 +393,21 @@ export class UsersService {
|
||||
/**
|
||||
* Register User
|
||||
* Create new user without the need to be logged in.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static registerUser(
|
||||
data: TDataRegisterUser,
|
||||
): CancelablePromise<UserPublic> {
|
||||
const { requestBody } = data
|
||||
data: UsersRegisterUserData,
|
||||
): CancelablePromise<UsersRegisterUserResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/users/signup",
|
||||
body: requestBody,
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -298,21 +415,22 @@ export class UsersService {
|
||||
/**
|
||||
* Read User By Id
|
||||
* Get a specific user by id.
|
||||
* @param data The data for the request.
|
||||
* @param data.userId
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readUserById(
|
||||
data: TDataReadUserById,
|
||||
): CancelablePromise<UserPublic> {
|
||||
const { userId } = data
|
||||
data: UsersReadUserByIdData,
|
||||
): CancelablePromise<UsersReadUserByIdResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/users/{user_id}",
|
||||
path: {
|
||||
user_id: userId,
|
||||
user_id: data.userId,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -320,23 +438,25 @@ export class UsersService {
|
||||
/**
|
||||
* Update User
|
||||
* Update a user.
|
||||
* @param data The data for the request.
|
||||
* @param data.userId
|
||||
* @param data.requestBody
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updateUser(
|
||||
data: TDataUpdateUser,
|
||||
): CancelablePromise<UserPublic> {
|
||||
const { requestBody, userId } = data
|
||||
data: UsersUpdateUserData,
|
||||
): CancelablePromise<UsersUpdateUserResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "PATCH",
|
||||
url: "/api/v1/users/{user_id}",
|
||||
path: {
|
||||
user_id: userId,
|
||||
user_id: data.userId,
|
||||
},
|
||||
body: requestBody,
|
||||
body: data.requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -344,45 +464,47 @@ export class UsersService {
|
||||
/**
|
||||
* Delete User
|
||||
* Delete a user.
|
||||
* @param data The data for the request.
|
||||
* @param data.userId
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteUser(data: TDataDeleteUser): CancelablePromise<Message> {
|
||||
const { userId } = data
|
||||
public static deleteUser(
|
||||
data: UsersDeleteUserData,
|
||||
): CancelablePromise<UsersDeleteUserResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "DELETE",
|
||||
url: "/api/v1/users/{user_id}",
|
||||
path: {
|
||||
user_id: userId,
|
||||
user_id: data.userId,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type TDataTestEmail = {
|
||||
emailTo: string
|
||||
}
|
||||
|
||||
export class UtilsService {
|
||||
/**
|
||||
* Test Email
|
||||
* Test emails.
|
||||
* @param data The data for the request.
|
||||
* @param data.emailTo
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static testEmail(data: TDataTestEmail): CancelablePromise<Message> {
|
||||
const { emailTo } = data
|
||||
public static testEmail(
|
||||
data: UtilsTestEmailData,
|
||||
): CancelablePromise<UtilsTestEmailResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/utils/test-email/",
|
||||
query: {
|
||||
email_to: emailTo,
|
||||
email_to: data.emailTo,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
422: "Validation Error",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -392,138 +514,10 @@ export class UtilsService {
|
||||
* @returns boolean Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static healthCheck(): CancelablePromise<boolean> {
|
||||
public static healthCheck(): CancelablePromise<UtilsHealthCheckResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/utils/health-check/",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type TDataReadItems = {
|
||||
limit?: number
|
||||
skip?: number
|
||||
}
|
||||
export type TDataCreateItem = {
|
||||
requestBody: ItemCreate
|
||||
}
|
||||
export type TDataReadItem = {
|
||||
id: string
|
||||
}
|
||||
export type TDataUpdateItem = {
|
||||
id: string
|
||||
requestBody: ItemUpdate
|
||||
}
|
||||
export type TDataDeleteItem = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export class ItemsService {
|
||||
/**
|
||||
* Read Items
|
||||
* Retrieve items.
|
||||
* @returns ItemsPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readItems(
|
||||
data: TDataReadItems = {},
|
||||
): CancelablePromise<ItemsPublic> {
|
||||
const { limit = 100, skip = 0 } = data
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/items/",
|
||||
query: {
|
||||
skip,
|
||||
limit,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Item
|
||||
* Create new item.
|
||||
* @returns ItemPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static createItem(
|
||||
data: TDataCreateItem,
|
||||
): CancelablePromise<ItemPublic> {
|
||||
const { requestBody } = data
|
||||
return __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/items/",
|
||||
body: requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Item
|
||||
* Get item by ID.
|
||||
* @returns ItemPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readItem(data: TDataReadItem): CancelablePromise<ItemPublic> {
|
||||
const { id } = data
|
||||
return __request(OpenAPI, {
|
||||
method: "GET",
|
||||
url: "/api/v1/items/{id}",
|
||||
path: {
|
||||
id,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Item
|
||||
* Update an item.
|
||||
* @returns ItemPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updateItem(
|
||||
data: TDataUpdateItem,
|
||||
): CancelablePromise<ItemPublic> {
|
||||
const { id, requestBody } = data
|
||||
return __request(OpenAPI, {
|
||||
method: "PUT",
|
||||
url: "/api/v1/items/{id}",
|
||||
path: {
|
||||
id,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: "application/json",
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Item
|
||||
* Delete an item.
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteItem(data: TDataDeleteItem): CancelablePromise<Message> {
|
||||
const { id } = data
|
||||
return __request(OpenAPI, {
|
||||
method: "DELETE",
|
||||
url: "/api/v1/items/{id}",
|
||||
path: {
|
||||
id,
|
||||
},
|
||||
errors: {
|
||||
422: `Validation Error`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type Body_login_login_access_token = {
|
||||
grant_type?: string | null
|
||||
username: string
|
||||
password: string
|
||||
scope?: string
|
||||
client_id?: string | null
|
||||
client_secret?: string | null
|
||||
}
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: Array<ValidationError>
|
||||
}
|
||||
|
||||
export type ItemCreate = {
|
||||
title: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ItemPublic = {
|
||||
title: string
|
||||
description?: string | null
|
||||
id: string
|
||||
owner_id: string
|
||||
}
|
||||
|
||||
export type ItemsPublic = {
|
||||
data: Array<ItemPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type ItemUpdate = {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type Message = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NewPassword = {
|
||||
token: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type Token = {
|
||||
access_token: string
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export type UpdatePassword = {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type UserCreate = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
export type UserPublic = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
id: string
|
||||
}
|
||||
|
||||
export type UserRegister = {
|
||||
email: string
|
||||
password: string
|
||||
full_name?: string | null
|
||||
}
|
||||
|
||||
export type UsersPublic = {
|
||||
data: Array<UserPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type UserUpdate = {
|
||||
email?: string | null
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdateMe = {
|
||||
full_name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export type ValidationError = {
|
||||
loc: Array<string | number>
|
||||
msg: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type ItemsReadItemsData = {
|
||||
limit?: number
|
||||
skip?: number
|
||||
}
|
||||
|
||||
export type ItemsReadItemsResponse = ItemsPublic
|
||||
|
||||
export type ItemsCreateItemData = {
|
||||
requestBody: ItemCreate
|
||||
}
|
||||
|
||||
export type ItemsCreateItemResponse = ItemPublic
|
||||
|
||||
export type ItemsReadItemData = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ItemsReadItemResponse = ItemPublic
|
||||
|
||||
export type ItemsUpdateItemData = {
|
||||
id: string
|
||||
requestBody: ItemUpdate
|
||||
}
|
||||
|
||||
export type ItemsUpdateItemResponse = ItemPublic
|
||||
|
||||
export type ItemsDeleteItemData = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ItemsDeleteItemResponse = Message
|
||||
|
||||
export type LoginLoginAccessTokenData = {
|
||||
formData: Body_login_login_access_token
|
||||
}
|
||||
|
||||
export type LoginLoginAccessTokenResponse = Token
|
||||
|
||||
export type LoginTestTokenResponse = UserPublic
|
||||
|
||||
export type LoginRecoverPasswordData = {
|
||||
email: string
|
||||
}
|
||||
|
||||
export type LoginRecoverPasswordResponse = Message
|
||||
|
||||
export type LoginResetPasswordData = {
|
||||
requestBody: NewPassword
|
||||
}
|
||||
|
||||
export type LoginResetPasswordResponse = Message
|
||||
|
||||
export type LoginRecoverPasswordHtmlContentData = {
|
||||
email: string
|
||||
}
|
||||
|
||||
export type LoginRecoverPasswordHtmlContentResponse = string
|
||||
|
||||
export type UsersReadUsersData = {
|
||||
limit?: number
|
||||
skip?: number
|
||||
}
|
||||
|
||||
export type UsersReadUsersResponse = UsersPublic
|
||||
|
||||
export type UsersCreateUserData = {
|
||||
requestBody: UserCreate
|
||||
}
|
||||
|
||||
export type UsersCreateUserResponse = UserPublic
|
||||
|
||||
export type UsersReadUserMeResponse = UserPublic
|
||||
|
||||
export type UsersDeleteUserMeResponse = Message
|
||||
|
||||
export type UsersUpdateUserMeData = {
|
||||
requestBody: UserUpdateMe
|
||||
}
|
||||
|
||||
export type UsersUpdateUserMeResponse = UserPublic
|
||||
|
||||
export type UsersUpdatePasswordMeData = {
|
||||
requestBody: UpdatePassword
|
||||
}
|
||||
|
||||
export type UsersUpdatePasswordMeResponse = Message
|
||||
|
||||
export type UsersRegisterUserData = {
|
||||
requestBody: UserRegister
|
||||
}
|
||||
|
||||
export type UsersRegisterUserResponse = UserPublic
|
||||
|
||||
export type UsersReadUserByIdData = {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export type UsersReadUserByIdResponse = UserPublic
|
||||
|
||||
export type UsersUpdateUserData = {
|
||||
requestBody: UserUpdate
|
||||
userId: string
|
||||
}
|
||||
|
||||
export type UsersUpdateUserResponse = UserPublic
|
||||
|
||||
export type UsersDeleteUserData = {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export type UsersDeleteUserResponse = Message
|
||||
|
||||
export type UtilsTestEmailData = {
|
||||
emailTo: string
|
||||
}
|
||||
|
||||
export type UtilsTestEmailResponse = Message
|
||||
|
||||
export type UtilsHealthCheckResponse = boolean
|
||||
@@ -1,45 +1,48 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type UserCreate, UsersService } from "@/client"
|
||||
import type { ApiError } from "@/client/core/ApiError"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "@/utils"
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DialogActionTrigger,
|
||||
DialogTitle,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type UserCreate, UsersService } from "../../client"
|
||||
import type { ApiError } from "../../client/core/ApiError"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
interface AddUserProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
import { useState } from "react"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
import { Checkbox } from "../ui/checkbox"
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
interface UserCreateForm extends UserCreate {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
const AddUser = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<UserCreateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -57,12 +60,12 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
mutationFn: (data: UserCreate) =>
|
||||
UsersService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User created successfully.", "success")
|
||||
showSuccessToast("User created successfully.")
|
||||
reset()
|
||||
onClose()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
@@ -74,20 +77,34 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button value="add-user" my={4}>
|
||||
<FaPlus fontSize="16px" />
|
||||
Add User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>
|
||||
Fill in the form below to add a new user to the system.
|
||||
</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.email}
|
||||
errorText={errors.email?.message}
|
||||
label="Email"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
@@ -97,24 +114,27 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
invalid={!!errors.full_name}
|
||||
errorText={errors.full_name?.message}
|
||||
label="Full Name"
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name")}
|
||||
placeholder="Full name"
|
||||
type="text"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.password}
|
||||
errorText={errors.password?.message}
|
||||
label="Set Password"
|
||||
>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
@@ -127,16 +147,14 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
mt={4}
|
||||
isRequired
|
||||
isInvalid={!!errors.confirm_password}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.confirm_password}
|
||||
errorText={errors.confirm_password?.message}
|
||||
label="Confirm Password"
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", {
|
||||
@@ -148,34 +166,64 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex mt={4}>
|
||||
<FormControl>
|
||||
<Checkbox {...register("is_superuser")} colorScheme="teal">
|
||||
</Field>
|
||||
</VStack>
|
||||
|
||||
<Flex mt={4} direction="column" gap={4}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_superuser"
|
||||
render={({ field }) => (
|
||||
<Field disabled={field.disabled} colorPalette="teal">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={({ checked }) => field.onChange(checked)}
|
||||
>
|
||||
Is superuser?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Checkbox {...register("is_active")} colorScheme="teal">
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<Field disabled={field.disabled} colorPalette="teal">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={({ checked }) => field.onChange(checked)}
|
||||
>
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogCloseTrigger />
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Button, DialogTitle, Text } from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { FiTrash2 } from "react-icons/fi"
|
||||
|
||||
import { UsersService } from "@/client"
|
||||
import {
|
||||
DialogActionTrigger,
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
|
||||
const DeleteUser = ({ id }: { id: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm()
|
||||
|
||||
const deleteUser = async (id: string) => {
|
||||
await UsersService.deleteUser({ userId: id })
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteUser,
|
||||
onSuccess: () => {
|
||||
showSuccessToast("The user was deleted successfully")
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
showErrorToast("An error occurred while deleting the user")
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
role="alertdialog"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" colorPalette="red">
|
||||
<FiTrash2 fontSize="16px" />
|
||||
Delete User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>
|
||||
All items associated with this user will also be{" "}
|
||||
<strong>permanently deleted.</strong> Are you sure? You will not
|
||||
be able to undo this action.
|
||||
</Text>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="red"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogCloseTrigger />
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteUser
|
||||
@@ -1,51 +1,53 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type UserPublic,
|
||||
type UserUpdate,
|
||||
UsersService,
|
||||
} from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
Button,
|
||||
DialogActionTrigger,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
Flex,
|
||||
Input,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useState } from "react"
|
||||
import { FaExchangeAlt } from "react-icons/fa"
|
||||
|
||||
import { type UserPublic, type UserUpdate, UsersService } from "@/client"
|
||||
import type { ApiError } from "@/client/core/ApiError"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "@/utils"
|
||||
import { Checkbox } from "../ui/checkbox"
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
interface EditUserProps {
|
||||
user: UserPublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface UserUpdateForm extends UserUpdate {
|
||||
confirm_password: string
|
||||
confirm_password?: string
|
||||
}
|
||||
|
||||
const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
const EditUser = ({ user }: EditUserProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserUpdateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -56,11 +58,12 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
mutationFn: (data: UserUpdateForm) =>
|
||||
UsersService.updateUser({ userId: user.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
onClose()
|
||||
showSuccessToast("User updated successfully.")
|
||||
reset()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
@@ -74,26 +77,33 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<FaExchangeAlt fontSize="16px" />
|
||||
Edit User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>Update the user details below.</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.email}
|
||||
errorText={errors.email?.message}
|
||||
label="Email"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
@@ -103,16 +113,27 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
<Input id="name" {...register("full_name")} type="text" />
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
invalid={!!errors.full_name}
|
||||
errorText={errors.full_name?.message}
|
||||
label="Full Name"
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name")}
|
||||
placeholder="Full name"
|
||||
type="text"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.password}
|
||||
errorText={errors.password?.message}
|
||||
label="Set Password"
|
||||
>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
@@ -124,12 +145,14 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.confirm_password}
|
||||
errorText={errors.confirm_password?.message}
|
||||
label="Confirm Password"
|
||||
>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", {
|
||||
@@ -140,40 +163,59 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex>
|
||||
<FormControl mt={4}>
|
||||
<Checkbox {...register("is_superuser")} colorScheme="teal">
|
||||
</Field>
|
||||
</VStack>
|
||||
|
||||
<Flex mt={4} direction="column" gap={4}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_superuser"
|
||||
render={({ field }) => (
|
||||
<Field disabled={field.disabled} colorPalette="teal">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={({ checked }) => field.onChange(checked)}
|
||||
>
|
||||
Is superuser?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<Checkbox {...register("is_active")} colorScheme="teal">
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<Field disabled={field.disabled} colorPalette="teal">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={({ checked }) => field.onChange(checked)}
|
||||
>
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</DialogBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
</DialogFooter>
|
||||
<DialogCloseTrigger />
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { BsThreeDotsVertical } from "react-icons/bs"
|
||||
import { FiEdit, FiTrash } from "react-icons/fi"
|
||||
|
||||
import type { ItemPublic, UserPublic } from "../../client"
|
||||
import EditUser from "../Admin/EditUser"
|
||||
import EditItem from "../Items/EditItem"
|
||||
import Delete from "./DeleteAlert"
|
||||
|
||||
interface ActionsMenuProps {
|
||||
type: string
|
||||
value: ItemPublic | UserPublic
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => {
|
||||
const editUserModal = useDisclosure()
|
||||
const deleteModal = useDisclosure()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
isDisabled={disabled}
|
||||
as={Button}
|
||||
rightIcon={<BsThreeDotsVertical />}
|
||||
variant="unstyled"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
onClick={editUserModal.onOpen}
|
||||
icon={<FiEdit fontSize="16px" />}
|
||||
>
|
||||
Edit {type}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={deleteModal.onOpen}
|
||||
icon={<FiTrash fontSize="16px" />}
|
||||
color="ui.danger"
|
||||
>
|
||||
Delete {type}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
{type === "User" ? (
|
||||
<EditUser
|
||||
user={value as UserPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
) : (
|
||||
<EditItem
|
||||
item={value as ItemPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)}
|
||||
<Delete
|
||||
type={type}
|
||||
id={value.id}
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={deleteModal.onClose}
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionsMenu
|
||||
@@ -1,113 +0,0 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { ItemsService, UsersService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
|
||||
interface DeleteProps {
|
||||
type: string
|
||||
id: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm()
|
||||
|
||||
const deleteEntity = async (id: string) => {
|
||||
if (type === "Item") {
|
||||
await ItemsService.deleteItem({ id: id })
|
||||
} else if (type === "User") {
|
||||
await UsersService.deleteUser({ userId: id })
|
||||
} else {
|
||||
throw new Error(`Unexpected type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteEntity,
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Success",
|
||||
`The ${type.toLowerCase()} was deleted successfully.`,
|
||||
"success",
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
showToast(
|
||||
"An error occurred.",
|
||||
`An error occurred while deleting the ${type.toLowerCase()}.`,
|
||||
"error",
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [type === "Item" ? "items" : "users"],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<AlertDialogHeader>Delete {type}</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
{type === "User" && (
|
||||
<span>
|
||||
All items associated with this user will also be{" "}
|
||||
<strong>permantly deleted. </strong>
|
||||
</span>
|
||||
)}
|
||||
Are you sure? You will not be able to undo this action.
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter gap={3}>
|
||||
<Button variant="danger" type="submit" isLoading={isSubmitting}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Delete
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IconButton } from "@chakra-ui/react"
|
||||
import { BsThreeDotsVertical } from "react-icons/bs"
|
||||
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
|
||||
|
||||
import type { ItemPublic } from "@/client"
|
||||
import DeleteItem from "../Items/DeleteItem"
|
||||
import EditItem from "../Items/EditItem"
|
||||
|
||||
interface ItemActionsMenuProps {
|
||||
item: ItemPublic
|
||||
}
|
||||
|
||||
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
|
||||
return (
|
||||
<MenuRoot>
|
||||
<MenuTrigger asChild>
|
||||
<IconButton variant="ghost" color="inherit">
|
||||
<BsThreeDotsVertical />
|
||||
</IconButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<EditItem item={item} />
|
||||
<DeleteItem id={item.id} />
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,31 @@
|
||||
import type { ComponentType, ElementType } from "react"
|
||||
import { Flex, Image, useBreakpointValue } from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
import UserMenu from "./UserMenu"
|
||||
|
||||
interface NavbarProps {
|
||||
type: string
|
||||
addModalAs: ComponentType | ElementType
|
||||
}
|
||||
function Navbar() {
|
||||
const display = useBreakpointValue({ base: "none", md: "flex" })
|
||||
|
||||
const Navbar = ({ type, addModalAs }: NavbarProps) => {
|
||||
const addModal = useDisclosure()
|
||||
|
||||
const AddModal = addModalAs
|
||||
return (
|
||||
<>
|
||||
<Flex py={8} gap={4}>
|
||||
{/* TODO: Complete search functionality */}
|
||||
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<Icon as={FaSearch} color='ui.dim' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
|
||||
</InputGroup> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
onClick={addModal.onOpen}
|
||||
<Flex
|
||||
display={display}
|
||||
justify="space-between"
|
||||
position="sticky"
|
||||
color="white"
|
||||
align="center"
|
||||
bg="bg.muted"
|
||||
w="100%"
|
||||
top={0}
|
||||
p={4}
|
||||
>
|
||||
<Icon as={FaPlus} /> Add {type}
|
||||
</Button>
|
||||
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} />
|
||||
<Link to="/">
|
||||
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" px={2} />
|
||||
</Link>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<UserMenu />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,55 @@
|
||||
import { Button, Container, Text } from "@chakra-ui/react"
|
||||
import { Button, Center, Flex, Text } from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
h="100vh"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
maxW="sm"
|
||||
centerContent
|
||||
<Flex
|
||||
height="100vh"
|
||||
align="center"
|
||||
justify="center"
|
||||
flexDir="column"
|
||||
data-testid="not-found"
|
||||
p={4}
|
||||
>
|
||||
<Flex alignItems="center" zIndex={1}>
|
||||
<Flex flexDir="column" ml={4} align="center" justify="center" p={4}>
|
||||
<Text
|
||||
fontSize="8xl"
|
||||
color="ui.main"
|
||||
fontSize={{ base: "6xl", md: "8xl" }}
|
||||
fontWeight="bold"
|
||||
lineHeight="1"
|
||||
mb={4}
|
||||
>
|
||||
404
|
||||
</Text>
|
||||
<Text fontSize="md">Oops!</Text>
|
||||
<Text fontSize="md">Page not found.</Text>
|
||||
<Button
|
||||
as={Link}
|
||||
to="/"
|
||||
color="ui.main"
|
||||
borderColor="ui.main"
|
||||
variant="outline"
|
||||
mt={4}
|
||||
<Text fontSize="2xl" fontWeight="bold" mb={2}>
|
||||
Oops!
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
fontSize="lg"
|
||||
color="gray.600"
|
||||
mb={4}
|
||||
textAlign="center"
|
||||
zIndex={1}
|
||||
>
|
||||
Go back
|
||||
The page you are looking for was not found.
|
||||
</Text>
|
||||
<Center zIndex={1}>
|
||||
<Link to="/">
|
||||
<Button
|
||||
variant="solid"
|
||||
colorScheme="teal"
|
||||
mt={4}
|
||||
alignSelf="center"
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</Container>
|
||||
</Link>
|
||||
</Center>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
IconButton,
|
||||
Image,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { Box, Flex, IconButton, Text } from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { FiLogOut, FiMenu } from "react-icons/fi"
|
||||
import { useState } from "react"
|
||||
import { FaBars } from "react-icons/fa"
|
||||
import { FiLogOut } from "react-icons/fi"
|
||||
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
import type { UserPublic } from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import type { UserPublic } from "@/client"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import {
|
||||
DrawerBackdrop,
|
||||
DrawerBody,
|
||||
DrawerCloseTrigger,
|
||||
DrawerContent,
|
||||
DrawerRoot,
|
||||
DrawerTrigger,
|
||||
} from "../ui/drawer"
|
||||
import SidebarItems from "./SidebarItems"
|
||||
|
||||
const Sidebar = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const bgColor = useColorModeValue("ui.light", "ui.dark")
|
||||
const textColor = useColorModeValue("ui.dark", "ui.light")
|
||||
const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate")
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { logout } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
@@ -36,78 +29,68 @@ const Sidebar = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile */}
|
||||
<DrawerRoot
|
||||
placement="start"
|
||||
open={open}
|
||||
onOpenChange={(e) => setOpen(e.open)}
|
||||
>
|
||||
<DrawerBackdrop />
|
||||
<DrawerTrigger asChild>
|
||||
<IconButton
|
||||
onClick={onOpen}
|
||||
variant="ghost"
|
||||
color="inherit"
|
||||
display={{ base: "flex", md: "none" }}
|
||||
aria-label="Open Menu"
|
||||
position="absolute"
|
||||
fontSize="20px"
|
||||
zIndex="100"
|
||||
m={4}
|
||||
icon={<FiMenu />}
|
||||
/>
|
||||
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxW="250px">
|
||||
<DrawerCloseButton />
|
||||
<DrawerBody py={8}>
|
||||
>
|
||||
<FaBars />
|
||||
</IconButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent maxW="280px">
|
||||
<DrawerCloseTrigger />
|
||||
<DrawerBody>
|
||||
<Flex flexDir="column" justify="space-between">
|
||||
<Box>
|
||||
<Image src={Logo} alt="logo" p={6} />
|
||||
<SidebarItems onClose={onClose} />
|
||||
<SidebarItems />
|
||||
<Flex
|
||||
as="button"
|
||||
onClick={handleLogout}
|
||||
p={2}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<FiLogOut />
|
||||
<Text ml={2}>Log out</Text>
|
||||
<Text>Log Out</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
{currentUser?.email && (
|
||||
<Text color={textColor} noOfLines={2} fontSize="sm" p={2}>
|
||||
<Text fontSize="sm" p={2}>
|
||||
Logged in as: {currentUser.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</DrawerBody>
|
||||
<DrawerCloseTrigger />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</DrawerRoot>
|
||||
|
||||
{/* Desktop */}
|
||||
|
||||
<Box
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
h="100vh"
|
||||
position="sticky"
|
||||
top="0"
|
||||
display={{ base: "none", md: "flex" }}
|
||||
>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
justify="space-between"
|
||||
bg={secBgColor}
|
||||
position="sticky"
|
||||
bg="bg.subtle"
|
||||
top={0}
|
||||
minW="280px"
|
||||
h="100vh"
|
||||
p={4}
|
||||
borderRadius={12}
|
||||
>
|
||||
<Box>
|
||||
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
|
||||
<Box w="100%">
|
||||
<SidebarItems />
|
||||
</Box>
|
||||
{currentUser?.email && (
|
||||
<Text
|
||||
color={textColor}
|
||||
noOfLines={2}
|
||||
fontSize="sm"
|
||||
p={2}
|
||||
maxW="180px"
|
||||
>
|
||||
Logged in as: {currentUser.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react"
|
||||
import { Box, Flex, Icon, Text } from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Link as RouterLink } from "@tanstack/react-router"
|
||||
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
|
||||
import type { IconType } from "react-icons/lib"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
import type { UserPublic } from "@/client"
|
||||
|
||||
const items = [
|
||||
{ icon: FiHome, title: "Dashboard", path: "/" },
|
||||
@@ -15,39 +16,43 @@ interface SidebarItemsProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface Item {
|
||||
icon: IconType
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const SidebarItems = ({ onClose }: SidebarItemsProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const textColor = useColorModeValue("ui.main", "ui.light")
|
||||
const bgActive = useColorModeValue("#E2E8F0", "#4A5568")
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
|
||||
const finalItems = currentUser?.is_superuser
|
||||
const finalItems: Item[] = currentUser?.is_superuser
|
||||
? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }]
|
||||
: items
|
||||
|
||||
const listItems = finalItems.map(({ icon, title, path }) => (
|
||||
<RouterLink key={title} to={path} onClick={onClose}>
|
||||
<Flex
|
||||
as={Link}
|
||||
to={path}
|
||||
w="100%"
|
||||
p={2}
|
||||
key={title}
|
||||
activeProps={{
|
||||
style: {
|
||||
background: bgActive,
|
||||
borderRadius: "12px",
|
||||
},
|
||||
gap={4}
|
||||
px={4}
|
||||
py={2}
|
||||
_hover={{
|
||||
background: "gray.subtle",
|
||||
}}
|
||||
color={textColor}
|
||||
onClick={onClose}
|
||||
alignItems="center"
|
||||
fontSize="sm"
|
||||
>
|
||||
<Icon as={icon} alignSelf="center" />
|
||||
<Text ml={2}>{title}</Text>
|
||||
</Flex>
|
||||
</RouterLink>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
|
||||
Menu
|
||||
</Text>
|
||||
<Box>{listItems}</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconButton } from "@chakra-ui/react"
|
||||
import { BsThreeDotsVertical } from "react-icons/bs"
|
||||
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
|
||||
|
||||
import type { UserPublic } from "@/client"
|
||||
import DeleteUser from "../Admin/DeleteUser"
|
||||
import EditUser from "../Admin/EditUser"
|
||||
|
||||
interface UserActionsMenuProps {
|
||||
user: UserPublic
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const UserActionsMenu = ({ user, disabled }: UserActionsMenuProps) => {
|
||||
return (
|
||||
<MenuRoot>
|
||||
<MenuTrigger asChild>
|
||||
<IconButton variant="ghost" color="inherit" disabled={disabled}>
|
||||
<BsThreeDotsVertical />
|
||||
</IconButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<EditUser user={user} />
|
||||
<DeleteUser id={user.id} />
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@chakra-ui/react"
|
||||
import { Box, Button, Flex, Text } from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { FaUserAstronaut } from "react-icons/fa"
|
||||
import { FiLogOut, FiUser } from "react-icons/fi"
|
||||
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu"
|
||||
|
||||
const UserMenu = () => {
|
||||
const { logout } = useAuth()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
@@ -22,36 +16,47 @@ const UserMenu = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
position="fixed"
|
||||
top={4}
|
||||
right={4}
|
||||
>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<FaUserAstronaut color="white" fontSize="18px" />}
|
||||
bg="ui.main"
|
||||
isRound
|
||||
<Flex>
|
||||
<MenuRoot>
|
||||
<MenuTrigger asChild p={2}>
|
||||
<Button
|
||||
data-testid="user-menu"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<FiUser fontSize="18px" />} as={Link} to="settings">
|
||||
My profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FiLogOut fontSize="18px" />}
|
||||
onClick={handleLogout}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
variant="solid"
|
||||
maxW="150px"
|
||||
truncate
|
||||
>
|
||||
Log out
|
||||
<FaUserAstronaut fontSize="18" />
|
||||
<Text>{user?.full_name || "User"}</Text>
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuContent>
|
||||
<Link to="settings">
|
||||
<MenuItem
|
||||
closeOnSelect
|
||||
value="user-settings"
|
||||
gap={2}
|
||||
py={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<FiUser fontSize="18px" />
|
||||
<Box flex="1">My Profile</Box>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Link>
|
||||
|
||||
<MenuItem
|
||||
value="logout"
|
||||
gap={2}
|
||||
py={2}
|
||||
onClick={handleLogout}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<FiLogOut />
|
||||
Log Out
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, type ItemCreate, ItemsService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import {
|
||||
Button,
|
||||
DialogActionTrigger,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useState } from "react"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
|
||||
interface AddItemProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
import { type ItemCreate, ItemsService } from "@/client"
|
||||
import type { ApiError } from "@/client/core/ApiError"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
const AddItem = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<ItemCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -45,12 +49,12 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
mutationFn: (data: ItemCreate) =>
|
||||
ItemsService.createItem({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item created successfully.", "success")
|
||||
showSuccessToast("Item created successfully.")
|
||||
reset()
|
||||
onClose()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
@@ -62,20 +66,32 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button value="add-item" my={4}>
|
||||
<FaPlus fontSize="16px" />
|
||||
Add Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>Fill in the details to add a new item.</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.title}
|
||||
errorText={errors.title?.message}
|
||||
label="Title"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
@@ -84,30 +100,46 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
invalid={!!errors.description}
|
||||
errorText={errors.description?.message}
|
||||
label="Description"
|
||||
>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
</Field>
|
||||
</VStack>
|
||||
</DialogBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogCloseTrigger />
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Button, DialogTitle, Text } from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { FiTrash2 } from "react-icons/fi"
|
||||
|
||||
import { ItemsService } from "@/client"
|
||||
import {
|
||||
DialogActionTrigger,
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
|
||||
const DeleteItem = ({ id }: { id: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm()
|
||||
|
||||
const deleteItem = async (id: string) => {
|
||||
await ItemsService.deleteItem({ id: id })
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteItem,
|
||||
onSuccess: () => {
|
||||
showSuccessToast("The item was deleted successfully")
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
showErrorToast("An error occurred while deleting the item")
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
role="alertdialog"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" colorPalette="red">
|
||||
<FiTrash2 fontSize="16px" />
|
||||
Delete Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogCloseTrigger />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>
|
||||
This item will be permanently deleted. Are you sure? You will not
|
||||
be able to undo this action.
|
||||
</Text>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="red"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteItem
|
||||
@@ -1,123 +1,150 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
ButtonGroup,
|
||||
DialogActionTrigger,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { FaExchangeAlt } from "react-icons/fa"
|
||||
|
||||
import { type ApiError, type ItemPublic, ItemsService } from "@/client"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
import {
|
||||
type ApiError,
|
||||
type ItemPublic,
|
||||
type ItemUpdate,
|
||||
ItemsService,
|
||||
} from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
interface EditItemProps {
|
||||
item: ItemPublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditItem = ({ item, isOpen, onClose }: EditItemProps) => {
|
||||
interface ItemUpdateForm {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const EditItem = ({ item }: EditItemProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ItemUpdate>({
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ItemUpdateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: item,
|
||||
defaultValues: {
|
||||
...item,
|
||||
description: item.description ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemUpdate) =>
|
||||
mutationFn: (data: ItemUpdateForm) =>
|
||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item updated successfully.", "success")
|
||||
onClose()
|
||||
showSuccessToast("Item updated successfully.")
|
||||
reset()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ItemUpdate> = async (data) => {
|
||||
const onSubmit: SubmitHandler<ItemUpdateForm> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<FaExchangeAlt fontSize="16px" />
|
||||
Edit Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>Update the item details below.</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.title}
|
||||
errorText={errors.title?.message}
|
||||
label="Title"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
invalid={!!errors.description}
|
||||
errorText={errors.description?.message}
|
||||
label="Description"
|
||||
>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
</Field>
|
||||
</VStack>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter gap={2}>
|
||||
<ButtonGroup>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogCloseTrigger />
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Skeleton, Table } from "@chakra-ui/react"
|
||||
|
||||
const PendingItems = () => (
|
||||
<Table.Root size={{ base: "sm", md: "md" }}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader w="30%">ID</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="30%">Title</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="30%">Description</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="10%">Actions</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Table.Row key={index}>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)
|
||||
|
||||
export default PendingItems
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Skeleton, Table } from "@chakra-ui/react"
|
||||
|
||||
const PendingUsers = () => (
|
||||
<Table.Root size={{ base: "sm", md: "md" }}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader w="20%">Full name</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="25%">Email</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="15%">Role</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="20%">Status</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="20%">Actions</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Table.Row key={index}>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Skeleton h="20px" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)
|
||||
|
||||
export default PendingUsers
|
||||
@@ -1,15 +1,10 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Heading,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
useColorMode,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Stack } from "@chakra-ui/react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Radio, RadioGroup } from "@/components/ui/radio"
|
||||
|
||||
const Appearance = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -17,18 +12,16 @@ const Appearance = () => {
|
||||
<Heading size="sm" py={4}>
|
||||
Appearance
|
||||
</Heading>
|
||||
<RadioGroup onChange={toggleColorMode} value={colorMode}>
|
||||
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setTheme(e.value)}
|
||||
value={theme}
|
||||
colorPalette="teal"
|
||||
>
|
||||
<Stack>
|
||||
{/* TODO: Add system default option */}
|
||||
<Radio value="light" colorScheme="teal">
|
||||
Light Mode
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
Default
|
||||
</Badge>
|
||||
</Radio>
|
||||
<Radio value="dark" colorScheme="teal">
|
||||
Dark Mode
|
||||
</Radio>
|
||||
<Radio value="system">System</Radio>
|
||||
<Radio value="light">Light Mode</Radio>
|
||||
<Radio value="dark">Dark Mode</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Container>
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { Box, Button, Container, Heading, VStack } from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { FiLock } from "react-icons/fi"
|
||||
|
||||
import { type ApiError, type UpdatePassword, UsersService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../../utils"
|
||||
import { type ApiError, type UpdatePassword, UsersService } from "@/client"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "@/utils"
|
||||
import { PasswordInput } from "../ui/password-input"
|
||||
|
||||
interface UpdatePasswordForm extends UpdatePassword {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const ChangePassword = () => {
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<UpdatePasswordForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -38,11 +29,11 @@ const ChangePassword = () => {
|
||||
mutationFn: (data: UpdatePassword) =>
|
||||
UsersService.updatePasswordMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
showSuccessToast("Password updated successfully.")
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -57,60 +48,39 @@ const ChangePassword = () => {
|
||||
Change Password
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
w={{ sm: "full", md: "300px" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl isRequired isInvalid={!!errors.current_password}>
|
||||
<FormLabel color={color} htmlFor="current_password">
|
||||
Current Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="current_password"
|
||||
{...register("current_password")}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
<VStack gap={4}>
|
||||
<PasswordInput
|
||||
type="current_password"
|
||||
startElement={<FiLock />}
|
||||
{...register("current_password", passwordRules())}
|
||||
placeholder="Current Password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.current_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.current_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
<PasswordInput
|
||||
type="new_password"
|
||||
startElement={<FiLock />}
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
placeholder="New Password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
<PasswordInput
|
||||
type="confirm_password"
|
||||
startElement={<FiLock />}
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
placeholder="Confirm Password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
</VStack>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="solid"
|
||||
mt={4}
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Text } from "@chakra-ui/react"
|
||||
|
||||
import DeleteConfirmation from "./DeleteConfirmation"
|
||||
|
||||
const DeleteAccount = () => {
|
||||
const confirmationModal = useDisclosure()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Delete Account
|
||||
@@ -21,15 +12,8 @@ const DeleteAccount = () => {
|
||||
Permanently delete your data and everything associated with your
|
||||
account.
|
||||
</Text>
|
||||
<Button variant="danger" mt={4} onClick={confirmationModal.onOpen}>
|
||||
Delete
|
||||
</Button>
|
||||
<DeleteConfirmation
|
||||
isOpen={confirmationModal.isOpen}
|
||||
onClose={confirmationModal.onClose}
|
||||
/>
|
||||
<DeleteConfirmation />
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default DeleteAccount
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from "@chakra-ui/react"
|
||||
import { Button, ButtonGroup, Text } from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React from "react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, UsersService } from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import { type ApiError, UsersService } from "@/client"
|
||||
import {
|
||||
DialogActionTrigger,
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
interface DeleteProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
const DeleteConfirmation = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
@@ -34,16 +32,12 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => UsersService.deleteUserMe(),
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Success",
|
||||
"Your account has been successfully deleted.",
|
||||
"success",
|
||||
)
|
||||
showSuccessToast("Your account has been successfully deleted")
|
||||
setIsOpen(false)
|
||||
logout()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
@@ -56,39 +50,58 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
role="alertdialog"
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<AlertDialogHeader>Confirmation Required</AlertDialogHeader>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="solid" colorPalette="red" mt={4}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<AlertDialogBody>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogCloseTrigger />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmation Required</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>
|
||||
All your account data will be{" "}
|
||||
<strong>permanently deleted.</strong> If you are sure, please
|
||||
click <strong>"Confirm"</strong> to proceed. This action cannot be
|
||||
undone.
|
||||
</AlertDialogBody>
|
||||
click <strong>"Confirm"</strong> to proceed. This action cannot
|
||||
be undone.
|
||||
</Text>
|
||||
</DialogBody>
|
||||
|
||||
<AlertDialogFooter gap={3}>
|
||||
<Button variant="danger" type="submit" isLoading={isSubmitting}>
|
||||
Confirm
|
||||
</Button>
|
||||
<DialogFooter gap={2}>
|
||||
<ButtonGroup>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isSubmitting}
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="red"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,9 @@ import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
@@ -20,15 +16,15 @@ import {
|
||||
type UserPublic,
|
||||
type UserUpdateMe,
|
||||
UsersService,
|
||||
} from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
} from "@/client"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "@/utils"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
const UserInformation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const { user: currentUser } = useAuth()
|
||||
const {
|
||||
@@ -54,10 +50,10 @@ const UserInformation = () => {
|
||||
mutationFn: (data: UserUpdateMe) =>
|
||||
UsersService.updateUserMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
showSuccessToast("User updated successfully.")
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
@@ -84,13 +80,9 @@ const UserInformation = () => {
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel color={color} htmlFor="name">
|
||||
Full name
|
||||
</FormLabel>
|
||||
<Field label="Full name">
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name", { maxLength: 30 })}
|
||||
type="text"
|
||||
size="md"
|
||||
@@ -98,23 +90,24 @@ const UserInformation = () => {
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
size="md"
|
||||
fontSize="md"
|
||||
py={2}
|
||||
color={!currentUser?.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
color={!currentUser?.full_name ? "gray" : "inherit"}
|
||||
truncate
|
||||
maxWidth="250px"
|
||||
>
|
||||
{currentUser?.full_name || "N/A"}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.email}>
|
||||
<FormLabel color={color} htmlFor="email">
|
||||
Email
|
||||
</FormLabel>
|
||||
</Field>
|
||||
<Field
|
||||
mt={4}
|
||||
label="Email"
|
||||
invalid={!!errors.email}
|
||||
errorText={errors.email?.message}
|
||||
>
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
@@ -124,26 +117,28 @@ const UserInformation = () => {
|
||||
w="auto"
|
||||
/>
|
||||
) : (
|
||||
<Text size="md" py={2} isTruncated maxWidth="250px">
|
||||
<Text fontSize="md" py={2} truncate maxWidth="250px">
|
||||
{currentUser?.email}
|
||||
</Text>
|
||||
)}
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
</Field>
|
||||
<Flex mt={4} gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="solid"
|
||||
onClick={toggleEditMode}
|
||||
type={editMode ? "button" : "submit"}
|
||||
isLoading={editMode ? isSubmitting : false}
|
||||
isDisabled={editMode ? !isDirty || !getValues("email") : false}
|
||||
loading={editMode ? isSubmitting : false}
|
||||
disabled={editMode ? !isDirty || !getValues("email") : false}
|
||||
>
|
||||
{editMode ? "Save" : "Edit"}
|
||||
</Button>
|
||||
{editMode && (
|
||||
<Button onClick={onCancel} isDisabled={isSubmitting}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react"
|
||||
import {
|
||||
AbsoluteCenter,
|
||||
Button as ChakraButton,
|
||||
Span,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
interface ButtonLoadingProps {
|
||||
loading?: boolean
|
||||
loadingText?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button(props, ref) {
|
||||
const { loading, disabled, loadingText, children, ...rest } = props
|
||||
return (
|
||||
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||
{loading && !loadingText ? (
|
||||
<>
|
||||
<AbsoluteCenter display="inline-flex">
|
||||
<Spinner size="inherit" color="inherit" />
|
||||
</AbsoluteCenter>
|
||||
<Span opacity={0}>{children}</Span>
|
||||
</>
|
||||
) : loading && loadingText ? (
|
||||
<>
|
||||
<Spinner size="inherit" color="inherit" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ChakraButton>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface CheckboxProps extends ChakraCheckbox.RootProps {
|
||||
icon?: React.ReactNode
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
|
||||
rootRef?: React.Ref<HTMLLabelElement>
|
||||
}
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
function Checkbox(props, ref) {
|
||||
const { icon, children, inputProps, rootRef, ...rest } = props
|
||||
return (
|
||||
<ChakraCheckbox.Root ref={rootRef} {...rest}>
|
||||
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraCheckbox.Control>
|
||||
{icon || <ChakraCheckbox.Indicator />}
|
||||
</ChakraCheckbox.Control>
|
||||
{children != null && (
|
||||
<ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>
|
||||
)}
|
||||
</ChakraCheckbox.Root>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ButtonProps } from "@chakra-ui/react"
|
||||
import { IconButton as ChakraIconButton } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { LuX } from "react-icons/lu"
|
||||
|
||||
export type CloseButtonProps = ButtonProps
|
||||
|
||||
export const CloseButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
CloseButtonProps
|
||||
>(function CloseButton(props, ref) {
|
||||
return (
|
||||
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
|
||||
{props.children ?? <LuX />}
|
||||
</ChakraIconButton>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
|
||||
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
|
||||
import { ThemeProvider, useTheme } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
import * as React from "react"
|
||||
import { LuMoon, LuSun } from "react-icons/lu"
|
||||
|
||||
export interface ColorModeProviderProps extends ThemeProviderProps {}
|
||||
|
||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorMode = "light" | "dark"
|
||||
|
||||
export interface UseColorModeReturn {
|
||||
colorMode: ColorMode
|
||||
setColorMode: (colorMode: ColorMode) => void
|
||||
toggleColorMode: () => void
|
||||
}
|
||||
|
||||
export function useColorMode(): UseColorModeReturn {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const toggleColorMode = () => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
return {
|
||||
colorMode: resolvedTheme as ColorMode,
|
||||
setColorMode: setTheme,
|
||||
toggleColorMode,
|
||||
}
|
||||
}
|
||||
|
||||
export function useColorModeValue<T>(light: T, dark: T) {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === "dark" ? dark : light
|
||||
}
|
||||
|
||||
export function ColorModeIcon() {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === "dark" ? <LuMoon /> : <LuSun />
|
||||
}
|
||||
|
||||
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
|
||||
|
||||
export const ColorModeButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ColorModeButtonProps
|
||||
>(function ColorModeButton(props, ref) {
|
||||
const { toggleColorMode } = useColorMode()
|
||||
return (
|
||||
<ClientOnly fallback={<Skeleton boxSize="8" />}>
|
||||
<IconButton
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
aria-label="Toggle color mode"
|
||||
size="sm"
|
||||
ref={ref}
|
||||
{...props}
|
||||
css={{
|
||||
_icon: {
|
||||
width: "5",
|
||||
height: "5",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorModeIcon />
|
||||
</IconButton>
|
||||
</ClientOnly>
|
||||
)
|
||||
})
|
||||
|
||||
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
||||
function LightMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme light"
|
||||
colorPalette="gray"
|
||||
colorScheme="light"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
||||
function DarkMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme dark"
|
||||
colorPalette="gray"
|
||||
colorScheme="dark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { CloseButton } from "./close-button"
|
||||
|
||||
interface DialogContentProps extends ChakraDialog.ContentProps {
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement>
|
||||
backdrop?: boolean
|
||||
}
|
||||
|
||||
export const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DialogContentProps
|
||||
>(function DialogContent(props, ref) {
|
||||
const {
|
||||
children,
|
||||
portalled = true,
|
||||
portalRef,
|
||||
backdrop = true,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
{backdrop && <ChakraDialog.Backdrop />}
|
||||
<ChakraDialog.Positioner>
|
||||
<ChakraDialog.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDialog.Content>
|
||||
</ChakraDialog.Positioner>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
|
||||
export const DialogCloseTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraDialog.CloseTriggerProps
|
||||
>(function DialogCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDialog.CloseTrigger
|
||||
position="absolute"
|
||||
top="2"
|
||||
insetEnd="2"
|
||||
{...props}
|
||||
asChild
|
||||
>
|
||||
<CloseButton size="sm" ref={ref}>
|
||||
{props.children}
|
||||
</CloseButton>
|
||||
</ChakraDialog.CloseTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const DialogRoot = ChakraDialog.Root
|
||||
export const DialogFooter = ChakraDialog.Footer
|
||||
export const DialogHeader = ChakraDialog.Header
|
||||
export const DialogBody = ChakraDialog.Body
|
||||
export const DialogBackdrop = ChakraDialog.Backdrop
|
||||
export const DialogTitle = ChakraDialog.Title
|
||||
export const DialogDescription = ChakraDialog.Description
|
||||
export const DialogTrigger = ChakraDialog.Trigger
|
||||
export const DialogActionTrigger = ChakraDialog.ActionTrigger
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Drawer as ChakraDrawer, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { CloseButton } from "./close-button"
|
||||
|
||||
interface DrawerContentProps extends ChakraDrawer.ContentProps {
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement>
|
||||
offset?: ChakraDrawer.ContentProps["padding"]
|
||||
}
|
||||
|
||||
export const DrawerContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DrawerContentProps
|
||||
>(function DrawerContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, offset, ...rest } = props
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraDrawer.Positioner padding={offset}>
|
||||
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDrawer.Content>
|
||||
</ChakraDrawer.Positioner>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
|
||||
export const DrawerCloseTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraDrawer.CloseTriggerProps
|
||||
>(function DrawerCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDrawer.CloseTrigger
|
||||
position="absolute"
|
||||
top="2"
|
||||
insetEnd="2"
|
||||
{...props}
|
||||
asChild
|
||||
>
|
||||
<CloseButton size="sm" ref={ref} />
|
||||
</ChakraDrawer.CloseTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const DrawerTrigger = ChakraDrawer.Trigger
|
||||
export const DrawerRoot = ChakraDrawer.Root
|
||||
export const DrawerFooter = ChakraDrawer.Footer
|
||||
export const DrawerHeader = ChakraDrawer.Header
|
||||
export const DrawerBody = ChakraDrawer.Body
|
||||
export const DrawerBackdrop = ChakraDrawer.Backdrop
|
||||
export const DrawerDescription = ChakraDrawer.Description
|
||||
export const DrawerTitle = ChakraDrawer.Title
|
||||
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Field as ChakraField } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface FieldProps extends Omit<ChakraField.RootProps, "label"> {
|
||||
label?: React.ReactNode
|
||||
helperText?: React.ReactNode
|
||||
errorText?: React.ReactNode
|
||||
optionalText?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||
function Field(props, ref) {
|
||||
const { label, children, helperText, errorText, optionalText, ...rest } =
|
||||
props
|
||||
return (
|
||||
<ChakraField.Root ref={ref} {...rest}>
|
||||
{label && (
|
||||
<ChakraField.Label>
|
||||
{label}
|
||||
<ChakraField.RequiredIndicator fallback={optionalText} />
|
||||
</ChakraField.Label>
|
||||
)}
|
||||
{children}
|
||||
{helperText && (
|
||||
<ChakraField.HelperText>{helperText}</ChakraField.HelperText>
|
||||
)}
|
||||
{errorText && (
|
||||
<ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>
|
||||
)}
|
||||
</ChakraField.Root>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { BoxProps, InputElementProps } from "@chakra-ui/react"
|
||||
import { Group, InputElement } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface InputGroupProps extends BoxProps {
|
||||
startElementProps?: InputElementProps
|
||||
endElementProps?: InputElementProps
|
||||
startElement?: React.ReactNode
|
||||
endElement?: React.ReactNode
|
||||
children: React.ReactElement<InputElementProps>
|
||||
startOffset?: InputElementProps["paddingStart"]
|
||||
endOffset?: InputElementProps["paddingEnd"]
|
||||
}
|
||||
|
||||
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
|
||||
function InputGroup(props, ref) {
|
||||
const {
|
||||
startElement,
|
||||
startElementProps,
|
||||
endElement,
|
||||
endElementProps,
|
||||
children,
|
||||
startOffset = "6px",
|
||||
endOffset = "6px",
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const child =
|
||||
React.Children.only<React.ReactElement<InputElementProps>>(children)
|
||||
|
||||
return (
|
||||
<Group ref={ref} {...rest}>
|
||||
{startElement && (
|
||||
<InputElement pointerEvents="none" {...startElementProps}>
|
||||
{startElement}
|
||||
</InputElement>
|
||||
)}
|
||||
{React.cloneElement(child, {
|
||||
...(startElement && {
|
||||
ps: `calc(var(--input-height) - ${startOffset})`,
|
||||
}),
|
||||
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
|
||||
...children.props,
|
||||
})}
|
||||
{endElement && (
|
||||
<InputElement placement="end" {...endElementProps}>
|
||||
{endElement}
|
||||
</InputElement>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"
|
||||
import { createRecipeContext } from "@chakra-ui/react"
|
||||
|
||||
export interface LinkButtonProps
|
||||
extends HTMLChakraProps<"a", RecipeProps<"button">> {}
|
||||
|
||||
const { withContext } = createRecipeContext({ key: "button" })
|
||||
|
||||
// Replace "a" with your framework's link component
|
||||
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a")
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { LuCheck, LuChevronRight } from "react-icons/lu"
|
||||
|
||||
interface MenuContentProps extends ChakraMenu.ContentProps {
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(
|
||||
function MenuContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraMenu.Positioner>
|
||||
<ChakraMenu.Content ref={ref} {...rest} />
|
||||
</ChakraMenu.Positioner>
|
||||
</Portal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const MenuArrow = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.ArrowProps
|
||||
>(function MenuArrow(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.Arrow ref={ref} {...props}>
|
||||
<ChakraMenu.ArrowTip />
|
||||
</ChakraMenu.Arrow>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuCheckboxItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.CheckboxItemProps
|
||||
>(function MenuCheckboxItem(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.CheckboxItem ps="8" ref={ref} {...props}>
|
||||
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
{props.children}
|
||||
</ChakraMenu.CheckboxItem>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuRadioItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.RadioItemProps
|
||||
>(function MenuRadioItem(props, ref) {
|
||||
const { children, ...rest } = props
|
||||
return (
|
||||
<ChakraMenu.RadioItem ps="8" ref={ref} {...rest}>
|
||||
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
|
||||
</ChakraMenu.RadioItem>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuItemGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.ItemGroupProps
|
||||
>(function MenuItemGroup(props, ref) {
|
||||
const { title, children, ...rest } = props
|
||||
return (
|
||||
<ChakraMenu.ItemGroup ref={ref} {...rest}>
|
||||
{title && (
|
||||
<ChakraMenu.ItemGroupLabel userSelect="none">
|
||||
{title}
|
||||
</ChakraMenu.ItemGroupLabel>
|
||||
)}
|
||||
{children}
|
||||
</ChakraMenu.ItemGroup>
|
||||
)
|
||||
})
|
||||
|
||||
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
|
||||
startIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const MenuTriggerItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
MenuTriggerItemProps
|
||||
>(function MenuTriggerItem(props, ref) {
|
||||
const { startIcon, children, ...rest } = props
|
||||
return (
|
||||
<ChakraMenu.TriggerItem ref={ref} {...rest}>
|
||||
{startIcon}
|
||||
{children}
|
||||
<LuChevronRight />
|
||||
</ChakraMenu.TriggerItem>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup
|
||||
export const MenuContextTrigger = ChakraMenu.ContextTrigger
|
||||
export const MenuRoot = ChakraMenu.Root
|
||||
export const MenuSeparator = ChakraMenu.Separator
|
||||
|
||||
export const MenuItem = ChakraMenu.Item
|
||||
export const MenuItemText = ChakraMenu.ItemText
|
||||
export const MenuItemCommand = ChakraMenu.ItemCommand
|
||||
export const MenuTrigger = ChakraMenu.Trigger
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client"
|
||||
|
||||
import type { ButtonProps, TextProps } from "@chakra-ui/react"
|
||||
import {
|
||||
Button,
|
||||
Pagination as ChakraPagination,
|
||||
IconButton,
|
||||
Text,
|
||||
createContext,
|
||||
usePaginationContext,
|
||||
} from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import {
|
||||
HiChevronLeft,
|
||||
HiChevronRight,
|
||||
HiMiniEllipsisHorizontal,
|
||||
} from "react-icons/hi2"
|
||||
import { LinkButton } from "./link-button"
|
||||
|
||||
interface ButtonVariantMap {
|
||||
current: ButtonProps["variant"]
|
||||
default: ButtonProps["variant"]
|
||||
ellipsis: ButtonProps["variant"]
|
||||
}
|
||||
|
||||
type PaginationVariant = "outline" | "solid" | "subtle"
|
||||
|
||||
interface ButtonVariantContext {
|
||||
size: ButtonProps["size"]
|
||||
variantMap: ButtonVariantMap
|
||||
getHref?: (page: number) => string
|
||||
}
|
||||
|
||||
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
|
||||
name: "RootPropsProvider",
|
||||
})
|
||||
|
||||
export interface PaginationRootProps
|
||||
extends Omit<ChakraPagination.RootProps, "type"> {
|
||||
size?: ButtonProps["size"]
|
||||
variant?: PaginationVariant
|
||||
getHref?: (page: number) => string
|
||||
}
|
||||
|
||||
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
|
||||
outline: { default: "ghost", ellipsis: "plain", current: "outline" },
|
||||
solid: { default: "outline", ellipsis: "outline", current: "solid" },
|
||||
subtle: { default: "ghost", ellipsis: "plain", current: "subtle" },
|
||||
}
|
||||
|
||||
export const PaginationRoot = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PaginationRootProps
|
||||
>(function PaginationRoot(props, ref) {
|
||||
const { size = "sm", variant = "outline", getHref, ...rest } = props
|
||||
return (
|
||||
<RootPropsProvider
|
||||
value={{ size, variantMap: variantMap[variant], getHref }}
|
||||
>
|
||||
<ChakraPagination.Root
|
||||
ref={ref}
|
||||
type={getHref ? "link" : "button"}
|
||||
{...rest}
|
||||
/>
|
||||
</RootPropsProvider>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationEllipsis = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraPagination.EllipsisProps
|
||||
>(function PaginationEllipsis(props, ref) {
|
||||
const { size, variantMap } = useRootProps()
|
||||
return (
|
||||
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
|
||||
<Button as="span" variant={variantMap.ellipsis} size={size}>
|
||||
<HiMiniEllipsisHorizontal />
|
||||
</Button>
|
||||
</ChakraPagination.Ellipsis>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationItem = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraPagination.ItemProps
|
||||
>(function PaginationItem(props, ref) {
|
||||
const { page } = usePaginationContext()
|
||||
const { size, variantMap, getHref } = useRootProps()
|
||||
|
||||
const current = page === props.value
|
||||
const variant = current ? variantMap.current : variantMap.default
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
|
||||
{props.value}
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.Item ref={ref} {...props} asChild>
|
||||
<Button variant={variant} size={size}>
|
||||
{props.value}
|
||||
</Button>
|
||||
</ChakraPagination.Item>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationPrevTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraPagination.PrevTriggerProps
|
||||
>(function PaginationPrevTrigger(props, ref) {
|
||||
const { size, variantMap, getHref } = useRootProps()
|
||||
const { previousPage } = usePaginationContext()
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={previousPage != null ? getHref(previousPage) : undefined}
|
||||
variant={variantMap.default}
|
||||
size={size}
|
||||
>
|
||||
<HiChevronLeft />
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
|
||||
<IconButton variant={variantMap.default} size={size}>
|
||||
<HiChevronLeft />
|
||||
</IconButton>
|
||||
</ChakraPagination.PrevTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationNextTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraPagination.NextTriggerProps
|
||||
>(function PaginationNextTrigger(props, ref) {
|
||||
const { size, variantMap, getHref } = useRootProps()
|
||||
const { nextPage } = usePaginationContext()
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={nextPage != null ? getHref(nextPage) : undefined}
|
||||
variant={variantMap.default}
|
||||
size={size}
|
||||
>
|
||||
<HiChevronRight />
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
|
||||
<IconButton variant={variantMap.default} size={size}>
|
||||
<HiChevronRight />
|
||||
</IconButton>
|
||||
</ChakraPagination.NextTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
|
||||
return (
|
||||
<ChakraPagination.Context>
|
||||
{({ pages }) =>
|
||||
pages.map((page, index) => {
|
||||
return page.type === "ellipsis" ? (
|
||||
<PaginationEllipsis key={index} index={index} {...props} />
|
||||
) : (
|
||||
<PaginationItem
|
||||
key={index}
|
||||
type="page"
|
||||
value={page.value}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ChakraPagination.Context>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageTextProps extends TextProps {
|
||||
format?: "short" | "compact" | "long"
|
||||
}
|
||||
|
||||
export const PaginationPageText = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
PageTextProps
|
||||
>(function PaginationPageText(props, ref) {
|
||||
const { format = "compact", ...rest } = props
|
||||
const { page, totalPages, pageRange, count } = usePaginationContext()
|
||||
const content = React.useMemo(() => {
|
||||
if (format === "short") return `${page} / ${totalPages}`
|
||||
if (format === "compact") return `${page} of ${totalPages}`
|
||||
return `${pageRange.start + 1} - ${Math.min(
|
||||
pageRange.end,
|
||||
count,
|
||||
)} of ${count}`
|
||||
}, [format, page, totalPages, pageRange, count])
|
||||
|
||||
return (
|
||||
<Text fontWeight="medium" ref={ref} {...rest}>
|
||||
{content}
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ButtonProps,
|
||||
GroupProps,
|
||||
InputProps,
|
||||
StackProps,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
Stack,
|
||||
mergeRefs,
|
||||
useControllableState,
|
||||
} from "@chakra-ui/react"
|
||||
import { forwardRef, useRef } from "react"
|
||||
import { FiEye, FiEyeOff } from "react-icons/fi"
|
||||
import { Field } from "./field"
|
||||
import { InputGroup } from "./input-group"
|
||||
|
||||
export interface PasswordVisibilityProps {
|
||||
defaultVisible?: boolean
|
||||
visible?: boolean
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
|
||||
}
|
||||
|
||||
export interface PasswordInputProps
|
||||
extends InputProps,
|
||||
PasswordVisibilityProps {
|
||||
rootProps?: GroupProps
|
||||
startElement?: React.ReactNode
|
||||
type: string
|
||||
errors: any
|
||||
}
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
function PasswordInput(props, ref) {
|
||||
const {
|
||||
rootProps,
|
||||
defaultVisible,
|
||||
visible: visibleProp,
|
||||
onVisibleChange,
|
||||
visibilityIcon = { on: <FiEye />, off: <FiEyeOff /> },
|
||||
startElement,
|
||||
type,
|
||||
errors,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [visible, setVisible] = useControllableState({
|
||||
value: visibleProp,
|
||||
defaultValue: defaultVisible || false,
|
||||
onChange: onVisibleChange,
|
||||
})
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<Field
|
||||
invalid={!!errors[type]}
|
||||
errorText={errors[type]?.message}
|
||||
alignSelf="start"
|
||||
>
|
||||
<InputGroup
|
||||
width="100%"
|
||||
startElement={startElement}
|
||||
endElement={
|
||||
<VisibilityTrigger
|
||||
disabled={rest.disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (rest.disabled) return
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
setVisible(!visible)
|
||||
}}
|
||||
>
|
||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
||||
</VisibilityTrigger>
|
||||
}
|
||||
{...rootProps}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={mergeRefs(ref, inputRef)}
|
||||
type={visible ? "text" : "password"}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const VisibilityTrigger = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function VisibilityTrigger(props, ref) {
|
||||
return (
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
me="-2"
|
||||
aspectRatio="square"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
height="calc(100% - {spacing.2})"
|
||||
aria-label="Toggle password visibility"
|
||||
color="inherit"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface PasswordStrengthMeterProps extends StackProps {
|
||||
max?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter = forwardRef<
|
||||
HTMLDivElement,
|
||||
PasswordStrengthMeterProps
|
||||
>(function PasswordStrengthMeter(props, ref) {
|
||||
const { max = 4, value, ...rest } = props
|
||||
|
||||
const percent = (value / max) * 100
|
||||
const { label, colorPalette } = getColorPalette(percent)
|
||||
|
||||
return (
|
||||
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
|
||||
<HStack width="full" ref={ref} {...rest}>
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
height="1"
|
||||
flex="1"
|
||||
rounded="sm"
|
||||
data-selected={index < value ? "" : undefined}
|
||||
layerStyle="fill.subtle"
|
||||
colorPalette="gray"
|
||||
_selected={{
|
||||
colorPalette,
|
||||
layerStyle: "fill.solid",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{label && <HStack textStyle="xs">{label}</HStack>}
|
||||
</Stack>
|
||||
)
|
||||
})
|
||||
|
||||
function getColorPalette(percent: number) {
|
||||
switch (true) {
|
||||
case percent < 33:
|
||||
return { label: "Low", colorPalette: "red" }
|
||||
case percent < 66:
|
||||
return { label: "Medium", colorPalette: "orange" }
|
||||
default:
|
||||
return { label: "High", colorPalette: "green" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { ChakraProvider } from "@chakra-ui/react"
|
||||
import React, { type PropsWithChildren } from "react"
|
||||
import { system } from "../../theme"
|
||||
import { ColorModeProvider } from "./color-mode"
|
||||
import { Toaster } from "./toaster"
|
||||
|
||||
export function CustomProvider(props: PropsWithChildren) {
|
||||
return (
|
||||
<ChakraProvider value={system}>
|
||||
<ColorModeProvider defaultTheme="light">
|
||||
{props.children}
|
||||
</ColorModeProvider>
|
||||
<Toaster />
|
||||
</ChakraProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface RadioProps extends ChakraRadioGroup.ItemProps {
|
||||
rootRef?: React.Ref<HTMLDivElement>
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
|
||||
}
|
||||
|
||||
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
|
||||
function Radio(props, ref) {
|
||||
const { children, inputProps, rootRef, ...rest } = props
|
||||
return (
|
||||
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
|
||||
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraRadioGroup.ItemIndicator />
|
||||
{children && (
|
||||
<ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>
|
||||
)}
|
||||
</ChakraRadioGroup.Item>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const RadioGroup = ChakraRadioGroup.Root
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
SkeletonProps as ChakraSkeletonProps,
|
||||
CircleProps,
|
||||
} from "@chakra-ui/react"
|
||||
import { Skeleton as ChakraSkeleton, Circle, Stack } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface SkeletonCircleProps extends ChakraSkeletonProps {
|
||||
size?: CircleProps["size"]
|
||||
}
|
||||
|
||||
export const SkeletonCircle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
SkeletonCircleProps
|
||||
>(function SkeletonCircle(props, ref) {
|
||||
const { size, ...rest } = props
|
||||
return (
|
||||
<Circle size={size} asChild ref={ref}>
|
||||
<ChakraSkeleton {...rest} />
|
||||
</Circle>
|
||||
)
|
||||
})
|
||||
|
||||
export interface SkeletonTextProps extends ChakraSkeletonProps {
|
||||
noOfLines?: number
|
||||
}
|
||||
|
||||
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
|
||||
function SkeletonText(props, ref) {
|
||||
const { noOfLines = 3, gap, ...rest } = props
|
||||
return (
|
||||
<Stack gap={gap} width="full" ref={ref}>
|
||||
{Array.from({ length: noOfLines }).map((_, index) => (
|
||||
<ChakraSkeleton
|
||||
height="4"
|
||||
key={index}
|
||||
{...props}
|
||||
_last={{ maxW: "80%" }}
|
||||
{...rest}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const Skeleton = ChakraSkeleton
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toaster as ChakraToaster,
|
||||
Portal,
|
||||
Spinner,
|
||||
Stack,
|
||||
Toast,
|
||||
createToaster,
|
||||
} from "@chakra-ui/react"
|
||||
|
||||
export const toaster = createToaster({
|
||||
placement: "top-end",
|
||||
pauseOnPageIdle: true,
|
||||
})
|
||||
|
||||
export const Toaster = () => {
|
||||
return (
|
||||
<Portal>
|
||||
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
|
||||
{(toast) => (
|
||||
<Toast.Root width={{ md: "sm" }} color={toast.meta?.color}>
|
||||
{toast.type === "loading" ? (
|
||||
<Spinner size="sm" color="blue.solid" />
|
||||
) : (
|
||||
<Toast.Indicator />
|
||||
)}
|
||||
<Stack gap="1" flex="1" maxWidth="100%">
|
||||
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
|
||||
{toast.description && (
|
||||
<Toast.Description>{toast.description}</Toast.Description>
|
||||
)}
|
||||
</Stack>
|
||||
{toast.action && (
|
||||
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
|
||||
)}
|
||||
{toast.meta?.closable && <Toast.CloseTrigger />}
|
||||
</Toast.Root>
|
||||
)}
|
||||
</ChakraToaster>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
|
||||
import { AxiosError } from "axios"
|
||||
import {
|
||||
type Body_login_login_access_token as AccessToken,
|
||||
type ApiError,
|
||||
@@ -10,8 +9,8 @@ import {
|
||||
type UserPublic,
|
||||
type UserRegister,
|
||||
UsersService,
|
||||
} from "../client"
|
||||
import useCustomToast from "./useCustomToast"
|
||||
} from "@/client"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
const isLoggedIn = () => {
|
||||
return localStorage.getItem("access_token") !== null
|
||||
@@ -20,9 +19,8 @@ const isLoggedIn = () => {
|
||||
const useAuth = () => {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: user, isLoading } = useQuery<UserPublic | null, Error>({
|
||||
const { data: user } = useQuery<UserPublic | null, Error>({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: UsersService.readUserMe,
|
||||
enabled: isLoggedIn(),
|
||||
@@ -34,20 +32,9 @@ const useAuth = () => {
|
||||
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/login" })
|
||||
showToast(
|
||||
"Account created.",
|
||||
"Your account has been created successfully.",
|
||||
"success",
|
||||
)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
showToast("Something went wrong.", errDetail, "error")
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
@@ -67,17 +54,7 @@ const useAuth = () => {
|
||||
navigate({ to: "/" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
if (Array.isArray(errDetail)) {
|
||||
errDetail = "Something went wrong"
|
||||
}
|
||||
|
||||
setError(errDetail)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -91,7 +68,6 @@ const useAuth = () => {
|
||||
loginMutation,
|
||||
logout,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
resetError: () => setError(null),
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { useToast } from "@chakra-ui/react"
|
||||
import { useCallback } from "react"
|
||||
"use client"
|
||||
|
||||
import { toaster } from "@/components/ui/toaster"
|
||||
|
||||
const useCustomToast = () => {
|
||||
const toast = useToast()
|
||||
|
||||
const showToast = useCallback(
|
||||
(title: string, description: string, status: "success" | "error") => {
|
||||
toast({
|
||||
title,
|
||||
const showSuccessToast = (description: string) => {
|
||||
toaster.create({
|
||||
title: "Success!",
|
||||
description,
|
||||
status,
|
||||
isClosable: true,
|
||||
position: "bottom-right",
|
||||
type: "success",
|
||||
})
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
}
|
||||
|
||||
return showToast
|
||||
const showErrorToast = (description: string) => {
|
||||
toaster.create({
|
||||
title: "Something went wrong!",
|
||||
description,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
return { showSuccessToast, showErrorToast }
|
||||
}
|
||||
|
||||
export default useCustomToast
|
||||
|
||||
+20
-8
@@ -1,19 +1,31 @@
|
||||
import { ChakraProvider } from "@chakra-ui/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import React, { StrictMode } from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
import { StrictMode } from "react"
|
||||
import { OpenAPI } from "./client"
|
||||
import theme from "./theme"
|
||||
import { ApiError, OpenAPI } from "./client"
|
||||
import { CustomProvider } from "./components/ui/provider"
|
||||
|
||||
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
||||
OpenAPI.TOKEN = async () => {
|
||||
return localStorage.getItem("access_token") || ""
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const handleApiError = (error: Error) => {
|
||||
if (error instanceof ApiError && [401, 403].includes(error.status)) {
|
||||
localStorage.removeItem("access_token")
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: handleApiError,
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: handleApiError,
|
||||
}),
|
||||
})
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
declare module "@tanstack/react-router" {
|
||||
@@ -24,10 +36,10 @@ declare module "@tanstack/react-router" {
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<CustomProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
</CustomProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import React, { Suspense } from "react"
|
||||
|
||||
import NotFound from "../components/Common/NotFound"
|
||||
import NotFound from "@/components/Common/NotFound"
|
||||
|
||||
const loadDevtools = () =>
|
||||
Promise.all([
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Flex, Spinner } from "@chakra-ui/react"
|
||||
import { Flex } from "@chakra-ui/react"
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import Sidebar from "../components/Common/Sidebar"
|
||||
import UserMenu from "../components/Common/UserMenu"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import Navbar from "@/components/Common/Navbar"
|
||||
import Sidebar from "@/components/Common/Sidebar"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout")({
|
||||
component: Layout,
|
||||
@@ -17,19 +17,17 @@ export const Route = createFileRoute("/_layout")({
|
||||
})
|
||||
|
||||
function Layout() {
|
||||
const { isLoading } = useAuth()
|
||||
|
||||
return (
|
||||
<Flex maxW="large" h="auto" position="relative">
|
||||
<Flex direction="column" h="100vh">
|
||||
<Navbar />
|
||||
<Flex flex="1" overflow="hidden">
|
||||
<Sidebar />
|
||||
{isLoading ? (
|
||||
<Flex justify="center" align="center" height="100vh" width="full">
|
||||
<Spinner size="xl" color="ui.main" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex flex="1" direction="column" p={4} overflowY="auto">
|
||||
<Outlet />
|
||||
)}
|
||||
<UserMenu />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { Badge, Container, Flex, Heading, Table } from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type UserPublic, UsersService } from "../../client"
|
||||
import AddUser from "../../components/Admin/AddUser"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import { type UserPublic, UsersService } from "@/client"
|
||||
import AddUser from "@/components/Admin/AddUser"
|
||||
import { UserActionsMenu } from "@/components/Common/UserActionsMenu"
|
||||
import PendingUsers from "@/components/Pending/PendingUsers"
|
||||
import {
|
||||
PaginationItems,
|
||||
PaginationNextTrigger,
|
||||
PaginationPrevTrigger,
|
||||
PaginationRoot,
|
||||
} from "@/components/ui/pagination.tsx"
|
||||
|
||||
const usersSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/admin")({
|
||||
component: Admin,
|
||||
validateSearch: (search) => usersSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getUsersQueryOptions({ page }: { page: number }) {
|
||||
@@ -43,114 +28,86 @@ function getUsersQueryOptions({ page }: { page: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_layout/admin")({
|
||||
component: Admin,
|
||||
validateSearch: (search) => usersSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
function UsersTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
const { page } = Route.useSearch()
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
const { data, isLoading, isPlaceholderData } = useQuery({
|
||||
...getUsersQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
const setPage = (page: number) =>
|
||||
navigate({
|
||||
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
|
||||
const users = data?.data.slice(0, PER_PAGE) ?? []
|
||||
const count = data?.count ?? 0
|
||||
|
||||
if (isLoading) {
|
||||
return <PendingUsers />
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th width="20%">Full name</Th>
|
||||
<Th width="50%">Email</Th>
|
||||
<Th width="10%">Role</Th>
|
||||
<Th width="10%">Status</Th>
|
||||
<Th width="10%">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{users?.data.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td
|
||||
color={!user.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
<Table.Root size={{ base: "sm", md: "md" }}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader w="20%">Full name</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="25%">Email</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="15%">Role</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="20%">Status</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="20%">Actions</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{users?.map((user) => (
|
||||
<Table.Row key={user.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Table.Cell w="20%" color={!user.full_name ? "gray" : "inherit"}>
|
||||
{user.full_name || "N/A"}
|
||||
{currentUser?.id === user.id && (
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{user.email}
|
||||
</Td>
|
||||
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
|
||||
<Td>
|
||||
<Flex gap={2}>
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
borderRadius="50%"
|
||||
bg={user.is_active ? "ui.success" : "ui.danger"}
|
||||
alignSelf="center"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell w="25%">{user.email}</Table.Cell>
|
||||
<Table.Cell w="15%">
|
||||
{user.is_superuser ? "Superuser" : "User"}
|
||||
</Table.Cell>
|
||||
<Table.Cell w="20%">
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu
|
||||
type="User"
|
||||
value={user}
|
||||
disabled={currentUser?.id === user.id ? true : false}
|
||||
</Table.Cell>
|
||||
<Table.Cell w="20%">
|
||||
<UserActionsMenu
|
||||
user={user}
|
||||
disabled={currentUser?.id === user.id}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
<Flex justifyContent="flex-end" mt={4}>
|
||||
<PaginationRoot
|
||||
count={count}
|
||||
pageSize={PER_PAGE}
|
||||
onPageChange={({ page }) => setPage(page)}
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
<Flex>
|
||||
<PaginationPrevTrigger />
|
||||
<PaginationItems />
|
||||
<PaginationNextTrigger />
|
||||
</Flex>
|
||||
</PaginationRoot>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
@@ -159,11 +116,11 @@ function UsersTable() {
|
||||
function Admin() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
<Heading size="lg" pt={12}>
|
||||
Users Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"User"} addModalAs={AddUser} />
|
||||
<AddUser />
|
||||
<UsersTable />
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Container, Text } from "@chakra-ui/react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout/")({
|
||||
component: Dashboard,
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
EmptyState,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { z } from "zod"
|
||||
|
||||
import { ItemsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddItem from "../../components/Items/AddItem"
|
||||
import { ItemsService } from "@/client"
|
||||
import { ItemActionsMenu } from "@/components/Common/ItemActionsMenu"
|
||||
import AddItem from "@/components/Items/AddItem"
|
||||
import PendingItems from "@/components/Pending/PendingItems"
|
||||
import {
|
||||
PaginationItems,
|
||||
PaginationNextTrigger,
|
||||
PaginationPrevTrigger,
|
||||
PaginationRoot,
|
||||
} from "@/components/ui/pagination.tsx"
|
||||
|
||||
const itemsSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
validateSearch: (search) => itemsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
@@ -41,91 +36,96 @@ function getItemsQueryOptions({ page }: { page: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
function ItemsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
validateSearch: (search) => itemsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
function ItemsTable() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { page } = Route.useSearch()
|
||||
|
||||
const { data, isLoading, isPlaceholderData } = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
const setPage = (page: number) =>
|
||||
navigate({
|
||||
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
const items = data?.data.slice(0, PER_PAGE) ?? []
|
||||
const count = data?.count ?? 0
|
||||
|
||||
if (isLoading) {
|
||||
return <PendingItems />
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState.Root>
|
||||
<EmptyState.Content>
|
||||
<EmptyState.Indicator>
|
||||
<FiSearch />
|
||||
</EmptyState.Indicator>
|
||||
<VStack textAlign="center">
|
||||
<EmptyState.Title>You don't have any items yet</EmptyState.Title>
|
||||
<EmptyState.Description>
|
||||
Add a new item to get started
|
||||
</EmptyState.Description>
|
||||
</VStack>
|
||||
</EmptyState.Content>
|
||||
</EmptyState.Root>
|
||||
)
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{items?.data.map((item) => (
|
||||
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td>{item.id}</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
<Table.Root size={{ base: "sm", md: "md" }}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader w="30%">ID</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="30%">Title</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="30%">Description</Table.ColumnHeader>
|
||||
<Table.ColumnHeader w="10%">Actions</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{items?.map((item) => (
|
||||
<Table.Row key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Table.Cell truncate maxW="30%">
|
||||
{item.id}
|
||||
</Table.Cell>
|
||||
<Table.Cell truncate maxW="30%">
|
||||
{item.title}
|
||||
</Td>
|
||||
<Td
|
||||
color={!item.description ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
color={!item.description ? "gray" : "inherit"}
|
||||
truncate
|
||||
maxW="30%"
|
||||
>
|
||||
{item.description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Item"} value={item} />
|
||||
</Td>
|
||||
</Tr>
|
||||
</Table.Cell>
|
||||
<Table.Cell width="10%">
|
||||
<ItemActionsMenu item={item} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
<Flex justifyContent="flex-end" mt={4}>
|
||||
<PaginationRoot
|
||||
count={count}
|
||||
pageSize={PER_PAGE}
|
||||
onPageChange={({ page }) => setPage(page)}
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
<Flex>
|
||||
<PaginationPrevTrigger />
|
||||
<PaginationItems />
|
||||
<PaginationNextTrigger />
|
||||
</Flex>
|
||||
</PaginationRoot>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
@@ -134,11 +134,10 @@ function ItemsTable() {
|
||||
function Items() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
<Heading size="lg" pt={12}>
|
||||
Items Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"Item"} addModalAs={AddItem} />
|
||||
<AddItem />
|
||||
<ItemsTable />
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Container, Heading, Tabs } from "@chakra-ui/react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
import Appearance from "../../components/UserSettings/Appearance"
|
||||
import ChangePassword from "../../components/UserSettings/ChangePassword"
|
||||
import DeleteAccount from "../../components/UserSettings/DeleteAccount"
|
||||
import UserInformation from "../../components/UserSettings/UserInformation"
|
||||
import Appearance from "@/components/UserSettings/Appearance"
|
||||
import ChangePassword from "@/components/UserSettings/ChangePassword"
|
||||
import DeleteAccount from "@/components/UserSettings/DeleteAccount"
|
||||
import UserInformation from "@/components/UserSettings/UserInformation"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "My profile", component: UserInformation },
|
||||
{ title: "Password", component: ChangePassword },
|
||||
{ title: "Appearance", component: Appearance },
|
||||
{ title: "Danger zone", component: DeleteAccount },
|
||||
{ value: "my-profile", title: "My profile", component: UserInformation },
|
||||
{ value: "password", title: "Password", component: ChangePassword },
|
||||
{ value: "appearance", title: "Appearance", component: Appearance },
|
||||
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount },
|
||||
]
|
||||
|
||||
export const Route = createFileRoute("/_layout/settings")({
|
||||
@@ -28,31 +19,35 @@ export const Route = createFileRoute("/_layout/settings")({
|
||||
})
|
||||
|
||||
function UserSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { user: currentUser } = useAuth()
|
||||
const finalTabs = currentUser?.is_superuser
|
||||
? tabsConfig.slice(0, 3)
|
||||
: tabsConfig
|
||||
|
||||
if (!currentUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
|
||||
<Tabs.Root defaultValue="my-profile" variant="subtle">
|
||||
<Tabs.List>
|
||||
{finalTabs.map((tab) => (
|
||||
<Tabs.Trigger key={tab.value} value={tab.value}>
|
||||
{tab.title}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
</Tabs.List>
|
||||
{finalTabs.map((tab) => (
|
||||
<Tabs.Content key={tab.value} value={tab.value}>
|
||||
<tab.component />
|
||||
</TabPanel>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Tabs.Root>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons"
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Icon,
|
||||
Image,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Link,
|
||||
Text,
|
||||
useBoolean,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Image, Input, Text } from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { FiLock, FiMail } from "react-icons/fi"
|
||||
|
||||
import type { Body_login_login_access_token as AccessToken } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field } from "@/components/ui/field"
|
||||
import { InputGroup } from "@/components/ui/input-group"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import useAuth, { isLoggedIn } from "@/hooks/useAuth"
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
import type { Body_login_login_access_token as AccessToken } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { emailPattern } from "../utils"
|
||||
import { emailPattern, passwordRules } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: Login,
|
||||
@@ -37,7 +28,6 @@ export const Route = createFileRoute("/login")({
|
||||
})
|
||||
|
||||
function Login() {
|
||||
const [show, setShow] = useBoolean()
|
||||
const { loginMutation, error, resetError } = useAuth()
|
||||
const {
|
||||
register,
|
||||
@@ -84,7 +74,11 @@ function Login() {
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="username" isInvalid={!!errors.username || !!error}>
|
||||
<Field
|
||||
invalid={!!errors.username}
|
||||
errorText={errors.username?.message || !!error}
|
||||
>
|
||||
<InputGroup w="100%" startElement={<FiMail />}>
|
||||
<Input
|
||||
id="username"
|
||||
{...register("username", {
|
||||
@@ -93,50 +87,27 @@ function Login() {
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
{errors.username && (
|
||||
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!error}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
})}
|
||||
type={show ? "text" : "password"}
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<InputRightElement
|
||||
color="ui.dim"
|
||||
_hover={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
as={show ? ViewOffIcon : ViewIcon}
|
||||
onClick={setShow.toggle}
|
||||
aria-label={show ? "Hide password" : "Show password"}
|
||||
>
|
||||
{show ? <ViewOffIcon /> : <ViewIcon />}
|
||||
</Icon>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{error && <FormErrorMessage>{error}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
<Link as={RouterLink} to="/recover-password" color="blue.500">
|
||||
Forgot password?
|
||||
</Link>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
</Field>
|
||||
<PasswordInput
|
||||
type="password"
|
||||
startElement={<FiLock />}
|
||||
{...register("password", passwordRules())}
|
||||
placeholder="Password"
|
||||
errors={errors}
|
||||
/>
|
||||
<RouterLink to="/recover-password" className="main-link">
|
||||
Forgot Password?
|
||||
</RouterLink>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting} size="md">
|
||||
Log In
|
||||
</Button>
|
||||
<Text>
|
||||
Don't have an account?{" "}
|
||||
<Link as={RouterLink} to="/signup" color="blue.500">
|
||||
Sign up
|
||||
</Link>
|
||||
<RouterLink to="/signup" className="main-link">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</Text>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Input, Text } from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { FiMail } from "react-icons/fi"
|
||||
|
||||
import { type ApiError, LoginService } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../utils"
|
||||
import { type ApiError, LoginService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field } from "@/components/ui/field"
|
||||
import { InputGroup } from "@/components/ui/input-group"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "@/utils"
|
||||
|
||||
interface FormData {
|
||||
email: string
|
||||
@@ -38,7 +34,7 @@ function RecoverPassword() {
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>()
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
|
||||
const recoverPassword = async (data: FormData) => {
|
||||
await LoginService.recoverPassword({
|
||||
@@ -49,15 +45,11 @@ function RecoverPassword() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: recoverPassword,
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Email sent.",
|
||||
"We sent an email with a link to get back into your account.",
|
||||
"success",
|
||||
)
|
||||
showSuccessToast("Password recovery email sent successfully.")
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -79,10 +71,11 @@ function RecoverPassword() {
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Password Recovery
|
||||
</Heading>
|
||||
<Text align="center">
|
||||
<Text textAlign="center">
|
||||
A password recovery email will be sent to the registered account.
|
||||
</Text>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<Field invalid={!!errors.email} errorText={errors.email?.message}>
|
||||
<InputGroup w="100%" startElement={<FiMail />}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
@@ -92,11 +85,9 @@ function RecoverPassword() {
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</Container>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Text } from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { FiLock } from "react-icons/fi"
|
||||
|
||||
import { type ApiError, LoginService, type NewPassword } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../utils"
|
||||
import { type ApiError, LoginService, type NewPassword } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "@/utils"
|
||||
|
||||
interface NewPasswordForm extends NewPassword {
|
||||
confirm_password: string
|
||||
@@ -46,7 +40,7 @@ function ResetPassword() {
|
||||
new_password: "",
|
||||
},
|
||||
})
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const resetPassword = async (data: NewPassword) => {
|
||||
@@ -60,12 +54,12 @@ function ResetPassword() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
showSuccessToast("Password updated successfully.")
|
||||
reset()
|
||||
navigate({ to: "/login" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -90,31 +84,21 @@ function ResetPassword() {
|
||||
<Text textAlign="center">
|
||||
Please enter your new password and confirm it to reset your password.
|
||||
</Text>
|
||||
<FormControl mt={4} isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
<PasswordInput
|
||||
startElement={<FiLock />}
|
||||
type="new_password"
|
||||
errors={errors}
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
<PasswordInput
|
||||
startElement={<FiLock />}
|
||||
type="confirm_password"
|
||||
errors={errors}
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>{errors.confirm_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit">
|
||||
<Button variant="solid" type="submit">
|
||||
Reset Password
|
||||
</Button>
|
||||
</Container>
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Flex, Image, Input, Text } from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { FiLock, FiUser } from "react-icons/fi"
|
||||
|
||||
import type { UserRegister } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field } from "@/components/ui/field"
|
||||
import { InputGroup } from "@/components/ui/input-group"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import useAuth, { isLoggedIn } from "@/hooks/useAuth"
|
||||
import { confirmPasswordRules, emailPattern, passwordRules } from "@/utils"
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
import type { UserRegister } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { confirmPasswordRules, emailPattern, passwordRules } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignUp,
|
||||
@@ -80,25 +74,25 @@ function SignUp() {
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="full_name" isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="full_name" srOnly>
|
||||
Full Name
|
||||
</FormLabel>
|
||||
<Field
|
||||
invalid={!!errors.full_name}
|
||||
errorText={errors.full_name?.message}
|
||||
>
|
||||
<InputGroup w="100%" startElement={<FiUser />}>
|
||||
<Input
|
||||
id="full_name"
|
||||
minLength={3}
|
||||
{...register("full_name", { required: "Full Name is required" })}
|
||||
{...register("full_name", {
|
||||
required: "Full Name is required",
|
||||
})}
|
||||
placeholder="Full Name"
|
||||
type="text"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="email" isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="username" srOnly>
|
||||
Email
|
||||
</FormLabel>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Field invalid={!!errors.email} errorText={errors.email?.message}>
|
||||
<InputGroup w="100%" startElement={<FiUser />}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
@@ -108,52 +102,30 @@ function SignUp() {
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password" srOnly>
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<PasswordInput
|
||||
type="password"
|
||||
startElement={<FiLock />}
|
||||
{...register("password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
id="confirm_password"
|
||||
isInvalid={!!errors.confirm_password}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password" srOnly>
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
|
||||
<Input
|
||||
id="confirm_password"
|
||||
<PasswordInput
|
||||
type="confirm_password"
|
||||
startElement={<FiLock />}
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Repeat Password"
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Text>
|
||||
Already have an account?{" "}
|
||||
<Link as={RouterLink} to="/login" color="blue.500">
|
||||
<RouterLink to="/login" className="main-link">
|
||||
Log In
|
||||
</Link>
|
||||
</RouterLink>
|
||||
</Text>
|
||||
</Container>
|
||||
</Flex>
|
||||
|
||||
+21
-51
@@ -1,61 +1,31 @@
|
||||
import { extendTheme } from "@chakra-ui/react"
|
||||
import { createSystem, defaultConfig } from "@chakra-ui/react"
|
||||
import { buttonRecipe } from "./theme/button.recipe"
|
||||
|
||||
const disabledStyles = {
|
||||
_disabled: {
|
||||
backgroundColor: "ui.main",
|
||||
export const system = createSystem(defaultConfig, {
|
||||
globalCss: {
|
||||
html: {
|
||||
fontSize: "16px",
|
||||
},
|
||||
}
|
||||
|
||||
const theme = extendTheme({
|
||||
body: {
|
||||
fontSize: "0.875rem",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
".main-link": {
|
||||
color: "ui.main",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
tokens: {
|
||||
colors: {
|
||||
ui: {
|
||||
main: "#009688",
|
||||
secondary: "#EDF2F7",
|
||||
success: "#48BB78",
|
||||
danger: "#E53E3E",
|
||||
light: "#FAFAFA",
|
||||
dark: "#1A202C",
|
||||
darkSlate: "#252D3D",
|
||||
dim: "#A0AEC0",
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
variants: {
|
||||
primary: {
|
||||
backgroundColor: "ui.main",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#00766C",
|
||||
},
|
||||
_disabled: {
|
||||
...disabledStyles,
|
||||
_hover: {
|
||||
...disabledStyles,
|
||||
},
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: "ui.danger",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#E32727",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tabs: {
|
||||
variants: {
|
||||
enclosed: {
|
||||
tab: {
|
||||
_selected: {
|
||||
color: "ui.main",
|
||||
},
|
||||
main: { value: "#009688" },
|
||||
},
|
||||
},
|
||||
},
|
||||
recipes: {
|
||||
button: buttonRecipe,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default theme
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineRecipe } from "@chakra-ui/react"
|
||||
|
||||
export const buttonRecipe = defineRecipe({
|
||||
base: {
|
||||
fontWeight: "bold",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
colorPalette: "teal",
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
ghost: {
|
||||
bg: "transparent",
|
||||
_hover: {
|
||||
bg: "gray.100",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiError } from "./client"
|
||||
import useCustomToast from "./hooks/useCustomToast"
|
||||
|
||||
export const emailPattern = {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
@@ -43,11 +44,12 @@ export const confirmPasswordRules = (
|
||||
return rules
|
||||
}
|
||||
|
||||
export const handleError = (err: ApiError, showToast: any) => {
|
||||
export const handleError = (err: ApiError) => {
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const errDetail = (err.body as any)?.detail
|
||||
let errorMessage = errDetail || "Something went wrong."
|
||||
if (Array.isArray(errDetail) && errDetail.length > 0) {
|
||||
errorMessage = errDetail[0].msg
|
||||
}
|
||||
showToast("Error", errorMessage, "error")
|
||||
showErrorToast(errorMessage)
|
||||
}
|
||||
|
||||
@@ -115,3 +115,13 @@ test("Logged-out user cannot access protected routes", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await page.waitForURL("/login")
|
||||
})
|
||||
|
||||
test("Redirects to /login when token is wrong", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("access_token", "invalid_token")
|
||||
})
|
||||
await page.goto("/settings")
|
||||
await page.waitForURL("/login")
|
||||
await expect(page).toHaveURL("/login")
|
||||
})
|
||||
@@ -50,7 +50,9 @@ test("User can reset password successfully using the link", async ({
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await page.goto(`http://localhost:1080/messages/${emailData.id}.html`)
|
||||
await page.goto(
|
||||
`${process.env.MAILCATCHER_HOST}/messages/${emailData.id}.html`,
|
||||
)
|
||||
|
||||
const selector = 'a[href*="/reset-password?token="]'
|
||||
|
||||
@@ -62,8 +64,8 @@ test("User can reset password successfully using the link", async ({
|
||||
// Set the new password and confirm it
|
||||
await page.goto(url)
|
||||
|
||||
await page.getByLabel("Set Password").fill(newPassword)
|
||||
await page.getByLabel("Confirm Password").fill(newPassword)
|
||||
await page.getByPlaceholder("New Password").fill(newPassword)
|
||||
await page.getByPlaceholder("Confirm Password").fill(newPassword)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
await expect(page.getByText("Password updated successfully")).toBeVisible()
|
||||
|
||||
@@ -77,8 +79,8 @@ test("Expired or invalid reset link", async ({ page }) => {
|
||||
|
||||
await page.goto(invalidUrl)
|
||||
|
||||
await page.getByLabel("Set Password").fill(password)
|
||||
await page.getByLabel("Confirm Password").fill(password)
|
||||
await page.getByPlaceholder("New Password").fill(password)
|
||||
await page.getByPlaceholder("Confirm Password").fill(password)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
|
||||
await expect(page.getByText("Invalid token")).toBeVisible()
|
||||
@@ -103,7 +105,9 @@ test("Weak new password validation", async ({ page, request }) => {
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await page.goto(`http://localhost:1080/messages/${emailData.id}.html`)
|
||||
await page.goto(
|
||||
`${process.env.MAILCATCHER_HOST}/messages/${emailData.id}.html`,
|
||||
)
|
||||
|
||||
const selector = 'a[href*="/reset-password?token="]'
|
||||
let url = await page.getAttribute(selector, "href")
|
||||
@@ -111,8 +115,8 @@ test("Weak new password validation", async ({ page, request }) => {
|
||||
|
||||
// Set a weak new password
|
||||
await page.goto(url)
|
||||
await page.getByLabel("Set Password").fill(weakPassword)
|
||||
await page.getByLabel("Confirm Password").fill(weakPassword)
|
||||
await page.getByPlaceholder("New Password").fill(weakPassword)
|
||||
await page.getByPlaceholder("Confirm Password").fill(weakPassword)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
|
||||
await expect(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user