Merge pull request 'add authentication system and missing frontend routes' (#1) from auth into main
Reviewed-on: https://git.skyforest.net/jlobbes/fastapi-svelte-template/pulls/1
This commit is contained in:
commit
b34149e085
49 changed files with 1413 additions and 294 deletions
|
@ -2,7 +2,9 @@
|
|||
|
||||
Built using fastapi.
|
||||
|
||||
## Database Migrations
|
||||
## Deployment
|
||||
|
||||
### Database Migrations
|
||||
|
||||
You must run database migrations after first deploying the database, by running
|
||||
|
||||
|
@ -11,3 +13,15 @@ sudo docker exec -w /app/todo/database -it todo-backend alembic upgrade head
|
|||
```
|
||||
|
||||
once you have launched the `todo-backend`-container.
|
||||
|
||||
## Development
|
||||
|
||||
### Populate the database
|
||||
|
||||
You can populate the database with fake users and todo-items by running
|
||||
|
||||
```bash
|
||||
sudo docker exec -it todo-backend ash -c 'python -c "import todo.utils.fakery as fk; fk.populate_database()"'
|
||||
```
|
||||
|
||||
once you have applied all database migrations.
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
alembic==1.10.4
|
||||
anyio==3.6.2
|
||||
bcrypt==4.0.1
|
||||
click==8.1.3
|
||||
Faker==18.9.0
|
||||
fastapi==0.95.1
|
||||
|
@ -8,8 +9,10 @@ h11==0.14.0
|
|||
idna==3.4
|
||||
Mako==1.2.4
|
||||
MarkupSafe==2.1.2
|
||||
passlib==1.7.4
|
||||
psycopg2-binary==2.9.6
|
||||
pydantic==1.10.7
|
||||
PyJWT==2.7.0
|
||||
python-dateutil==2.8.2
|
||||
six==1.16.0
|
||||
sniffio==1.3.0
|
||||
|
|
99
backend/todo/auth/auth.py
Normal file
99
backend/todo/auth/auth.py
Normal file
|
@ -0,0 +1,99 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.database.engine import get_db
|
||||
from todo.config import get_settings
|
||||
from todo.schemas.common import is_valid_id
|
||||
from todo.schemas.users import User
|
||||
import todo.crud.users as userscrud
|
||||
from todo.utils.exceptions import NotFoundException
|
||||
|
||||
|
||||
class AuthHandler():
|
||||
"""This class handles operations related to authentication and authorization."""
|
||||
|
||||
security = HTTPBearer()
|
||||
crypt_context = CryptContext(schemes=['bcrypt'], deprecated=['auto'])
|
||||
jwt_secret = get_settings().jwt_secret
|
||||
|
||||
def hash_password(self, input_str: str) -> str:
|
||||
"""Returns a salted hash of the input string.
|
||||
|
||||
Used to hash plaintext passwords prior to storage.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError("Expected a string.")
|
||||
if not len(input_str):
|
||||
raise ValueError("Input string cannot be empty.")
|
||||
|
||||
return self.crypt_context.hash(input_str)
|
||||
|
||||
|
||||
def verify_password(self, plaintext: str, hash: str) -> bool:
|
||||
"""Checks whether the hashed plaintext password matches the provided hash.
|
||||
|
||||
Used to compare a provided plaintext password to a stored hash during authentication.
|
||||
"""
|
||||
|
||||
for input_str in [plaintext, hash]:
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError("Expected a string.")
|
||||
if not len(input_str):
|
||||
raise ValueError("Input string cannot be empty.")
|
||||
|
||||
return self.crypt_context.verify(plaintext, hash)
|
||||
|
||||
|
||||
def encode_token(self, user_id: int) -> str:
|
||||
"""Creates a fresh JWT containing the specified user id."""
|
||||
|
||||
if not is_valid_id(user_id):
|
||||
raise ValueError("Invalid user ID.")
|
||||
|
||||
payload = {
|
||||
'exp': datetime.utcnow() + timedelta(seconds=get_settings().jwt_expiration_time),
|
||||
'iat': datetime.utcnow(),
|
||||
'sub': user_id
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
self.jwt_secret,
|
||||
algorithm='HS256',
|
||||
)
|
||||
|
||||
|
||||
def decode_token(self, token: str) -> int:
|
||||
"""Decodes the input JWT token and returns the user id stored in it.
|
||||
|
||||
If the token's signature does not match its contents, or if the token has
|
||||
expired, an HTTPException is raised.
|
||||
"""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
|
||||
return payload['sub']
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(401, "JWT signature has expired.")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(401, "JWT token is invalid.")
|
||||
|
||||
|
||||
def get_current_user_id(self, auth: HTTPAuthorizationCredentials) -> int :
|
||||
return self.decode_token(auth.credentials)
|
||||
|
||||
|
||||
def get_current_user(self, auth: HTTPAuthorizationCredentials = Security(security), db: Session = Depends(get_db)) -> User:
|
||||
user_id = self.decode_token(auth.credentials)
|
||||
return userscrud.read_user(db, user_id)
|
||||
|
||||
def asset_current_user_is_admin(self, auth: HTTPAuthorizationCredentials = Security(security), db: Session = Depends(get_db)):
|
||||
user_id = self.decode_token(auth.credentials)
|
||||
if not userscrud.read_user(db, user_id).is_admin:
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
|
@ -35,8 +35,15 @@ class Settings(BaseSettings):
|
|||
pg_port = os.getenv("POSTGRES_PORT", "5432")
|
||||
pg_dbname = os.getenv("POSTGRES_DB", "todo")
|
||||
pg_user = url_encode(os.getenv("POSTGRES_USER", "todo"))
|
||||
# XXX Change the database password to a random string of at least 16 characters
|
||||
pg_password = url_encode(os.getenv("POSTGRES_PASSWORD", "todo"))
|
||||
|
||||
# XXX Change the JWT secret to a random string of at least 32 characters
|
||||
# The JWT secret is used to verify JWT signatures
|
||||
jwt_secret = os.getenv("JWT_SECRET", "todo")
|
||||
# Duration in seconds for how long newly created JWTs are valid
|
||||
jwt_expiration_time = int(os.getenv("JWT_EXPIRATION_TIME", "172800"))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
|
|
@ -7,8 +7,10 @@ from sqlalchemy.orm import Session
|
|||
from todo.models import todos as todomodel
|
||||
from todo.models import users as usermodel
|
||||
from todo.schemas import todos as todoschema
|
||||
from todo.schemas.users import User
|
||||
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
from todo.models.common import SortOrder
|
||||
from todo.dependencies.common import SortOrder
|
||||
from todo.dependencies.todos import SortableTodoItemField
|
||||
|
||||
|
||||
def create_todo(db: Session, todo: todoschema.TodoItemCreate, user_id: int) -> todoschema.TodoItem:
|
||||
|
@ -44,7 +46,7 @@ def read_todos_for_user(
|
|||
db: Session,
|
||||
user_id: int,
|
||||
skip: int = 0, limit: int = 100,
|
||||
sortby: todomodel.SortableTodoItemField = todomodel.SortableTodoItemField('updated'),
|
||||
sortby: SortableTodoItemField = SortableTodoItemField('updated'),
|
||||
sortorder: SortOrder = SortOrder['desc'],
|
||||
) -> list[todoschema.TodoItem]:
|
||||
"""Returns a range of todo-items of the user with the specified user_id from the database."""
|
||||
|
@ -64,6 +66,19 @@ def read_todos_for_user(
|
|||
return [todoschema.TodoItem.from_orm(db_todo) for db_todo in db_todos]
|
||||
|
||||
|
||||
def read_user_for_todo(
|
||||
db: Session,
|
||||
todo_id: int,
|
||||
) -> User:
|
||||
"""Returns the user who owns the todo-item with the specified ID."""
|
||||
|
||||
db_todo = db.query(todomodel.TodoItem).filter(todomodel.TodoItem.id == todo_id).first()
|
||||
if not db_todo:
|
||||
raise NotFoundException(f"Todo item with id '{todo_id}' not found.")
|
||||
|
||||
return User.from_orm(db_todo.user)
|
||||
|
||||
|
||||
def read_todos_count_for_user(db: Session, user_id: int) -> int:
|
||||
"""Returns the total number of todo-items of the user with the specified user_id."""
|
||||
|
||||
|
|
|
@ -6,19 +6,11 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from todo.models import users as usermodel
|
||||
from todo.schemas import users as userschema
|
||||
from todo.models.common import SortOrder
|
||||
from todo.dependencies.common import SortOrder
|
||||
from todo.dependencies.users import SortableUserField
|
||||
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""This is a placeholder for a secure password hashing algorithm.
|
||||
|
||||
It will convert a plaintext password into a secure, salted hash, for storage
|
||||
in the database.
|
||||
"""
|
||||
|
||||
# TODO actually hash the password!
|
||||
return password
|
||||
from todo.schemas.auth import UserWithPassword
|
||||
import todo.auth.auth as auth
|
||||
|
||||
|
||||
def create_user(db: Session, user: userschema.UserCreate) -> userschema.User:
|
||||
|
@ -28,7 +20,8 @@ def create_user(db: Session, user: userschema.UserCreate) -> userschema.User:
|
|||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
password=hash_password(user.password),
|
||||
password=auth.AuthHandler().hash_password(user.password),
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
db.add(db_user)
|
||||
|
@ -57,10 +50,23 @@ def read_user_by_email(db: Session, email: str) -> userschema.User:
|
|||
return userschema.User.from_orm(db_user)
|
||||
|
||||
|
||||
def read_user_by_email_with_password(db: Session, email: str) -> UserWithPassword:
|
||||
"""Queries the db for a user with the specified email and returns them.
|
||||
|
||||
The returned object also contains the user's hashed password.
|
||||
"""
|
||||
|
||||
db_user = db.query(usermodel.User).filter(usermodel.User.email == email).first()
|
||||
if not db_user:
|
||||
raise NotFoundException(f"User with email '{email}' not found.")
|
||||
|
||||
return UserWithPassword.from_orm(db_user)
|
||||
|
||||
|
||||
def read_users(
|
||||
db: Session,
|
||||
skip: int = 0, limit: int = 100,
|
||||
sortby: usermodel.SortableUserField = usermodel.SortableUserField('id'),
|
||||
sortby: SortableUserField = SortableUserField('id'),
|
||||
sortorder: SortOrder = SortOrder['asc'],
|
||||
) -> list[userschema.User]:
|
||||
"""Returns an range of users from the database."""
|
||||
|
@ -89,12 +95,12 @@ def update_user(db: Session, user: userschema.UserUpdate, id: int) -> userschema
|
|||
if not db_user:
|
||||
raise NotFoundException(f"User with id '{id}' not found.")
|
||||
|
||||
for key in ['email', 'first_name', 'last_name']:
|
||||
for key in ['email', 'first_name', 'last_name', 'is_admin']:
|
||||
value = getattr(user, key)
|
||||
if value is not None:
|
||||
setattr(db_user, key, value)
|
||||
if user.password is not None:
|
||||
setattr(db_user, "password", hash_password(user.password))
|
||||
setattr(db_user, "password", auth.AuthHandler().hash_password(user.password))
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
|
|
|
@ -6,7 +6,7 @@ Create Date: 2023-05-12 19:59:22.188464
|
|||
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.sql.functions import now
|
||||
|
||||
|
||||
|
@ -27,6 +27,7 @@ def upgrade() -> None:
|
|||
Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now()),
|
||||
Column('first_name', String, nullable=False),
|
||||
Column('last_name', String, nullable=False, index=True),
|
||||
Column('is_admin', Boolean, nullable=False, default=False, index=True),
|
||||
)
|
||||
|
||||
|
||||
|
|
43
backend/todo/dependencies/common.py
Normal file
43
backend/todo/dependencies/common.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
"""This module provides FastAPI dependencies for commonly used query parameters."""
|
||||
|
||||
|
||||
from enum import Enum
|
||||
from typing import Callable
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from todo.database.engine import Base
|
||||
|
||||
|
||||
class SortOrder(Enum):
|
||||
"""Possible sort orders for database queries."""
|
||||
|
||||
asc = 'asc'
|
||||
desc = 'desc'
|
||||
|
||||
@property
|
||||
def call(self) -> Callable:
|
||||
"""Returns the sqlalchemy sort function depending on the instance value."""
|
||||
|
||||
if self.value == 'asc':
|
||||
return sqlalchemy.asc
|
||||
elif self.value == 'desc':
|
||||
return sqlalchemy.desc
|
||||
else:
|
||||
raise RuntimeError("Logic error.")
|
||||
|
||||
|
||||
class PaginationParams():
|
||||
"""Represents query parameters used for pagination, when querying for a list of items.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
skip : int
|
||||
The number of items to be skipped from the start of the start of the list.
|
||||
limit : int
|
||||
Limits the total number of returned list items to the specified number.
|
||||
"""
|
||||
|
||||
def __init__(self, skip: int = 0, limit: int = 100):
|
||||
self.skip = skip
|
||||
self.limit = limit
|
35
backend/todo/dependencies/todos.py
Normal file
35
backend/todo/dependencies/todos.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
"""Query parameters used to sort todo-items."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Column
|
||||
|
||||
from todo.models.todos import TodoItem
|
||||
from todo.dependencies.common import PaginationParams, SortOrder
|
||||
|
||||
|
||||
class SortableTodoItemField(Enum):
|
||||
"""Defines which fields todo-item lists can be sorted on."""
|
||||
|
||||
id = 'id'
|
||||
title = 'title'
|
||||
done = 'done'
|
||||
created = 'created'
|
||||
updated = 'updated'
|
||||
finished = 'finished'
|
||||
|
||||
@property
|
||||
def field(self) -> Column:
|
||||
return getattr(TodoItem, self.value)
|
||||
|
||||
|
||||
class TodoItemSortablePaginationParams(PaginationParams):
|
||||
def __init__(
|
||||
self,
|
||||
skip: int = 0, limit: int = 100,
|
||||
sortby: SortableTodoItemField = SortableTodoItemField['updated'],
|
||||
sortorder: SortOrder = SortOrder['desc'],
|
||||
):
|
||||
super().__init__(skip=skip, limit=limit)
|
||||
self.sortby = sortby
|
||||
self.sortorder = sortorder
|
36
backend/todo/dependencies/users.py
Normal file
36
backend/todo/dependencies/users.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
"""Query parameters used to sort users."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Column
|
||||
|
||||
from todo.models.users import User
|
||||
from todo.dependencies.common import PaginationParams, SortOrder
|
||||
|
||||
|
||||
class SortableUserField(Enum):
|
||||
"""Defines which fields user lists can be sorted on."""
|
||||
|
||||
id = 'id'
|
||||
email = 'email'
|
||||
created = 'created'
|
||||
updated = 'updated'
|
||||
first_name = 'first_name'
|
||||
last_name = 'last_name'
|
||||
is_admin = 'is_admin'
|
||||
|
||||
@property
|
||||
def field(self) -> Column:
|
||||
return getattr(User, self.value)
|
||||
|
||||
|
||||
class UserSortablePaginationParams(PaginationParams):
|
||||
def __init__(
|
||||
self,
|
||||
skip: int = 0, limit: int = 100,
|
||||
sortby: SortableUserField = SortableUserField['id'],
|
||||
sortorder: SortOrder = SortOrder['asc'],
|
||||
):
|
||||
super().__init__(skip=skip, limit=limit)
|
||||
self.sortby = sortby
|
||||
self.sortorder = sortorder
|
|
@ -6,10 +6,10 @@ This module defines the API routes provided by the backend.
|
|||
from fastapi import FastAPI
|
||||
|
||||
from todo import config
|
||||
from todo.routes.users import router as users_router
|
||||
from todo.routes.users import tag_metadata as users_tags
|
||||
from todo.routes.todos import router as todos_router
|
||||
from todo.routes.todos import tag_metadata as todos_tags
|
||||
from todo.routes.users import router as router_users, tag_metadata as tags_users
|
||||
from todo.routes.todos import router as router_todos, tag_metadata as tags_todos
|
||||
from todo.routes.auth import router as router_auth, tag_metadata as tags_auth
|
||||
from todo.routes.admin import router as router_admin, tag_metadata as tags_admin
|
||||
|
||||
|
||||
s = config.get_settings()
|
||||
|
@ -23,13 +23,17 @@ app = FastAPI(
|
|||
"url": f"{s.contact_url}",
|
||||
},
|
||||
openapi_tags=[
|
||||
users_tags,
|
||||
todos_tags,
|
||||
tags_users,
|
||||
tags_todos,
|
||||
tags_auth,
|
||||
tags_admin,
|
||||
],
|
||||
)
|
||||
|
||||
app.include_router(users_router)
|
||||
app.include_router(todos_router)
|
||||
app.include_router(router_users)
|
||||
app.include_router(router_todos)
|
||||
app.include_router(router_auth)
|
||||
app.include_router(router_admin)
|
||||
|
||||
|
||||
@app.get("/hello/")
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
"""This module contains common utilities for handling models."""
|
||||
|
||||
import enum
|
||||
from typing import Callable
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
|
||||
class SortOrder(enum.Enum):
|
||||
"""Possible sort orders for database queries."""
|
||||
|
||||
asc = 'asc'
|
||||
ASC = 'asc'
|
||||
desc = 'desc'
|
||||
DESC = 'desc'
|
||||
|
||||
@property
|
||||
def call(self) -> Callable:
|
||||
"""Returns the sqlalchemy sort function depending on the instance value."""
|
||||
|
||||
if self.value == 'asc':
|
||||
return sqlalchemy.asc
|
||||
elif self.value == 'desc':
|
||||
return sqlalchemy.desc
|
||||
else:
|
||||
raise RuntimeError("Logic error.")
|
|
@ -1,8 +1,6 @@
|
|||
"""This module defines the SQL data model for todo items."""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Date, Enum, CheckConstraint, Boolean
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.sql.functions import now
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
@ -26,18 +24,3 @@ class TodoItem(Base):
|
|||
|
||||
user_id = Column('user_id', Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False, index=True)
|
||||
user = relationship(User, back_populates="todo_items", uselist=False)
|
||||
|
||||
|
||||
class SortableTodoItemField(enum.Enum):
|
||||
"""Defines which fields todo-item lists can be sorted on."""
|
||||
|
||||
id = 'id'
|
||||
title = 'title'
|
||||
done = 'done'
|
||||
created = 'created'
|
||||
updated = 'updated'
|
||||
finished = 'finished'
|
||||
|
||||
@property
|
||||
def field(self) -> Column:
|
||||
return getattr(TodoItem, self.value)
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
"""This module defines the SQL data model for users."""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.sql.functions import now
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
@ -21,20 +19,6 @@ class User(Base):
|
|||
updated = Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now())
|
||||
first_name = Column('first_name', String, nullable=False)
|
||||
last_name = Column('last_name', String, nullable=False, index=True)
|
||||
is_admin = Column('is_admin', Boolean, nullable=False, default=False, index=True)
|
||||
|
||||
todo_items = relationship("TodoItem", back_populates="user", uselist=True, cascade="all, delete")
|
||||
|
||||
|
||||
class SortableUserField(enum.Enum):
|
||||
"""Defines which fields user lists can be sorted on."""
|
||||
|
||||
id = 'id'
|
||||
email = 'email'
|
||||
created = 'created'
|
||||
updated = 'updated'
|
||||
first_name = 'first_name'
|
||||
last_name = 'last_name'
|
||||
|
||||
@property
|
||||
def field(self) -> Column:
|
||||
return getattr(User, self.value)
|
||||
|
|
67
backend/todo/routes/admin.py
Normal file
67
backend/todo/routes/admin.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
"""This module contains endpoints for admin operations."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.database.engine import get_db
|
||||
from todo.schemas import users as userschema
|
||||
from todo.schemas import common as commonschema
|
||||
from todo.crud import users as usercrud
|
||||
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
from todo.utils.exceptions import create_exception_dict as fmt
|
||||
from todo.dependencies.users import UserSortablePaginationParams
|
||||
import todo.auth.auth as auth
|
||||
|
||||
|
||||
auth_handler = auth.AuthHandler()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin"],
|
||||
dependencies=[Depends(auth_handler.asset_current_user_is_admin)],
|
||||
)
|
||||
|
||||
tag_metadata = {
|
||||
"name": "admin",
|
||||
"description": "Operations for administrators."
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/", response_model=userschema.User)
|
||||
def create_user(
|
||||
user: userschema.UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Check if user already exists
|
||||
try:
|
||||
usercrud.read_user_by_email(db, email=user.email)
|
||||
raise HTTPException(400, "A user with this email address is already registered.")
|
||||
except NotFoundException:
|
||||
pass
|
||||
|
||||
return usercrud.create_user(db=db, user=user)
|
||||
|
||||
|
||||
@router.get("/users/", response_model=list[userschema.User])
|
||||
def read_users(
|
||||
commons: Annotated[UserSortablePaginationParams, Depends(UserSortablePaginationParams)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return usercrud.read_users(
|
||||
db=db,
|
||||
skip=commons.skip,
|
||||
limit=commons.limit,
|
||||
sortby=commons.sortby,
|
||||
sortorder=commons.sortorder
|
||||
)
|
||||
except InvalidFilterParameterException as e:
|
||||
raise HTTPException(400, fmt(str(e)))
|
||||
|
||||
|
||||
@router.get("/users/total/", response_model=commonschema.ItemCount)
|
||||
def read_users_count(db: Session = Depends(get_db)):
|
||||
|
||||
return commonschema.ItemCount(total=usercrud.read_users_count(db=db))
|
41
backend/todo/routes/auth.py
Normal file
41
backend/todo/routes/auth.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
"""This module contains endpoints for operations related to user authentication."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.database.engine import get_db
|
||||
from todo.utils.exceptions import NotFoundException, create_exception_dict as fmt
|
||||
import todo.auth.auth as auth
|
||||
from todo.crud.users import read_user_by_email_with_password
|
||||
from todo.schemas.users import UserLogin as UserLoginSchema
|
||||
from todo.schemas.auth import AuthResponseToken
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth",
|
||||
tags=["authentication"]
|
||||
)
|
||||
|
||||
tag_metadata = {
|
||||
"name": "authentication",
|
||||
"description": "Operations related to user authentication."
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponseToken)
|
||||
def login(credentials: UserLoginSchema, db: Session = Depends(get_db)):
|
||||
"""Returns a JWT for the user whose credentials were provided.
|
||||
|
||||
The JWT can be submitted as a Bearer token on subsequent requests to authenticate
|
||||
the user.
|
||||
"""
|
||||
|
||||
try:
|
||||
user = read_user_by_email_with_password(db, credentials.email)
|
||||
except NotFoundException:
|
||||
raise HTTPException(401, fmt("Invalid email or password."))
|
||||
|
||||
if not auth.AuthHandler().verify_password(credentials.password, user.password):
|
||||
raise HTTPException(401, fmt("Invalid email or password."))
|
||||
|
||||
return AuthResponseToken(token=auth.AuthHandler().encode_token(user.id))
|
|
@ -1,19 +1,21 @@
|
|||
"""This module contains endpoints for operations related to users."""
|
||||
"""This module contains endpoints for operations related to todo-items."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.database.engine import get_db
|
||||
from todo.schemas import todos as todoschema
|
||||
from todo.schemas import users as userschema
|
||||
from todo.crud import todos as todocrud
|
||||
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
from todo.utils.exceptions import create_exception_dict as fmt
|
||||
from todo.models.todos import SortableTodoItemField
|
||||
from todo.models.common import SortOrder
|
||||
|
||||
from todo.dependencies.todos import TodoItemSortablePaginationParams
|
||||
import todo.auth.auth as auth
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/todo",
|
||||
prefix="/todos",
|
||||
tags=["todo-items"]
|
||||
)
|
||||
|
||||
|
@ -22,9 +24,19 @@ tag_metadata = {
|
|||
"description": "Operations related to todo items."
|
||||
}
|
||||
|
||||
auth_handler = auth.AuthHandler()
|
||||
|
||||
|
||||
@router.post("/user/{user_id}", response_model=todoschema.TodoItem)
|
||||
def create_todo(todo: todoschema.TodoItemCreate, user_id: int, db: Session = Depends(get_db)):
|
||||
def create_todo(
|
||||
todo: todoschema.TodoItemCreate,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
if not (current_user.is_admin or current_user.id == user_id):
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
||||
|
||||
try:
|
||||
return todocrud.create_todo(db=db, todo=todo, user_id=user_id)
|
||||
except NotFoundException as e:
|
||||
|
@ -32,23 +44,41 @@ def create_todo(todo: todoschema.TodoItemCreate, user_id: int, db: Session = Dep
|
|||
|
||||
|
||||
@router.get("/{todo_id}", response_model=todoschema.TodoItem)
|
||||
def read_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
def read_todo(
|
||||
todo_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
try:
|
||||
return todocrud.read_todo(db=db, todo_id=todo_id)
|
||||
todo = todocrud.read_todo(db=db, todo_id=todo_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, fmt(str(e)))
|
||||
|
||||
todo_owner = todocrud.read_user_for_todo(db=db, todo_id=todo_id)
|
||||
|
||||
if not (current_user.is_admin or current_user.id == todo_owner.id):
|
||||
raise HTTPException(403, "You are not authorized to view this content.")
|
||||
|
||||
return todo
|
||||
|
||||
|
||||
@router.get("/user/{user_id}", response_model=list[todoschema.TodoItem])
|
||||
def read_todos(
|
||||
user_id: int,
|
||||
skip: int = 0, limit: int = 100,
|
||||
sortby: SortableTodoItemField = SortableTodoItemField['updated'],
|
||||
sortorder: SortOrder = SortOrder['desc'],
|
||||
db: Session = Depends(get_db)
|
||||
commons: Annotated[TodoItemSortablePaginationParams, Depends(TodoItemSortablePaginationParams)],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
if not (current_user.is_admin or current_user.id == user_id):
|
||||
raise HTTPException(403, "You are not authorized to view this content.")
|
||||
|
||||
try:
|
||||
return todocrud.read_todos_for_user(db=db, user_id=user_id, skip=skip, limit=limit, sortby=sortby, sortorder=sortorder)
|
||||
return todocrud.read_todos_for_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
skip=commons.skip, limit=commons.limit,
|
||||
sortby=commons.sortby, sortorder=commons.sortorder,
|
||||
)
|
||||
except InvalidFilterParameterException as e:
|
||||
raise HTTPException(400, fmt(str(e)))
|
||||
except NotFoundException as e:
|
||||
|
@ -56,7 +86,14 @@ def read_todos(
|
|||
|
||||
|
||||
@router.get("/user/{user_id}/total", response_model=dict[str, int])
|
||||
def read_todos_count(user_id: int, db: Session = Depends(get_db)):
|
||||
def read_todos_count(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
if not (current_user.is_admin or current_user.id == user_id):
|
||||
raise HTTPException(403, "You are not authorized to view this content.")
|
||||
|
||||
try:
|
||||
return {"total": todocrud.read_todos_count_for_user(db=db, user_id=user_id)}
|
||||
except NotFoundException as e:
|
||||
|
@ -64,16 +101,33 @@ def read_todos_count(user_id: int, db: Session = Depends(get_db)):
|
|||
|
||||
|
||||
@router.patch("/{todo_id}", response_model=todoschema.TodoItem)
|
||||
def update_todo(todo_id: int, todo: todoschema.TodoItemUpdate, db: Session = Depends(get_db)):
|
||||
def update_todo(
|
||||
todo_id: int,
|
||||
todo: todoschema.TodoItemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
try:
|
||||
todo_owner = todocrud.read_user_for_todo(db=db, todo_id=todo_id)
|
||||
if not (current_user.is_admin or current_user.id == todo_owner.id):
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
||||
|
||||
return todocrud.update_todo(db=db, todo=todo, todo_id=todo_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, fmt(str(e)))
|
||||
|
||||
|
||||
@router.delete("/{todo_id}", response_model=todoschema.TodoItem)
|
||||
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
def delete_todo(
|
||||
todo_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
try:
|
||||
todo_owner = todocrud.read_user_for_todo(db=db, todo_id=todo_id)
|
||||
if not (current_user.is_admin or current_user.id == todo_owner.id):
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
||||
|
||||
return todocrud.delete_todo(db=db, todo_id=todo_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, fmt(str(e)))
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
"""This module contains endpoints for operations related to users."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
@ -8,8 +10,8 @@ from todo.schemas import users as userschema
|
|||
from todo.crud import users as usercrud
|
||||
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
from todo.utils.exceptions import create_exception_dict as fmt
|
||||
from todo.models.common import SortOrder
|
||||
from todo.models.users import SortableUserField
|
||||
from todo.dependencies.users import UserSortablePaginationParams
|
||||
import todo.auth.auth as auth
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
|
@ -22,54 +24,72 @@ tag_metadata = {
|
|||
"description": "Operations related to users."
|
||||
}
|
||||
|
||||
auth_handler = auth.AuthHandler()
|
||||
|
||||
|
||||
@router.post("/", response_model=userschema.User)
|
||||
def create_user(user: userschema.UserCreate, db: Session = Depends(get_db)):
|
||||
def create_user(
|
||||
user: userschema.UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Check if user already exists
|
||||
try:
|
||||
# An exception is expected here, because we need to check if a user with this email is already registered
|
||||
usercrud.read_user_by_email(db, email=user.email)
|
||||
raise HTTPException(400, "A user with this email address is already registered.")
|
||||
except NotFoundException:
|
||||
pass
|
||||
|
||||
if user.is_admin:
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
||||
|
||||
return usercrud.create_user(db=db, user=user)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=userschema.User)
|
||||
def read_user(id: int, db: Session = Depends(get_db)):
|
||||
@router.get("/{user_id}", response_model=userschema.User)
|
||||
def read_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
try:
|
||||
return usercrud.read_user(db=db, id=id)
|
||||
user = usercrud.read_user(db=db, id=user_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, fmt(str(e)))
|
||||
|
||||
|
||||
@router.get("/", response_model=list[userschema.User])
|
||||
def read_users(
|
||||
skip: int = 0, limit: int = 100,
|
||||
sortby: SortableUserField = SortableUserField['id'],
|
||||
sortorder: SortOrder = SortOrder['asc'],
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
return usercrud.read_users(db=db, skip=skip, limit=limit, sortby=sortby, sortorder=sortorder)
|
||||
except InvalidFilterParameterException as e:
|
||||
raise HTTPException(400, fmt(str(e)))
|
||||
|
||||
|
||||
@router.get("/total/", response_model=dict[str, int])
|
||||
def read_users_count(db: Session = Depends(get_db)):
|
||||
return {"total": usercrud.read_users_count(db=db)}
|
||||
if current_user.is_admin or current_user.id == user_id:
|
||||
return user
|
||||
raise HTTPException(403, "You are not authorized to view this content.")
|
||||
|
||||
|
||||
@router.patch("/{id}", response_model=userschema.User)
|
||||
def update_user(id: int, user: userschema.UserUpdate, db: Session = Depends(get_db)):
|
||||
def update_user(
|
||||
user_id: int,
|
||||
user: userschema.UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
if not (current_user.is_admin or current_user.id == user_id):
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
||||
|
||||
if user.is_admin and not current_user.is_admin:
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
||||
|
||||
try:
|
||||
return usercrud.update_user(db=db, user=user, id=id)
|
||||
return usercrud.update_user(db=db, user=user, id=user_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, fmt(str(e)))
|
||||
|
||||
|
||||
@router.delete("/{id}", response_model=userschema.User)
|
||||
def delete_user(id: int, db: Session = Depends(get_db)):
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: userschema.User = Depends(auth_handler.get_current_user),
|
||||
):
|
||||
if not (current_user.is_admin or current_user.id == user_id):
|
||||
raise HTTPException(403, "You are not authorized to perform this action.")
|
||||
|
||||
try:
|
||||
return usercrud.delete_user(db=db, id=id)
|
||||
return usercrud.delete_user(db=db, id=user_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, fmt(str(e)))
|
||||
|
|
24
backend/todo/schemas/auth.py
Normal file
24
backend/todo/schemas/auth.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""This module declareds the pydantic ORM representation for authentication responses."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from todo.schemas.users import User
|
||||
|
||||
|
||||
class UserWithPassword(User):
|
||||
"""Schema used during authentication.
|
||||
|
||||
As this object includes the user's hashed password, it should NEVER be sent
|
||||
to the client.
|
||||
"""
|
||||
|
||||
password: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class AuthResponseToken(BaseModel):
|
||||
"""Schema used when responding to a login request."""
|
||||
|
||||
token: str
|
27
backend/todo/schemas/common.py
Normal file
27
backend/todo/schemas/common.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
"""This module contains common schemas and common functions for schema validation."""
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
|
||||
def is_valid_id(id: int) -> bool:
|
||||
"""Checks whether the specified id is a valid ID.
|
||||
|
||||
Performs a shallow check on whether the ID is a valid primary key.
|
||||
"""
|
||||
|
||||
if not isinstance(id, int):
|
||||
raise TypeError(f"Expected an integer but got: {type(id)}")
|
||||
|
||||
return id > 0
|
||||
|
||||
|
||||
class ItemCount(BaseModel):
|
||||
"""A schema used to represent an item count."""
|
||||
|
||||
total: int
|
||||
|
||||
@validator('total')
|
||||
def assert_total_is_valid(cls, total):
|
||||
if total < 0:
|
||||
raise ValueError("An item count cannot be negative.")
|
||||
return total
|
|
@ -6,6 +6,8 @@ from typing import Optional
|
|||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from todo.schemas.common import is_valid_id
|
||||
|
||||
|
||||
class AbstractTodoItemValidator(BaseModel, ABC):
|
||||
"""Base class for todo-item validators shared with child classes."""
|
||||
|
@ -47,7 +49,7 @@ class TodoItemUpdate(AbstractTodoItemValidator):
|
|||
|
||||
|
||||
class TodoItem(AbstractTodoItem):
|
||||
"""Schema for user info displaying."""
|
||||
"""Schema for todo-item displaying."""
|
||||
|
||||
id: int
|
||||
done: bool
|
||||
|
@ -55,5 +57,11 @@ class TodoItem(AbstractTodoItem):
|
|||
updated: datetime
|
||||
finished: datetime | None
|
||||
|
||||
@validator('id')
|
||||
def assert_id_is_valid(cls, id):
|
||||
if not is_valid_id(id):
|
||||
raise ValueError("ID is invalid.")
|
||||
return id
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
|
@ -7,6 +7,8 @@ from re import compile
|
|||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from todo.schemas.common import is_valid_id
|
||||
|
||||
|
||||
def is_valid_email_format(input_str: str) -> bool:
|
||||
"""Checks whether the input string is a valid email address format.
|
||||
|
@ -55,6 +57,14 @@ class AbstractUser(AbstractUserValidator, ABC):
|
|||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class UserLogin(AbstractUserValidator, ABC):
|
||||
"""Schema used during user login"""
|
||||
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserCreate(AbstractUser):
|
||||
|
@ -83,6 +93,7 @@ class UserUpdate(AbstractUserValidator):
|
|||
email: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
is_admin: Optional[bool]
|
||||
|
||||
password: Optional[str] = None
|
||||
password_confirmation: Optional[str] = None
|
||||
|
@ -106,5 +117,11 @@ class User(AbstractUser):
|
|||
created: datetime
|
||||
updated: datetime
|
||||
|
||||
@validator('id')
|
||||
def assert_id_is_valid(cls, id):
|
||||
if not is_valid_id(id):
|
||||
raise ValueError("ID is invalid.")
|
||||
return id
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
|
@ -6,13 +6,16 @@ from faker import Faker
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.schemas.users import UserCreate, User
|
||||
from todo.crud.users import hash_password
|
||||
from todo.auth.auth import AuthHandler
|
||||
from todo.models.users import User as UserModel
|
||||
from todo.schemas.todos import TodoItemCreate, TodoItem
|
||||
from todo.models.todos import TodoItem as TodoItemModel
|
||||
from todo.database.engine import get_db
|
||||
|
||||
|
||||
auth_handler = AuthHandler()
|
||||
|
||||
|
||||
def _get_faker() -> Faker:
|
||||
"""Creates and returns a Faker object."""
|
||||
|
||||
|
@ -24,10 +27,10 @@ def get_fake_user_details() -> UserCreate:
|
|||
|
||||
fk = _get_faker()
|
||||
|
||||
password = fk.password(length=randint(6, 16))
|
||||
email = fk.profile(fields=['mail'])['mail']
|
||||
first_name = fk.first_name()
|
||||
last_name = fk.last_name()
|
||||
password = email
|
||||
|
||||
return UserCreate(
|
||||
email=email,
|
||||
|
@ -35,6 +38,7 @@ def get_fake_user_details() -> UserCreate:
|
|||
last_name=last_name,
|
||||
password=password,
|
||||
password_confirmation=password,
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
|
||||
|
@ -70,14 +74,16 @@ def create_fake_user(db: Session = next(get_db())) -> User:
|
|||
fk = _get_faker()
|
||||
created = fk.date_time_between('-2y', 'now')
|
||||
updated = fk.date_time_between(created, 'now')
|
||||
is_admin = randint(0, 10) == 0
|
||||
|
||||
db_user = UserModel(
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
password=hash_password(user.password),
|
||||
password=auth_handler.hash_password(user.password),
|
||||
created=created,
|
||||
updated=updated,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
|
||||
db.add(db_user)
|
||||
|
|
|
@ -67,6 +67,8 @@ services:
|
|||
POSTGRES_DB: "todo"
|
||||
POSTGRES_USER: "todo"
|
||||
POSTGRES_PASSWORD: "todo"
|
||||
JWT_SECRET: "todo"
|
||||
JWT_EXPIRATION_TIME: "172800"
|
||||
todo-db:
|
||||
image: postgres:alpine
|
||||
container_name: todo-db
|
||||
|
|
85
frontend/package-lock.json
generated
85
frontend/package-lock.json
generated
|
@ -8,11 +8,16 @@
|
|||
"name": "fastapi-svelte-template",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.0.3"
|
||||
"dompurify": "^3.0.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"marked": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.2.4",
|
||||
"@sveltejs/kit": "^1.5.0",
|
||||
"@types/dompurify": "^3.0.2",
|
||||
"@types/marked": "^5.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.23",
|
||||
"sass": "^1.62.1",
|
||||
|
@ -2027,12 +2032,27 @@
|
|||
"integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.2.tgz",
|
||||
"integrity": "sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
|
||||
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/marked": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.0.tgz",
|
||||
"integrity": "sha512-YcZe50jhltsCq7rc9MNZC/4QB/OnA2Pd6hrOSTOFajtabN+38slqgDDCeE/0F83SjkKBQcsZUj7VLWR0H5cKRA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/pug": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz",
|
||||
|
@ -2045,6 +2065,12 @@
|
|||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
|
||||
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
|
@ -2370,6 +2396,11 @@
|
|||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz",
|
||||
"integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ=="
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
|
||||
|
@ -2716,6 +2747,11 @@
|
|||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
@ -2761,6 +2797,17 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-5.0.4.tgz",
|
||||
"integrity": "sha512-r0W8/DK56fAkV0qfUCO9cEt/VlFWUzoJOqEigvijmsVkTuPOHckh7ZutNJepRO1AxHhK96/9txonHg4bWd/aLA==",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
@ -5648,12 +5695,27 @@
|
|||
"integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/dompurify": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.2.tgz",
|
||||
"integrity": "sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"@types/estree": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
|
||||
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/marked": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.0.tgz",
|
||||
"integrity": "sha512-YcZe50jhltsCq7rc9MNZC/4QB/OnA2Pd6hrOSTOFajtabN+38slqgDDCeE/0F83SjkKBQcsZUj7VLWR0H5cKRA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/pug": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz",
|
||||
|
@ -5666,6 +5728,12 @@
|
|||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/trusted-types": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
|
||||
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==",
|
||||
"dev": true
|
||||
},
|
||||
"any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
|
@ -5874,6 +5942,11 @@
|
|||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true
|
||||
},
|
||||
"dompurify": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz",
|
||||
"integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ=="
|
||||
},
|
||||
"dotenv": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
|
||||
|
@ -6145,6 +6218,11 @@
|
|||
"integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==",
|
||||
"dev": true
|
||||
},
|
||||
"jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||
},
|
||||
"kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
@ -6181,6 +6259,11 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.13"
|
||||
}
|
||||
},
|
||||
"marked": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-5.0.4.tgz",
|
||||
"integrity": "sha512-r0W8/DK56fAkV0qfUCO9cEt/VlFWUzoJOqEigvijmsVkTuPOHckh7ZutNJepRO1AxHhK96/9txonHg4bWd/aLA=="
|
||||
},
|
||||
"merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.2.4",
|
||||
"@sveltejs/kit": "^1.5.0",
|
||||
"@types/dompurify": "^3.0.2",
|
||||
"@types/marked": "^5.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.23",
|
||||
"sass": "^1.62.1",
|
||||
|
@ -26,6 +28,9 @@
|
|||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.0.3"
|
||||
"dompurify": "^3.0.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"marked": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,6 +30,14 @@
|
|||
h6 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply underline text-secondary-500;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
@apply text-secondary-700;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
@ -60,7 +68,6 @@
|
|||
table {
|
||||
@apply border-collapse;
|
||||
}
|
||||
|
||||
th:first-of-type {
|
||||
@apply rounded-tl-md;
|
||||
}
|
||||
|
@ -73,7 +80,6 @@
|
|||
tr:last-of-type td:last-of-type {
|
||||
@apply rounded-br-md;
|
||||
}
|
||||
|
||||
tr {
|
||||
@apply hover:bg-secondary-300/50;
|
||||
@apply border-y border-primary/50;
|
||||
|
@ -82,7 +88,6 @@
|
|||
tr:first-of-type, tr:last-of-type {
|
||||
@apply border-y-0;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply bg-primary font-semibold text-secondary-100;
|
||||
}
|
||||
|
@ -94,4 +99,24 @@
|
|||
@apply p-2 text-start;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
@apply border border-secondary-300;
|
||||
@apply p-2;
|
||||
}
|
||||
legend {
|
||||
@apply text-black/50;
|
||||
}
|
||||
input {
|
||||
@apply border border-secondary;
|
||||
@apply bg-secondary-50;
|
||||
@apply rounded-md;
|
||||
@apply p-1;
|
||||
}
|
||||
input:focus {
|
||||
@apply outline-none border-none ring-2 ring-secondary-400;
|
||||
}
|
||||
label {
|
||||
@apply text-sm text-secondary;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,182 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import type { User } from './types';
|
||||
import type { ItemCount, TodoItem, User } from './types';
|
||||
import { getResponseBodyOrError } from '$lib/api/utils';
|
||||
import { getTokenFromLocalstorage } from '$lib/auth/session';
|
||||
|
||||
const globalPrefix = '/api';
|
||||
|
||||
const usersPrefix = `${globalPrefix}/users`;
|
||||
export async function readUser(fetch: CallableFunction, userId: number): Promise<User> {
|
||||
const endpoint = `${usersPrefix}/${userId}`;
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
throw error(response.status, response.statusText);
|
||||
/**
|
||||
* Retrieves the currently logged in user's JWT from localstorage,
|
||||
* or throws an error if it is not present.
|
||||
*
|
||||
* @throws {error} - If the current user is not logged in or if their JWT is not saved in localstorage.
|
||||
*/
|
||||
function getTokenOrError(): string {
|
||||
let token = getTokenFromLocalstorage();
|
||||
if (token === null) {
|
||||
throw error(401, 'You are not logged in.');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
const responseJson = await response.json();
|
||||
/**
|
||||
* Retrieves the user with the specified ID from the backend API.
|
||||
*
|
||||
* @param {number} userId - The ID of the user whom to retrieve.
|
||||
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
|
||||
* @throws{error} - If the request fails or is not permitted.
|
||||
*/
|
||||
export async function readUser(userId: number, jwt: string = getTokenOrError()): Promise<User> {
|
||||
const endpoint = `/api/users/${userId}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const responseJson = await getResponseBodyOrError(response);
|
||||
return responseJson as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all users from the backend API.
|
||||
*
|
||||
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
|
||||
* @throws{error} - If the request fails or is not permitted.
|
||||
*/
|
||||
export async function readUsers(
|
||||
skip: number,
|
||||
limit: number,
|
||||
sortby: string,
|
||||
sortorder: string,
|
||||
jwt: string = getTokenOrError(),
|
||||
): Promise<User[]> {
|
||||
|
||||
const urlParameters = new URLSearchParams({
|
||||
skip: `${skip}`,
|
||||
limit: `${limit}`,
|
||||
sortby: `${sortby}`,
|
||||
sortorder: `${sortorder}`,
|
||||
});
|
||||
const endpoint = `/api/admin/users/?${urlParameters}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const responseJson = await getResponseBodyOrError(response);
|
||||
return responseJson as User[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the total user count from the backend API.
|
||||
*
|
||||
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
|
||||
* @throws{error} - If the request fails or is not permitted.
|
||||
*/
|
||||
export async function readUserCount(jwt: string = getTokenOrError()): Promise<number> {
|
||||
const endpoint = '/api/admin/users/total/';
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const responseJson = await getResponseBodyOrError(response);
|
||||
const itemCount = responseJson as ItemCount;
|
||||
return itemCount.total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all todo-items for the user with the specified ID from the backend API.
|
||||
*
|
||||
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
|
||||
* @throws{error} - If the request fails or is not permitted.
|
||||
*/
|
||||
export async function readTodos(
|
||||
userId: number,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sortby: string,
|
||||
sortorder: string,
|
||||
jwt: string = getTokenOrError(),
|
||||
): Promise<TodoItem[]> {
|
||||
|
||||
const urlParameters = new URLSearchParams({
|
||||
skip: `${skip}`,
|
||||
limit: `${limit}`,
|
||||
sortby: `${sortby}`,
|
||||
sortorder: `${sortorder}`,
|
||||
});
|
||||
const endpoint = `/api/todos/user/${userId}?${urlParameters}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const responseJson = await getResponseBodyOrError(response);
|
||||
return responseJson as TodoItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the total todo-item count for the user with the specified ID from the backend API.
|
||||
*
|
||||
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
|
||||
* @throws{error} - If the request fails or is not permitted.
|
||||
*/
|
||||
export async function readTodoCount(
|
||||
userId: number,
|
||||
jwt: string = getTokenOrError()
|
||||
): Promise<number> {
|
||||
const endpoint = `/api/todos/user/${userId}/total/`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const responseJson = await getResponseBodyOrError(response);
|
||||
const itemCount = responseJson as ItemCount;
|
||||
return itemCount.total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the todo-item with the specified ID from the backend API.
|
||||
*
|
||||
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
|
||||
* @throws{error} - If the request fails or is not permitted.
|
||||
*/
|
||||
export async function readTodo(
|
||||
todoId: number,
|
||||
jwt: string = getTokenOrError()
|
||||
): Promise<TodoItem> {
|
||||
const endpoint = `/api/todos/${todoId}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const responseJson = await getResponseBodyOrError(response);
|
||||
return responseJson as TodoItem;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,23 @@
|
|||
export type ItemCount = {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: number,
|
||||
email: string,
|
||||
first_name: string,
|
||||
last_ame: string,
|
||||
last_name: string,
|
||||
created: Date,
|
||||
updated: Date
|
||||
updated: Date,
|
||||
is_admin: boolean,
|
||||
}
|
||||
|
||||
export type TodoItem = {
|
||||
id: number,
|
||||
title: string,
|
||||
description: string,
|
||||
done: boolean,
|
||||
created: Date,
|
||||
updated: Date,
|
||||
finished: Date,
|
||||
}
|
||||
|
|
27
frontend/src/lib/api/utils.ts
Normal file
27
frontend/src/lib/api/utils.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Returns the specified response object's body as an object if the status is ok.
|
||||
* If not, raises an error after attempting to extract a detailed
|
||||
* error message from the response body.
|
||||
* @param {Response} response - The response to inspect
|
||||
* @throws {Error} With a detailed Error.message if the response's status is not ok.
|
||||
* @returns {unknown} The object contained by the response object
|
||||
*/
|
||||
export async function getResponseBodyOrError(response: Response): Promise<unknown> {
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if ('detail' in json) {
|
||||
if (typeof json.detail === 'string') {
|
||||
throw error(response.status, json.detail);
|
||||
} else if (Array.isArray(json.detail)) {
|
||||
if ('msg' in json.detail[0] && typeof json.detail[0].msg === 'string') {
|
||||
throw error(response.status, json.detail[0].msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error(response.status, `API request failed: ${response.statusText}`);
|
||||
}
|
||||
return json;
|
||||
}
|
157
frontend/src/lib/auth/session.ts
Normal file
157
frontend/src/lib/auth/session.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
import { getResponseBodyOrError } from '$lib/api/utils';
|
||||
import { readUser } from '$lib/api/endpoints';
|
||||
import jwt_decode from 'jwt-decode';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Svelte store to hold the current StoredUser object.
|
||||
*/
|
||||
export const storedUser = writable();
|
||||
|
||||
// Name of the key holding the auth JWT in localstorage
|
||||
const jwtKey = 'jwt';
|
||||
|
||||
// Name of the key holding the authenticated user in localstorage
|
||||
const userKey = 'user';
|
||||
export type StoredUser = {
|
||||
id: number,
|
||||
email: string,
|
||||
isAdmin: boolean,
|
||||
sessionExpires: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the specified token in localstorage.
|
||||
*
|
||||
* @param {string} token - The token to save in localstorage.
|
||||
*/
|
||||
function saveTokenToLocalstorage(token: string): void {
|
||||
localStorage.setItem(jwtKey, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and returns the token, if present, from localstorage.
|
||||
*/
|
||||
export function getTokenFromLocalstorage(): string | null {
|
||||
return localStorage.getItem(jwtKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the saved token from localstorage.
|
||||
*/
|
||||
function clearTokenInLocalstorage(): void {
|
||||
localStorage.removeItem(jwtKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the specified StoredUser object in localstorage.
|
||||
*
|
||||
* @param {StoredUser} user - The user to write to localstorage.
|
||||
*/
|
||||
function saveUserToLocalstorage(user: StoredUser): void {
|
||||
localStorage.setItem(userKey, JSON.stringify(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and returns the user, if present, from localstorage.
|
||||
*/
|
||||
function getUserFromLocalstorage(): StoredUser | null {
|
||||
let item: string | null = localStorage.getItem(userKey);
|
||||
if (typeof item !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(item) as StoredUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the saved user from localstorage.
|
||||
*/
|
||||
function clearUserInLocalstorage(): void {
|
||||
localStorage.removeItem(userKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an API request containing the specified login credentials to the backend,
|
||||
* and returns the JWT retrieved from the response on sucess.
|
||||
*
|
||||
* @param {string} email - The email address of the user whose JWT to retrieve.
|
||||
* @param {string} password - The password used to authenticate the user whose JWT is being retrieved.
|
||||
* @throws {Error} - If the API request failed or the supplied credentials were invalid.
|
||||
*/
|
||||
async function requestJwt(email: string, password: string): Promise<string> {
|
||||
type Token = {
|
||||
token: string
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
});
|
||||
|
||||
const responseBody = await getResponseBodyOrError(response) as Token;
|
||||
return responseBody.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the user currently saved in localstorage into the storedUser
|
||||
* svelte store.
|
||||
*/
|
||||
export function loadUserIntoStore(): void {
|
||||
storedUser.set(getUserFromLocalstorage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the storedUser svelte store.
|
||||
*/
|
||||
function clearUserFromStore(): void {
|
||||
storedUser.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an API request to retrieve the specified user's JWT from the backend,
|
||||
* and stores the retrieved token in localstorage. Then, retrieves the authenticated
|
||||
* user's basic account information (StoredUser), saves it in localsotrage and in the
|
||||
* currentUser svelte store.
|
||||
*
|
||||
* @param {string} email - The email address of the user whose token and information to retrieve.
|
||||
* @param {string} password - The password used to authenticate the user whose token is being retrieved.
|
||||
* @throws {Error} - If any API request fails or the supplied credentials were invalid.
|
||||
*/
|
||||
export async function login(email: string, password: string): Promise<void> {
|
||||
const token = await requestJwt(email, password);
|
||||
|
||||
interface Token {
|
||||
sub: number,
|
||||
exp: number
|
||||
}
|
||||
const parsedToken = jwt_decode(token) as Token;
|
||||
const userId = parsedToken.sub;
|
||||
|
||||
const user = await readUser(userId, token);
|
||||
|
||||
saveTokenToLocalstorage(token);
|
||||
saveUserToLocalstorage({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.is_admin,
|
||||
sessionExpires: new Date(parsedToken.exp),
|
||||
});
|
||||
loadUserIntoStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Purges the currently logged in user's information and token from localstorage
|
||||
* and the svelte store.
|
||||
*/
|
||||
export function logout(): void {
|
||||
clearUserFromStore();
|
||||
clearUserInLocalstorage();
|
||||
clearTokenInLocalstorage();
|
||||
}
|
|
@ -1,33 +1,35 @@
|
|||
<script lang='ts'>
|
||||
import { onMount } from 'svelte';
|
||||
import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons';
|
||||
import Th from '$lib/data-table/Th.svelte';
|
||||
import Td from '$lib/data-table/Td.svelte';
|
||||
import Th from '$lib/components/data-table/Th.svelte';
|
||||
import Td from '$lib/components/data-table/Td.svelte';
|
||||
import type { Column, Endpoint } from '$lib/components/data-table/types';
|
||||
import { interpolateString as i } from '$lib/components/data-table/utils';
|
||||
|
||||
/**
|
||||
* The caption for the data table.
|
||||
*/
|
||||
export let caption: string | null = null;
|
||||
/**
|
||||
* The columns for the data table.
|
||||
*/
|
||||
export let columns: Column[];
|
||||
|
||||
export let itemCountEndpoint: string;
|
||||
export let itemsEndpoint: string;
|
||||
|
||||
type Column = {
|
||||
field: string,
|
||||
heading: string,
|
||||
type?: string,
|
||||
sortable?: boolean,
|
||||
};
|
||||
|
||||
type TotalItemCountApiResponse = {
|
||||
total: number
|
||||
};
|
||||
|
||||
// API error message bodies are expected to be in one of the following formats:
|
||||
interface ApiErrorMessage {
|
||||
detail: string
|
||||
};
|
||||
interface ApiErrorList {
|
||||
detail: { msg: string; loc?: string[]; type?: string }[]
|
||||
};
|
||||
/**
|
||||
* The function which fetches rows for the data table.
|
||||
* Must support pagination and sorting.
|
||||
*/
|
||||
export let getItemsEndpoint: Endpoint = {
|
||||
callable: () => [],
|
||||
args: [],
|
||||
}
|
||||
/**
|
||||
* The function which fetches the total number of items. Used for pagination.
|
||||
*/
|
||||
export let getItemCountEndpoint: Endpoint = {
|
||||
callable: () => 0,
|
||||
args: [],
|
||||
}
|
||||
|
||||
type SortEvent = {
|
||||
detail: {
|
||||
|
@ -117,66 +119,19 @@
|
|||
await updateTable();
|
||||
}
|
||||
|
||||
async function getTotalItemCount(): Promise<number> {
|
||||
const response = await fetch(itemCountEndpoint);
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error body as a known format
|
||||
if ('detail' in json) {
|
||||
if (typeof json.detail === 'string') {
|
||||
const errorBody = json as ApiErrorMessage;
|
||||
errorMessages.push(errorBody.detail);
|
||||
} else {
|
||||
const errorBody = json as ApiErrorList;
|
||||
for (let detail of errorBody.detail) {
|
||||
if (typeof detail.msg === 'string') errorMessages.push(detail.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiResponse = json as TotalItemCountApiResponse;
|
||||
return apiResponse.total;
|
||||
}
|
||||
|
||||
async function getItems<T>(offset: number = currentItemsOffset, count: number = currentItemsPerPage): Promise<T[]> {
|
||||
const urlParameters = new URLSearchParams({
|
||||
skip: `${offset}`,
|
||||
limit: `${count}`,
|
||||
sortby: `${currentSortField}`,
|
||||
sortorder: `${currentSortOrder}`,
|
||||
});
|
||||
|
||||
const response = await fetch(`${itemsEndpoint}?${urlParameters}`);
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error body as a known format
|
||||
if ('detail' in json) {
|
||||
if (typeof json.detail === 'string') {
|
||||
const errorBody = json as ApiErrorMessage;
|
||||
errorMessages.push(errorBody.detail);
|
||||
} else {
|
||||
const errorBody = json as ApiErrorList;
|
||||
for (let detail of errorBody.detail) {
|
||||
if (typeof detail.msg === 'string') errorMessages.push(detail.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiResponse = json as T[];
|
||||
return apiResponse;
|
||||
}
|
||||
|
||||
async function updateTable() {
|
||||
try {
|
||||
currentState = 'loading';
|
||||
totalItemCount = await getTotalItemCount();
|
||||
currentItems = await getItems();
|
||||
totalItemCount = await getItemCountEndpoint.callable(
|
||||
...getItemCountEndpoint.args,
|
||||
);
|
||||
currentItems = await getItemsEndpoint.callable(
|
||||
...getItemsEndpoint.args,
|
||||
currentItemsOffset,
|
||||
currentItemsPerPage,
|
||||
currentSortField,
|
||||
currentSortOrder
|
||||
);
|
||||
currentState = 'finished';
|
||||
} catch (error: any) {
|
||||
if (!errorMessages.length) {
|
||||
|
@ -229,6 +184,8 @@
|
|||
<Td
|
||||
data={item[column.field]}
|
||||
type={column.type ?? 'string'}
|
||||
isLink={column.isLink ?? false}
|
||||
linkTarget={i(column.linkTarget, item) ?? ''}
|
||||
/>
|
||||
{/each}
|
||||
</tr>
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
export let data: any;
|
||||
export let type: 'string' | 'date' | 'boolean' = 'string';
|
||||
export let isLink: boolean = false;
|
||||
export let linkTarget: string = "";
|
||||
</script>
|
||||
|
||||
<td>
|
||||
|
@ -18,7 +20,11 @@
|
|||
{:else}
|
||||
<Icon src={XMark} mini class="w-6 h-6" />
|
||||
{/if}
|
||||
{:else}
|
||||
{#if isLink}
|
||||
<a href={linkTarget}>{data}</a>
|
||||
{:else}
|
||||
{data}
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
13
frontend/src/lib/components/data-table/types.ts
Normal file
13
frontend/src/lib/components/data-table/types.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export type Column = {
|
||||
field: string,
|
||||
heading: string,
|
||||
type?: string,
|
||||
sortable?: boolean,
|
||||
isLink?: boolean,
|
||||
linkTarget?: string,
|
||||
}
|
||||
|
||||
export type Endpoint = {
|
||||
callable: Function,
|
||||
args: any[],
|
||||
}
|
21
frontend/src/lib/components/data-table/utils.ts
Normal file
21
frontend/src/lib/components/data-table/utils.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Replaces placeholders in a template string with values from a replacement items object.
|
||||
*
|
||||
* The placeholders are in the format %key%, whereby %key% is replaced with the value of the 'key'
|
||||
* attribute in the replacementItems object.
|
||||
*
|
||||
* @param {string} templateString - The template string containing placeholders to be replaced.
|
||||
* @param {object} replacementItems - The object containing key-value pairs for replacement.
|
||||
* @returns {string} The modified string with all placeholders replaced.
|
||||
*/
|
||||
export function interpolateString(templateString: string, replacementItems: { [key: string]: any }): string {
|
||||
if (typeof templateString !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return templateString.replace(/%(\w+)%/g, (match, key) => {
|
||||
if (replacementItems.hasOwnProperty(key)) {
|
||||
return replacementItems[key].toString();
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
33
frontend/src/lib/components/navbar/Navbar.svelte
Normal file
33
frontend/src/lib/components/navbar/Navbar.svelte
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { logout, storedUser } from '$lib/auth/session';
|
||||
import type { StoredUser } from '$lib/auth/session';
|
||||
|
||||
let user: StoredUser | null = null;
|
||||
storedUser.subscribe((value) => {
|
||||
user = value;
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<ul>
|
||||
<li class="inline">
|
||||
<a href="/">Home</a>
|
||||
{#if user}
|
||||
<a href={`/users/${user.id}/todos`}>Todos</a>
|
||||
<a href={`/users/${user.id}`}>My Profile</a>
|
||||
{#if user.isAdmin}
|
||||
<a href={`/users/all`}>All Users</a>
|
||||
{/if}
|
||||
<button on:click={handleLogout}>Log Out</button>
|
||||
{:else}
|
||||
<a href="/login">Log In</a>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
18
frontend/src/lib/utils/markdown.ts
Normal file
18
frontend/src/lib/utils/markdown.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { marked } from 'marked';
|
||||
import dompurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Converts the input text, which is assumed to be in markdown format, into HTML and returns it.
|
||||
* The returned HTML is sanitized to prevent XSS.
|
||||
*
|
||||
* @param {string} text - The input text in markdown format to convert.
|
||||
* @returns {string} The HTML representation of the input text.
|
||||
*/
|
||||
export function markdownToHtml(text: string): string {
|
||||
// Remove zerowidth chars from beginning of input string
|
||||
// This is probably not necessary...
|
||||
text = text.replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, "");
|
||||
|
||||
// Return markdown parsed as HTML and subsequently sanitized
|
||||
return dompurify.sanitize(marked.parse(text));
|
||||
}
|
|
@ -22,14 +22,22 @@
|
|||
</svelte:head>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import "../app.css";
|
||||
import { page } from '$app/stores';
|
||||
import { loadUserIntoStore } from '$lib/auth/session';
|
||||
import Navbar from '$lib/components/navbar/Navbar.svelte';
|
||||
|
||||
export let title = import.meta.env.PUBLIC_TITLE;
|
||||
export let description = import.meta.env.PUBLIC_DESCRIPTION;
|
||||
export let author = import.meta.env.PUBLIC_AUTHOR;
|
||||
|
||||
const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href
|
||||
|
||||
onMount(async () => {
|
||||
loadUserIntoStore();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Navbar />
|
||||
<slot />
|
||||
|
|
61
frontend/src/routes/login/+page.svelte
Normal file
61
frontend/src/routes/login/+page.svelte
Normal file
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { login } from '$lib/auth/session';
|
||||
|
||||
let email: string = "";
|
||||
let password: string = "";
|
||||
|
||||
let formError = false;
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
function isValidEmail(email: string | null) {
|
||||
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
return typeof email === 'string' ? emailRegex.test(email) : false;
|
||||
}
|
||||
|
||||
function isValidpassword(password: string | null) {
|
||||
return typeof password === 'string' ? password.length > 0 : false;
|
||||
}
|
||||
|
||||
async function handleFormSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
console.log("Invalid email address format.");
|
||||
return;
|
||||
}
|
||||
if (!isValidpassword(password)) {
|
||||
console.log("Invalid password format.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
goto('/');
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message;
|
||||
formError = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-y-4">
|
||||
<h1>Log In</h1>
|
||||
<form action="" method="post" on:submit={handleFormSubmit}>
|
||||
<fieldset class="flex flex-col gap-y-4 max-w-lg">
|
||||
<legend>Please enter your login credentials</legend>
|
||||
<div class="flex flex-col">
|
||||
<input bind:value={email} type="email" name="email" placeholder="user@example.com" required class="block" />
|
||||
<label for="email" class="block">Email</label>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<input bind:value={password} type="password" name="password" required class="block" />
|
||||
<label for="password" class="block">Password</label>
|
||||
</div>
|
||||
<button type="submit" name="submit">Log In</button>
|
||||
{#if formError}
|
||||
<p class="text-red-500">{errorMessage}</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
11
frontend/src/routes/todos/[todo]/+page.svelte
Normal file
11
frontend/src/routes/todos/[todo]/+page.svelte
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import type { TodoDetailPage } from './+page';
|
||||
import { markdownToHtml } from '$lib/utils/markdown';
|
||||
|
||||
export let data: TodoDetailPage;
|
||||
</script>
|
||||
|
||||
<h1>{data.todo.title}</h1>
|
||||
<div>
|
||||
{@html markdownToHtml(data.todo.description)}
|
||||
</div>
|
18
frontend/src/routes/todos/[todo]/+page.ts
Normal file
18
frontend/src/routes/todos/[todo]/+page.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import type { PageLoad } from './$types';
|
||||
import type { TodoItem } from '$lib/api/types';
|
||||
import { readTodo } from '$lib/api/endpoints';
|
||||
|
||||
export const ssr = false;
|
||||
export const load = (async ({ params }) => {
|
||||
// check if user exists
|
||||
const todoId = params.todo;
|
||||
const todo = await readTodo(todoId);
|
||||
return {
|
||||
todo: todo
|
||||
};
|
||||
|
||||
}) satisfies PageLoad;
|
||||
|
||||
export interface TodoDetailPage {
|
||||
todo: TodoItem
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Table from '$lib/data-table/Table.svelte'
|
||||
|
||||
const table = {
|
||||
caption: "List of users",
|
||||
columns: [
|
||||
{
|
||||
field: "id",
|
||||
heading: "ID",
|
||||
},
|
||||
{
|
||||
field: "email",
|
||||
heading: "Email",
|
||||
},
|
||||
{
|
||||
field: "first_name",
|
||||
heading: "First Name",
|
||||
},
|
||||
{
|
||||
field: "last_name",
|
||||
heading: "Last Name",
|
||||
},
|
||||
{
|
||||
field: "created",
|
||||
heading: "Created",
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
field: "updated",
|
||||
heading: "Updated",
|
||||
type: 'date',
|
||||
},
|
||||
],
|
||||
itemCountEndpoint: "/api/users/total/",
|
||||
itemsEndpoint: "/api/users/",
|
||||
};
|
||||
</script>
|
||||
|
||||
<Table {...table} />
|
|
@ -5,3 +5,4 @@
|
|||
</script>
|
||||
|
||||
<h1>Profile page for {data.user.email}</h1>
|
||||
<a href={`/users/${data.user.id}/todos`}>{data.user.first_name}'s Todos</a>
|
|
@ -1,15 +1,12 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
import type { User } from '$lib/api/types';
|
||||
import { readUser } from '$lib/api/endpoints';
|
||||
|
||||
export const ssr = false;
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
export const load = (async ({ params }) => {
|
||||
// check if user exists
|
||||
const userId = params.user;
|
||||
const response = await fetch(`/api/users/${userId}`);
|
||||
if (!response.ok) throw error(response.status, response.statusText);
|
||||
|
||||
const user = await response.json() as User;
|
||||
const user = await readUser(userId);
|
||||
return {
|
||||
user: user
|
||||
};
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import Table from '$lib/data-table/Table.svelte'
|
||||
import type { Column } from '$lib/data-table/Table.svelte';
|
||||
import Table from '$lib/components/data-table/Table.svelte'
|
||||
import type { Column } from '$lib/components/data-table/types';
|
||||
import type { UserTodosPage } from "./+page";
|
||||
import { readTodos, readTodoCount } from '$lib/api/endpoints';
|
||||
|
||||
export let data: UserTodosPage;
|
||||
|
||||
const cols: Column[] = [
|
||||
const columns: Column[] = [
|
||||
{
|
||||
field: "id",
|
||||
heading: "ID",
|
||||
|
@ -13,11 +14,8 @@
|
|||
{
|
||||
field: "title",
|
||||
heading: "Title",
|
||||
},
|
||||
{
|
||||
field: "description",
|
||||
heading: "Description",
|
||||
sortable: false,
|
||||
isLink: true,
|
||||
linkTarget: '/todos/%id%',
|
||||
},
|
||||
{
|
||||
field: "done",
|
||||
|
@ -43,9 +41,19 @@
|
|||
|
||||
const table = {
|
||||
caption: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`,
|
||||
columns: cols,
|
||||
itemsEndpoint: `/api/todo/user/${data.user.id}/`,
|
||||
itemCountEndpoint: `/api/todo/user/${data.user.id}/total/`,
|
||||
columns: columns,
|
||||
getItemsEndpoint: {
|
||||
callable: readTodos,
|
||||
args: [
|
||||
data.user.id
|
||||
]
|
||||
},
|
||||
getItemCountEndpoint: {
|
||||
callable: readTodoCount,
|
||||
args: [
|
||||
data.user.id
|
||||
]
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
56
frontend/src/routes/users/all/+page.svelte
Normal file
56
frontend/src/routes/users/all/+page.svelte
Normal file
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import Table from '$lib/components/data-table/Table.svelte';
|
||||
import type { Column } from '$lib/components/data-table/types';
|
||||
import { readUsers, readUserCount } from '$lib/api/endpoints';
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
field: "id",
|
||||
heading: "ID",
|
||||
},
|
||||
{
|
||||
field: "email",
|
||||
heading: "Email",
|
||||
isLink: true,
|
||||
linkTarget: '/users/%id%',
|
||||
},
|
||||
{
|
||||
field: "first_name",
|
||||
heading: "First Name",
|
||||
},
|
||||
{
|
||||
field: "last_name",
|
||||
heading: "Last Name",
|
||||
},
|
||||
{
|
||||
field: "created",
|
||||
heading: "Created",
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
field: "updated",
|
||||
heading: "Updated",
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
field: "is_admin",
|
||||
heading: "Admin",
|
||||
type: 'boolean',
|
||||
},
|
||||
]
|
||||
|
||||
const table = {
|
||||
caption: "List of users",
|
||||
columns: columns,
|
||||
getItemsEndpoint: {
|
||||
callable: readUsers,
|
||||
args: []
|
||||
},
|
||||
getItemCountEndpoint: {
|
||||
callable: readUserCount,
|
||||
args: []
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Table {...table} />
|
|
@ -63,6 +63,8 @@ services:
|
|||
POSTGRES_DB: "todo"
|
||||
POSTGRES_USER: "todo"
|
||||
POSTGRES_PASSWORD: "todo"
|
||||
JWT_SECRET: "todo"
|
||||
JWT_EXPIRATION_TIME: "172800"
|
||||
todo-db:
|
||||
image: postgres:alpine
|
||||
container_name: todo-db
|
||||
|
|
Loading…
Add table
Reference in a new issue