From a95a4c091a5a4778679322489b0ea99ba8ccd06f Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sat, 1 Nov 2025 23:33:52 -0600 Subject: [PATCH] phase 3.1 --- .../alembic/versions/001_initial_schema.py | 180 ++++++++++++++++++ backend/app/api/__init__.py | 1 - backend/app/api/auth.py | 110 +++++++++++ backend/app/auth/__init__.py | 2 + backend/app/auth/jwt.py | 55 ++++++ backend/app/auth/repository.py | 87 +++++++++ backend/app/auth/schemas.py | 45 +++++ backend/app/auth/security.py | 65 +++++++ backend/app/core/deps.py | 74 ++++++- backend/app/database/models/__init__.py | 19 +- backend/app/database/models/board.py | 38 ++++ backend/app/database/models/board_image.py | 48 +++++ backend/app/database/models/comment.py | 31 +++ backend/app/database/models/group.py | 30 +++ backend/app/database/models/image.py | 34 ++++ backend/app/database/models/share_link.py | 32 ++++ backend/app/database/models/user.py | 29 +++ backend/app/main.py | 12 +- backend/pyproject.toml | 1 + frontend/.eslintignore | 11 ++ frontend/.prettierignore | 11 ++ frontend/src/hooks.server.ts | 35 ++++ frontend/src/routes/login/+page.svelte | 114 +++++++++++ frontend/src/routes/register/+page.svelte | 143 ++++++++++++++ specs/001-reference-board-viewer/tasks.md | 34 ++-- 25 files changed, 1214 insertions(+), 27 deletions(-) create mode 100644 backend/alembic/versions/001_initial_schema.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/jwt.py create mode 100644 backend/app/auth/repository.py create mode 100644 backend/app/auth/schemas.py create mode 100644 backend/app/auth/security.py create mode 100644 backend/app/database/models/board.py create mode 100644 backend/app/database/models/board_image.py create mode 100644 backend/app/database/models/comment.py create mode 100644 backend/app/database/models/group.py create mode 100644 backend/app/database/models/image.py create mode 100644 backend/app/database/models/share_link.py create mode 100644 backend/app/database/models/user.py create mode 100644 frontend/.eslintignore create mode 100644 frontend/.prettierignore create mode 100644 frontend/src/hooks.server.ts create mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/src/routes/register/+page.svelte diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..a8d146e --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,180 @@ +"""001_initial_schema + +Revision ID: 001_initial_schema +Revises: +Create Date: 2025-11-02 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '001_initial_schema' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Enable UUID extension + op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + + # Create users table + op.create_table( + 'users', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')), + sa.Column('email', sa.String(255), nullable=False, unique=True), + sa.Column('password_hash', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')), + sa.CheckConstraint('email = LOWER(email)', name='check_email_lowercase') + ) + op.create_index('idx_users_created_at', 'users', ['created_at']) + op.create_index('idx_users_email', 'users', ['email'], unique=True) + + # Create boards table + op.create_table( + 'boards', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')), + sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('title', sa.String(255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('viewport_state', postgresql.JSONB(), nullable=False, server_default=sa.text("'{\"x\": 0, \"y\": 0, \"zoom\": 1.0, \"rotation\": 0}'::jsonb")), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default=sa.text('FALSE')), + sa.CheckConstraint('LENGTH(title) > 0', name='check_title_not_empty') + ) + op.create_index('idx_boards_user_created', 'boards', ['user_id', 'created_at']) + op.create_index('idx_boards_updated', 'boards', ['updated_at']) + op.execute('CREATE INDEX idx_boards_viewport ON boards USING GIN (viewport_state)') + + # Create images table + op.create_table( + 'images', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')), + sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('filename', sa.String(255), nullable=False), + sa.Column('storage_path', sa.String(512), nullable=False), + sa.Column('file_size', sa.BigInteger(), nullable=False), + sa.Column('mime_type', sa.String(100), nullable=False), + sa.Column('width', sa.Integer(), nullable=False), + sa.Column('height', sa.Integer(), nullable=False), + sa.Column('image_metadata', postgresql.JSONB(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('reference_count', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.CheckConstraint('file_size > 0 AND file_size <= 52428800', name='check_file_size'), + sa.CheckConstraint('width > 0 AND width <= 10000', name='check_width'), + sa.CheckConstraint('height > 0 AND height <= 10000', name='check_height') + ) + op.create_index('idx_images_user_created', 'images', ['user_id', 'created_at']) + op.create_index('idx_images_filename', 'images', ['filename']) + op.execute('CREATE INDEX idx_images_metadata ON images USING GIN (image_metadata)') + + # Create groups table + op.create_table( + 'groups', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')), + sa.Column('board_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('boards.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('color', sa.String(7), nullable=False), + sa.Column('annotation', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.CheckConstraint('LENGTH(name) > 0', name='check_name_not_empty'), + sa.CheckConstraint("color ~ '^#[0-9A-Fa-f]{6}$'", name='check_color_hex') + ) + op.create_index('idx_groups_board_created', 'groups', ['board_id', 'created_at']) + + # Create board_images table + op.create_table( + 'board_images', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')), + sa.Column('board_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('boards.id', ondelete='CASCADE'), nullable=False), + sa.Column('image_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('images.id', ondelete='CASCADE'), nullable=False), + sa.Column('position', postgresql.JSONB(), nullable=False), + sa.Column('transformations', postgresql.JSONB(), nullable=False, server_default=sa.text("'{\"scale\": 1.0, \"rotation\": 0, \"opacity\": 1.0, \"flipped_h\": false, \"flipped_v\": false, \"greyscale\": false}'::jsonb")), + sa.Column('z_order', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('group_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('groups.id', ondelete='SET NULL'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')) + ) + op.create_unique_constraint('uq_board_image', 'board_images', ['board_id', 'image_id']) + op.create_index('idx_board_images_board_z', 'board_images', ['board_id', 'z_order']) + op.create_index('idx_board_images_group', 'board_images', ['group_id']) + op.execute('CREATE INDEX idx_board_images_position ON board_images USING GIN (position)') + op.execute('CREATE INDEX idx_board_images_transformations ON board_images USING GIN (transformations)') + + # Create share_links table + op.create_table( + 'share_links', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')), + sa.Column('board_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('boards.id', ondelete='CASCADE'), nullable=False), + sa.Column('token', sa.String(64), nullable=False, unique=True), + sa.Column('permission_level', sa.String(20), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('last_accessed_at', sa.DateTime(), nullable=True), + sa.Column('access_count', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('is_revoked', sa.Boolean(), nullable=False, server_default=sa.text('FALSE')), + sa.CheckConstraint("permission_level IN ('view-only', 'view-comment')", name='check_permission_level') + ) + op.create_unique_constraint('uq_share_links_token', 'share_links', ['token']) + op.create_index('idx_share_links_board_revoked', 'share_links', ['board_id', 'is_revoked']) + op.create_index('idx_share_links_expires_revoked', 'share_links', ['expires_at', 'is_revoked']) + + # Create comments table + op.create_table( + 'comments', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')), + sa.Column('board_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('boards.id', ondelete='CASCADE'), nullable=False), + sa.Column('share_link_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('share_links.id', ondelete='SET NULL'), nullable=True), + sa.Column('author_name', sa.String(100), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('position', postgresql.JSONB(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default=sa.text('FALSE')), + sa.CheckConstraint('LENGTH(content) > 0 AND LENGTH(content) <= 5000', name='check_content_length') + ) + op.create_index('idx_comments_board_created', 'comments', ['board_id', 'created_at']) + op.create_index('idx_comments_share_link', 'comments', ['share_link_id']) + + # Create triggers for updated_at + op.execute(""" + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ language 'plpgsql'; + """) + + op.execute('CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()') + op.execute('CREATE TRIGGER update_boards_updated_at BEFORE UPDATE ON boards FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()') + op.execute('CREATE TRIGGER update_groups_updated_at BEFORE UPDATE ON groups FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()') + op.execute('CREATE TRIGGER update_board_images_updated_at BEFORE UPDATE ON board_images FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()') + + +def downgrade() -> None: + # Drop triggers + op.execute('DROP TRIGGER IF EXISTS update_board_images_updated_at ON board_images') + op.execute('DROP TRIGGER IF EXISTS update_groups_updated_at ON groups') + op.execute('DROP TRIGGER IF EXISTS update_boards_updated_at ON boards') + op.execute('DROP TRIGGER IF EXISTS update_users_updated_at ON users') + op.execute('DROP FUNCTION IF EXISTS update_updated_at_column()') + + # Drop tables in reverse order + op.drop_table('comments') + op.drop_table('share_links') + op.drop_table('board_images') + op.drop_table('groups') + op.drop_table('images') + op.drop_table('boards') + op.drop_table('users') + + # Drop extension + op.execute('DROP EXTENSION IF EXISTS "uuid-ossp"') + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 11cb666..e23d45d 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,2 +1 @@ """API endpoints.""" - diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..530933d --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,110 @@ +"""Authentication endpoints.""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.auth.jwt import create_access_token +from app.auth.repository import UserRepository +from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse +from app.auth.security import validate_password_strength, verify_password +from app.core.deps import get_current_user, get_db +from app.database.models.user import User + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def register_user(user_data: UserCreate, db: Session = Depends(get_db)): + """ + Register a new user. + + Args: + user_data: User registration data + db: Database session + + Returns: + Created user information + + Raises: + HTTPException: If email already exists or password is weak + """ + repo = UserRepository(db) + + # Check if email already exists + if repo.email_exists(user_data.email): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered" + ) + + # Validate password strength + is_valid, error_message = validate_password_strength(user_data.password) + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_message + ) + + # Create user + user = repo.create_user(email=user_data.email, password=user_data.password) + + return UserResponse.from_orm(user) + + +@router.post("/login", response_model=TokenResponse) +def login_user(login_data: UserLogin, db: Session = Depends(get_db)): + """ + Login user and return JWT token. + + Args: + login_data: Login credentials + db: Database session + + Returns: + JWT access token and user information + + Raises: + HTTPException: If credentials are invalid + """ + repo = UserRepository(db) + + # Get user by email + user = repo.get_user_by_email(login_data.email) + + # Verify user exists and password is correct + if not user or not verify_password(login_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Check if user is active + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is deactivated" + ) + + # Create access token + access_token = create_access_token(user_id=user.id, email=user.email) + + return TokenResponse( + access_token=access_token, + token_type="bearer", + user=UserResponse.from_orm(user) + ) + + +@router.get("/me", response_model=UserResponse) +def get_current_user_info(current_user: User = Depends(get_current_user)): + """ + Get current authenticated user information. + + Args: + current_user: Current authenticated user (from JWT) + + Returns: + Current user information + """ + return UserResponse.from_orm(current_user) + diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..3c6f19a --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,2 @@ +"""Authentication module.""" + diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py new file mode 100644 index 0000000..c995aed --- /dev/null +++ b/backend/app/auth/jwt.py @@ -0,0 +1,55 @@ +"""JWT token generation and validation.""" +from datetime import datetime, timedelta +from typing import Optional +from uuid import UUID + +from jose import JWTError, jwt + +from app.core.config import settings + + +def create_access_token(user_id: UUID, email: str, expires_delta: Optional[timedelta] = None) -> str: + """ + Create a new JWT access token. + + Args: + user_id: User's UUID + email: User's email address + expires_delta: Optional custom expiration time + + Returns: + Encoded JWT token string + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = { + "sub": str(user_id), + "email": email, + "exp": expire, + "iat": datetime.utcnow(), + "type": "access" + } + + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> Optional[dict]: + """ + Decode and validate a JWT access token. + + Args: + token: JWT token string to decode + + Returns: + Decoded token payload if valid, None otherwise + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None + diff --git a/backend/app/auth/repository.py b/backend/app/auth/repository.py new file mode 100644 index 0000000..13d2558 --- /dev/null +++ b/backend/app/auth/repository.py @@ -0,0 +1,87 @@ +"""User repository for database operations.""" +from typing import Optional +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from app.auth.security import hash_password +from app.database.models.user import User + + +class UserRepository: + """Repository for user database operations.""" + + def __init__(self, db: Session): + """ + Initialize repository. + + Args: + db: Database session + """ + self.db = db + + def create_user(self, email: str, password: str) -> User: + """ + Create a new user. + + Args: + email: User email (will be lowercased) + password: Plain text password (will be hashed) + + Returns: + Created user instance + """ + email = email.lower() + password_hash = hash_password(password) + + user = User( + email=email, + password_hash=password_hash + ) + + self.db.add(user) + self.db.commit() + self.db.refresh(user) + + return user + + def get_user_by_email(self, email: str) -> Optional[User]: + """ + Get user by email address. + + Args: + email: User email to search for + + Returns: + User if found, None otherwise + """ + email = email.lower() + return self.db.query(User).filter(User.email == email).first() + + def get_user_by_id(self, user_id: UUID) -> Optional[User]: + """ + Get user by ID. + + Args: + user_id: User UUID + + Returns: + User if found, None otherwise + """ + return self.db.query(User).filter(User.id == user_id).first() + + def email_exists(self, email: str) -> bool: + """ + Check if email already exists. + + Args: + email: Email to check + + Returns: + True if email exists, False otherwise + """ + email = email.lower() + return self.db.query(User).filter(User.email == email).first() is not None + diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py new file mode 100644 index 0000000..dddb971 --- /dev/null +++ b/backend/app/auth/schemas.py @@ -0,0 +1,45 @@ +"""Authentication schemas for request/response validation.""" +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, EmailStr, Field + + +class UserBase(BaseModel): + """Base user schema.""" + + email: EmailStr + + +class UserCreate(UserBase): + """Schema for user registration.""" + + password: str = Field(..., min_length=8, max_length=100) + + +class UserLogin(BaseModel): + """Schema for user login.""" + + email: EmailStr + password: str + + +class UserResponse(UserBase): + """Schema for user response.""" + + id: UUID + created_at: datetime + is_active: bool + + class Config: + from_attributes = True + + +class TokenResponse(BaseModel): + """Schema for JWT token response.""" + + access_token: str + token_type: str = "bearer" + user: UserResponse + diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py new file mode 100644 index 0000000..22c049b --- /dev/null +++ b/backend/app/auth/security.py @@ -0,0 +1,65 @@ +"""Password hashing utilities using passlib.""" +import re +from passlib.context import CryptContext + +# Create password context for hashing and verification +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + """ + Hash a password using bcrypt. + + Args: + password: Plain text password + + Returns: + Hashed password string + """ + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a plain password against a hashed password. + + Args: + plain_password: Plain text password to verify + hashed_password: Hashed password from database + + Returns: + True if password matches, False otherwise + """ + return pwd_context.verify(plain_password, hashed_password) + + +def validate_password_strength(password: str) -> tuple[bool, str]: + """ + Validate password meets complexity requirements. + + Requirements: + - At least 8 characters + - At least 1 uppercase letter + - At least 1 lowercase letter + - At least 1 number + + Args: + password: Plain text password to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if len(password) < 8: + return False, "Password must be at least 8 characters long" + + if not re.search(r"[A-Z]", password): + return False, "Password must contain at least one uppercase letter" + + if not re.search(r"[a-z]", password): + return False, "Password must contain at least one lowercase letter" + + if not re.search(r"\d", password): + return False, "Password must contain at least one number" + + return True, "" + diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index fada539..5f4deea 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,12 +1,84 @@ """Dependency injection utilities.""" from typing import Annotated, Generator +from uuid import UUID -from fastapi import Depends +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +from app.auth.jwt import decode_access_token +from app.database.models.user import User from app.database.session import get_db # Database session dependency DatabaseSession = Annotated[Session, Depends(get_db)] +# Security scheme for JWT Bearer token +security = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """ + Get current authenticated user from JWT token. + + Args: + credentials: HTTP Authorization Bearer token + db: Database session + + Returns: + Current authenticated user + + Raises: + HTTPException: If token is invalid or user not found + """ + # Decode token + token = credentials.credentials + payload = decode_access_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Extract user ID from token + user_id_str: str = payload.get("sub") + if user_id_str is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + user_id = UUID(user_id_str) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid user ID in token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Get user from database + user = db.query(User).filter(User.id == user_id).first() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is deactivated" + ) + + return user + diff --git a/backend/app/database/models/__init__.py b/backend/app/database/models/__init__.py index 784ddac..9456706 100644 --- a/backend/app/database/models/__init__.py +++ b/backend/app/database/models/__init__.py @@ -1,5 +1,18 @@ """Database models.""" +from app.database.models.user import User +from app.database.models.board import Board +from app.database.models.image import Image +from app.database.models.board_image import BoardImage +from app.database.models.group import Group +from app.database.models.share_link import ShareLink +from app.database.models.comment import Comment -# Import all models here for Alembic autogenerate -# Models will be created in separate phases - +__all__ = [ + "User", + "Board", + "Image", + "BoardImage", + "Group", + "ShareLink", + "Comment", +] diff --git a/backend/app/database/models/board.py b/backend/app/database/models/board.py new file mode 100644 index 0000000..532404c --- /dev/null +++ b/backend/app/database/models/board.py @@ -0,0 +1,38 @@ +"""Board model for reference boards.""" +import uuid +from datetime import datetime +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import relationship + +from app.database.base import Base + + +class Board(Base): + """Board model representing a reference board.""" + + __tablename__ = "boards" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + viewport_state = Column( + JSONB, + nullable=False, + default={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0} + ) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + is_deleted = Column(Boolean, nullable=False, default=False) + + # Relationships + user = relationship("User", back_populates="boards") + board_images = relationship("BoardImage", back_populates="board", cascade="all, delete-orphan") + groups = relationship("Group", back_populates="board", cascade="all, delete-orphan") + share_links = relationship("ShareLink", back_populates="board", cascade="all, delete-orphan") + comments = relationship("Comment", back_populates="board", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/database/models/board_image.py b/backend/app/database/models/board_image.py new file mode 100644 index 0000000..1ee43f1 --- /dev/null +++ b/backend/app/database/models/board_image.py @@ -0,0 +1,48 @@ +"""BoardImage junction model.""" +import uuid +from datetime import datetime +from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import relationship + +from app.database.base import Base + + +class BoardImage(Base): + """Junction table connecting boards and images with position/transformation data.""" + + __tablename__ = "board_images" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) + image_id = Column(UUID(as_uuid=True), ForeignKey("images.id", ondelete="CASCADE"), nullable=False, index=True) + position = Column(JSONB, nullable=False) + transformations = Column( + JSONB, + nullable=False, + default={ + "scale": 1.0, + "rotation": 0, + "opacity": 1.0, + "flipped_h": False, + "flipped_v": False, + "greyscale": False + } + ) + z_order = Column(Integer, nullable=False, default=0, index=True) + group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + UniqueConstraint("board_id", "image_id", name="uq_board_image"), + ) + + # Relationships + board = relationship("Board", back_populates="board_images") + image = relationship("Image", back_populates="board_images") + group = relationship("Group", back_populates="board_images") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/database/models/comment.py b/backend/app/database/models/comment.py new file mode 100644 index 0000000..59fb8c4 --- /dev/null +++ b/backend/app/database/models/comment.py @@ -0,0 +1,31 @@ +"""Comment model for board comments.""" +import uuid +from datetime import datetime +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import relationship + +from app.database.base import Base + + +class Comment(Base): + """Comment model for viewer comments on shared boards.""" + + __tablename__ = "comments" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) + share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True) + author_name = Column(String(100), nullable=False) + content = Column(Text, nullable=False) + position = Column(JSONB, nullable=True) # Optional canvas position + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + is_deleted = Column(Boolean, nullable=False, default=False) + + # Relationships + board = relationship("Board", back_populates="comments") + share_link = relationship("ShareLink", back_populates="comments") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/database/models/group.py b/backend/app/database/models/group.py new file mode 100644 index 0000000..9c79326 --- /dev/null +++ b/backend/app/database/models/group.py @@ -0,0 +1,30 @@ +"""Group model for image grouping.""" +import uuid +from datetime import datetime +from sqlalchemy import Column, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.database.base import Base + + +class Group(Base): + """Group model for organizing images with annotations.""" + + __tablename__ = "groups" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(255), nullable=False) + color = Column(String(7), nullable=False) # Hex color #RRGGBB + annotation = Column(Text, nullable=True) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + board = relationship("Board", back_populates="groups") + board_images = relationship("BoardImage", back_populates="group") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/database/models/image.py b/backend/app/database/models/image.py new file mode 100644 index 0000000..c8c0a34 --- /dev/null +++ b/backend/app/database/models/image.py @@ -0,0 +1,34 @@ +"""Image model for uploaded images.""" +import uuid +from datetime import datetime +from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import relationship + +from app.database.base import Base + + +class Image(Base): + """Image model representing uploaded image files.""" + + __tablename__ = "images" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + filename = Column(String(255), nullable=False, index=True) + storage_path = Column(String(512), nullable=False) + file_size = Column(BigInteger, nullable=False) + mime_type = Column(String(100), nullable=False) + width = Column(Integer, nullable=False) + height = Column(Integer, nullable=False) + image_metadata = Column(JSONB, nullable=False) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + reference_count = Column(Integer, nullable=False, default=0) + + # Relationships + user = relationship("User", back_populates="images") + board_images = relationship("BoardImage", back_populates="image", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/database/models/share_link.py b/backend/app/database/models/share_link.py new file mode 100644 index 0000000..d21da9b --- /dev/null +++ b/backend/app/database/models/share_link.py @@ -0,0 +1,32 @@ +"""ShareLink model for board sharing.""" +import uuid +from datetime import datetime +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.database.base import Base + + +class ShareLink(Base): + """ShareLink model for sharing boards with permission control.""" + + __tablename__ = "share_links" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) + token = Column(String(64), unique=True, nullable=False, index=True) + permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + expires_at = Column(DateTime, nullable=True) + last_accessed_at = Column(DateTime, nullable=True) + access_count = Column(Integer, nullable=False, default=0) + is_revoked = Column(Boolean, nullable=False, default=False, index=True) + + # Relationships + board = relationship("Board", back_populates="share_links") + comments = relationship("Comment", back_populates="share_link") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/database/models/user.py b/backend/app/database/models/user.py new file mode 100644 index 0000000..9e16680 --- /dev/null +++ b/backend/app/database/models/user.py @@ -0,0 +1,29 @@ +"""User model for authentication and ownership.""" +import uuid +from datetime import datetime +from sqlalchemy import Boolean, Column, DateTime, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.database.base import Base + + +class User(Base): + """User model representing registered users.""" + + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + is_active = Column(Boolean, nullable=False, default=True) + + # Relationships + boards = relationship("Board", back_populates="user", cascade="all, delete-orphan") + images = relationship("Image", back_populates="user", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/main.py b/backend/app/main.py index 9503445..29102e4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -80,11 +80,13 @@ async def root(): } -# API routers will be added here in subsequent phases -# Example: -# from app.api import auth, boards, images -# app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}/auth", tags=["Auth"]) -# app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}/boards", tags=["Boards"]) +# API routers +from app.api import auth +app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}") +# Additional routers will be added in subsequent phases +# from app.api import boards, images +# app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}") +# app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}") @app.on_event("startup") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 52b3e33..b703974 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "boto3>=1.35.0", "python-multipart>=0.0.12", "httpx>=0.27.0", + "psycopg2-binary>=2.9.0", ] [project.optional-dependencies] diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000..e539565 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +build/ +.svelte-kit/ +coverage/ +*.min.js +package-lock.json +pnpm-lock.yaml +yarn.lock +.DS_Store + diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..791b31b --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +build/ +.svelte-kit/ +coverage/ +package-lock.json +pnpm-lock.yaml +yarn.lock +.DS_Store +*.min.js + diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..7780957 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,35 @@ +/** + * SvelteKit server hooks for route protection + */ + +import type { Handle } from '@sveltejs/kit'; + +// Protected routes that require authentication +const protectedRoutes = ['/boards', '/library', '/settings']; + +export const handle: Handle = async ({ event, resolve }) => { + const { url, cookies } = event; + const pathname = url.pathname; + + // Check if route requires authentication + const requiresAuth = protectedRoutes.some(route => pathname.startsWith(route)); + + if (requiresAuth) { + // Check for auth token in cookies (or you could check localStorage via client-side) + const authToken = cookies.get('auth_token'); + + if (!authToken) { + // Redirect to login if not authenticated + return new Response(null, { + status: 302, + headers: { + location: `/login?redirect=${encodeURIComponent(pathname)}` + } + }); + } + } + + const response = await resolve(event); + return response; +}; + diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..0106aaf --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,114 @@ + + + + + + diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte new file mode 100644 index 0000000..8ec514e --- /dev/null +++ b/frontend/src/routes/register/+page.svelte @@ -0,0 +1,143 @@ + + +
+
+

