├── .gitignore ├── backend └── app │ ├── README.md │ ├── app │ ├── __init__.py │ ├── schemas │ │ ├── __init__.py │ │ ├── extras │ │ │ ├── __init__.py │ │ │ ├── token.py │ │ │ ├── health.py │ │ │ └── current_user.py │ │ ├── requests │ │ │ ├── __init__.py │ │ │ └── users.py │ │ └── responses │ │ │ ├── __init__.py │ │ │ └── users.py │ ├── integrations │ │ └── __init__.py │ ├── repositories │ │ ├── __init__.py │ │ └── user.py │ ├── models │ │ ├── __init__.py │ │ └── user.py │ ├── main.py │ └── controllers │ │ ├── __init__.py │ │ ├── user.py │ │ └── auth.py │ ├── core │ ├── __init__.py │ ├── fastapi │ │ ├── __init__.py │ │ ├── dependencies │ │ │ ├── __init__.py │ │ │ ├── logging.py │ │ │ ├── current_user.py │ │ │ ├── authentication.py │ │ │ └── permissions.py │ │ └── middlewares │ │ │ ├── __init__.py │ │ │ ├── sqlalchemy.py │ │ │ ├── response_logger.py │ │ │ ├── auth-archive.py │ │ │ └── authentication.py │ ├── factory │ │ ├── __init__.py │ │ └── factory.py │ ├── utils │ │ ├── __init__.py │ │ └── datetime.py │ ├── controller │ │ ├── __init__.py │ │ └── base.py │ ├── db │ │ ├── mixins │ │ │ ├── __init__.py │ │ │ └── timestamp.py │ │ ├── __init__.py │ │ ├── standalone_session.py │ │ ├── session.py │ │ └── transactional.py │ ├── repository │ │ ├── __init__.py │ │ └── base.py │ ├── cache │ │ ├── cache_tag.py │ │ ├── base │ │ │ ├── __init__.py │ │ │ ├── key_maker.py │ │ │ └── backend.py │ │ ├── __init__.py │ │ ├── custom_key_maker.py │ │ ├── redis_backend.py │ │ └── cache_manager.py │ ├── security │ │ ├── __init__.py │ │ ├── password.py │ │ ├── jwt.py │ │ └── access_control.py │ ├── exceptions │ │ ├── __init__.py │ │ └── base.py │ ├── config.py │ └── server.py │ ├── worker │ ├── tasks │ │ └── __init__.py │ └── __init__.py │ ├── poetry.toml │ ├── migrations │ ├── README │ ├── script.py.mako │ └── env.py │ ├── .vscode │ └── settings.json │ ├── api │ ├── __init__.py │ └── v1 │ │ ├── users │ │ ├── __init__.py │ │ └── users.py │ │ ├── monitoring │ │ ├── __init__.py │ │ └── health.py │ │ └── __init__.py │ ├── main.py │ ├── mypy.ini │ ├── ruff.toml │ ├── pyproject.toml │ ├── alembic.ini │ └── .gitignore ├── frontend ├── src │ ├── middlewares │ │ └── auth-middleware.ts │ ├── pages │ │ ├── index.tsx │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ └── hello.ts │ │ └── auth │ │ │ └── login │ │ │ ├── index.tsx │ │ │ └── user-auth-form.tsx │ ├── lib │ │ └── utils.ts │ ├── components │ │ └── ui │ │ │ ├── label.tsx │ │ │ ├── input.tsx │ │ │ ├── button.tsx │ │ │ └── icons.tsx │ └── styles │ │ └── globals.css ├── .stylelintignore ├── public │ ├── favicon.ico │ ├── vercel.svg │ └── next.svg ├── postcss.config.js ├── next.config.js ├── components.json ├── .gitignore ├── tsconfig.json ├── .stylelintrc.js ├── .prettierrc.js ├── README.md ├── package.json ├── tailwind.config.js └── .eslintrc.js └── .vscode └── settings.json /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | -------------------------------------------------------------------------------- /backend/app/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/worker/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/app/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/app/schemas/extras/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/middlewares/auth-middleware.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/app/schemas/requests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/app/schemas/responses/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /backend/app/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /frontend/.stylelintignore: -------------------------------------------------------------------------------- 1 | *.* 2 | !*.scss 3 | !*.css 4 | !**/*.css 5 | !**/*.scss 6 | -------------------------------------------------------------------------------- /backend/app/core/factory/__init__.py: -------------------------------------------------------------------------------- 1 | from .factory import Factory 2 | 3 | __all__ = ["Factory"] 4 | -------------------------------------------------------------------------------- /frontend/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | return
Hello
; 3 | } 4 | -------------------------------------------------------------------------------- /backend/app/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .datetime import utcnow 2 | 3 | __all__ = [ 4 | "utcnow", 5 | ] 6 | -------------------------------------------------------------------------------- /backend/app/core/controller/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseController 2 | 3 | __all__ = ["BaseController"] 4 | -------------------------------------------------------------------------------- /backend/app/core/db/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .timestamp import TimestampMixin 2 | 3 | __all__ = ["TimestampMixin"] 4 | -------------------------------------------------------------------------------- /backend/app/core/repository/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseRepository 2 | 3 | __all__ = ["BaseRepository"] 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CibiAananth/fullstack-next-fastapi/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /backend/app/app/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import UserRepository 2 | 3 | __all__ = [ 4 | "UserRepository", 5 | ] 6 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /backend/app/core/cache/cache_tag.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CacheTag(Enum): 5 | GET_USER_LIST = "get_user_list" 6 | -------------------------------------------------------------------------------- /backend/app/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from core.db import Base 2 | 3 | from .user import User 4 | 5 | __all__ = [ 6 | "Base", 7 | "User", 8 | ] 9 | -------------------------------------------------------------------------------- /backend/app/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | app = FastAPI() 4 | 5 | 6 | @app.get("/") 7 | def read_root(): 8 | return {"Hello": "World"} 9 | -------------------------------------------------------------------------------- /backend/app/app/schemas/extras/token.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Token(BaseModel): 5 | access_token: str 6 | refresh_token: str 7 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | from .authentication import AuthenticationRequired 2 | from .logging import Logging 3 | 4 | __all__ = ["Logging"] 5 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /backend/app/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter" 4 | }, 5 | "python.formatting.provider": "none" 6 | } 7 | -------------------------------------------------------------------------------- /backend/app/app/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import AuthController 2 | from .user import UserController 3 | 4 | __all__ = [ 5 | "AuthController", 6 | "UserController", 7 | ] 8 | -------------------------------------------------------------------------------- /backend/app/core/cache/base/__init__.py: -------------------------------------------------------------------------------- 1 | from .backend import BaseBackend 2 | from .key_maker import BaseKeyMaker 3 | 4 | __all__ = [ 5 | "BaseKeyMaker", 6 | "BaseBackend", 7 | ] 8 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /backend/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .v1 import v1_router 4 | 5 | router = APIRouter() 6 | router.include_router(v1_router, prefix="/v1") 7 | 8 | 9 | __all__ = ["router"] 10 | -------------------------------------------------------------------------------- /backend/app/app/schemas/extras/health.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class Health(BaseModel): 5 | version: str = Field(..., example="1.0.0") 6 | status: str = Field(..., example="OK") 7 | -------------------------------------------------------------------------------- /frontend/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css'; 2 | 3 | import type { AppProps } from 'next/app'; 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /backend/app/api/v1/users/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .users import user_router 4 | 5 | users_router = APIRouter() 6 | users_router.include_router(user_router, tags=["Users"]) 7 | 8 | __all__ = ["users_router"] 9 | -------------------------------------------------------------------------------- /backend/app/core/cache/base/key_maker.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Callable 3 | 4 | 5 | class BaseKeyMaker(ABC): 6 | @abstractmethod 7 | async def make(self, function: Callable, prefix: str) -> str: 8 | ... 9 | -------------------------------------------------------------------------------- /backend/app/core/security/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_control import AccessControl 2 | from .jwt import JWTHandler 3 | from .password import PasswordHandler 4 | 5 | __all__ = [ 6 | "AccessControl", 7 | "JWTHandler", 8 | "PasswordHandler", 9 | ] 10 | -------------------------------------------------------------------------------- /backend/app/api/v1/monitoring/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .health import health_router 4 | 5 | monitoring_router = APIRouter() 6 | monitoring_router.include_router(health_router, prefix="/health", tags=["Health"]) 7 | 8 | __all__ = ["monitoring_router"] 9 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/dependencies/logging.py: -------------------------------------------------------------------------------- 1 | from fastapi import BackgroundTasks 2 | 3 | 4 | class Logging: 5 | def __init__(self, background_task: BackgroundTasks): 6 | background_task.add_task(self._send_log) 7 | 8 | async def _send_log(self): 9 | pass 10 | -------------------------------------------------------------------------------- /backend/app/core/utils/datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | 4 | def utcnow() -> datetime: 5 | """ 6 | Returns the current time in UTC but with tzinfo set, as opposed 7 | to datetime.utcnow which does not. 8 | """ 9 | return datetime.now(timezone.utc) 10 | -------------------------------------------------------------------------------- /backend/app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .monitoring import monitoring_router 4 | from .users import users_router 5 | 6 | v1_router = APIRouter() 7 | v1_router.include_router(monitoring_router, prefix="/monitoring") 8 | v1_router.include_router(users_router, prefix="/users") 9 | -------------------------------------------------------------------------------- /backend/app/core/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache_manager import Cache 2 | from .cache_tag import CacheTag 3 | from .custom_key_maker import CustomKeyMaker 4 | from .redis_backend import RedisBackend 5 | 6 | __all__ = [ 7 | "Cache", 8 | "RedisBackend", 9 | "CustomKeyMaker", 10 | "CacheTag", 11 | ] 12 | -------------------------------------------------------------------------------- /backend/app/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from core.config import config 3 | 4 | print("postgres", config.POSTGRES_URL) 5 | 6 | if __name__ == "__main__": 7 | uvicorn.run( 8 | app="core.server:app", 9 | reload=True if config.ENVIRONMENT != "production" else False, 10 | workers=1, 11 | ) 12 | -------------------------------------------------------------------------------- /frontend/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /backend/app/app/schemas/extras/current_user.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | config = ConfigDict(validate_assignment=True) 5 | 6 | 7 | @dataclass(config=config) 8 | class CurrentUser(BaseModel): 9 | id: str = Field(None, description="User ID") 10 | -------------------------------------------------------------------------------- /backend/app/api/v1/monitoring/health.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.schemas.extras.health import Health 4 | from core.config import config 5 | 6 | health_router = APIRouter() 7 | 8 | 9 | @health_router.get("/") 10 | async def health() -> Health: 11 | return Health(version=config.RELEASE_VERSION, status="Healthy") 12 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .authentication import AuthBackend, AuthenticationMiddleware 2 | from .response_logger import ResponseLoggerMiddleware 3 | from .sqlalchemy import SQLAlchemyMiddleware 4 | 5 | __all__ = [ 6 | "SQLAlchemyMiddleware", 7 | "ResponseLoggerMiddleware", 8 | "AuthenticationMiddleware", 9 | "AuthBackend", 10 | ] 11 | -------------------------------------------------------------------------------- /backend/app/worker/__init__.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | from core.config import config 4 | 5 | celery_app = Celery( 6 | "worker", 7 | backend=config.CELERY_BACKEND_URL, 8 | broker=config.CELERY_BROKER_URL, 9 | ) 10 | 11 | celery_app.conf.task_routes = {"worker.celery_worker.test_celery": "test-queue"} 12 | celery_app.conf.update(task_track_started=True) 13 | -------------------------------------------------------------------------------- /frontend/src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | type Data = { 5 | name: string; 6 | }; 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }); 13 | } 14 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/dependencies/current_user.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, Request 2 | 3 | from app.controllers.user import UserController 4 | from core.factory import Factory 5 | 6 | 7 | async def get_current_user( 8 | request: Request, 9 | user_controller: UserController = Depends(Factory().get_user_controller), 10 | ): 11 | return await user_controller.get_by_id(request.user.id) 12 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "./src/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/app/core/cache/base/backend.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class BaseBackend(ABC): 6 | @abstractmethod 7 | async def get(self, key: str) -> Any: 8 | ... 9 | 10 | @abstractmethod 11 | async def set(self, response: Any, key: str, ttl: int = 60) -> None: 12 | ... 13 | 14 | @abstractmethod 15 | async def delete_startswith(self, value: str) -> None: 16 | ... 17 | -------------------------------------------------------------------------------- /backend/app/app/schemas/responses/users.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4, BaseModel, ConfigDict, Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | config = ConfigDict(from_attributes=True) 5 | 6 | 7 | @dataclass(config=config) 8 | class UserResponse(BaseModel): 9 | email: str = Field(..., example="john.doe@example.com") 10 | username: str = Field(..., example="john.doe") 11 | uuid: UUID4 = Field(..., example="a3b8f042-1e16-4f0a-a8f0-421e16df0a2f") 12 | -------------------------------------------------------------------------------- /backend/app/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy, sqlalchemy.ext.mypy.plugin 3 | strict = True 4 | ignore_missing_imports = True 5 | follow_imports = silent 6 | warn_redundant_casts = True 7 | warn_unused_ignores = True 8 | disallow_any_generics = True 9 | check_untyped_defs = True 10 | no_implicit_reexport = True 11 | disallow_untyped_defs = True 12 | 13 | [pydantic-mypy] 14 | init_forbid_extra = True 15 | init_typed = True 16 | warn_required_dynamic_aliases = True 17 | -------------------------------------------------------------------------------- /backend/app/core/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .session import ( 2 | Base, 3 | get_session, 4 | reset_session_context, 5 | session, 6 | set_session_context, 7 | ) 8 | from .standalone_session import standalone_session 9 | from .transactional import Propagation, Transactional 10 | 11 | __all__ = [ 12 | "Base", 13 | "session", 14 | "get_session", 15 | "set_session_context", 16 | "reset_session_context", 17 | "standalone_session", 18 | "Transactional", 19 | "Propagation", 20 | ] 21 | -------------------------------------------------------------------------------- /backend/app/core/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | BadRequestException, 3 | CustomException, 4 | DuplicateValueException, 5 | ForbiddenException, 6 | NotFoundException, 7 | UnauthorizedException, 8 | UnprocessableEntity, 9 | ) 10 | 11 | __all__ = [ 12 | "CustomException", 13 | "BadRequestException", 14 | "NotFoundException", 15 | "ForbiddenException", 16 | "UnauthorizedException", 17 | "UnprocessableEntity", 18 | "DuplicateValueException", 19 | ] 20 | -------------------------------------------------------------------------------- /backend/app/core/security/password.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | 4 | class PasswordHandler: 5 | pwd_context = CryptContext( 6 | schemes=["bcrypt"], 7 | deprecated="auto", 8 | ) 9 | 10 | @staticmethod 11 | def hash(password: str): 12 | return PasswordHandler.pwd_context.hash(password) 13 | 14 | @staticmethod 15 | def verify(hashed_password, plain_password): 16 | return PasswordHandler.pwd_context.verify(plain_password, hashed_password) 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /backend/app/core/db/mixins/timestamp.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | from sqlalchemy import Column, DateTime, func 4 | from sqlalchemy.ext.declarative import declared_attr 5 | 6 | 7 | class TimestampMixin: 8 | @declared_attr 9 | def created_at(cls): 10 | return Column(DateTime, default=func.now(), nullable=False) 11 | 12 | @declared_attr 13 | def updated_at(cls): 14 | return Column( 15 | DateTime, 16 | default=func.now(), 17 | onupdate=func.now(), 18 | nullable=False, 19 | ) 20 | -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /backend/app/app/controllers/user.py: -------------------------------------------------------------------------------- 1 | from core.controller import BaseController 2 | 3 | from app.models import User 4 | from app.repositories import UserRepository 5 | 6 | 7 | class UserController(BaseController[User]): 8 | def __init__(self, user_repository: UserRepository): 9 | super().__init__(model=User, repository=user_repository) 10 | self.user_repository = user_repository 11 | 12 | async def get_by_username(self, username: str) -> User | None: 13 | return await self.user_repository.get_by_username(username) 14 | 15 | async def get_by_email(self, email: str) -> User | None: 16 | return await self.user_repository.get_by_email(email) 17 | -------------------------------------------------------------------------------- /backend/app/core/db/standalone_session.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from .session import reset_session_context, session, set_session_context 4 | 5 | 6 | def standalone_session(func): 7 | async def _standalone_session(*args, **kwargs): 8 | session_id = str(uuid4()) 9 | context = set_session_context(session_id=session_id) 10 | 11 | try: 12 | await func(*args, **kwargs) 13 | except Exception as exception: 14 | await session.rollback() 15 | raise exception 16 | finally: 17 | await session.remove() 18 | reset_session_context(context=context) 19 | 20 | return _standalone_session 21 | -------------------------------------------------------------------------------- /backend/app/core/cache/custom_key_maker.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Callable 3 | 4 | from core.cache.base import BaseKeyMaker 5 | 6 | 7 | class CustomKeyMaker(BaseKeyMaker): 8 | async def make(self, function: Callable, prefix: str) -> str: 9 | module = inspect.getmodule(function) 10 | if module is None: 11 | raise ValueError("Function does not have a module.") 12 | 13 | path = f"{prefix}::{module.__name__}.{function.__name__}" 14 | args = "" 15 | 16 | for arg in inspect.signature(function).parameters.values(): 17 | args += arg.name 18 | 19 | if args: 20 | return f"{path}.{args}" 21 | 22 | return path 23 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/dependencies/authentication.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from fastapi import Depends 4 | from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 5 | 6 | from core.exceptions.base import CustomException 7 | 8 | 9 | class AuthenticationRequiredException(CustomException): 10 | code = HTTPStatus.UNAUTHORIZED 11 | error_code = HTTPStatus.UNAUTHORIZED 12 | message = "Authentication required" 13 | 14 | 15 | class AuthenticationRequired: 16 | def __init__( 17 | self, 18 | token: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)), 19 | ): 20 | if not token: 21 | raise AuthenticationRequiredException() 22 | -------------------------------------------------------------------------------- /backend/app/ruff.toml: -------------------------------------------------------------------------------- 1 | src = ["app"] 2 | # Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default. 3 | select = ["E", "F"] 4 | # Same as Black. 5 | line-length = 88 6 | # Allow unused variables when underscore-prefixed. 7 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 8 | # Exclude a variety of commonly ignored directories. 9 | exclude = [ 10 | ".bzr", 11 | ".direnv", 12 | ".eggs", 13 | ".git", 14 | ".git-rewrite", 15 | ".hg", 16 | ".mypy_cache", 17 | ".nox", 18 | ".pants.d", 19 | ".pytype", 20 | ".ruff_cache", 21 | ".svn", 22 | ".tox", 23 | ".venv", 24 | "__pypackages__", 25 | "_build", 26 | "buck-out", 27 | "build", 28 | "dist", 29 | "node_modules", 30 | "venv", 31 | ] -------------------------------------------------------------------------------- /frontend/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as LabelPrimitive from '@radix-ui/react-label'; 5 | import { cva, type VariantProps } from 'class-variance-authority'; 6 | 7 | import { cn } from '@/lib/utils'; 8 | 9 | const labelVariants = cva( 10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | 25 | Label.displayName = LabelPrimitive.Root.displayName; 26 | 27 | export { Label }; 28 | -------------------------------------------------------------------------------- /frontend/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | 24 | Input.displayName = 'Input'; 25 | 26 | export { Input }; 27 | -------------------------------------------------------------------------------- /backend/app/core/factory/factory.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from app.controllers import AuthController, UserController 4 | from app.models import User 5 | from app.repositories import UserRepository 6 | from fastapi import Depends 7 | 8 | from core.db import get_session 9 | 10 | 11 | class Factory: 12 | """ 13 | This is the factory container that will instantiate all the controllers and 14 | repositories which can be accessed by the rest of the application. 15 | """ 16 | 17 | # Repositories 18 | user_repository = partial(UserRepository, User) 19 | 20 | def get_user_controller(self, db_session=Depends(get_session)): 21 | return UserController( 22 | user_repository=self.user_repository(db_session=db_session) 23 | ) 24 | 25 | def get_auth_controller(self, db_session=Depends(get_session)): 26 | return AuthController( 27 | user_repository=self.user_repository(db_session=db_session), 28 | ) 29 | -------------------------------------------------------------------------------- /frontend/.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['stylelint-config-standard', 'stylelint-config-tailwindcss'], 3 | plugins: ['stylelint-scss'], 4 | rules: { 5 | 'selector-class-pattern': null, 6 | 'block-no-empty': true, 7 | 'no-duplicate-selectors': true, 8 | 'color-no-invalid-hex': true, 9 | 'at-rule-empty-line-before': [ 10 | 'always', 11 | { 12 | except: ['blockless-after-same-name-blockless', 'first-nested'], 13 | ignore: ['after-comment'], 14 | }, 15 | ], 16 | 'declaration-empty-line-before': [ 17 | 'always', 18 | { 19 | except: ['after-declaration', 'first-nested'], 20 | ignore: ['after-comment', 'inside-single-line-block'], 21 | }, 22 | ], 23 | 'max-empty-lines': 1, 24 | 'rule-empty-line-before': [ 25 | 'always-multi-line', 26 | { 27 | except: ['first-nested'], 28 | ignore: ['after-comment'], 29 | }, 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /backend/app/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Cibi Aananth "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.11" 10 | fastapi = "^0.100.0" 11 | uvicorn = {extras = ["standard"], version = "^0.23.0"} 12 | pydantic = {extras = ["email", "mypy"], version = "^2.0.3"} 13 | alembic = "^1.11.1" 14 | sqlalchemy = {extras = ["asyncio", "mypy"], version = "^2.0.19"} 15 | pydantic-settings = "^2.0.2" 16 | passlib = "^1.7.4" 17 | types-python-jose = "^3.3.4.8" 18 | types-passlib = "^1.7.7.12" 19 | python-jose = {extras = ["cryptography"], version = "^3.3.0"} 20 | ujson = "^5.8.0" 21 | redis = "^4.6.0" 22 | types-redis = "^4.6.0.3" 23 | types-ujson = "^5.8.0.1" 24 | celery = "^5.3.1" 25 | asyncpg = "^0.28.0" 26 | celery-types = "^0.18.0" 27 | 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | mypy = "^1.4.1" 31 | ruff = "^0.0.278" 32 | black = "^23.7.0" 33 | 34 | [build-system] 35 | requires = ["poetry-core"] 36 | build-backend = "poetry.core.masonry.api" 37 | -------------------------------------------------------------------------------- /backend/app/core/config.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic_settings import BaseSettings 4 | 5 | 6 | class EnvironmentType(str, Enum): 7 | DEVELOPMENT = "development" 8 | PRODUCTION = "production" 9 | TEST = "test" 10 | 11 | 12 | class BaseConfig(BaseSettings): 13 | class Config: 14 | case_sensitive = True 15 | env_file = ".env" 16 | extra = "ignore" 17 | 18 | 19 | class Config(BaseConfig): 20 | DEBUG: int = 0 21 | DEFAULT_LOCALE: str = "en_US" 22 | ENVIRONMENT: str = EnvironmentType.DEVELOPMENT 23 | POSTGRES_URL: str = "postgresql+asyncpg://user:password@127.0.0.1:5432/db-name" 24 | REDIS_URL: str = "redis://localhost:6379/7" 25 | RELEASE_VERSION: str = "0.1" 26 | APP_VERSION: str = "0.1.0" 27 | SHOW_SQL_ALCHEMY_QUERIES: int = 0 28 | SECRET_KEY: str = "super-secret-key" 29 | JWT_ALGORITHM: str = "HS256" 30 | JWT_EXPIRE_MINUTES: int = 60 * 24 31 | CELERY_BROKER_URL: str = "amqp://rabbit:password@localhost:5672" 32 | CELERY_BACKEND_URL: str = "redis://localhost:6379/0" 33 | 34 | 35 | config: Config = Config() 36 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/middlewares/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from sqlalchemy.ext.asyncio import ( 4 | AsyncSession, 5 | async_scoped_session, 6 | ) 7 | from starlette.types import ASGIApp, Receive, Scope, Send 8 | 9 | from core.db.session import reset_session_context, session, set_session_context 10 | 11 | 12 | class SQLAlchemyMiddleware: 13 | def __init__(self, app: ASGIApp) -> None: 14 | self.app = app 15 | 16 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 17 | session_id = str(uuid4()) 18 | context = set_session_context(session_id=session_id) 19 | 20 | try: 21 | await self.app(scope, receive, send) 22 | except Exception as exception: 23 | raise exception 24 | finally: 25 | if isinstance(session, async_scoped_session): 26 | await session.remove() 27 | elif isinstance(session, AsyncSession): 28 | await session.close() # or some other method depending on what you want to do 29 | reset_session_context(context=context) 30 | -------------------------------------------------------------------------------- /backend/app/core/cache/redis_backend.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from typing import Any 3 | 4 | import redis.asyncio as aioredis 5 | import ujson 6 | 7 | from core.cache.base import BaseBackend 8 | from core.config import config 9 | 10 | redis = aioredis.from_url(url=config.REDIS_URL) 11 | 12 | 13 | class RedisBackend(BaseBackend): 14 | async def get(self, key: str) -> Any: 15 | result = await redis.get(key) 16 | if not result: 17 | return 18 | 19 | try: 20 | return ujson.loads(result.decode("utf8")) 21 | except UnicodeDecodeError: 22 | return pickle.loads(result) 23 | 24 | async def set(self, response: Any, key: str, ttl: int = 60) -> None: 25 | if isinstance(response, dict): 26 | response = ujson.dumps(response) 27 | elif isinstance(response, object): 28 | response = pickle.dumps(response) 29 | 30 | await redis.set(name=key, value=response, ex=ttl) 31 | 32 | async def delete_startswith(self, value: str) -> None: 33 | async for key in redis.scan_iter(f"{value}::*"): 34 | await redis.delete(key) 35 | -------------------------------------------------------------------------------- /backend/app/app/repositories/user.py: -------------------------------------------------------------------------------- 1 | from core.repository import BaseRepository 2 | 3 | from app.models import User 4 | 5 | 6 | class UserRepository(BaseRepository[User]): 7 | """ 8 | User repository provides all the database operations for the User model. 9 | """ 10 | 11 | async def get_by_username( 12 | self, username: str, join_: set[str] | None = None 13 | ) -> User | None: 14 | """ 15 | Get user by username. 16 | 17 | :param username: Username. 18 | :param join_: Join relations. 19 | :return: User. 20 | """ 21 | query = await self._query(join_) 22 | query = query.filter(User.username == username) 23 | return await self._one_or_none(query) 24 | 25 | async def get_by_email( 26 | self, email: str, join_: set[str] | None = None 27 | ) -> User | None: 28 | """ 29 | Get user by email. 30 | 31 | :param email: Email. 32 | :param join_: Join relations. 33 | :return: User. 34 | """ 35 | query = await self._query(join_) 36 | query = query.filter(User.email == email) 37 | return await self._one_or_none(query) 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "css.validate": false, 4 | "less.validate": false, 5 | "scss.validate": false, 6 | "eslint.validate": ["json", "jsonc"], 7 | "stylelint.validate": ["css", "scss", "tailwindcss"], 8 | "editor.quickSuggestions": { 9 | "strings": true 10 | }, 11 | "files.associations": { 12 | "*.css": "tailwindcss", 13 | "*.scss": "tailwindcss" 14 | }, 15 | "[css]": { 16 | "editor.defaultFormatter": "stylelint.vscode-stylelint", 17 | "editor.codeActionsOnSave": { 18 | "source.fixAll.stylelint": true 19 | } 20 | }, 21 | "[tailwindcss]": { 22 | "editor.defaultFormatter": "stylelint.vscode-stylelint", 23 | "editor.codeActionsOnSave": { 24 | "source.fixAll.stylelint": true 25 | } 26 | }, 27 | "stylelint.snippet": ["css", "less", "postcss", "tailwindcss"], 28 | "[python]": { 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.ruff": true, 31 | "source.organizeImports.ruff": true 32 | }, 33 | "editor.defaultFormatter": "ms-python.black-formatter" 34 | }, 35 | "python.formatting.provider": "none", 36 | "mypy.dmypyExecutable": "", 37 | "mypy.runUsingActiveInterpreter": true, 38 | "editor.formatOnSaveMode": "file" 39 | } 40 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */ 4 | module.exports = { 5 | printWidth: 80, 6 | singleQuote: true, 7 | semi: true, 8 | trailingComma: 'all', 9 | quoteProps: 'as-needed', 10 | jsxSingleQuote: false, 11 | arrowParens: 'avoid', 12 | htmlWhitespaceSensitivity: 'ignore', 13 | 14 | importOrder: [ 15 | '^(react/(.*)$)|^(react$)', 16 | '^(next/(.*)$)|^(next$)', 17 | '', 18 | '', 19 | '^types$', 20 | '^@/types/(.*)$', 21 | '^@/config/(.*)$', 22 | '^@/lib/(.*)$', 23 | '^@/hooks/(.*)$', 24 | '^@/components/ui/(.*)$', 25 | '^@/components/(.*)$', 26 | '^@/registry/(.*)$', 27 | '^@/styles/(.*)$', 28 | '^@/app/(.*)$', 29 | '', 30 | '^[./]', 31 | ], 32 | importOrderSeparation: false, 33 | importOrderSortSpecifiers: true, 34 | importOrderBuiltinModulesToTop: true, 35 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], 36 | importOrderMergeDuplicateImports: true, 37 | importOrderCombineTypeAndValueImports: true, 38 | 39 | tailwindConfig: './tailwind.config.js', 40 | tailwindFunctions: ['clsx', 'cva', 'tw'], 41 | 42 | plugins: [ 43 | '@ianvs/prettier-plugin-sort-imports', 44 | 'prettier-plugin-tailwindcss', 45 | ], 46 | pluginSearchDirs: false, // needed for tailwindcss plugin 47 | }; 48 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/dependencies/permissions.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from app.controllers.user import UserController 4 | from fastapi import Depends, Request 5 | 6 | from core.exceptions import CustomException 7 | from core.factory import Factory 8 | from core.security.access_control import ( 9 | AccessControl, 10 | Authenticated, 11 | Everyone, 12 | Principal, 13 | RolePrincipal, 14 | UserPrincipal, 15 | ) 16 | 17 | 18 | class InsufficientPermissionsException(CustomException): 19 | code = HTTPStatus.FORBIDDEN 20 | error_code = HTTPStatus.FORBIDDEN 21 | message = "Insufficient permissions" 22 | 23 | 24 | async def get_user_principals( 25 | request: Request, 26 | user_controller: UserController = Depends(Factory().get_user_controller), 27 | ) -> list[Principal]: 28 | user_id = request.user.id 29 | principals: list[Principal] = [Everyone] 30 | 31 | if not user_id: 32 | return principals 33 | 34 | user = await user_controller.get_by_id(id_=user_id) 35 | 36 | principals.append(Authenticated) 37 | principals.append(UserPrincipal(str(user.id))) 38 | 39 | if user.is_admin: 40 | principals.append(RolePrincipal("admin")) 41 | 42 | return principals 43 | 44 | 45 | Permissions = AccessControl( 46 | user_principals_getter=get_user_principals, 47 | permission_exception=InsufficientPermissionsException, 48 | ) 49 | -------------------------------------------------------------------------------- /backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from uuid import uuid4 3 | 4 | from core.db import Base 5 | from core.db.mixins import TimestampMixin 6 | from core.security.access_control import Allow, Everyone, RolePrincipal, UserPrincipal 7 | from sqlalchemy import BigInteger, Column, Unicode 8 | from sqlalchemy.dialects.postgresql import UUID 9 | 10 | 11 | class UserPermission(Enum): 12 | CREATE = "create" 13 | READ = "read" 14 | EDIT = "edit" 15 | DELETE = "delete" 16 | 17 | 18 | class User(Base, TimestampMixin): 19 | __tablename__ = "global_users" 20 | 21 | id = Column(BigInteger, primary_key=True, autoincrement=True) 22 | uuid = Column(UUID(as_uuid=True), default=uuid4, unique=True, nullable=False) 23 | email = Column(Unicode(255), nullable=False, unique=True) 24 | password = Column(Unicode(255), nullable=False) 25 | username = Column(Unicode(255), nullable=False, unique=True) 26 | 27 | __mapper_args__ = {"eager_defaults": True} 28 | 29 | def __acl__(self): 30 | basic_permissions = [UserPermission.READ, UserPermission.CREATE] 31 | self_permissions = [ 32 | UserPermission.READ, 33 | UserPermission.EDIT, 34 | UserPermission.CREATE, 35 | ] 36 | all_permissions = list(UserPermission) 37 | 38 | return [ 39 | (Allow, Everyone, basic_permissions), 40 | (Allow, UserPrincipal(value=self.id), self_permissions), 41 | (Allow, RolePrincipal(value="admin"), all_permissions), 42 | ] 43 | -------------------------------------------------------------------------------- /backend/app/core/exceptions/base.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | 4 | class CustomException(Exception): 5 | code = HTTPStatus.BAD_GATEWAY 6 | error_code = HTTPStatus.BAD_GATEWAY 7 | message = HTTPStatus.BAD_GATEWAY.description 8 | 9 | def __init__(self, message=None): 10 | if message: 11 | self.message = message 12 | 13 | 14 | class BadRequestException(CustomException): 15 | code = HTTPStatus.BAD_REQUEST 16 | error_code = HTTPStatus.BAD_REQUEST 17 | message = HTTPStatus.BAD_REQUEST.description 18 | 19 | 20 | class NotFoundException(CustomException): 21 | code = HTTPStatus.NOT_FOUND 22 | error_code = HTTPStatus.NOT_FOUND 23 | message = HTTPStatus.NOT_FOUND.description 24 | 25 | 26 | class ForbiddenException(CustomException): 27 | code = HTTPStatus.FORBIDDEN 28 | error_code = HTTPStatus.FORBIDDEN 29 | message = HTTPStatus.FORBIDDEN.description 30 | 31 | 32 | class UnauthorizedException(CustomException): 33 | code = HTTPStatus.UNAUTHORIZED 34 | error_code = HTTPStatus.UNAUTHORIZED 35 | message = HTTPStatus.UNAUTHORIZED.description 36 | 37 | 38 | class UnprocessableEntity(CustomException): 39 | code = HTTPStatus.UNPROCESSABLE_ENTITY 40 | error_code = HTTPStatus.UNPROCESSABLE_ENTITY 41 | message = HTTPStatus.UNPROCESSABLE_ENTITY.description 42 | 43 | 44 | class DuplicateValueException(CustomException): 45 | code = HTTPStatus.UNPROCESSABLE_ENTITY 46 | error_code = HTTPStatus.UNPROCESSABLE_ENTITY 47 | message = HTTPStatus.UNPROCESSABLE_ENTITY.description 48 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/middlewares/response_logger.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, ConfigDict, Field 4 | from pydantic.dataclasses import dataclass 5 | from starlette.datastructures import Headers 6 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 7 | 8 | config = ConfigDict(arbitrary_types_allowed=True) 9 | 10 | 11 | @dataclass(config=config) 12 | class ResponseInfo(BaseModel): 13 | headers: Optional[Headers] = Field(default=None, title="Response header") 14 | body: str = Field(default="", title="응답 바디") 15 | status_code: Optional[int] = Field(default=None, title="Status code") 16 | 17 | 18 | class ResponseLoggerMiddleware: 19 | def __init__(self, app: ASGIApp) -> None: 20 | self.app = app 21 | 22 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 23 | if scope["type"] != "http": 24 | return await self.app(scope, receive, send) 25 | 26 | response_info = ResponseInfo() 27 | 28 | async def _logging_send(message: Message) -> None: 29 | if message.get("type") == "http.response.start": 30 | response_info.headers = Headers(raw=message.get("headers")) 31 | response_info.status_code = message.get("status") 32 | elif message.get("type") == "http.response.body": 33 | if body := message.get("body"): 34 | response_info.body += body.decode("utf8") 35 | 36 | await send(message) 37 | 38 | await self.app(scope, receive, _logging_send) 39 | -------------------------------------------------------------------------------- /backend/app/app/schemas/requests/users.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=all 2 | 3 | import re 4 | 5 | from pydantic import BaseModel, EmailStr, constr, field_validator 6 | 7 | 8 | class RegisterUserRequest(BaseModel): 9 | email: EmailStr 10 | password: constr(min_length=8, max_length=64) # type: ignore 11 | username: constr(min_length=3, max_length=64) # type: ignore 12 | 13 | @field_validator("password") 14 | def password_must_contain_special_characters(cls, v): 15 | if not re.search(r"[^a-zA-Z0-9]", v): 16 | raise ValueError("Password must contain special characters") 17 | return v 18 | 19 | @field_validator("password") 20 | def password_must_contain_numbers(cls, v): 21 | if not re.search(r"[0-9]", v): 22 | raise ValueError("Password must contain numbers") 23 | return v 24 | 25 | @field_validator("password") 26 | def password_must_contain_uppercase(cls, v): 27 | if not re.search(r"[A-Z]", v): 28 | raise ValueError("Password must contain uppercase characters") 29 | return v 30 | 31 | @field_validator("password") 32 | def password_must_contain_lowercase(cls, v): 33 | if not re.search(r"[a-z]", v): 34 | raise ValueError("Password must contain lowercase characters") 35 | return v 36 | 37 | @field_validator("username") 38 | def username_must_not_contain_special_characters(cls, v): 39 | if re.search(r"[^a-zA-Z0-9]", v): 40 | raise ValueError("Username must not contain special characters") 41 | return v 42 | 43 | 44 | class LoginUserRequest(BaseModel): 45 | email: EmailStr 46 | password: str 47 | -------------------------------------------------------------------------------- /backend/app/core/cache/cache_manager.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from .base import BaseBackend, BaseKeyMaker 4 | from .cache_tag import CacheTag 5 | 6 | 7 | class CacheManager: 8 | def __init__(self): 9 | self.backend = None 10 | self.key_maker = None 11 | 12 | def init(self, backend: BaseBackend, key_maker: BaseKeyMaker) -> None: 13 | self.backend = backend 14 | self.key_maker = key_maker 15 | 16 | def cached( 17 | self, prefix: str | None = None, tag: CacheTag | None = None, ttl: int = 60 18 | ): 19 | def _cached(function): 20 | @wraps(function) 21 | async def __cached(*args, **kwargs): 22 | if not self.backend or not self.key_maker: 23 | raise ValueError("Backend or KeyMaker not initialized") 24 | 25 | key = await self.key_maker.make( 26 | function=function, 27 | prefix=prefix if prefix else tag.value, 28 | ) 29 | cached_response = await self.backend.get(key=key) 30 | if cached_response: 31 | return cached_response 32 | 33 | response = await function(*args, **kwargs) 34 | await self.backend.set(response=response, key=key, ttl=ttl) 35 | return response 36 | 37 | return __cached 38 | 39 | return _cached 40 | 41 | async def remove_by_tag(self, tag: CacheTag) -> None: 42 | await self.backend.delete_startswith(value=tag.value) 43 | 44 | async def remove_by_prefix(self, prefix: str) -> None: 45 | await self.backend.delete_startswith(value=prefix) 46 | 47 | 48 | Cache = CacheManager() 49 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/middlewares/auth-archive.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | from jose import JWTError, jwt 4 | from starlette.authentication import ( 5 | AuthCredentials, 6 | AuthenticationBackend, 7 | AuthenticationError, 8 | SimpleUser, 9 | ) 10 | from starlette.middleware.authentication import ( 11 | AuthenticationMiddleware as BaseAuthenticationMiddleware, 12 | ) 13 | from starlette.requests import HTTPConnection 14 | 15 | from core.config import config 16 | 17 | 18 | class AuthBackend(AuthenticationBackend): 19 | async def authenticate( 20 | self, conn: HTTPConnection 21 | ) -> Optional[Tuple[AuthCredentials, SimpleUser]]: 22 | authorization: Optional[str] = conn.headers.get("Authorization") 23 | if not authorization: 24 | return None 25 | 26 | try: 27 | scheme, token = authorization.split(" ") 28 | if scheme.lower() != "bearer": 29 | return None 30 | except ValueError: 31 | return None 32 | 33 | if not token: 34 | return None 35 | 36 | try: 37 | payload = jwt.decode( 38 | token, 39 | config.SECRET_KEY, 40 | algorithms=[config.JWT_ALGORITHM], 41 | ) 42 | user_id: Optional[str] = payload.get("user_id") 43 | if user_id is None: 44 | return None 45 | except JWTError: 46 | raise AuthenticationError("Invalid token") 47 | 48 | user = SimpleUser(user_id) 49 | credentials = AuthCredentials(["authenticated"]) 50 | return credentials, user 51 | 52 | 53 | class AuthenticationMiddleware(BaseAuthenticationMiddleware): 54 | pass 55 | -------------------------------------------------------------------------------- /backend/app/core/security/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from http import HTTPStatus 3 | 4 | from jose import ExpiredSignatureError, JWTError, jwt 5 | 6 | from core.config import config 7 | from core.exceptions import CustomException 8 | 9 | 10 | class JWTDecodeError(CustomException): 11 | code = HTTPStatus.UNAUTHORIZED 12 | message = "Invalid token" 13 | 14 | 15 | class JWTExpiredError(CustomException): 16 | code = HTTPStatus.UNAUTHORIZED 17 | message = "Token expired" 18 | 19 | 20 | class JWTHandler: 21 | secret_key = config.SECRET_KEY 22 | algorithm = config.JWT_ALGORITHM 23 | expire_minutes = config.JWT_EXPIRE_MINUTES 24 | 25 | @staticmethod 26 | def encode(payload: dict) -> str: 27 | expire = datetime.utcnow() + timedelta(minutes=JWTHandler.expire_minutes) 28 | payload.update({"exp": expire}) 29 | return jwt.encode( 30 | payload, JWTHandler.secret_key, algorithm=JWTHandler.algorithm 31 | ) 32 | 33 | @staticmethod 34 | def decode(token: str) -> dict: 35 | try: 36 | return jwt.decode( 37 | token, JWTHandler.secret_key, algorithms=[JWTHandler.algorithm] 38 | ) 39 | except ExpiredSignatureError as exception: 40 | raise JWTExpiredError() from exception 41 | except JWTError as exception: 42 | raise JWTDecodeError() from exception 43 | 44 | @staticmethod 45 | def decode_expired(token: str) -> dict: 46 | try: 47 | return jwt.decode( 48 | token, 49 | JWTHandler.secret_key, 50 | algorithms=[JWTHandler.algorithm], 51 | options={"verify_exp": False}, 52 | ) 53 | except JWTError as exception: 54 | raise JWTDecodeError() from exception 55 | -------------------------------------------------------------------------------- /frontend/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --muted: 210 40% 96.1%; 10 | --muted-foreground: 215.4 16.3% 46.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 222.2 84% 4.9%; 15 | --border: 214.3 31.8% 91.4%; 16 | --input: 214.3 31.8% 91.4%; 17 | --primary: 222.2 47.4% 11.2%; 18 | --primary-foreground: 210 40% 98%; 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | --accent: 210 40% 96.1%; 22 | --accent-foreground: 222.2 47.4% 11.2%; 23 | --destructive: 0 84.2% 60.2%; 24 | --destructive-foreground: 210 40% 98%; 25 | --ring: 215 20.2% 65.1%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 222.2 84% 4.9%; 31 | --foreground: 210 40% 98%; 32 | --muted: 217.2 32.6% 17.5%; 33 | --muted-foreground: 215 20.2% 65.1%; 34 | --popover: 222.2 84% 4.9%; 35 | --popover-foreground: 210 40% 98%; 36 | --card: 222.2 84% 4.9%; 37 | --card-foreground: 210 40% 98%; 38 | --border: 217.2 32.6% 17.5%; 39 | --input: 217.2 32.6% 17.5%; 40 | --primary: 210 40% 98%; 41 | --primary-foreground: 222.2 47.4% 11.2%; 42 | --secondary: 217.2 32.6% 17.5%; 43 | --secondary-foreground: 210 40% 98%; 44 | --accent: 217.2 32.6% 17.5%; 45 | --accent-foreground: 210 40% 98%; 46 | --destructive: 0 62.8% 30.6%; 47 | --destructive-foreground: 0 85.7% 97.3%; 48 | --ring: 217.2 32.6% 17.5%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | 57 | body { 58 | @apply bg-background text-foreground; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 18 | 19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 20 | 21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /backend/app/core/db/session.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar, Token 2 | from typing import Union 3 | 4 | from sqlalchemy.ext.asyncio import ( 5 | AsyncSession, 6 | async_scoped_session, 7 | create_async_engine, 8 | ) 9 | from sqlalchemy.orm import Session, declarative_base, sessionmaker 10 | from sqlalchemy.sql.expression import Delete, Insert, Update 11 | 12 | from core.config import config 13 | 14 | session_context: ContextVar[str] = ContextVar("session_context") 15 | 16 | 17 | def get_session_context() -> str: 18 | return session_context.get() 19 | 20 | 21 | def set_session_context(session_id: str) -> Token: 22 | return session_context.set(session_id) 23 | 24 | 25 | def reset_session_context(context: Token) -> None: 26 | session_context.reset(context) 27 | 28 | 29 | engines = { 30 | "writer": create_async_engine(config.POSTGRES_URL, pool_recycle=3600), 31 | "reader": create_async_engine(config.POSTGRES_URL, pool_recycle=3600), 32 | } 33 | 34 | 35 | class RoutingSession(Session): 36 | def get_bind(self, mapper=None, clause=None, **kwargs): 37 | if self._flushing or isinstance(clause, (Update, Delete, Insert)): 38 | return engines["writer"].sync_engine 39 | return engines["reader"].sync_engine 40 | 41 | 42 | async_session_factory = sessionmaker( 43 | class_=AsyncSession, 44 | sync_session_class=RoutingSession, 45 | expire_on_commit=False, 46 | ) 47 | 48 | session: Union[AsyncSession, async_scoped_session] = async_scoped_session( 49 | session_factory=async_session_factory, # type: ignore 50 | scopefunc=get_session_context, 51 | ) 52 | 53 | 54 | async def get_session(): 55 | """ 56 | Get the database session. 57 | This can be used for dependency injection. 58 | 59 | :return: The database session. 60 | """ 61 | async with session() as s: 62 | yield s 63 | 64 | 65 | Base = declarative_base() 66 | -------------------------------------------------------------------------------- /backend/app/core/db/transactional.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from functools import wraps 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from core.db import session 7 | 8 | 9 | class Propagation(Enum): 10 | REQUIRED = "required" 11 | REQUIRED_NEW = "required_new" 12 | 13 | 14 | class Transactional: 15 | def __init__(self, propagation: Propagation = Propagation.REQUIRED): 16 | self.propagation = propagation 17 | 18 | def __call__(self, function): 19 | @wraps(function) 20 | async def decorator(*args, **kwargs): 21 | async with session() as s: # s is an AsyncSession 22 | try: 23 | if self.propagation == Propagation.REQUIRED: 24 | result = await self._run_required( 25 | s, function=function, args=args, kwargs=kwargs 26 | ) 27 | elif self.propagation == Propagation.REQUIRED_NEW: 28 | result = await self._run_required_new( 29 | s, function=function, args=args, kwargs=kwargs 30 | ) 31 | else: 32 | result = await self._run_required( 33 | s, function=function, args=args, kwargs=kwargs 34 | ) 35 | except Exception as exception: 36 | await s.rollback() 37 | raise exception 38 | else: 39 | await s.commit() 40 | return result 41 | 42 | return decorator 43 | 44 | async def _run_required( 45 | self, session: AsyncSession, function, args, kwargs 46 | ) -> None: 47 | result = await function(*args, **kwargs) 48 | return result 49 | 50 | async def _run_required_new( 51 | self, session: AsyncSession, function, args, kwargs 52 | ) -> None: 53 | async with session.begin(): 54 | result = await function(*args, **kwargs) 55 | return result 56 | -------------------------------------------------------------------------------- /frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 16 | outline: 17 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 21 | link: 'text-primary underline-offset-4 hover:underline', 22 | }, 23 | size: { 24 | default: 'h-9 px-4 py-2', 25 | sm: 'h-8 rounded-md px-3 text-xs', 26 | lg: 'h-10 rounded-md px-8', 27 | icon: 'h-9 w-9', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button'; 46 | 47 | return ( 48 | 53 | ); 54 | }, 55 | ); 56 | 57 | Button.displayName = 'Button'; 58 | 59 | export { Button, buttonVariants }; 60 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/login/index.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | import { buttonVariants } from '@/components/ui/button'; 6 | 7 | import { UserAuthForm } from './user-auth-form'; 8 | 9 | export const metadata: Metadata = { 10 | title: 'Authentication', 11 | description: 'Login forms', 12 | }; 13 | 14 | export default function AuthenticationPage() { 15 | return ( 16 | <> 17 |
18 | 25 | Login 26 | 27 |
28 |
29 |
30 |

Sign In

31 |

32 | Enter your email below to sign in 33 |

34 |
35 | 36 |

37 | By clicking continue, you agree to our{' '} 38 | 42 | Terms of Service 43 | 44 | {` and `} 45 | 49 | Privacy Policy 50 | 51 | . 52 |

53 |
54 |
55 |
56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "preinstall": "npx only-allow pnpm" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-icons": "^1.3.0", 14 | "@radix-ui/react-label": "^2.0.2", 15 | "@radix-ui/react-slot": "^1.0.2", 16 | "autoprefixer": "10.4.14", 17 | "class-variance-authority": "^0.6.1", 18 | "clsx": "^2.0.0", 19 | "next": "13.4.10", 20 | "postcss": "8.4.26", 21 | "react": "18.2.0", 22 | "react-dom": "18.2.0", 23 | "tailwind-merge": "^1.14.0", 24 | "tailwindcss": "3.3.3", 25 | "tailwindcss-animate": "^1.0.6" 26 | }, 27 | "engines": { 28 | "node": ">=18.15.0" 29 | }, 30 | "volta": { 31 | "node": "18.16.1" 32 | }, 33 | "packageManager": "pnpm@7.15.0", 34 | "devDependencies": { 35 | "@babel/eslint-parser": "^7.22.9", 36 | "@ianvs/prettier-plugin-sort-imports": "^4.1.0", 37 | "@types/node": "20.4.2", 38 | "@types/react": "18.2.15", 39 | "@types/react-dom": "18.2.7", 40 | "@typescript-eslint/eslint-plugin": "^6.0.0", 41 | "@typescript-eslint/parser": "^6.0.0", 42 | "commitlint": "^17.6.6", 43 | "conventional-changelog-conventionalcommits": "^6.1.0", 44 | "cz-customizable": "^7.0.0", 45 | "eslint": "8.45.0", 46 | "eslint-config-next": "13.4.10", 47 | "eslint-config-prettier": "^8.8.0", 48 | "eslint-config-stylelint": "^19.0.0", 49 | "eslint-import-resolver-alias": "^1.1.2", 50 | "eslint-plugin-css": "^0.8.0", 51 | "eslint-plugin-jest": "^27.2.3", 52 | "eslint-plugin-prettier": "^5.0.0", 53 | "eslint-plugin-react": "^7.32.2", 54 | "eslint-plugin-tailwindcss": "^3.13.0", 55 | "husky": "^8.0.3", 56 | "prettier-plugin-tailwindcss": "^0.4.1", 57 | "stylelint": "^15.10.1", 58 | "stylelint-config-standard": "^34.0.0", 59 | "stylelint-config-tailwindcss": "^0.0.7", 60 | "tslint-config-prettier": "^1.18.0", 61 | "typescript": "5.1.6" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /backend/app/core/fastapi/middlewares/authentication.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | from app.schemas.extras.current_user import CurrentUser 4 | from jose import JWTError, jwt 5 | from starlette.authentication import ( 6 | AuthCredentials, 7 | AuthenticationBackend, 8 | BaseUser, 9 | UnauthenticatedUser, 10 | ) 11 | from starlette.middleware.authentication import ( 12 | AuthenticationMiddleware as BaseAuthenticationMiddleware, 13 | ) 14 | from starlette.requests import HTTPConnection 15 | 16 | from core.config import config 17 | 18 | 19 | class CustomUser(BaseUser): 20 | def __init__(self, user: CurrentUser): 21 | self.user = user 22 | 23 | 24 | class AuthBackend(AuthenticationBackend): 25 | async def authenticate( 26 | self, request: HTTPConnection 27 | ) -> Optional[Tuple[AuthCredentials, BaseUser]]: 28 | authorization: Optional[str] = request.headers.get("Authorization") 29 | if not authorization: 30 | return AuthCredentials(), UnauthenticatedUser() 31 | 32 | try: 33 | scheme, token = authorization.split() 34 | if scheme.lower() != "bearer": 35 | return AuthCredentials(), UnauthenticatedUser() 36 | except ValueError: 37 | return AuthCredentials(), UnauthenticatedUser() 38 | 39 | try: 40 | payload = jwt.decode( 41 | token, 42 | config.SECRET_KEY, 43 | algorithms=[config.JWT_ALGORITHM], 44 | ) 45 | user_id: Optional[str] = payload.get("user_id") 46 | except JWTError: 47 | return AuthCredentials(), UnauthenticatedUser() 48 | 49 | if user_id is None: 50 | return AuthCredentials(), UnauthenticatedUser() 51 | 52 | current_user = CurrentUser(id=user_id) 53 | custom_user = CustomUser(current_user) 54 | # custom_user.username = current_user.id 55 | credentials = AuthCredentials(["authenticated"]) 56 | return credentials, custom_user 57 | 58 | 59 | class AuthenticationMiddleware(BaseAuthenticationMiddleware): 60 | pass 61 | -------------------------------------------------------------------------------- /backend/app/api/v1/users/users.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from app.controllers import AuthController, UserController 4 | from app.models.user import User, UserPermission 5 | from app.schemas.extras.token import Token 6 | from app.schemas.requests.users import LoginUserRequest, RegisterUserRequest 7 | from app.schemas.responses.users import UserResponse 8 | from core.factory import Factory 9 | from core.fastapi.dependencies import AuthenticationRequired 10 | from core.fastapi.dependencies.current_user import get_current_user 11 | from core.fastapi.dependencies.permissions import Permissions 12 | from fastapi import APIRouter, Depends 13 | 14 | user_router = APIRouter() 15 | 16 | 17 | @user_router.get("/", dependencies=[Depends(AuthenticationRequired)]) 18 | async def get_users( 19 | user_controller: UserController = Depends(Factory().get_user_controller), 20 | assert_access: Callable = Depends(Permissions(UserPermission.READ.value)), 21 | ) -> list[UserResponse]: 22 | users = await user_controller.get_all() 23 | 24 | assert_access(resource=users) 25 | print(users, [UserResponse.model_construct(**user.__dict__) for user in users]) 26 | return [UserResponse.model_construct(**user.__dict__) for user in users] 27 | 28 | 29 | @user_router.post("/", status_code=201) 30 | async def register_user( 31 | register_user_request: RegisterUserRequest, 32 | auth_controller: AuthController = Depends(Factory().get_auth_controller), 33 | ) -> UserResponse: 34 | return await auth_controller.register( 35 | email=register_user_request.email, 36 | password=register_user_request.password, 37 | username=register_user_request.username, 38 | ) 39 | 40 | 41 | @user_router.post("/login") 42 | async def login_user( 43 | login_user_request: LoginUserRequest, 44 | auth_controller: AuthController = Depends(Factory().get_auth_controller), 45 | ) -> Token: 46 | return await auth_controller.login( 47 | email=login_user_request.email, password=login_user_request.password 48 | ) 49 | 50 | 51 | @user_router.get("/me", dependencies=[Depends(AuthenticationRequired)]) 52 | def get_user( 53 | user: User = Depends(get_current_user), 54 | ) -> UserResponse: 55 | return user 56 | -------------------------------------------------------------------------------- /backend/app/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | file_template = %%(year)d%%(month).2d%%(day).2d%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # sqlalchemy.url = 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/login/user-auth-form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | import { Button } from '@/components/ui/button'; 5 | import { Icons } from '@/components/ui/icons'; 6 | import { Input } from '@/components/ui/input'; 7 | import { Label } from '@/components/ui/label'; 8 | 9 | interface UserAuthFormProps extends React.HTMLAttributes {} 10 | 11 | export function UserAuthForm({ className, ...props }: UserAuthFormProps) { 12 | const [isLoading, setIsLoading] = React.useState(false); 13 | 14 | async function onSubmit(event: React.SyntheticEvent) { 15 | event.preventDefault(); 16 | setIsLoading(true); 17 | 18 | setTimeout(() => { 19 | setIsLoading(false); 20 | }, 3000); 21 | } 22 | 23 | return ( 24 |
25 |
26 |
27 |
28 | 31 | 40 |
41 | 47 |
48 |
49 |
50 |
51 | 52 |
53 |
54 | 55 | Or continue with 56 | 57 |
58 |
59 | 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ['class'], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: '2rem', 14 | screens: { 15 | '2xl': '1400px', 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: 'hsl(var(--border))', 21 | input: 'hsl(var(--input))', 22 | ring: 'hsl(var(--ring))', 23 | background: 'hsl(var(--background))', 24 | foreground: 'hsl(var(--foreground))', 25 | primary: { 26 | DEFAULT: 'hsl(var(--primary))', 27 | foreground: 'hsl(var(--primary-foreground))', 28 | }, 29 | secondary: { 30 | DEFAULT: 'hsl(var(--secondary))', 31 | foreground: 'hsl(var(--secondary-foreground))', 32 | }, 33 | destructive: { 34 | DEFAULT: 'hsl(var(--destructive))', 35 | foreground: 'hsl(var(--destructive-foreground))', 36 | }, 37 | muted: { 38 | DEFAULT: 'hsl(var(--muted))', 39 | foreground: 'hsl(var(--muted-foreground))', 40 | }, 41 | accent: { 42 | DEFAULT: 'hsl(var(--accent))', 43 | foreground: 'hsl(var(--accent-foreground))', 44 | }, 45 | popover: { 46 | DEFAULT: 'hsl(var(--popover))', 47 | foreground: 'hsl(var(--popover-foreground))', 48 | }, 49 | card: { 50 | DEFAULT: 'hsl(var(--card))', 51 | foreground: 'hsl(var(--card-foreground))', 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)', 58 | }, 59 | keyframes: { 60 | 'accordion-down': { 61 | from: { height: 0 }, 62 | to: { height: 'var(--radix-accordion-content-height)' }, 63 | }, 64 | 'accordion-up': { 65 | from: { height: 'var(--radix-accordion-content-height)' }, 66 | to: { height: 0 }, 67 | }, 68 | }, 69 | animation: { 70 | 'accordion-down': 'accordion-down 0.2s ease-out', 71 | 'accordion-up': 'accordion-up 0.2s ease-out', 72 | }, 73 | }, 74 | }, 75 | plugins: [require('tailwindcss-animate')], 76 | }; 77 | -------------------------------------------------------------------------------- /backend/app/app/controllers/auth.py: -------------------------------------------------------------------------------- 1 | from core.controller import BaseController 2 | from core.db import Propagation, Transactional 3 | from core.exceptions import BadRequestException, UnauthorizedException 4 | from core.security import JWTHandler, PasswordHandler 5 | from pydantic import EmailStr 6 | 7 | from app.models import User 8 | from app.repositories import UserRepository 9 | from app.schemas.extras.token import Token 10 | 11 | 12 | class AuthController(BaseController[User]): 13 | def __init__(self, user_repository: UserRepository): 14 | super().__init__(model=User, repository=user_repository) 15 | self.user_repository = user_repository 16 | 17 | @Transactional(propagation=Propagation.REQUIRED) 18 | async def register(self, email: EmailStr, password: str, username: str) -> User: 19 | # Check if user exists with email 20 | user = await self.user_repository.get_by_email(email) 21 | 22 | if user: 23 | raise BadRequestException("User already exists with this email") 24 | 25 | # Check if user exists with username 26 | user = await self.user_repository.get_by_username(username) 27 | 28 | if user: 29 | raise BadRequestException("User already exists with this username") 30 | 31 | password = PasswordHandler.hash(password) 32 | 33 | return await self.user_repository.create( 34 | { 35 | "email": email, 36 | "password": password, 37 | "username": username, 38 | } 39 | ) 40 | 41 | async def login(self, email: EmailStr, password: str) -> Token: 42 | user = await self.user_repository.get_by_email(email) 43 | 44 | if not user: 45 | raise BadRequestException("Invalid credentials") 46 | 47 | if not PasswordHandler.verify(user.password, password): 48 | raise BadRequestException("Invalid credentials") 49 | 50 | return Token( 51 | access_token=JWTHandler.encode(payload={"user_id": user.id}), 52 | refresh_token=JWTHandler.encode(payload={"sub": "refresh_token"}), 53 | ) 54 | 55 | async def refresh_token(self, access_token: str, refresh_token: str) -> Token: 56 | token = JWTHandler.decode(access_token) 57 | decoded_token = JWTHandler.decode(refresh_token) 58 | if decoded_token.get("sub") != "refresh_token": 59 | raise UnauthorizedException("Invalid refresh token") 60 | 61 | return Token( 62 | access_token=JWTHandler.encode(payload={"user_id": token.get("user_id")}), 63 | refresh_token=JWTHandler.encode(payload={"sub": "refresh_token"}), 64 | ) 65 | -------------------------------------------------------------------------------- /backend/app/core/server.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from api import router 4 | from fastapi import Depends, FastAPI, Request 5 | from fastapi.middleware import Middleware 6 | from fastapi.middleware.cors import CORSMiddleware 7 | from fastapi.responses import JSONResponse 8 | 9 | from core.cache import Cache, CustomKeyMaker, RedisBackend 10 | from core.config import config 11 | from core.exceptions import CustomException 12 | from core.fastapi.dependencies import Logging 13 | from core.fastapi.middlewares import ( 14 | AuthBackend, 15 | AuthenticationMiddleware, 16 | ResponseLoggerMiddleware, 17 | SQLAlchemyMiddleware, 18 | ) 19 | 20 | 21 | def on_auth_error(request: Request, exc: Exception): 22 | status_code, error_code, message = 401, None, str(exc) 23 | if isinstance(exc, CustomException): 24 | status_code = int(exc.code) 25 | error_code = exc.error_code 26 | message = exc.message 27 | 28 | return JSONResponse( 29 | status_code=status_code, 30 | content={"error_code": error_code, "message": message}, 31 | ) 32 | 33 | 34 | def init_routers(app_: FastAPI) -> None: 35 | app_.include_router(router) 36 | 37 | 38 | def init_listeners(app_: FastAPI) -> None: 39 | @app_.exception_handler(CustomException) 40 | async def custom_exception_handler(request: Request, exc: CustomException): 41 | return JSONResponse( 42 | status_code=exc.code, 43 | content={"error_code": exc.error_code, "message": exc.message}, 44 | ) 45 | 46 | 47 | def make_middleware() -> List[Middleware]: 48 | middleware = [ 49 | Middleware( 50 | CORSMiddleware, 51 | allow_origins=["*"], 52 | allow_credentials=True, 53 | allow_methods=["*"], 54 | allow_headers=["*"], 55 | ), 56 | Middleware( 57 | AuthenticationMiddleware, 58 | backend=AuthBackend(), 59 | on_error=on_auth_error, 60 | ), 61 | Middleware(SQLAlchemyMiddleware), 62 | Middleware(ResponseLoggerMiddleware), 63 | ] 64 | return middleware 65 | 66 | 67 | def init_cache() -> None: 68 | Cache.init(backend=RedisBackend(), key_maker=CustomKeyMaker()) 69 | 70 | 71 | def create_app() -> FastAPI: 72 | app_ = FastAPI( 73 | title="SEAM", 74 | description="Session Eval and Accuracy Management", 75 | version=config.APP_VERSION or "0.1.0", 76 | docs_url=None if config.ENVIRONMENT == "production" else "/docs", 77 | redoc_url=None if config.ENVIRONMENT == "production" else "/redoc", 78 | dependencies=[Depends(Logging)], 79 | middleware=make_middleware(), 80 | ) 81 | init_routers(app_=app_) 82 | init_listeners(app_=app_) 83 | init_cache() 84 | return app_ 85 | 86 | 87 | app = create_app() 88 | -------------------------------------------------------------------------------- /backend/app/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import sys 4 | from logging.config import fileConfig 5 | 6 | from alembic import context 7 | from app.models import Base 8 | 9 | # For auto generate schemas 10 | from core.config import config as app_config 11 | from sqlalchemy import pool 12 | from sqlalchemy.ext.asyncio import create_async_engine 13 | 14 | parent_dir = os.path.abspath(os.path.join(os.getcwd(), "..")) 15 | sys.path.append(parent_dir) 16 | 17 | # this is the Alembic Config object, which provides 18 | # access to the values within the .ini file in use. 19 | config = context.config 20 | 21 | # Interpret the config file for Python logging. 22 | # This line sets up loggers basically. 23 | if config.config_file_name is not None: 24 | fileConfig(config.config_file_name) 25 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 26 | 27 | 28 | # add your model's MetaData object here 29 | # for 'autogenerate' support 30 | # from myapp import mymodel 31 | # target_metadata = mymodel.Base.metadata 32 | 33 | 34 | target_metadata = Base.metadata 35 | 36 | # other values from the config, defined by the needs of env.py, 37 | # can be acquired: 38 | # my_important_option = config.get_main_option("my_important_option") 39 | # ... etc. 40 | 41 | 42 | def run_migrations_offline(): 43 | """Run migrations in 'offline' mode. 44 | This configures the context with just a URL 45 | and not an Engine, though an Engine is acceptable 46 | here as well. By skipping the Engine creation 47 | we don't even need a DBAPI to be available. 48 | Calls to context.execute() here emit the given string to the 49 | script output. 50 | """ 51 | config.get_main_option("sqlalchemy.url") 52 | context.configure( 53 | url=app_config.POSTGRES_URL, 54 | target_metadata=target_metadata, 55 | literal_binds=True, 56 | dialect_opts={"paramstyle": "named"}, 57 | ) 58 | 59 | with context.begin_transaction(): 60 | context.run_migrations() 61 | 62 | 63 | def do_run_migrations(connection): 64 | context.configure(connection=connection, target_metadata=target_metadata) 65 | 66 | with context.begin_transaction(): 67 | context.run_migrations() 68 | 69 | 70 | async def run_migrations_online(): 71 | """Run migrations in 'online' mode. 72 | In this scenario we need to create an Engine 73 | and associate a connection with the context. 74 | """ 75 | connectable = create_async_engine(app_config.POSTGRES_URL, poolclass=pool.NullPool) 76 | 77 | async with connectable.connect() as connection: 78 | await connection.run_sync(do_run_migrations) 79 | 80 | await connectable.dispose() 81 | 82 | 83 | if context.is_offline_mode(): 84 | run_migrations_offline() 85 | else: 86 | asyncio.run(run_migrations_online()) 87 | -------------------------------------------------------------------------------- /backend/app/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ -------------------------------------------------------------------------------- /backend/app/core/controller/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, Set, Type, TypeVar 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel 5 | 6 | from core.db import Base, Propagation, Transactional 7 | from core.exceptions import NotFoundException 8 | from core.repository import BaseRepository 9 | 10 | ModelType = TypeVar("ModelType", bound=Base) 11 | 12 | 13 | class BaseController(Generic[ModelType]): 14 | """Base class for data controllers.""" 15 | 16 | def __init__(self, model: Type[ModelType], repository: BaseRepository): 17 | self.model_class = model 18 | self.repository = repository 19 | 20 | async def get_by_id(self, id_: int, join_: set[str] | None = None) -> ModelType: 21 | """ 22 | Returns the model instance matching the id. 23 | 24 | :param id_: The id to match. 25 | :param join_: The joins to make. 26 | :return: The model instance. 27 | """ 28 | 29 | db_obj = await self.repository.get_by( 30 | field="id", value=id_, join_=join_, unique=True 31 | ) 32 | if not db_obj: 33 | raise NotFoundException( 34 | f"{self.model_class.__tablename__.title()} with id: {id} does not exist" 35 | ) 36 | if isinstance(db_obj, list): # Handle the case where `a` is a list of strings 37 | return db_obj[0] # Return the first element of the list 38 | return db_obj 39 | 40 | async def get_by_uuid(self, uuid: UUID, join_: set[str] | None = None) -> ModelType: 41 | """ 42 | Returns the model instance matching the uuid. 43 | 44 | :param uuid: The uuid to match. 45 | :param join_: The joins to make. 46 | :return: The model instance. 47 | """ 48 | 49 | db_obj = await self.repository.get_by( 50 | field="uuid", value=uuid, join_=join_, unique=True 51 | ) 52 | if not db_obj: 53 | raise NotFoundException( 54 | f"{self.model_class.__tablename__.title()} with id: {uuid} does not exist" 55 | ) 56 | if isinstance(db_obj, list): # Handle the case where `a` is a list of strings 57 | return db_obj[0] # Return the first element of the list 58 | return db_obj 59 | 60 | async def get_all( 61 | self, skip: int = 0, limit: int = 100, join_: set[str] | None = None 62 | ) -> list[ModelType]: 63 | """ 64 | Returns a list of records based on pagination params. 65 | 66 | :param skip: The number of records to skip. 67 | :param limit: The number of records to return. 68 | :param join_: The joins to make. 69 | :return: A list of records. 70 | """ 71 | 72 | response = await self.repository.get_all(skip, limit, join_) 73 | return response 74 | 75 | @Transactional(propagation=Propagation.REQUIRED) 76 | async def create(self, attributes: dict[str, Any]) -> ModelType: 77 | """ 78 | Creates a new Object in the DB. 79 | 80 | :param attributes: The attributes to create the object with. 81 | :return: The created object. 82 | """ 83 | create = await self.repository.create(attributes) 84 | return create 85 | 86 | @Transactional(propagation=Propagation.REQUIRED) 87 | async def delete(self, model: ModelType) -> bool: 88 | """ 89 | Deletes the Object from the DB. 90 | 91 | :param model: The model to delete. 92 | :return: True if the object was deleted, False otherwise. 93 | """ 94 | await self.repository.delete(model) 95 | return True 96 | 97 | @staticmethod 98 | async def extract_attributes_from_schema( 99 | schema: BaseModel, excludes: Set[str] | None = None 100 | ) -> dict[str, Any]: 101 | """ 102 | Extracts the attributes from the schema. 103 | 104 | :param schema: The schema to extract the attributes from. 105 | :param excludes: The attributes to exclude. 106 | :return: The attributes. 107 | """ 108 | return schema.model_dump(exclude=excludes, exclude_unset=True) 109 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | browser: true, 6 | es6: true, 7 | }, 8 | extends: [ 9 | 'next', 10 | 'next/core-web-vitals', 11 | 'eslint:recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'plugin:react/recommended', 14 | 'plugin:react/jsx-runtime', 15 | 'plugin:css/recommended', 16 | 'plugin:prettier/recommended', 17 | 'plugin:tailwindcss/recommended', 18 | 'stylelint', 19 | ], 20 | plugins: [ 21 | '@typescript-eslint', 22 | 'react', 23 | 'import', 24 | // 'simple-import-sort', 25 | 'css', 26 | 'prettier', 27 | ], 28 | parser: '@babel/eslint-parser', 29 | parserOptions: { 30 | requireConfigFile: false, 31 | sourceType: 'module', 32 | ecmaFeatures: { 33 | jsx: true, 34 | }, 35 | babelOptions: { 36 | caller: { 37 | // Eslint supports top level await when a parser for it is included. We enable the parser by default for Babel. 38 | supportsTopLevelAwait: true, 39 | }, 40 | }, 41 | }, 42 | settings: { 43 | 'import/parsers': { 44 | '@typescript-eslint/parser': ['.ts', '.tsx'], 45 | }, 46 | react: { 47 | version: 'detect', 48 | }, 49 | 'import/resolver': { 50 | alias: { 51 | map: [['@', `${__dirname}/src`]], 52 | extensions: ['.js', '.jsx', '.ts', '.d.ts', '.tsx', '.css'], 53 | }, 54 | node: { 55 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 56 | }, 57 | typescript: { 58 | alwaysTryTypes: true, 59 | }, 60 | }, 61 | }, 62 | overrides: [ 63 | { 64 | files: ['test/**/*.js', 'test/**/*.ts', '**/*.test.ts'], 65 | extends: ['plugin:jest/recommended'], 66 | rules: { 67 | 'jest/expect-expect': 'off', 68 | 'jest/no-disabled-tests': 'off', 69 | 'jest/no-conditional-expect': 'off', 70 | 'jest/valid-title': 'off', 71 | 'jest/no-interpolation-in-snapshots': 'off', 72 | 'jest/no-export': 'off', 73 | }, 74 | }, 75 | { 76 | files: [ 77 | '**/__tests__/*.{j,t}s?(x)', 78 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 79 | ], 80 | env: { jest: true }, 81 | }, 82 | { 83 | files: ['**/*.ts', '**/*.tsx'], 84 | parser: '@typescript-eslint/parser', 85 | parserOptions: { 86 | tsconfigRootDir: __dirname, 87 | project: ['./tsconfig.json'], 88 | ecmaVersion: 'latest', // Allows for the parsing of modern ECMAScript features 89 | sourceType: 'module', // Allows for the use of imports 90 | ecmaFeatures: { 91 | jsx: true, // Allows for the parsing of JSX 92 | }, 93 | warnOnUnsupportedTypeScriptVersion: false, 94 | }, 95 | }, 96 | ], 97 | ignorePatterns: ['.eslintrc.js', '**/*.json'], 98 | rules: { 99 | '@next/next/no-html-link-for-pages': 'off', 100 | 101 | 'n/no-missing-import': 'off', // to disable path alias errors 102 | 'node/no-missing-import': 'off', // to disable path alias errors 103 | 'node/no-unpublished-import': 'off', // to disable no unpublished errors 104 | 105 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 106 | 107 | 'prettier/prettier': 'error', 108 | 109 | 'import/no-unresolved': 'error', 110 | 'import/extensions': [ 111 | 'error', 112 | 'always', 113 | { ts: 'never', tsx: 'never', js: 'ignorePackages' }, 114 | ], 115 | 'import/no-duplicates': 'error', 116 | 'import/no-extraneous-dependencies': 'warn', 117 | 'import/no-mutable-exports': 'error', 118 | 'import/no-self-import': 'error', 119 | 'import/no-useless-path-segments': 'error', 120 | 121 | 'sort-imports': 'off', 122 | 123 | // 'simple-import-sort/imports': [ 124 | // 'error', 125 | // { 126 | // groups: [ 127 | // ['./*.(css|scss|less)'], 128 | // // ['@/(styles|theme)'], 129 | // ['@/(styles|theme)(/.*|$)'], 130 | // ['^react(-|$)'], 131 | // ['(next|@next)(-|$|/)'], 132 | // ['^@?\\w'], 133 | // [ 134 | // '^@/(utils|services|hooks|hoc|types|contexts|dictionary|components|db|utilities|styles)(/.*|$)', 135 | // ], 136 | // // ['^@/services/'], 137 | // // ['^@/db/'], 138 | // // ['^@/utilities/'], 139 | // ['^@/'], 140 | // ['^\\.'], 141 | // ], 142 | // }, 143 | // ], 144 | 145 | // Note: you must disable the base rule as it can report incorrect errors 146 | 'no-unused-expressions': 'off', 147 | '@typescript-eslint/no-unused-expressions': 'error', 148 | 149 | // Note: you must disable the base rule as it can report incorrect errors 150 | 'no-extra-semi': 'off', 151 | '@typescript-eslint/no-extra-semi': 'error', 152 | 153 | 'no-unused-vars': 'off', 154 | '@typescript-eslint/no-unused-vars': 'error', 155 | 156 | 'no-use-before-define': 'off', 157 | '@typescript-eslint/no-use-before-define': 'error', 158 | 159 | 'no-dupe-class-members': 'off', 160 | '@typescript-eslint/no-dupe-class-members': 'error', 161 | }, 162 | }; 163 | -------------------------------------------------------------------------------- /backend/app/core/security/access_control.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from dataclasses import dataclass 3 | from typing import Any, List, Union 4 | 5 | from fastapi import Depends, HTTPException 6 | from starlette.status import HTTP_403_FORBIDDEN 7 | 8 | Allow: str = "allow" 9 | Deny: str = "deny" 10 | 11 | 12 | @dataclass(frozen=True) 13 | class Principal: 14 | key: str 15 | value: str 16 | 17 | def __repr__(self) -> str: 18 | return f"{self.key}:{self.value}" 19 | 20 | def __str__(self) -> str: 21 | return self.__repr__() 22 | 23 | 24 | @dataclass(frozen=True) 25 | class SystemPrincipal(Principal): 26 | def __init__(self, value: str) -> None: 27 | super().__init__(key="system", value=value) 28 | 29 | 30 | @dataclass(frozen=True) 31 | class UserPrincipal(Principal): 32 | def __init__(self, value: str) -> None: 33 | super().__init__(key="user", value=value) 34 | 35 | 36 | @dataclass(frozen=True) 37 | class RolePrincipal(Principal): 38 | def __init__(self, value: str) -> None: 39 | super().__init__(key="role", value=value) 40 | 41 | 42 | @dataclass(frozen=True) 43 | class ItemPrincipal(Principal): 44 | def __init__(self, value: str) -> None: 45 | super().__init__(key="item", value=value) 46 | 47 | 48 | @dataclass(frozen=True) 49 | class ActionPrincipal(Principal): 50 | def __init__(self, value: str) -> None: 51 | super().__init__(key="action", value=value) 52 | 53 | 54 | Everyone = SystemPrincipal(value="everyone") 55 | Authenticated = SystemPrincipal(value="authenticated") 56 | 57 | 58 | class AllowAll: 59 | def __contains__(self, item: Any) -> bool: 60 | return True 61 | 62 | def __repr__(self) -> str: 63 | return "*" 64 | 65 | def __str__(self) -> str: 66 | return self.__repr__() 67 | 68 | 69 | default_exception = HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Forbidden") 70 | 71 | 72 | class AccessControl: 73 | def __init__( 74 | self, 75 | user_principals_getter: Any, 76 | permission_exception: Any = default_exception, 77 | ) -> None: 78 | self.user_principals_getter = user_principals_getter 79 | self.permission_exception = permission_exception 80 | 81 | def __call__(self, permissions: str): 82 | def _permission_dependency(principals=Depends(self.user_principals_getter)): 83 | assert_access = functools.partial( 84 | self.assert_access, principals, permissions 85 | ) 86 | return assert_access 87 | 88 | return _permission_dependency 89 | 90 | def assert_access(self, principals: list, permissions: str, resource: Any): 91 | if not self.has_permission( 92 | principals=principals, 93 | required_permissions=permissions, 94 | resource=resource, 95 | ): 96 | raise self.permission_exception 97 | 98 | def has_permission( 99 | self, 100 | principals: List[Principal], 101 | required_permissions: Union[str, List[str]], 102 | resource: Any, 103 | ): 104 | if not isinstance(resource, list): 105 | resource = [resource] 106 | 107 | permits = [] 108 | for resource_obj in resource: 109 | granted = False 110 | acl = self._acl(resource_obj) 111 | if not isinstance(required_permissions, list): 112 | required_permissions = [required_permissions] 113 | 114 | for action, principal, permission in acl: 115 | is_required_permissions_in_permission = any( 116 | required_permission in permission 117 | for required_permission in required_permissions 118 | ) 119 | 120 | if (action == Allow and is_required_permissions_in_permission) and ( 121 | principal in principals or principal == Everyone 122 | ): 123 | granted = True 124 | break 125 | 126 | permits.append(granted) 127 | 128 | return all(permits) 129 | 130 | def show_permissions(self, principals: List[Principal], resource: Any): 131 | if not isinstance(resource, list): 132 | resource = [resource] 133 | 134 | permissions = [] 135 | for resource_obj in resource: 136 | local_permissions = [] 137 | acl = self._acl(resource_obj) 138 | 139 | for action, principal, permission in acl: 140 | if action == Allow and principal in principals or principal == Everyone: 141 | local_permissions.append(permission) 142 | 143 | permissions.append(local_permissions) 144 | 145 | # get intersection of permissions 146 | permissions = [self._flatten(permission) for permission in permissions] 147 | permissions = functools.reduce(set.intersection, map(set, permissions)) # type: ignore # noqa: E501 148 | 149 | return list(permissions) 150 | 151 | def _acl(self, resource): 152 | acl = getattr(resource, "__acl__", []) 153 | if callable(acl): 154 | return acl() 155 | return acl 156 | 157 | def _flatten(self, any_list: List[Any]) -> List[Any]: 158 | flat_list = [] 159 | for element in any_list: 160 | if isinstance(element, list): 161 | flat_list += self._flatten(element) 162 | else: 163 | flat_list.append(element) 164 | return flat_list 165 | -------------------------------------------------------------------------------- /backend/app/core/repository/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, Optional, Sequence, Type, TypeVar 2 | 3 | from sqlalchemy import Select, func 4 | from sqlalchemy.engine import Result 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from sqlalchemy.sql.expression import select 7 | 8 | from core.db import Base 9 | 10 | ModelType = TypeVar("ModelType", bound=Base) 11 | 12 | 13 | class BaseRepository(Generic[ModelType]): 14 | """Base class for data repositories.""" 15 | 16 | def __init__(self, model: Type[ModelType], db_session: AsyncSession): 17 | self.session = db_session 18 | self.model_class: Type[ModelType] = model 19 | 20 | async def create(self, attributes: Optional[dict[str, Any]] = None) -> ModelType: 21 | """ 22 | Creates the model instance. 23 | 24 | :param attributes: The attributes to create the model with. 25 | :return: The created model instance. 26 | """ 27 | if attributes is None: 28 | attributes = {} 29 | model = self.model_class(**attributes) 30 | self.session.add(model) 31 | return model 32 | 33 | async def get_all( 34 | self, skip: int = 0, limit: int = 100, join_: set[str] | None = None 35 | ) -> list[ModelType]: 36 | """ 37 | Returns a list of model instances. 38 | 39 | :param skip: The number of records to skip. 40 | :param limit: The number of record to return. 41 | :param join_: The joins to make. 42 | :return: A list of model instances. 43 | """ 44 | query = await self._query(join_) 45 | query = query.offset(skip).limit(limit) 46 | 47 | return await self._all(query) 48 | 49 | async def get_by( 50 | self, 51 | field: str, 52 | value: Any, 53 | join_: set[str] | None = None, 54 | unique: bool = False, 55 | ) -> ModelType | list[ModelType]: 56 | """ 57 | Returns the model instance matching the field and value. 58 | 59 | :param field: The field to match. 60 | :param value: The value to match. 61 | :param join_: The joins to make. 62 | :return: The model instance. 63 | """ 64 | query = await self._query(join_) 65 | query = await self._get_by(query, field, value) 66 | 67 | if unique: 68 | return await self._one(query) 69 | 70 | return await self._all(query) 71 | 72 | async def delete(self, model: ModelType) -> None: 73 | """ 74 | Deletes the model. 75 | 76 | :param model: The model to delete. 77 | :return: None 78 | """ 79 | await self.session.delete(model) 80 | 81 | async def _query( 82 | self, 83 | join_: set[str] | None = None, 84 | order_: dict | None = None, 85 | ) -> Select: 86 | """ 87 | Returns a callable that can be used to query the model. 88 | 89 | :param join_: The joins to make. 90 | :param order_: The order of the results. (e.g desc, asc) 91 | :return: A callable that can be used to query the model. 92 | """ 93 | query = select(self.model_class) 94 | query = await self._maybe_join(query, join_) 95 | query = await self._maybe_ordered(query, order_) 96 | 97 | return query 98 | 99 | async def _all(self, query: Select) -> list[ModelType]: 100 | """ 101 | Returns all results from the query. 102 | 103 | :param query: The query to execute. 104 | :return: A list of model instances. 105 | """ 106 | result: Result = await self.session.execute(query) 107 | scalars: Sequence[ModelType] = result.scalars().all() 108 | return list(scalars) 109 | 110 | async def _first(self, query: Select) -> ModelType | None: 111 | """ 112 | Returns the first result from the query. 113 | 114 | :param query: The query to execute. 115 | :return: The first model instance. 116 | """ 117 | result: Result = await self.session.execute(query) 118 | return result.scalars().first() 119 | 120 | async def _one_or_none(self, query: Select) -> ModelType | None: 121 | """Returns the first result from the query or None.""" 122 | result: Result = await self.session.execute(query) 123 | return result.scalars().one_or_none() 124 | 125 | async def _one(self, query: Select) -> ModelType: 126 | """ 127 | Returns the first result from the query or raises NoResultFound. 128 | 129 | :param query: The query to execute. 130 | :return: The first model instance. 131 | """ 132 | result: Result = await self.session.execute(query) 133 | return result.scalars().one() 134 | 135 | async def _count(self, query: Select) -> int: 136 | """ 137 | Returns the count of the records. 138 | 139 | :param query: The query to execute. 140 | """ 141 | subquery = query.subquery() 142 | result: Result = await self.session.execute( 143 | select(func.count()).select_from(subquery) 144 | ) 145 | return result.scalar_one() 146 | 147 | async def _sort_by( 148 | self, 149 | query: Select, 150 | sort_by: str, 151 | order: str | None = "asc", 152 | model: Type[ModelType] | None = None, 153 | case_insensitive: bool = False, 154 | ) -> Select: 155 | """ 156 | Returns the query sorted by the given column. 157 | 158 | :param query: The query to sort. 159 | :param sort_by: The column to sort by. 160 | :param order: The order to sort by. 161 | :param model: The model to sort. 162 | :param case_insensitive: Whether to sort case insensitively. 163 | :return: The sorted query. 164 | """ 165 | model = model or self.model_class 166 | 167 | if not hasattr(model, sort_by): 168 | raise AttributeError(f"{model} has no attribute {sort_by}") 169 | 170 | if case_insensitive: 171 | order_column = func.lower(getattr(model, sort_by)) 172 | else: 173 | order_column = getattr(model, sort_by) 174 | 175 | if order == "desc": 176 | return query.order_by(order_column.desc()) 177 | 178 | return query.order_by(order_column.asc()) 179 | 180 | async def _get_by(self, query: Select, field: str, value: Any) -> Select: 181 | """ 182 | Returns the query filtered by the given column. 183 | 184 | :param query: The query to filter. 185 | :param field: The column to filter by. 186 | :param value: The value to filter by. 187 | :return: The filtered query. 188 | """ 189 | return query.where(getattr(self.model_class, field) == value) 190 | 191 | async def _add_join_to_query(self, query: Select, join_: str) -> Select: 192 | """ 193 | Returns the query with the given join. 194 | 195 | :param query: The query to join. 196 | :param join_: The join to make. 197 | :return: The query with the given join. 198 | """ 199 | return getattr(self, "_join_" + join_)(query) 200 | 201 | async def _maybe_join(self, query: Select, join_: set[str] | None = None) -> Select: 202 | """ 203 | Returns the query with the given joins. 204 | 205 | :param query: The query to join. 206 | :param join_: The joins to make. 207 | :return: The query with the given joins. 208 | """ 209 | if not join_: 210 | return query 211 | 212 | if not isinstance(join_, set): 213 | raise TypeError("join_ must be a set") 214 | 215 | for join_operation in join_: 216 | query = await self._add_join_to_query(query, join_operation) 217 | 218 | return query 219 | 220 | async def _maybe_ordered(self, query: Select, order_: dict | None = None) -> Select: 221 | """ 222 | Returns the query ordered by the given column. 223 | 224 | :param query: The query to order. 225 | :param order_: The order to make. 226 | :return: The query ordered by the given column. 227 | """ 228 | if order_: 229 | if order_["asc"]: 230 | for order in order_["asc"]: 231 | query = query.order_by(getattr(self.model_class, order).asc()) 232 | else: 233 | for order in order_["desc"]: 234 | query = query.order_by(getattr(self.model_class, order).desc()) 235 | 236 | return query 237 | -------------------------------------------------------------------------------- /frontend/src/components/ui/icons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type IconProps = React.HTMLAttributes; 4 | 5 | export const Icons = { 6 | twitter: (props: IconProps) => ( 7 | 8 | 9 | 10 | ), 11 | gitHub: (props: IconProps) => ( 12 | 13 | 17 | 18 | ), 19 | radix: (props: IconProps) => ( 20 | 21 | 25 | 26 | 30 | 31 | ), 32 | aria: (props: IconProps) => ( 33 | 34 | 35 | 36 | ), 37 | npm: (props: IconProps) => ( 38 | 39 | 43 | 44 | ), 45 | yarn: (props: IconProps) => ( 46 | 47 | 51 | 52 | ), 53 | pnpm: (props: IconProps) => ( 54 | 55 | 59 | 60 | ), 61 | react: (props: IconProps) => ( 62 | 63 | 67 | 68 | ), 69 | tailwind: (props: IconProps) => ( 70 | 71 | 75 | 76 | ), 77 | google: (props: IconProps) => ( 78 | 79 | 83 | 84 | ), 85 | apple: (props: IconProps) => ( 86 | 87 | 91 | 92 | ), 93 | paypal: (props: IconProps) => ( 94 | 95 | 99 | 100 | ), 101 | spinner: (props: IconProps) => ( 102 | 114 | 115 | 116 | ), 117 | }; 118 | --------------------------------------------------------------------------------