diff --git a/src/baby_monitor/models/db/user.py b/src/baby_monitor/models/db/user.py index 498026a..c4f4be2 100644 --- a/src/baby_monitor/models/db/user.py +++ b/src/baby_monitor/models/db/user.py @@ -6,6 +6,7 @@ from datetime import datetime if TYPE_CHECKING: from sqlalchemy.orm import DeclarativeBase + Base = DeclarativeBase else: from baby_monitor.repositories.dependencies.get_database import Base diff --git a/src/baby_monitor/models/invitation.py b/src/baby_monitor/models/invitation.py index 0703f48..7d381cf 100644 --- a/src/baby_monitor/models/invitation.py +++ b/src/baby_monitor/models/invitation.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Mapped, mapped_column if TYPE_CHECKING: from sqlalchemy.orm import DeclarativeBase + Base = DeclarativeBase else: from baby_monitor.repositories.dependencies.get_database import Base @@ -21,9 +22,7 @@ class Invitation(Base): __tablename__ = "invitations" - id: Mapped[int] = mapped_column( - Integer, primary_key=True, autoincrement=True - ) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) token: Mapped[str] = mapped_column(String, unique=True, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False @@ -32,9 +31,7 @@ class Invitation(Base): DateTime(timezone=True), nullable=False ) created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False) - is_consumed: Mapped[bool] = mapped_column( - Boolean, default=False, nullable=False - ) + is_consumed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) consumed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/src/baby_monitor/repositories/dependencies/get_database.py b/src/baby_monitor/repositories/dependencies/get_database.py index 6de03cb..4c32397 100644 --- a/src/baby_monitor/repositories/dependencies/get_database.py +++ b/src/baby_monitor/repositories/dependencies/get_database.py @@ -36,7 +36,7 @@ def init_db() -> None: # This must be done before create_all() is called from baby_monitor.models.db.user import User # noqa: F401 from baby_monitor.models.invitation import Invitation # noqa: F401 - + # Ensure data directory exists DATA_DIR.mkdir(parents=True, exist_ok=True) Base.metadata.create_all(bind=engine) diff --git a/src/baby_monitor/repositories/invitation/sqlite_invitation.py b/src/baby_monitor/repositories/invitation/sqlite_invitation.py index 6d3260b..e51882c 100644 --- a/src/baby_monitor/repositories/invitation/sqlite_invitation.py +++ b/src/baby_monitor/repositories/invitation/sqlite_invitation.py @@ -34,9 +34,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface): # Convert timezone-aware datetimes to naive UTC for SQLite created_at_utc = datetime.now(UTC).replace(tzinfo=None) expires_at_utc = ( - expires_at.replace(tzinfo=None) - if expires_at.tzinfo - else expires_at + expires_at.replace(tzinfo=None) if expires_at.tzinfo else expires_at ) invitation = Invitation( @@ -60,9 +58,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface): Returns: True if valid and not consumed, False otherwise """ - invitation = ( - self.db.query(Invitation).filter(Invitation.token == token).first() - ) + invitation = self.db.query(Invitation).filter(Invitation.token == token).first() if not invitation: return False @@ -88,9 +84,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface): Returns: True if successfully consumed, False if invalid or already used """ - invitation = ( - self.db.query(Invitation).filter(Invitation.token == token).first() - ) + invitation = self.db.query(Invitation).filter(Invitation.token == token).first() if not invitation: return False @@ -114,7 +108,5 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface): def cleanup_expired(self) -> None: """Remove expired invitation tokens from storage.""" now_utc = datetime.now(UTC).replace(tzinfo=None) - self.db.query(Invitation).filter( - Invitation.expires_at < now_utc - ).delete() + self.db.query(Invitation).filter(Invitation.expires_at < now_utc).delete() self.db.commit() diff --git a/src/baby_monitor/routers/admin.py b/src/baby_monitor/routers/admin.py index 4af4a6f..2f90071 100644 --- a/src/baby_monitor/routers/admin.py +++ b/src/baby_monitor/routers/admin.py @@ -34,31 +34,31 @@ async def generate_invitation_link( ], ) -> InvitationResponse: """Generate a new invitation link for user registration. - + The invitation token is valid for 24 hours and can be used once. Requires admin role. - + Args: user_id: Admin user ID (from verify_admin) invitation_repository: Invitation storage backend - + Returns: InvitationResponse with token and expiration details """ - + # Generate secure random token invitation_token = secrets.token_urlsafe(32) - + # Calculate expiration (24 hours from now) expires_at = datetime.now(UTC) + timedelta(hours=24) - + # Store invitation token in database invitation_repository.create_invitation( token=invitation_token, created_by_user_id=user_id, expires_at=expires_at, ) - + return InvitationResponse( token=invitation_token, expires_at=expires_at.isoformat(), diff --git a/src/baby_monitor/routers/auth.py b/src/baby_monitor/routers/auth.py index 43edc48..c81b940 100644 --- a/src/baby_monitor/routers/auth.py +++ b/src/baby_monitor/routers/auth.py @@ -52,27 +52,23 @@ def verify_token( def verify_admin( user_id: Annotated[int, Depends(verify_token)], - user_repo: Annotated[ - UserRepositoryInterface, Depends(get_user_repository) - ], + user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], ) -> int: """Verify the user is an admin and return user_id.""" # First try to get user from database user = user_repo.get_by_id(user_id) - + if user: # Database user - check is_admin field if not user.get("is_admin", False): - raise HTTPException( - status_code=403, detail="Admin access required" - ) + raise HTTPException(status_code=403, detail="Admin access required") return user_id - + # If not in database but has valid token with user_id=1, # it's the environment-based admin (only assigned during env admin login) if user_id == 1: return user_id - + # User not found and not environment admin raise HTTPException(status_code=401, detail="User not found") @@ -80,12 +76,8 @@ def verify_admin( @router.post("/login", response_model=LoginResponse) def login( credentials: LoginRequest, - token_repo: Annotated[ - TokenRepositoryInterface, Depends(get_token_repository) - ], - user_repo: Annotated[ - UserRepositoryInterface, Depends(get_user_repository) - ], + token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)], + user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], creds_repo: Annotated[ CredentialsRepositoryInterface, Depends(get_credentials_repository) ], @@ -112,11 +104,9 @@ def login( access_token=access_token, is_admin=user.get("is_admin", False), ) - + # Fallback to admin credentials from environment - if creds_repo.verify_admin_credentials( - credentials.username, credentials.password - ): + if creds_repo.verify_admin_credentials(credentials.username, credentials.password): # Generate a secure random token access_token = secrets.token_urlsafe(32) # Store token with user_id (hardcoded 1 for admin) @@ -128,10 +118,8 @@ def login( access_token=access_token, is_admin=True, ) - - raise HTTPException( - status_code=401, detail="Invalid username or password" - ) + + raise HTTPException(status_code=401, detail="Invalid username or password") @router.post("/logout") @@ -162,19 +150,15 @@ def verify_invitation( @router.post("/register", response_model=RegisterResponse) def register( request: RegisterRequest, - user_repo: Annotated[ - UserRepositoryInterface, Depends(get_user_repository) - ], + user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], invitation_repo: Annotated[ InvitationRepositoryInterface, Depends(get_invitation_repository) ], - token_repo: Annotated[ - TokenRepositoryInterface, Depends(get_token_repository) - ], + token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)], ) -> RegisterResponse: """ Register a new user with an invitation token. - + Verifies the invitation, creates the user, consumes the invitation, and returns an authentication token. """ @@ -184,7 +168,7 @@ def register( status_code=400, detail="Invalid or expired invitation token", ) - + # Check if username already exists existing_user = user_repo.get_by_username(request.username) if existing_user: @@ -192,21 +176,21 @@ def register( status_code=400, detail="Username already exists", ) - + # Create the new user # TODO: Hash password before storing (currently plain text) user = user_repo.create( username=request.username, hashed_password=request.password, ) - + # Consume the invitation token invitation_repo.consume_invitation(request.invitation_token) - + # Generate authentication token access_token = secrets.token_urlsafe(32) token_repo.store(access_token, user_id=user["id"], ttl=3600) - + return RegisterResponse( message="Registration successful", username=user["username"], @@ -218,16 +202,14 @@ def register( @router.get("/me") def get_current_user( user_id: Annotated[int, Depends(verify_token)], - user_repo: Annotated[ - UserRepositoryInterface, Depends(get_user_repository) - ], + user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], creds_repo: Annotated[ CredentialsRepositoryInterface, Depends(get_credentials_repository) ], ) -> dict: """Get current user info including admin status.""" user = user_repo.get_by_id(user_id) - + # If user not found in DB, might be env-based admin if not user: # Return admin info from environment @@ -236,7 +218,7 @@ def get_current_user( "username": creds_repo.get_admin_username(), "is_admin": True, # Env-based admin is always admin } - + return { "id": user["id"], "username": user["username"], diff --git a/src/baby_monitor/static/admin.html b/src/baby_monitor/static/admin.html index bd4d956..e27d0c9 100644 --- a/src/baby_monitor/static/admin.html +++ b/src/baby_monitor/static/admin.html @@ -1,315 +1,319 @@ - + - - - + + + Baby Monitor - Admin - - + +
-