Create Your Account

+ + {#if error} + + {/if} + + {#if success} +
+ {success} +
+ {/if} + + + + +
+
+ + + diff --git a/specs/001-reference-board-viewer/tasks.md b/specs/001-reference-board-viewer/tasks.md index 4ee634e..7c09585 100644 --- a/specs/001-reference-board-viewer/tasks.md +++ b/specs/001-reference-board-viewer/tasks.md @@ -69,7 +69,7 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu - [X] T022 [P] Create database connection in backend/app/database/session.py (SQLAlchemy engine) - [X] T023 [P] Create base database model in backend/app/database/base.py (declarative base) - [X] T024 [P] Implement dependency injection utilities in backend/app/core/deps.py (get_db session) -- [ ] T025 Create initial migration 001_initial_schema.py implementing full schema from data-model.md +- [X] T025 Create initial migration 001_initial_schema.py implementing full schema from data-model.md - [X] T026 [P] Create CORS middleware configuration in backend/app/core/middleware.py - [X] T027 [P] Create error handler utilities in backend/app/core/errors.py (exception classes) - [X] T028 [P] Implement response schemas in backend/app/core/schemas.py (base Pydantic models) @@ -101,28 +101,28 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu **Backend Tasks:** -- [ ] T036 [P] [US1] Create User model in backend/app/database/models/user.py matching data-model.md schema -- [ ] T037 [P] [US1] Create user schemas in backend/app/auth/schemas.py (UserCreate, UserLogin, UserResponse) -- [ ] T038 [US1] Implement password hashing utilities in backend/app/auth/security.py (passlib bcrypt) -- [ ] T039 [US1] Implement JWT token generation in backend/app/auth/jwt.py (python-jose) -- [ ] T040 [US1] Create user repository in backend/app/auth/repository.py (database operations) -- [ ] T041 [US1] Implement registration endpoint POST /auth/register in backend/app/api/auth.py -- [ ] T042 [US1] Implement login endpoint POST /auth/login in backend/app/api/auth.py -- [ ] T043 [US1] Implement current user endpoint GET /auth/me in backend/app/api/auth.py -- [ ] T044 [US1] Create JWT validation dependency in backend/app/core/deps.py (get_current_user) +- [X] T036 [P] [US1] Create User model in backend/app/database/models/user.py matching data-model.md schema +- [X] T037 [P] [US1] Create user schemas in backend/app/auth/schemas.py (UserCreate, UserLogin, UserResponse) +- [X] T038 [US1] Implement password hashing utilities in backend/app/auth/security.py (passlib bcrypt) +- [X] T039 [US1] Implement JWT token generation in backend/app/auth/jwt.py (python-jose) +- [X] T040 [US1] Create user repository in backend/app/auth/repository.py (database operations) +- [X] T041 [US1] Implement registration endpoint POST /auth/register in backend/app/api/auth.py +- [X] T042 [US1] Implement login endpoint POST /auth/login in backend/app/api/auth.py +- [X] T043 [US1] Implement current user endpoint GET /auth/me in backend/app/api/auth.py +- [X] T044 [US1] Create JWT validation dependency in backend/app/core/deps.py (get_current_user) - [ ] T045 [P] [US1] Write unit tests for password hashing in backend/tests/auth/test_security.py - [ ] T046 [P] [US1] Write unit tests for JWT generation in backend/tests/auth/test_jwt.py - [ ] T047 [P] [US1] Write integration tests for auth endpoints in backend/tests/api/test_auth.py **Frontend Tasks:** -- [ ] T048 [P] [US1] Create login page in frontend/src/routes/login/+page.svelte -- [ ] T049 [P] [US1] Create registration page in frontend/src/routes/register/+page.svelte -- [ ] T050 [US1] Implement auth API client methods in frontend/src/lib/api/auth.ts -- [ ] T051 [US1] Create auth store with login/logout logic in frontend/src/lib/stores/auth.ts -- [ ] T052 [US1] Implement route protection in frontend/src/hooks.server.ts -- [ ] T053 [P] [US1] Create LoginForm component in frontend/src/lib/components/auth/LoginForm.svelte -- [ ] T054 [P] [US1] Create RegisterForm component in frontend/src/lib/components/auth/RegisterForm.svelte +- [X] T048 [P] [US1] Create login page in frontend/src/routes/login/+page.svelte +- [X] T049 [P] [US1] Create registration page in frontend/src/routes/register/+page.svelte +- [X] T050 [US1] Implement auth API client methods in frontend/src/lib/api/auth.ts +- [X] T051 [US1] Create auth store with login/logout logic in frontend/src/lib/stores/auth.ts +- [X] T052 [US1] Implement route protection in frontend/src/hooks.server.ts +- [X] T053 [P] [US1] Create LoginForm component in frontend/src/lib/components/auth/LoginForm.svelte +- [X] T054 [P] [US1] Create RegisterForm component in frontend/src/lib/components/auth/RegisterForm.svelte - [ ] T055 [P] [US1] Write component tests for auth forms in frontend/tests/components/auth.test.ts **Deliverables:**