Patient Management API with Auth
Build a FastAPI + Vue 3 SaaS with JWT auth and PostgreSQL patient records.
Introduction
30 Second Summary
Clinics manage sensitive patient data every day. From appointment records to treatment histories, keeping this information secure and organized is critical for any healthcare business.
In this project, you will build a full-stack patient management system for an aesthetic clinic. You'll create a secure API backend with FastAPI and a modern dashboard frontend with Vue 3.
What You'll Build
A full-stack aesthetic clinic management system with JWT-authenticated API endpoints and a single-page application frontend.
By the end of this project, you'll have:
- A FastAPI backend with JWT authentication protecting patient CRUD endpoints.
- A PostgreSQL database with User, Patient, and Treatment models connected via async SQLAlchemy.
- A Vue 3 + Pinia frontend with login/register flow and a patient management dashboard.
- Secret Mission: Add role-based access control differentiating admin and staff permissions.
Are there any prerequisites?
This project assumes familiarity with Python and Vue 3. You should also have PostgreSQL running in Docker Desktop before you begin.
Set Up the Backend Environment
Every SaaS application needs a solid foundation before you start building features. For this project, that means a Python environment with the right libraries, a running database, and an organized folder structure.
In this step, you will create an isolated Python virtual environment, install the backend dependencies, spin up a PostgreSQL database in Docker, and lay out the project directories that will hold your API code.
In this step, get ready to:
- Create a Python virtual environment and install backend dependencies.
- Start a PostgreSQL Docker container and verify connectivity.
- Initialize the FastAPI project structure with a layered layout.
Create a Python virtual environment and install dependencies
A virtual environment keeps this project's packages separate from your system Python. This prevents version conflicts when different projects need different library versions.
- Check that Python 3.11 is available by running this command:
python3 --version
✔️ I see version 3.11 or higher
Python is ready. Continue to the next instruction below.
ⓧ I see an older version
Your system's default python3 points to an older release. On Debian 12, install the 3.11 package and use the full binary name:
sudo apt update && sudo apt install python3.11 python3.11-venv
Then use python3.11 instead of python3 for the remaining commands in this step.
ⓧ Command not found
Python is not installed. On Debian 12, install it by running:
sudo apt update && sudo apt install python3.11 python3.11-venv
After the install finishes, run python3.11 --version to confirm it reports 3.11.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and the backend directory by running these commands:
mkdir -p clinic-saas/backend
cd clinic-saas/backend
- Create a Python virtual environment inside the backend directory by running:
python3 -m venv venv
- Activate the virtual environment by running:
source venv/bin/activate
What does activating do?
Activating the virtual environment changes your shell's PATH so that python and pip point to the versions inside venv/. You will see (venv) at the start of your terminal prompt.
Now create the requirements file that lists every package the backend needs.
- Create requirements.txt inside the backend folder with the following content:
fastapi[standard]==0.138.0
sqlalchemy[asyncio]==2.0.51
asyncpg==0.31.0
alembic==1.18.4
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4
pydantic-settings==2.9.1
What are these packages?
- fastapi[standard] is the web framework that serves your API and includes Uvicorn for running the dev server.
- sqlalchemy[asyncio] is the ORM for defining database models with async support.
- asyncpg is the async PostgreSQL driver that SQLAlchemy uses under the hood.
- alembic handles database migrations so you can version-control schema changes.
- python-jose[cryptography] creates and verifies JWT tokens for authentication.
- passlib[bcrypt] hashes passwords securely before storing them.
- pydantic-settings loads configuration from environment variables and .env files.
- Install all the dependencies by running:
pip install -r requirements.txt
- Verify the install succeeded by running:
pip list | grep fastapi
You should see fastapi with version 0.138.0 in the output.
Install failing with a build error?
Some packages (like bcrypt) require a C compiler. On Debian 12, install the build tools:
sudo apt install build-essential libffi-dev
Then re-run pip install -r requirements.txt.
If the issue persists, help me debug this pip install error.
Create the PostgreSQL database in Docker
The backend stores patient and user data in PostgreSQL. Running PostgreSQL inside a Docker container keeps your system clean and makes the database easy to start, stop, and delete.
- Start a PostgreSQL container by running this command:
docker run -d \
--name clinic-db \
-e POSTGRES_DB=clinic_db \
-e POSTGRES_USER=clinic_user \
-e POSTGRES_PASSWORD=clinic_pass \
-p 5432:5432 \
-v clinic-data:/var/lib/postgresql/data \
postgres:16
What does this command do?
- --name clinic-db gives the container a memorable name you can reference later.
- POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD configure the database name, user, and password on first start.
- -p 5432:5432 maps the container's PostgreSQL port to your machine so the backend can connect via localhost:5432.
- -v clinic-data:/var/lib/postgresql/data persists data to a Docker volume so it survives container restarts.
- Verify the container is running by executing:
docker ps --filter name=clinic-db
You should see one row showing the clinic-db container with a status of Up.
- Test the database connection by running:
docker exec clinic-db psql -U clinic_user -d clinic_db -c "SELECT 1;"
You should see a small table with the value 1 in a single row.
Connection refused or container not starting?
Make sure Docker Desktop is running. If port 5432 is already in use by a local PostgreSQL instance, stop the local service first or change the host port mapping (e.g. -p 5433:5432).
Check container logs for errors by running docker logs clinic-db.
If the issue persists, help me debug my Docker PostgreSQL container.
Initialize the FastAPI project structure
A layered folder structure keeps your code organized as the project grows. Each directory has a single responsibility: models define database tables, schemas define request/response shapes, services hold business logic, and api holds route handlers.
- Create the project directories by running these commands from inside the backend folder:
mkdir -p app/models app/schemas app/api app/services
touch app/__init__.py
- Confirm the structure exists by running:
ls app/
You should see __init__.py, api, models, schemas, and services listed.
Next, create the configuration file that centralizes all settings for the application.
- Create app/config.py with the following content:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://clinic_user:clinic_pass@localhost:5432/clinic_db"
SECRET_KEY: str = "your-secret-key-here"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
class Config:
env_file = ".env"
settings = Settings()
What does this code do?
- Settings inherits from BaseSettings, which automatically reads matching environment variables and overrides the defaults.
- DATABASE_URL points to the PostgreSQL container you just started, using the asyncpg driver.
- SECRET_KEY signs JWT tokens. The default placeholder works for local development.
- The Config inner class tells pydantic-settings to also look for a .env file in the project root.
- Create app/database.py with the following content:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=True)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
What does this code do?
- create_async_engine creates an async connection pool to PostgreSQL. The echo=True flag logs all SQL queries during development.
- async_sessionmaker is a factory that produces database sessions for each request.
- Base is the parent class that all your database models will inherit from.
- get_db() is a FastAPI dependency that opens a session, yields it to the route handler, and closes it automatically when the request finishes.
- Save both files.
- Verify the config module loads correctly by running:
python -c "from app.config import settings; print(settings.DATABASE_URL)"
You should see postgresql+asyncpg://clinic_user:clinic_pass@localhost:5432/clinic_db printed.
ModuleNotFoundError when importing app?
Make sure you are running the command from inside the backend/ directory (the folder containing the app/ folder). Also confirm your virtual environment is active by checking for (venv) in your prompt.
If you still see an error, help me fix this import error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/config.py and app/database.py.
ⓧ I'd like to double check the full code
Here is backend/requirements.txt:
fastapi[standard]==0.138.0
sqlalchemy[asyncio]==2.0.51
asyncpg==0.31.0
alembic==1.18.4
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4
pydantic-settings==2.9.1
Here is backend/app/config.py:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://clinic_user:clinic_pass@localhost:5432/clinic_db"
SECRET_KEY: str = "your-secret-key-here"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
class Config:
env_file = ".env"
settings = Settings()
Here is backend/app/database.py:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=True)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
And backend/app/__init__.py is an empty file.
Your backend environment is ready. You have a virtual environment with all dependencies installed, a PostgreSQL database accepting connections, and a clean project layout waiting for your models and routes. Next up, you will define the database models and run your first migration.
Define Database Models and Run Migrations
Your project structure and database connection are ready. Now it is time to define the data your clinic application will store.
Every SaaS application revolves around its data models. In this step, you will define three SQLAlchemy models (User, Patient, Treatment), create Pydantic schemas for API validation, and run Alembic migrations to build the actual database tables.
In this step, get ready to:
- Define SQLAlchemy async models for User, Patient, and Treatment.
- Create Pydantic schemas for request/response validation.
- Configure Alembic for async migrations and apply them to PostgreSQL.
Define the SQLAlchemy models
Each model maps to a table in PostgreSQL. You will create three models across three files inside backend/app/models/.
- Create the User model in backend/app/models/user.py by adding this code:
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(50), default="staff")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
What does this code do?
This defines a users table using SQLAlchemy's modern Mapped type annotation style.
- Mapped[int] and Mapped[str] declare the Python type for each column.
- mapped_column(...) configures database-level constraints like unique=True and index=True.
- server_default=func.now() tells PostgreSQL to set the timestamp automatically when a row is inserted.
- The role column defaults to "staff" so new users start with limited permissions.
- Save backend/app/models/user.py.
- Create the Patient model in backend/app/models/patient.py by adding this code:
from datetime import datetime
from sqlalchemy import String, Text, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Patient(Base):
__tablename__ = "patients"
id: Mapped[int] = mapped_column(primary_key=True)
first_name: Mapped[str] = mapped_column(String(100))
last_name: Mapped[str] = mapped_column(String(100))
phone: Mapped[str] = mapped_column(String(20), nullable=True)
email: Mapped[str] = mapped_column(String(255), nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
treatments: Mapped[list["Treatment"]] = relationship(back_populates="patient")
What does this code do?
The Patient model stores clinic client information. Fields like phone, email, and notes are nullable=True because not every patient record will have them filled in.
The treatments relationship creates a link to the Treatment model. This lets you access all treatments for a patient directly via patient.treatments in Python.
- Save backend/app/models/patient.py.
- Create the Treatment model in backend/app/models/treatment.py by adding this code:
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Numeric, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Treatment(Base):
__tablename__ = "treatments"
id: Mapped[int] = mapped_column(primary_key=True)
patient_id: Mapped[int] = mapped_column(ForeignKey("patients.id"))
treatment_type: Mapped[str] = mapped_column(String(100))
description: Mapped[str] = mapped_column(Text, nullable=True)
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
cost: Mapped[float] = mapped_column(Numeric(10, 2), nullable=True)
patient: Mapped["Patient"] = relationship(back_populates="treatments")
What does this code do?
The Treatment model records each procedure performed on a patient. The ForeignKey("patients.id") on patient_id creates a database-level link between treatments and patients.
Numeric(10, 2) stores the cost with two decimal places, which is appropriate for currency values. The patient relationship with back_populates="treatments" completes the bidirectional link to the Patient model.
- Save backend/app/models/treatment.py.
- Register all three models in backend/app/models/__init__.py by adding these imports:
from app.models.user import User
from app.models.patient import Patient
from app.models.treatment import Treatment
Why import models here?
Importing the models in __init__.py ensures that SQLAlchemy's Base registry knows about all three tables. Alembic reads Base.metadata to auto-detect which tables to create, so every model must be imported before you generate migrations.
- Save backend/app/models/__init__.py.
- Confirm all four model files exist by running:
ls backend/app/models/
You should see __init__.py, user.py, patient.py, and treatment.py in the output.
Don't see all four files?
Make sure you saved each file in the correct directory. The path should be backend/app/models/, not backend/models/ or app/models/.
help me find where my model files ended up
Now you will create the Pydantic schemas that define what data the API accepts and returns.
- Create the user schemas in backend/app/schemas/user.py by adding this code:
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: int
email: str
role: str
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
token_type: str
What are Pydantic schemas for?
Pydantic schemas validate incoming request data and shape outgoing response data. UserCreate ensures the API only accepts a valid email and password. UserResponse controls exactly which fields are returned to the client (notice hashed_password is excluded).
The model_config = {"from_attributes": True} setting lets Pydantic read data directly from SQLAlchemy model instances, bridging the ORM layer to the API layer.
- Save backend/app/schemas/user.py.
- Create the patient and treatment schemas in backend/app/schemas/patient.py by adding this code:
from datetime import datetime
from pydantic import BaseModel
class PatientCreate(BaseModel):
first_name: str
last_name: str
phone: str | None = None
email: str | None = None
notes: str | None = None
class PatientResponse(BaseModel):
id: int
first_name: str
last_name: str
phone: str | None
email: str | None
notes: str | None
created_at: datetime
model_config = {"from_attributes": True}
class TreatmentCreate(BaseModel):
treatment_type: str
description: str | None = None
cost: float | None = None
class TreatmentResponse(BaseModel):
id: int
patient_id: int
treatment_type: str
description: str | None
date: datetime
cost: float | None
model_config = {"from_attributes": True}
What does this code do?
These schemas mirror the Patient and Treatment models but separate input from output. PatientCreate only asks for the fields a user would type in (no id or created_at since the database generates those).
PatientResponse and TreatmentResponse include all fields that should be visible in API responses, including the auto-generated id and timestamps.
- Save backend/app/schemas/patient.py.
Seeing import errors in your editor?
If your editor underlines EmailStr with a red squiggle, that is normal. The email-validator package (a dependency of fastapi[standard]) provides it. Make sure your editor is using the Python interpreter from your virtual environment, not the system Python.
help me configure my editor to use the virtual environment Python
Configure Alembic for async migrations
Alembic is a database migration tool that tracks changes to your schema over time. You will initialize it with the async template so it works with your async SQLAlchemy engine.
- Make sure you are inside the backend/ directory, then initialize Alembic by running:
alembic init --template async migrations
What does this command do?
This creates a migrations/ directory with an env.py pre-configured for async database drivers. It also creates an alembic.ini configuration file in the backend/ directory.
- Confirm the new files exist by running:
ls migrations/
You should see env.py, script.py.mako, and a versions/ directory.
Now you need to tell Alembic where your database is and which models to track.
- Open alembic.ini in the backend/ directory.
- Find the line that starts with sqlalchemy.url.
sqlalchemy.url = driver://user:pass@localhost/dbname
- Replace it with your PostgreSQL connection string:
sqlalchemy.url = postgresql+asyncpg://clinic_user:clinic_pass@localhost:5432/clinic_db
- Save alembic.ini.
Next, you need to update env.py so Alembic knows about your models.
- Open migrations/env.py.
- Find this section near the top of the file:
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
- Replace it with the following to import your models and point to the Base metadata:
# Import all models so Base.metadata is populated
from app.models import User, Patient, Treatment
from app.database import Base
target_metadata = Base.metadata
Why import the models here?
Alembic's autogenerate feature compares your Python models against the current database schema. By importing all three models, their table definitions register with Base.metadata. Without these imports, Alembic would see an empty metadata and generate no migrations.
- Save migrations/env.py.
Now generate the initial migration that creates all three tables.
- Generate the migration by running:
alembic revision --autogenerate -m "initial tables"
You should see output indicating that Alembic detected the users, patients, and treatments tables.
Seeing a ModuleNotFoundError?
If you see an error about app not being found, make sure you are running the command from the backend/ directory (where app/ lives). Also confirm your virtual environment is activated.
help me fix ModuleNotFoundError when running alembic
Run the migration and verify the tables
The generated migration file contains the SQL operations to create your tables. Now you will apply it to the running PostgreSQL database.
- Apply the migration by running:
alembic upgrade head
What does this command do?
The upgrade head command tells Alembic to apply all pending migrations up to the latest revision. It connects to your PostgreSQL database using the async engine and executes the CREATE TABLE statements for users, patients, and treatments.
- Verify the tables were created by running:
docker exec -it clinic-db psql -U clinic_user -d clinic_db -c "\dt"
You should see a table listing that includes alembic_version, users, patients, and treatments.
Tables not showing up?
Make sure the Docker container clinic-db is still running. Check with docker ps. If the migration ran without errors but tables are missing, double-check that your alembic.ini connection string matches the database name and credentials you used when creating the container.
help me debug why my Alembic migration did not create tables
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file created in this step.
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(50), default="staff")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
from datetime import datetime
from sqlalchemy import String, Text, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Patient(Base):
__tablename__ = "patients"
id: Mapped[int] = mapped_column(primary_key=True)
first_name: Mapped[str] = mapped_column(String(100))
last_name: Mapped[str] = mapped_column(String(100))
phone: Mapped[str] = mapped_column(String(20), nullable=True)
email: Mapped[str] = mapped_column(String(255), nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
treatments: Mapped[list["Treatment"]] = relationship(back_populates="patient")
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Numeric, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Treatment(Base):
__tablename__ = "treatments"
id: Mapped[int] = mapped_column(primary_key=True)
patient_id: Mapped[int] = mapped_column(ForeignKey("patients.id"))
treatment_type: Mapped[str] = mapped_column(String(100))
description: Mapped[str] = mapped_column(Text, nullable=True)
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
cost: Mapped[float] = mapped_column(Numeric(10, 2), nullable=True)
patient: Mapped["Patient"] = relationship(back_populates="treatments")
from app.models.user import User
from app.models.patient import Patient
from app.models.treatment import Treatment
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: int
email: str
role: str
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
token_type: str
from datetime import datetime
from pydantic import BaseModel
class PatientCreate(BaseModel):
first_name: str
last_name: str
phone: str | None = None
email: str | None = None
notes: str | None = None
class PatientResponse(BaseModel):
id: int
first_name: str
last_name: str
phone: str | None
email: str | None
notes: str | None
created_at: datetime
model_config = {"from_attributes": True}
class TreatmentCreate(BaseModel):
treatment_type: str
description: str | None = None
cost: float | None = None
class TreatmentResponse(BaseModel):
id: int
patient_id: int
treatment_type: str
description: str | None
date: datetime
cost: float | None
model_config = {"from_attributes": True}
Your database schema is locked in. Next up, you will build the JWT authentication system that protects your API endpoints.
Build the JWT Authentication System
Your database tables are ready and waiting for data. The next challenge is making sure only authorized users can access them.
In this step, you will build the authentication layer that protects your API. You will use JWT (JSON Web Tokens) to issue signed tokens when users log in, and validate those tokens on every protected request.
In this step, get ready to:
- Build password hashing and JWT token utilities.
- Create register and login endpoints that issue access tokens.
- Create a reusable dependency that validates tokens on protected routes.
Build the auth utilities
Every authentication system needs two core capabilities: securely storing passwords and issuing verifiable tokens. You will build both in a dedicated service file.
- Create a new file at backend/app/services/auth.py and add the following code:
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.user import User
# Set up the password hashing context using bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Tell FastAPI where clients send credentials to get a token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
What does this code do?
The CryptContext from passlib handles bcrypt hashing. It automatically salts passwords so identical inputs produce different hashes.
The OAuth2PasswordBearer tells FastAPI's auto-docs where the token endpoint lives. It also extracts the Bearer token from incoming requests for you.
These two functions are thin wrappers around passlib. They keep the rest of your codebase decoupled from the hashing implementation.
- Below verify_password, add the create_access_token function:
def create_access_token(data: dict) -> str:
# Copy the payload so we don't mutate the caller's dict
to_encode = data.copy()
# Set token expiry based on config
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
What does this code do?
This function takes a dictionary of claims (like the user's email and role), stamps an expiry time onto it, and signs the whole thing with your secret key using the HS256 algorithm.
The resulting string is the JWT that clients will send back on every request to prove their identity.
- Save backend/app/services/auth.py.
Why save now?
You will add one more function to this file in substep 3. Saving now lets you verify there are no syntax errors before moving on to the endpoints.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.user import User
# Set up the password hashing context using bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Tell FastAPI where clients send credentials to get a token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict) -> str:
# Copy the payload so we don't mutate the caller's dict
to_encode = data.copy()
# Set token expiry based on config
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
Create register and login endpoints
With the utilities ready, you can now expose two endpoints: one for creating accounts and one for exchanging credentials for a token.
- Create a new file at backend/app/api/auth.py and add the register endpoint:
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse, Token
from app.services.auth import hash_password, verify_password, create_access_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if email is already taken
result = await db.execute(select(User).where(User.email == user_data.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
# Hash the password and save the new user
user = User(email=user_data.email, hashed_password=hash_password(user_data.password))
db.add(user)
await db.commit()
await db.refresh(user)
return user
What does this code do?
The /register endpoint accepts an email and password. It first checks the database for duplicates. If the email is free, it hashes the password, inserts the user, and returns the new user record (without the password).
The APIRouter with prefix="/api/auth" means this endpoint becomes POST /api/auth/register when mounted in the app.
- Below the register function, add the login endpoint:
@router.post("/login", response_model=Token)
async def login(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Look up the user by email
result = await db.execute(select(User).where(User.email == user_data.email))
user = result.scalar_one_or_none()
# Reject if user doesn't exist or password is wrong
if not user or not verify_password(user_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
# Issue a signed JWT containing the user's email and role
access_token = create_access_token(data={"sub": user.email, "role": user.role})
return {"access_token": access_token, "token_type": "bearer"}
What does this code do?
The /login endpoint verifies the email exists and the password matches the stored hash. If both checks pass, it creates a JWT with the user's email as the sub (subject) claim and their role as an additional claim.
The client stores this token and sends it in the Authorization header on every subsequent request.
- Save backend/app/api/auth.py.
Seeing import errors in your editor?
If your editor shows red squiggles under the imports, make sure your virtual environment is activated. The packages were installed in the venv, not globally.
help me fix import errors in my FastAPI auth file
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse, Token
from app.services.auth import hash_password, verify_password, create_access_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == user_data.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(email=user_data.email, hashed_password=hash_password(user_data.password))
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/login", response_model=Token)
async def login(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == user_data.email))
user = result.scalar_one_or_none()
if not user or not verify_password(user_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token(data={"sub": user.email, "role": user.role})
return {"access_token": access_token, "token_type": "bearer"}
Create the get_current_user dependency and wire up the app
FastAPI's dependency injection system lets you declare a function that runs before any endpoint. You will build a dependency that extracts the JWT from the request, validates it, and returns the authenticated user.
- Return to backend/app/services/auth.py and add the get_current_user function below create_access_token:
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Decode the JWT and extract the email from the 'sub' claim
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# Look up the user in the database
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
What does this code do?
This async function is a FastAPI dependency. Any endpoint that declares current_user: User = Depends(get_current_user) will automatically require a valid JWT.
It decodes the token, extracts the sub claim (the user's email), queries the database, and returns the User object. If anything fails, the request gets a 401 response before the endpoint code even runs.
- Save backend/app/services/auth.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
Now you need the FastAPI application that wires the routers together. The patient endpoints do not exist yet, but you will create a stub so the app starts cleanly.
- Create a stub file at backend/app/api/patients.py with just a router placeholder:
from fastapi import APIRouter
# Placeholder router - endpoints added in the next step
router = APIRouter(prefix="/api/patients", tags=["patients"])
- Create backend/app/main.py with the application setup and CORS middleware:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.patients import router as patients_router
# Create the FastAPI application instance
app = FastAPI(title="Aesthetic Clinic API")
# Allow the Vue frontend to make requests from localhost:5173
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(patients_router)
@app.get("/")
async def root():
return {"message": "Aesthetic Clinic API"}
What does this code do?
The CORSMiddleware allows your Vue 3 frontend (which will run on port 5173) to make cross-origin requests to this API. Without it, browsers block the requests.
The two include_router calls mount the auth endpoints and the (currently empty) patient endpoints onto the app. The root GET / endpoint is a quick health check.
- Save both backend/app/api/patients.py and backend/app/main.py.
- Start the development server by running this command from the backend/ directory:
fastapi dev
- Open http://127.0.0.1:8000/docs in your browser. You should see the FastAPI interactive docs with the /api/auth/register and /api/auth/login endpoints listed under the auth section.
Server not starting?
Make sure your virtual environment is activated and you are running the command from inside the backend/ directory. The fastapi CLI needs to find the app package.
If you see a ModuleNotFoundError, confirm that all packages from requirements.txt are installed in your active venv.
help me fix my FastAPI server startup error
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.patients import router as patients_router
app = FastAPI(title="Aesthetic Clinic API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(patients_router)
@app.get("/")
async def root():
return {"message": "Aesthetic Clinic API"}
Your authentication system is live. Next up, you will build the patient and treatment endpoints that use get_current_user to lock down access to clinic data.
Build the Patient and Treatment CRUD API
Your authentication system is locked in. Every route you build from here can require a valid JWT before it responds to any request.
In this step, you'll build the patient and treatment CRUD endpoints that power the clinic's data operations. Each endpoint will use the get_current_user dependency you built in the previous step, so only authenticated users can access patient data.
In this step, get ready to:
- Build patient endpoints (create, list, get by ID) protected by JWT auth.
- Build treatment endpoints (create and list treatments for a patient).
- Test the complete API flow through FastAPI's interactive docs.
Build patient CRUD endpoints
The patient endpoints handle creating new patient records, listing all patients, and fetching a single patient by ID. Each one requires a valid JWT token via the get_current_user dependency.
- Open backend/app/api/patients.py and add the imports, router definition, and the create patient endpoint by pasting the following code:
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.patient import Patient
from app.models.treatment import Treatment
from app.schemas.patient import PatientCreate, PatientResponse, TreatmentCreate, TreatmentResponse
from app.services.auth import get_current_user
router = APIRouter(prefix="/api/patients", tags=["patients"])
@router.post("/", response_model=PatientResponse, status_code=status.HTTP_201_CREATED)
async def create_patient(
patient_data: PatientCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
patient = Patient(**patient_data.model_dump())
db.add(patient)
await db.commit()
await db.refresh(patient)
return patient
What does this code do?
The imports bring in everything the router needs: FastAPI utilities, SQLAlchemy query tools, your database models, Pydantic schemas, and the auth dependency.
- router = APIRouter(prefix="/api/patients", tags=["patients"]) groups all patient routes under the /api/patients path.
- current_user: User = Depends(get_current_user) injects the JWT validation. If the token is missing or invalid, FastAPI returns a 401 before your function body runs.
- patient_data.model_dump() converts the Pydantic schema into a dictionary that SQLAlchemy can unpack into a Patient instance.
Don't save yet. You'll add the read endpoints next.
- Below the create_patient function, add the list and get-by-ID endpoints:
@router.get("/", response_model=list[PatientResponse])
async def list_patients(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Patient).order_by(Patient.created_at.desc()))
return result.scalars().all()
@router.get("/{patient_id}", response_model=PatientResponse)
async def get_patient(
patient_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Patient).where(Patient.id == patient_id))
patient = result.scalar_one_or_none()
if not patient:
raise HTTPException(status_code=404, detail="Patient not found")
return patient
What does this code do?
- list_patients returns all patients ordered by created_at descending, so the newest records appear first.
- get_patient looks up a single patient by their primary key. If no match is found, it raises a 404 error with a clear detail message.
- Both endpoints include the get_current_user dependency, meaning unauthenticated requests are rejected automatically.
- Save backend/app/api/patients.py.
Seeing an import error?
- Make sure the import for get_current_user points to app.services.auth, not app.api.auth.
- Confirm that PatientCreate and PatientResponse are imported from app.schemas.patient.
Why is my patients router throwing an import error?
Build treatment endpoints
Treatments belong to a specific patient. The endpoints nest under the patient's ID path, making the relationship explicit in the URL structure.
- Below the get_patient function in backend/app/api/patients.py, add the treatment endpoints:
@router.post("/{patient_id}/treatments", response_model=TreatmentResponse, status_code=status.HTTP_201_CREATED)
async def create_treatment(
patient_id: int,
treatment_data: TreatmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Patient).where(Patient.id == patient_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Patient not found")
treatment = Treatment(patient_id=patient_id, **treatment_data.model_dump())
db.add(treatment)
await db.commit()
await db.refresh(treatment)
return treatment
@router.get("/{patient_id}/treatments", response_model=list[TreatmentResponse])
async def list_treatments(
patient_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Treatment).where(Treatment.patient_id == patient_id).order_by(Treatment.date.desc())
)
return result.scalars().all()
What does this code do?
- create_treatment first verifies the patient exists before inserting a treatment. This prevents orphaned records pointing to a non-existent patient.
- list_treatments filters treatments by patient_id and orders them by date descending, so the most recent treatment appears first.
- The URL pattern /{patient_id}/treatments makes the parent-child relationship between patients and treatments clear in the API design.
- Save backend/app/api/patients.py.
Seeing a 'Patient not found' error when creating a treatment?
- Make sure you created a patient first. Treatment creation requires an existing patient_id in the URL path.
- Double check that the patient_id in your request URL matches the ID returned when you created the patient.
Help me debug why my treatment creation returns Patient not found.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to testing.
ⓧ I'd like to double check the full code
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.patient import Patient
from app.models.treatment import Treatment
from app.schemas.patient import PatientCreate, PatientResponse, TreatmentCreate, TreatmentResponse
from app.services.auth import get_current_user
router = APIRouter(prefix="/api/patients", tags=["patients"])
@router.post("/", response_model=PatientResponse, status_code=status.HTTP_201_CREATED)
async def create_patient(
patient_data: PatientCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
patient = Patient(**patient_data.model_dump())
db.add(patient)
await db.commit()
await db.refresh(patient)
return patient
@router.get("/", response_model=list[PatientResponse])
async def list_patients(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Patient).order_by(Patient.created_at.desc()))
return result.scalars().all()
@router.get("/{patient_id}", response_model=PatientResponse)
async def get_patient(
patient_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Patient).where(Patient.id == patient_id))
patient = result.scalar_one_or_none()
if not patient:
raise HTTPException(status_code=404, detail="Patient not found")
return patient
@router.post("/{patient_id}/treatments", response_model=TreatmentResponse, status_code=status.HTTP_201_CREATED)
async def create_treatment(
patient_id: int,
treatment_data: TreatmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Patient).where(Patient.id == patient_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Patient not found")
treatment = Treatment(patient_id=patient_id, **treatment_data.model_dump())
db.add(treatment)
await db.commit()
await db.refresh(treatment)
return treatment
@router.get("/{patient_id}/treatments", response_model=list[TreatmentResponse])
async def list_treatments(
patient_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Treatment).where(Treatment.patient_id == patient_id).order_by(Treatment.date.desc())
)
return result.scalars().all()
Test the full API flow using the interactive docs
FastAPI auto-generates interactive API documentation at /docs. You can register a user, log in, and test every endpoint without writing a single line of client code.
- Start your backend server by running this command from your backend folder:
fastapi dev
- Open your browser and navigate to http://localhost:8000/docs.
You should see the Swagger UI with all your endpoints grouped under auth and patients tags.
- Expand the POST /api/auth/register endpoint and click Try it out.
- Enter a test email and password in the request body, then click Execute.
{
"email": "nurse@clinic.com",
"password": "testpass123"
}
- You should see a 201 response with the user's id, email, and role.
- Now expand POST /api/auth/login, click Try it out, enter the same email and password, and click Execute.
- Copy the access_token value from the response (without the quotes).
- Click the Authorize button at the top of the page.
- Paste your token into the Value field and click Authorize.
Why the Authorize button?
Once you authorize, the Swagger UI attaches your JWT to every subsequent request automatically. This mimics how a frontend app would include the token in the Authorization header.
- Expand POST /api/patients/, click Try it out, and create a patient:
{
"first_name": "Jane",
"last_name": "Smith",
"phone": "0412345678",
"email": "jane@example.com",
"notes": "New patient consultation"
}
- Click Execute. You should see a 201 response with the patient data including an id and created_at timestamp.
- Note the patient's id from the response (likely 1).
- Expand POST /api/patients/{patient_id}/treatments, click Try it out, enter 1 for the patient_id parameter, and submit this body:
{
"treatment_type": "Botox",
"description": "Forehead and crow's feet",
"cost": 350.00
}
- Click Execute. You should see a 201 response with the treatment record linked to patient ID 1.
- Expand GET /api/patients/ and click Execute to confirm your patient appears in the list.
- Expand GET /api/patients/{patient_id}/treatments, enter 1 for patient_id, and click Execute to see the treatment you added.
Getting a 401 Unauthorized on patient endpoints?
- Make sure you clicked the Authorize button and pasted your token. The lock icons next to each endpoint should appear closed (locked).
- Tokens expire after 30 minutes. If you've been testing for a while, log in again to get a fresh token.
- Confirm you copied only the token string, not the surrounding quotes or the Bearer prefix. The Swagger UI adds the prefix automatically.
Why am I getting 401 Unauthorized when testing my patient endpoints?
Your API is fully functional. You can register users, authenticate with JWT, and manage patient and treatment data. Next up, you'll build the Vue 3 frontend that consumes this API.
Build the Vue 3 Frontend with Pinia Auth
Your API is fully operational. Users can register, log in, and manage patients and treatments through the interactive docs. Now it's time to give your clinic a real user interface.
In this step you'll scaffold a Vue 3 single-page application that talks to the FastAPI backend. The frontend will handle login, registration, and a patient dashboard, all powered by Pinia for state management.
In this step, get ready to:
- Scaffold a Vue 3 + Vite project with Pinia and Vue Router.
- Build the Pinia auth store with login, register, token storage, and logout.
- Build the patient dashboard page that fetches data from the protected API.
Scaffold the Vue 3 + Vite project and install dependencies
The official way to start a Vue 3 project is with the create-vue scaffolding tool. It sets up Vite as the build tool and lets you opt in to Router and Pinia during creation.
- Open a new terminal window. Your FastAPI dev server should keep running in the existing one.
- Navigate to your project's root directory (the folder containing backend/) by running:
cd ~/Desktop/aesthetic-clinic
- Scaffold your Vue 3 frontend by running:
npm create vue@latest
What prompts will I see?
The create-vue tool asks a series of questions. Answer each one as follows:
- Project name: frontend
- Add TypeScript: No
- Add JSX Support: No
- Add Vue Router: Yes
- Add Pinia: Yes
- Add Vitest: No
- Add End-to-End Testing: No
- Add ESLint: No
- Add Vue DevTools: No
- Move into the new project folder and install its dependencies by running:
cd frontend
npm install
- Confirm the project runs by starting the dev server:
npm run dev
You should see output indicating the Vite dev server is running at http://localhost:5173. Open that URL in your browser to confirm the default Vue welcome page loads.
Why is CORS already configured?
If you look at backend/app/main.py, you'll see the CORS middleware already allows requests from http://localhost:5173. That's the exact port Vite uses, so your frontend can talk to the backend without any additional setup.
Dev server not starting?
- Make sure you ran npm install before npm run dev. The scaffold step only creates the project structure.
- If port 5173 is occupied, Vite will use the next available port. Check the terminal output for the actual URL.
Help me troubleshoot my Vite dev server
- Stop the dev server with Ctrl+C for now. You'll restart it after building the stores and views.
- Clear out the scaffolded example files. Delete everything inside src/views/, src/components/, and src/stores/ so you start fresh.
rm -rf src/views/* src/components/* src/stores/*
Build the Pinia auth store and Vue Router guards
Pinia stores hold your application state in a reactive, composable way. The auth store manages the JWT token lifecycle: logging in, persisting the token to localStorage, and decoding it to extract the user's email and role.
- Create the auth store file at frontend/src/stores/auth.js and paste the following code:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const API_URL = 'http://localhost:8000/api'
export const useAuthStore = defineStore('auth', () => {
// Persist token across page reloads using localStorage
const token = ref(localStorage.getItem('token') || null)
const user = ref(null)
// Reactive check for whether the user is logged in
const isAuthenticated = computed(() => !!token.value)
async function login(email, password) {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) throw new Error('Invalid credentials')
const data = await response.json()
token.value = data.access_token
localStorage.setItem('token', data.access_token)
// Decode the JWT payload to extract user info
const payload = JSON.parse(atob(data.access_token.split('.')[1]))
user.value = { email: payload.sub, role: payload.role }
}
async function register(email, password) {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Registration failed')
}
return await response.json()
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('token')
}
return { token, user, isAuthenticated, login, register, logout }
})
What does this code do?
- token is initialized from localStorage so the user stays logged in across page refreshes.
- login() calls your FastAPI login endpoint, stores the JWT, and decodes its payload with atob() to read the email and role without a second network request.
- isAuthenticated is a computed property. Any component watching it reacts automatically when the token changes.
- Save frontend/src/stores/auth.js.
Next, set up Vue Router with a navigation guard that redirects unauthenticated users to the login page.
- Open frontend/src/router/index.js (the scaffold created this file) and replace its contents with:
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
// Define application routes
const routes = [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{
path: '/',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// Navigation guard: redirect to login if not authenticated
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login' }
}
})
export default router
What is a navigation guard?
The beforeEach hook runs before every route change. If the target route has meta.requiresAuth set to true and the user has no token, Vue Router redirects them to the login page automatically.
- Save frontend/src/router/index.js.
Now create the login view so users can authenticate.
- Create frontend/src/views/LoginView.vue and paste the following code:
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const router = useRouter()
const email = ref('')
const password = ref('')
const error = ref('')
const isRegistering = ref(false)
async function handleSubmit() {
error.value = ''
try {
if (isRegistering.value) {
await auth.register(email.value, password.value)
// After registering, log in automatically
await auth.login(email.value, password.value)
} else {
await auth.login(email.value, password.value)
}
router.push('/')
} catch (e) {
error.value = e.message
}
}
</script>
<template>
<div class="login-container">
<h1>Aesthetic Clinic</h1>
<form @submit.prevent="handleSubmit">
<input v-model="email" type="email" placeholder="Email" required />
<input v-model="password" type="password" placeholder="Password" required />
<button type="submit">{{ isRegistering ? 'Register' : 'Login' }}</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
<p>
<a href="#" @click.prevent="isRegistering = !isRegistering">
{{ isRegistering ? 'Already have an account? Login' : 'Need an account? Register' }}
</a>
</p>
</div>
</template>
<style scoped>
.login-container { max-width: 360px; margin: 80px auto; text-align: center; }
input { display: block; width: 100%; margin: 8px 0; padding: 10px; box-sizing: border-box; }
button { width: 100%; padding: 10px; margin-top: 12px; cursor: pointer; }
.error { color: red; margin-top: 8px; }
</style>
What does this component do?
This single view handles both login and registration. The isRegistering flag toggles between modes. On successful auth, the router pushes the user to the dashboard.
- Save frontend/src/views/LoginView.vue.
Seeing import errors in your editor?
If VS Code underlines the store import, that's expected until all files exist. The errors will disappear once you save all the files in this step.
Help me fix import errors in my Vue project
Build the patient dashboard page
The dashboard needs its own Pinia store to manage patient and treatment data. This store attaches the JWT to every API request using an authorization header.
- Create frontend/src/stores/patients.js and paste the following code:
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAuthStore } from './auth'
const API_URL = 'http://localhost:8000/api'
export const usePatientsStore = defineStore('patients', () => {
const patients = ref([])
const selectedPatient = ref(null)
const treatments = ref([])
// Build headers with the JWT for authenticated requests
function authHeaders() {
const auth = useAuthStore()
return { Authorization: `Bearer ${auth.token}`, 'Content-Type': 'application/json' }
}
async function fetchPatients() {
const response = await fetch(`${API_URL}/patients/`, { headers: authHeaders() })
if (!response.ok) throw new Error('Failed to fetch patients')
patients.value = await response.json()
}
async function createPatient(patientData) {
const response = await fetch(`${API_URL}/patients/`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(patientData),
})
if (!response.ok) throw new Error('Failed to create patient')
const newPatient = await response.json()
patients.value.unshift(newPatient)
return newPatient
}
async function fetchTreatments(patientId) {
const response = await fetch(`${API_URL}/patients/${patientId}/treatments`, {
headers: authHeaders(),
})
if (!response.ok) throw new Error('Failed to fetch treatments')
treatments.value = await response.json()
}
return { patients, selectedPatient, treatments, fetchPatients, createPatient, fetchTreatments }
})
What does this code do?
- authHeaders() grabs the current JWT from the auth store and formats it as a Bearer token header for every outgoing request.
- fetchPatients() hits GET /api/patients/ and populates the reactive patients array.
- createPatient() uses unshift() to add the new patient at the top of the list for immediate UI feedback.
- Save frontend/src/stores/patients.js.
Now build the dashboard view that displays patients and their treatments.
- Create frontend/src/views/DashboardView.vue and paste the following code:
<script setup>
import { onMounted, ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import { usePatientsStore } from '../stores/patients'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const patientsStore = usePatientsStore()
const router = useRouter()
// New patient form fields
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const email = ref('')
onMounted(async () => {
await patientsStore.fetchPatients()
})
async function addPatient() {
await patientsStore.createPatient({
first_name: firstName.value,
last_name: lastName.value,
phone: phone.value || null,
email: email.value || null,
})
firstName.value = ''
lastName.value = ''
phone.value = ''
email.value = ''
}
async function selectPatient(patient) {
patientsStore.selectedPatient = patient
await patientsStore.fetchTreatments(patient.id)
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<div class="dashboard">
<header>
<h1>Patient Dashboard</h1>
<span>{{ auth.user?.email }}</span>
<button @click="handleLogout">Logout</button>
</header>
<section class="add-patient">
<h2>Add Patient</h2>
<form @submit.prevent="addPatient">
<input v-model="firstName" placeholder="First name" required />
<input v-model="lastName" placeholder="Last name" required />
<input v-model="phone" placeholder="Phone" />
<input v-model="email" type="email" placeholder="Email" />
<button type="submit">Add</button>
</form>
</section>
<section class="patients-list">
<h2>Patients</h2>
<ul>
<li
v-for="patient in patientsStore.patients"
:key="patient.id"
@click="selectPatient(patient)"
:class="{ active: patientsStore.selectedPatient?.id === patient.id }"
>
{{ patient.first_name }} {{ patient.last_name }}
</li>
</ul>
</section>
<section v-if="patientsStore.selectedPatient" class="treatments">
<h2>Treatments for {{ patientsStore.selectedPatient.first_name }}</h2>
<ul>
<li v-for="t in patientsStore.treatments" :key="t.id">
{{ t.treatment_type }} - ${{ t.cost ?? 'N/A' }}
</li>
<li v-if="patientsStore.treatments.length === 0">No treatments recorded yet.</li>
</ul>
</section>
</div>
</template>
<style scoped>
.dashboard { max-width: 800px; margin: 0 auto; padding: 20px; }
header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
header h1 { flex: 1; }
.add-patient form { display: flex; gap: 8px; flex-wrap: wrap; }
.add-patient input { padding: 8px; }
.patients-list ul { list-style: none; padding: 0; }
.patients-list li { padding: 10px; cursor: pointer; border-bottom: 1px solid #eee; }
.patients-list li.active { background: #e8f4ff; }
.treatments ul { list-style: none; padding: 0; }
.treatments li { padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
</style>
What does this component do?
- onMounted calls fetchPatients() as soon as the page loads, populating the patient list from the API.
- Clicking a patient calls selectPatient() which fetches that patient's treatments and displays them in a separate section.
- The form at the top lets you add new patients directly from the dashboard.
- Save frontend/src/views/DashboardView.vue.
Finally, update the root App.vue to render the router view.
- Open frontend/src/App.vue and replace its contents with:
<script setup>
// App.vue just renders whichever route is active
</script>
<template>
<RouterView />
</template>
- Save frontend/src/App.vue.
- Start the frontend dev server again by running:
npm run dev
- Open http://localhost:5173 in your browser. You should be redirected to the login page since you have no token yet.
- Click the Need an account? Register link and create a new user (or use the account you created via the API docs earlier).
- After logging in, you should see the Patient Dashboard with the "Add Patient" form and any patients you created earlier through the API.
Login failing or seeing a CORS error?
- Confirm your FastAPI server is still running on port 8000 in the other terminal.
- Check that backend/app/main.py has http://localhost:5173 in the allow_origins list.
- Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) to see the exact error message.
Help me debug CORS errors between my Vue frontend and FastAPI backend
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files.
ⓧ I'd like to double check the full code
Here is the complete code for both Pinia store files:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const API_URL = 'http://localhost:8000/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || null)
const user = ref(null)
const isAuthenticated = computed(() => !!token.value)
async function login(email, password) {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) throw new Error('Invalid credentials')
const data = await response.json()
token.value = data.access_token
localStorage.setItem('token', data.access_token)
const payload = JSON.parse(atob(data.access_token.split('.')[1]))
user.value = { email: payload.sub, role: payload.role }
}
async function register(email, password) {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Registration failed')
}
return await response.json()
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('token')
}
return { token, user, isAuthenticated, login, register, logout }
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAuthStore } from './auth'
const API_URL = 'http://localhost:8000/api'
export const usePatientsStore = defineStore('patients', () => {
const patients = ref([])
const selectedPatient = ref(null)
const treatments = ref([])
function authHeaders() {
const auth = useAuthStore()
return { Authorization: `Bearer ${auth.token}`, 'Content-Type': 'application/json' }
}
async function fetchPatients() {
const response = await fetch(`${API_URL}/patients/`, { headers: authHeaders() })
if (!response.ok) throw new Error('Failed to fetch patients')
patients.value = await response.json()
}
async function createPatient(patientData) {
const response = await fetch(`${API_URL}/patients/`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(patientData),
})
if (!response.ok) throw new Error('Failed to create patient')
const newPatient = await response.json()
patients.value.unshift(newPatient)
return newPatient
}
async function fetchTreatments(patientId) {
const response = await fetch(`${API_URL}/patients/${patientId}/treatments`, {
headers: authHeaders(),
})
if (!response.ok) throw new Error('Failed to fetch treatments')
treatments.value = await response.json()
}
return { patients, selectedPatient, treatments, fetchPatients, createPatient, fetchTreatments }
})
Your full-stack clinic management system is live. The Vue frontend authenticates against the FastAPI backend, stores the JWT in the browser, and fetches protected patient data over the API. Next up: the Secret Mission adds role-based access control so admins and staff see different capabilities.
Secret mission
Add Role-Based Access Control
Your clinic API authenticates users, but every logged-in user can do everything. In a real clinic, only administrators should be able to delete patient records. Can you add role-based authorization so that admin and staff users have different permissions?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. The only resource consuming system resources is the PostgreSQL Docker container.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container (clinic-db) with clinic data.
- Docker volume (clinic-data) storing persistent database files.
- Backend project folder (backend/) with Python virtual environment and FastAPI code.
- Frontend project folder (frontend/) with Vue 3 application code.
Keep everything running
No action needed. Choose this if you are still actively building or want to continue developing the clinic application.
- Your Docker container stays running and the database retains all patient and treatment data.
- The FastAPI dev server and Vite dev server will need to be restarted manually if you closed your terminal.
- Your Python virtual environment remains in the backend/ folder.
Pause - I'll come back to this later
Stop the Docker container to free up memory. Your data is preserved in the volume and will be available when you restart.
- Stop the PostgreSQL container by running:
docker stop clinic-db
- When you are ready to resume, restart the container by running:
docker start clinic-db
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database and all project files.
- Remove the Docker container and its data volume by running:
docker rm -f clinic-db && docker volume rm clinic-data
- Delete the backend project folder by running:
rm -rf backend/
- Delete the frontend project folder by running:
rm -rf frontend/
Nice Work!
Nice Work!
You just built a full-stack aesthetic clinic management system from scratch. Your application has a secure API backend, a relational database with migrations, and a modern single-page frontend that ties it all together.
You've learned how to:
- Build a FastAPI backend with JWT authentication (register, login, and protected routes) connected to async PostgreSQL.
- Define SQLAlchemy data models for Patient and Treatment records with Alembic database migrations for schema management.
- Create a Vue 3 + Pinia frontend with a login flow and a patient dashboard that consumes your protected API.
- Secret Mission: Added role-based access control differentiating admin vs. staff permissions.
Ready to quiz yourself?