👨‍đŸ’ŧ Admin Dashboard

-

Manage user invitations and settings

+

👨‍đŸ’ŧ Admin Dashboard

+

Manage user invitations and settings

-
-

Generate Invitation Link

-

- Create a secure one-time link to invite a new user to register. -

- + + - +
+
+ +
- + diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html index 2e41d37..da17c70 100644 --- a/src/baby_monitor/static/index.html +++ b/src/baby_monitor/static/index.html @@ -125,13 +125,11 @@
- + - +
@@ -148,20 +146,20 @@ const burgerMenu = document.getElementById("burgerMenu"); const menuOverlay = document.getElementById("menuOverlay"); const menuBackdrop = document.getElementById("menuBackdrop"); - + function toggleMenu() { menuOverlay.classList.toggle("open"); menuBackdrop.classList.toggle("open"); } - + function closeMenu() { menuOverlay.classList.remove("open"); menuBackdrop.classList.remove("open"); } - + burgerMenu.addEventListener("click", toggleMenu); menuBackdrop.addEventListener("click", closeMenu); - + document.getElementById("menuLogout").addEventListener("click", () => { closeMenu(); logout(); diff --git a/src/baby_monitor/static/login.html b/src/baby_monitor/static/login.html index 8d2ca9e..eea5bc6 100644 --- a/src/baby_monitor/static/login.html +++ b/src/baby_monitor/static/login.html @@ -112,7 +112,7 @@ // Store token in localStorage localStorage.setItem("access_token", data.access_token); localStorage.setItem("username", data.username); - + // Redirect based on is_admin from login response setTimeout(() => { if (data.is_admin === true) { diff --git a/src/baby_monitor/static/register.html b/src/baby_monitor/static/register.html index 60f1a78..8e3aea0 100644 --- a/src/baby_monitor/static/register.html +++ b/src/baby_monitor/static/register.html @@ -1,368 +1,383 @@ - + - - - + + + Baby Monitor - Register - - + +
-
-
-

Validating invitation...

-
+
+
+

Validating invitation...

+
-