diff --git a/README.md b/README.md index 27efd63..44548c7 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Make sure the development database is running and tables and default permissions/roles are created first. ``` bash -uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload +uv run python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` Navigate to http://localhost:8000/ diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index aa2c30c..a883df9 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -129,7 +129,7 @@ docker compose up -d Make sure the development database is running and tables and default permissions/roles are created first. ``` bash -uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload +uv run python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` Navigate to http://localhost:8000/ diff --git a/docs/templates.qmd b/docs/templates.qmd deleted file mode 100644 index 2eafda8..0000000 --- a/docs/templates.qmd +++ /dev/null @@ -1,321 +0,0 @@ ---- -title: "Template Variables Documentation" ---- - -This file documents the required context variables for each template in the application. - -## Table of Contents - -- [authentication](#authentication) -- [components](#components) -- [dashboard](#dashboard) -- [dashboard > organizations](#dashboard-organizations) -- [dashboard > organizations > members](#dashboard-organizations-members) -- [emails](#emails) -- [errors](#errors) -- [root](#root) -- [users](#users) - -## authentication - -### forgot_password.html - -**Path:** `authentication/forgot_password.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `show_form` | | -| `url_for` | | - -### login.html - -**Path:** `authentication/login.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### register.html - -**Path:** `authentication/register.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `password_pattern` | | -| `url_for` | | - -### reset_password.html - -**Path:** `authentication/reset_password.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `email` | | -| `password_pattern` | | -| `token` | | -| `url_for` | | - -## components - -### footer.html - -**Path:** `components/footer.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### header.html - -**Path:** `components/header.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | -| `user` | | - -### logo.html - -**Path:** `components/logo.html` - -**No variables required** - -### nav.html - -**Path:** `components/nav.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### organizations.html - -**Path:** `components/organizations.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### silhouette.html - -**Path:** `components/silhouette.html` - -**No variables required** - -## dashboard - -### index.html - -**Path:** `dashboard/index.html` - -**No variables required** - -### organization_overview.html - -**Path:** `dashboard/organization_overview.html` - -**No variables required** - -## dashboard > organizations - -### create.html - -**Path:** `dashboard/organizations/create.html` - -**No variables required** - -### delete.html - -**Path:** `dashboard/organizations/delete.html` - -**No variables required** - -### detail.html - -**Path:** `dashboard/organizations/detail.html` - -**No variables required** - -### edit.html - -**Path:** `dashboard/organizations/edit.html` - -**No variables required** - -### members.html - -**Path:** `dashboard/organizations/members.html` - -**No variables required** - -## dashboard > organizations > members - -### delete.html - -**Path:** `dashboard/organizations/members/delete.html` - -**No variables required** - -### edit.html - -**Path:** `dashboard/organizations/members/edit.html` - -**No variables required** - -### invite.html - -**Path:** `dashboard/organizations/members/invite.html` - -**No variables required** - -## emails - -### base_email.html - -**Path:** `emails/base_email.html` - -**No variables required** - -### reset_email.html - -**Path:** `emails/reset_email.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `reset_url` | | - -### update_email_email.html - -**Path:** `emails/update_email_email.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `confirmation_url` | | -| `current_email` | | -| `new_email` | | - -## errors - -### error.html - -**Path:** `errors/error.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `detail` | | -| `status_code` | | -| `url_for` | | - -### validation_error.html - -**Path:** `errors/validation_error.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `errors` | | -| `url_for` | | - -## root - -### about.html - -**Path:** `about.html` - -**No variables required** - -### auth_base.html - -**Path:** `auth_base.html` - -**No variables required** - -### base.html - -**Path:** `base.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### index.html - -**Path:** `index.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `render_logo` | | -| `url_for` | | - -### privacy_policy.html - -**Path:** `privacy_policy.html` - -**No variables required** - -### terms_of_service.html - -**Path:** `terms_of_service.html` - -**No variables required** - -## users - -### organization.html - -**Path:** `users/organization.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `organization` | | -| `render_silhouette` | | -| `url_for` | | - -### profile.html - -**Path:** `users/profile.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `allowed_formats` | | -| `email_update_requested` | | -| `email_updated` | | -| `max_dimension` | | -| `max_file_size_mb` | | -| `min_dimension` | | -| `render_organizations` | | -| `render_silhouette` | | -| `show_form` | | -| `url_for` | | -| `user` | | diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index d51d636..13585ae 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -44,12 +44,11 @@ def __init__(self): ) -# TODO: Consolidate these two into a single validation error -class EmptyOrganizationNameError(HTTPException): - def __init__(self): +class OrganizationSetupError(HTTPException): + def __init__(self, message: str = "Organization setup failed"): super().__init__( - status_code=400, - detail="Organization name cannot be empty" + status_code=500, + detail=message ) @@ -69,6 +68,22 @@ def __init__(self): ) +class UserNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=404, + detail="User not found" + ) + + +class UserAlreadyMemberError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="User is already a member of this organization" + ) + + class InvalidPermissionError(HTTPException): """Raised when a user attempts to assign an invalid permission to a role""" diff --git a/index.qmd b/index.qmd index 4c6df13..3e8cf1c 100644 --- a/index.qmd +++ b/index.qmd @@ -131,7 +131,7 @@ docker compose up -d Make sure the development database is running and tables and default permissions/roles are created first. ``` bash -uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload +uv run python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` Navigate to http://localhost:8000/ diff --git a/main.py b/main.py index 865d919..080be61 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,8 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.exceptions import RequestValidationError, StarletteHTTPException +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException from routers import account, dashboard, organization, role, user, static_pages from utils.dependencies import ( get_optional_user diff --git a/pyproject.toml b/pyproject.toml index f7fdb5a..c8c8400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,12 @@ dependencies = [ dev = [ "graphviz<1.0.0,>=0.20.3", "quarto<1.0.0,>=0.1.0", - "mypy<2.0.0,>=1.11.2", "jupyter<2.0.0,>=1.1.1", "notebook<8.0.0,>=7.2.2", "pytest<9.0.0,>=8.3.3", "sqlalchemy-schemadisplay<3.0,>=2.0", "perplexity-cli", + "mypy>=1.15.0", ] [tool.uv.sources] diff --git a/routers/account.py b/routers/account.py index 237f989..ef081cd 100644 --- a/routers/account.py +++ b/routers/account.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, BackgroundTasks, Form, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates +from starlette.datastructures import URLPath from pydantic import EmailStr from sqlmodel import Session, select from utils.models import User, DataIntegrityError, Account @@ -33,7 +34,8 @@ CredentialsError, PasswordValidationError ) - +from routers.dashboard import router as dashboard_router +from routers.user import router as user_router logger = getLogger("uvicorn.error") router = APIRouter(prefix="/account", tags=["account"]) @@ -80,34 +82,15 @@ def validate_password_strength_and_match( # --- Routes --- -@router.post("/delete", response_class=RedirectResponse) -async def delete_account( - email: EmailStr = Form(...), - password: str = Form(...), - account: Account = Depends(get_authenticated_account), - session: Session = Depends(get_session) -): +@router.get("/logout", response_class=RedirectResponse) +def logout(): """ - Delete a user account after verifying credentials. + Log out a user by clearing their cookies. """ - # Verify the provided email matches the authenticated user - if email != account.email: - raise CredentialsError(message="Email does not match authenticated account") - - # Verify password - if not verify_password(password, account.hashed_password): - raise PasswordValidationError( - field="password", - message="Password is incorrect" - ) - - # Delete the account and associated user - # Note: The user will be deleted automatically by cascade relationship - session.delete(account) - session.commit() - - # Log out the user - return RedirectResponse(url="/account/logout", status_code=303) + response = RedirectResponse(url="/", status_code=303) + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return response @router.get("/login") @@ -120,9 +103,9 @@ async def read_login( Render login page or redirect to dashboard if already logged in. """ if user: - return RedirectResponse(url="/dashboard", status_code=302) + return RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=302) return templates.TemplateResponse( - "authentication/login.html", + "account/login.html", {"request": request, "user": user, "email_updated": email_updated} ) @@ -136,10 +119,10 @@ async def read_register( Render registration page or redirect to dashboard if already logged in. """ if user: - return RedirectResponse(url="/dashboard", status_code=302) + return RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=302) return templates.TemplateResponse( - "authentication/register.html", + "account/register.html", {"request": request, "user": user, "password_pattern": HTML_PASSWORD_PATTERN} ) @@ -154,10 +137,10 @@ async def read_forgot_password( Render forgot password page or redirect to dashboard if already logged in. """ if user: - return RedirectResponse(url="/dashboard", status_code=302) + return RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=302) return templates.TemplateResponse( - "authentication/forgot_password.html", + "account/forgot_password.html", {"request": request, "user": user, "show_form": show_form == "true"} ) @@ -180,11 +163,41 @@ async def read_reset_password( raise CredentialsError(message="Invalid or expired token") return templates.TemplateResponse( - "authentication/reset_password.html", + "account/reset_password.html", {"request": request, "user": user, "email": email, "token": token, "password_pattern": HTML_PASSWORD_PATTERN} ) +@router.post("/delete", response_class=RedirectResponse) +async def delete_account( + email: EmailStr = Form(...), + password: str = Form(...), + account: Account = Depends(get_authenticated_account), + session: Session = Depends(get_session) +): + """ + Delete a user account after verifying credentials. + """ + # Verify the provided email matches the authenticated user + if email != account.email: + raise CredentialsError(message="Email does not match authenticated account") + + # Verify password + if not verify_password(password, account.hashed_password): + raise PasswordValidationError( + field="password", + message="Password is incorrect" + ) + + # Delete the account and associated user + # Note: The user will be deleted automatically by cascade relationship + session.delete(account) + session.commit() + + # Log out the user + return RedirectResponse(url=router.url_path_for("logout"), status_code=303) + + @router.post("/register", response_class=RedirectResponse) async def register( name: str = Form(...), @@ -212,7 +225,7 @@ async def register( session.flush() # Flush to get the account ID # Create the user - account.user = User(name=name) + account.user = User(name=name, account_id=account.id) session.add(account) session.commit() session.refresh(account) @@ -222,7 +235,7 @@ async def register( refresh_token = create_refresh_token(data={"sub": email}) # Set cookie - response = RedirectResponse(url="/", status_code=303) + response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303) response.set_cookie( key="access_token", value=access_token, @@ -257,7 +270,7 @@ async def login( refresh_token = create_refresh_token(data={"sub": account.email}) # Set cookie - response = RedirectResponse(url="/", status_code=303) + response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303) response.set_cookie( key="access_token", value=access_token, @@ -287,11 +300,11 @@ async def refresh_token( """ _, refresh_token = tokens if not refresh_token: - return RedirectResponse(url="/login", status_code=303) + return RedirectResponse(url=router.url_path_for("read_login"), status_code=303) decoded_token = validate_token(refresh_token, token_type="refresh") if not decoded_token: - response = RedirectResponse(url="/login", status_code=303) + response = RedirectResponse(url=router.url_path_for("read_login"), status_code=303) response.delete_cookie("access_token") response.delete_cookie("refresh_token") return response @@ -300,14 +313,14 @@ async def refresh_token( account = session.exec(select(Account).where( Account.email == user_email)).one_or_none() if not account: - return RedirectResponse(url="/login", status_code=303) + return RedirectResponse(url=router.url_path_for("read_login"), status_code=303) new_access_token = create_access_token( data={"sub": account.email, "fresh": False} ) new_refresh_token = create_refresh_token(data={"sub": account.email}) - response = RedirectResponse(url="/", status_code=303) + response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303) response.set_cookie( key="access_token", value=new_access_token, @@ -379,18 +392,7 @@ async def reset_password( session.commit() session.refresh(authorized_account) - return RedirectResponse(url="/login", status_code=303) - - -@router.get("/logout", response_class=RedirectResponse) -def logout(): - """ - Log out a user by clearing their cookies. - """ - response = RedirectResponse(url="/", status_code=303) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - return response + return RedirectResponse(url=router.url_path_for("read_login"), status_code=303) @router.post("/update_email") @@ -429,8 +431,12 @@ async def request_email_update( session=session ) + # Generate URL with query parameters separately + profile_path: URLPath = user_router.url_path_for("read_profile") + redirect_url = f"{profile_path}?email_update_requested=true" + return RedirectResponse( - url="/profile?email_update_requested=true", + url=redirect_url, status_code=303 ) @@ -461,9 +467,13 @@ async def confirm_email_update( access_token = create_access_token(data={"sub": new_email, "fresh": True}) refresh_token = create_refresh_token(data={"sub": new_email}) + # Generate URL with query parameters separately + profile_path: URLPath = user_router.url_path_for("read_profile") + redirect_url = f"{profile_path}?email_updated=true" + # Set cookies before redirecting response = RedirectResponse( - url="/profile?email_updated=true", + url=redirect_url, status_code=303 ) diff --git a/routers/organization.py b/routers/organization.py index 82e596e..b36cf7b 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -4,11 +4,17 @@ from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from sqlmodel import Session, select -from utils.db import get_session, default_roles +from sqlalchemy.orm import selectinload +from utils.db import get_session, create_default_roles from utils.dependencies import get_authenticated_user, get_user_with_relations -from utils.models import Organization, User, Role, utc_time +from utils.models import Organization, User, Role, Account, utc_time from utils.enums import ValidPermissions -from exceptions.http_exceptions import OrganizationNotFoundError, OrganizationNameTakenError, InsufficientPermissionsError, EmptyOrganizationNameError +from exceptions.http_exceptions import ( + OrganizationNotFoundError, OrganizationNameTakenError, + InsufficientPermissionsError, OrganizationSetupError, + UserNotFoundError, UserAlreadyMemberError, DataIntegrityError +) +from pydantic import EmailStr logger = getLogger("uvicorn.error") @@ -23,7 +29,8 @@ async def read_organization( org_id: int, request: Request, - user: User = Depends(get_user_with_relations) + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session) ): # Get the organization only if the user is a member of it org = next( @@ -32,9 +39,35 @@ async def read_organization( ) if not org: raise OrganizationNotFoundError() - + + # Calculate the user's permissions for this organization + user_permissions = set() + for role in user.roles: + if role.organization_id == org_id: + for permission in role.permissions: + user_permissions.add(permission.name) + + # Load the organization with fully loaded roles and users + organization = session.exec( + select(Organization) + .where(Organization.id == org_id) + .options( + selectinload(Organization.roles).selectinload(Role.users).selectinload(User.account), + selectinload(Organization.roles).selectinload(Role.users).selectinload(User.roles), + selectinload(Organization.roles).selectinload(Role.permissions) + ) + ).first() + + # Pass all required context to the template return templates.TemplateResponse( - request, "users/organization.html", {"organization": org} + request, + "organization/organization.html", + { + "organization": organization, + "user": user, + "user_permissions": user_permissions, + "ValidPermissions": ValidPermissions + } ) @@ -65,27 +98,53 @@ def create_organization( session.flush() # Create default roles with organization_id - initial_roles = [ - Role(name=role_name, organization_id=db_org.id) - for role_name in default_roles - ] - session.add_all(initial_roles) - session.flush() + if db_org.id is None: + logger.error("Failed to obtain organization ID after flush.") + raise OrganizationSetupError() + + # Use the utility function to create default roles and assign permissions + # This also handles committing the roles and permissions + try: + create_default_roles(session, db_org.id, check_first=False) + except Exception as e: + logger.exception(f"Failed to create default roles for org ID {db_org.id}") + # Rollback might be needed if create_default_roles doesn't handle it + session.rollback() + raise OrganizationSetupError("Failed during role creation") from e + + # Refresh the org object to load the roles relationship + session.refresh(db_org) - # Get owner role for user assignment - owner_role = next(role for role in db_org.roles if role.name == "Owner") + # Get owner role for user assignment (roles should now exist) + owner_role = next((role for role in db_org.roles if role.name == "Owner"), None) + + if owner_role is None: + logger.error(f"'Owner' role not found for newly created org ID {db_org.id} after create_default_roles call.") + # Rollback might be needed + session.rollback() + raise OrganizationSetupError("Owner role missing after creation") # Assign user to owner role user.roles.append(owner_role) - # Commit changes - session.commit() - session.refresh(db_org) + # Commit the user role link + try: + session.commit() + logger.info(f"Successfully created organization '{db_org.name}' (ID: {db_org.id}) and assigned owner (User ID: {user.id}).") + except Exception as e: + logger.exception(f"Failed to commit user-owner role link for org ID {db_org.id} and user ID {user.id}") + session.rollback() + raise OrganizationSetupError("Failed to assign owner role") from e - return RedirectResponse(url=f"/organizations/{db_org.id}", status_code=303) + session.refresh(db_org) # Refresh again to be safe before redirect + + return RedirectResponse( + url=router.url_path_for("read_organization", org_id=db_org.id), + status_code=303 + ) -@router.post("/update/{org_id}", name="update_organization", response_class=RedirectResponse) +@router.post("/update/{org_id}", response_class=RedirectResponse) def update_organization( org_id: int, name: Annotated[str, Form( @@ -121,7 +180,7 @@ def update_organization( session.add(organization) session.commit() - return RedirectResponse(url=f"/profile", status_code=303) + return RedirectResponse(url=router.url_path_for("read_organization", org_id=org_id), status_code=303) @router.post("/delete/{org_id}", response_class=RedirectResponse) @@ -130,18 +189,98 @@ def delete_organization( user: User = Depends(get_user_with_relations), session: Session = Depends(get_session) ) -> RedirectResponse: - # Check if user has permission to delete organization + # Find the organization the user belongs to organization: Organization | None = next( (org for org in user.organizations if org.id == org_id), None) - if not organization or not any( - p.name == ValidPermissions.DELETE_ORGANIZATION - for role in organization.roles - for p in role.permissions - ): + + # Check if the user is a member and has permission to delete the organization + if not organization or not user.has_permission(ValidPermissions.DELETE_ORGANIZATION, organization): + logger.warning(f"User {user.id} attempted to delete organization {org_id} without permission.") raise InsufficientPermissionsError() # Delete organization + logger.info(f"User {user.id} deleting organization {org_id} ('{organization.name}').") session.delete(organization) session.commit() - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse(url="/user/profile", status_code=303) + + +@router.post("/invite/{org_id}", response_class=RedirectResponse) +def invite_member( + org_id: int, + email: Annotated[EmailStr, Form( + description="Email of the user to invite", + title="Email" + )], + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session) +) -> RedirectResponse: + # Check if the user has permission to invite members + if not user.has_permission(ValidPermissions.INVITE_USER, org_id): + raise InsufficientPermissionsError() + + # Find the organization with all needed relationships + organization = session.exec( + select(Organization) + .where(Organization.id == org_id) + .options( + selectinload(Organization.roles), + selectinload(Organization.roles).selectinload(Role.users) + ) + ).first() + + if not organization: + raise OrganizationNotFoundError() + + # Log organization and roles state + org_identity = session.identity_key(instance=organization) + role_info = [(r.id, r.name, session.identity_key(instance=r)) for r in organization.roles] + + # Find the account and associated user by email + account = session.exec( + select(Account) + .where(Account.email == email) + .options( + selectinload(Account.user) + ) + ).first() + + if not account or not account.user: + raise UserNotFoundError() + + invited_user = account.user + user_identity = session.identity_key(instance=invited_user) + + # Check if user is already a member of this organization + is_already_member = False + for role in organization.roles: + if invited_user.id in [u.id for u in role.users]: + is_already_member = True + break + + if is_already_member: + raise UserAlreadyMemberError() + + # Find the default "Member" role for this organization + member_role = next( + (role for role in organization.roles if role.name == "Member"), + None + ) + + if not member_role: + raise DataIntegrityError(resource="Organization roles") + + # Add the invited user to the Member role + try: + member_role.users.append(invited_user) + session.commit() + except Exception as e: + session.rollback() + raise + + # Return to the organization page + return RedirectResponse( + url=router.url_path_for("read_organization", org_id=org_id), + status_code=303 + ) diff --git a/routers/role.py b/routers/role.py index 83ce4de..f6ecb2a 100644 --- a/routers/role.py +++ b/routers/role.py @@ -10,6 +10,7 @@ from utils.dependencies import get_authenticated_user from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError +from routers.organization import router as organization_router logger = getLogger("uvicorn.error") @@ -55,7 +56,10 @@ def create_role( # Commit transaction session.commit() - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse( + url=organization_router.url_path_for("read_organization", org_id=organization_id), + status_code=303 + ) @router.post("/update", response_class=RedirectResponse) @@ -117,7 +121,10 @@ def update_role( session.commit() session.refresh(db_role) - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse( + url=organization_router.url_path_for("read_organization", org_id=organization_id), + status_code=303 + ) @router.post("/delete", response_class=RedirectResponse) @@ -149,4 +156,7 @@ def delete_role( session.delete(db_role) session.commit() - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse( + url=organization_router.url_path_for("read_organization", org_id=organization_id), + status_code=303 + ) diff --git a/routers/static_pages.py b/routers/static_pages.py index 03c27c0..207ee71 100644 --- a/routers/static_pages.py +++ b/routers/static_pages.py @@ -9,9 +9,9 @@ # Define valid static pages to prevent arbitrary template access VALID_PAGES = { - "about": "about.html", - "privacy-policy": "privacy_policy.html", - "terms-of-service": "terms_of_service.html" + "about": "static_pages/about.html", + "privacy-policy": "static_pages/privacy_policy.html", + "terms-of-service": "static_pages/terms_of_service.html" } @router.get("/{page_name}", name="read_static_page") diff --git a/routers/user.py b/routers/user.py index 629df25..30f15b4 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,12 +1,20 @@ -from fastapi import APIRouter, Depends, Form, UploadFile, File, Request +from fastapi import APIRouter, Depends, Form, UploadFile, File, Request, HTTPException from fastapi.responses import RedirectResponse, Response -from sqlmodel import Session -from typing import Optional +from sqlmodel import Session, select +from typing import Optional, List from fastapi.templating import Jinja2Templates -from utils.models import User, DataIntegrityError +from sqlalchemy.orm import selectinload +from utils.models import User, DataIntegrityError, Organization from utils.db import get_session -from utils.dependencies import get_authenticated_user +from utils.dependencies import get_authenticated_user, get_user_with_relations from utils.images import validate_and_process_image, MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES +from utils.enums import ValidPermissions +from exceptions.http_exceptions import ( + InsufficientPermissionsError, + UserNotFoundError, + OrganizationNotFoundError +) +from routers.organization import router as organization_router router = APIRouter(prefix="/user", tags=["user"]) templates = Jinja2Templates(directory="templates") @@ -18,7 +26,7 @@ @router.get("/profile") async def read_profile( request: Request, - user: User = Depends(get_authenticated_user), + user: User = Depends(get_user_with_relations), email_update_requested: Optional[str] = "false", email_updated: Optional[str] = "false" ): @@ -78,3 +86,110 @@ async def get_avatar( content=user.avatar_data, media_type=user.avatar_content_type ) + + +@router.post("/role/update", response_class=RedirectResponse) +def update_user_role( + user_id: int = Form(...), + organization_id: int = Form(...), + roles: Optional[List[int]] = Form(None), + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +) -> RedirectResponse: + """Update the roles of a user in an organization""" + # Check if the current user has permission to edit user roles + if not user.has_permission(ValidPermissions.EDIT_USER_ROLE, organization_id): + raise InsufficientPermissionsError() + + # Find the organization + organization = session.exec( + select(Organization) + .where(Organization.id == organization_id) + .options(selectinload(Organization.roles)) + ).first() + + if not organization: + raise OrganizationNotFoundError() + + # Find the target user + target_user = session.exec( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles)) + ).first() + + if not target_user: + raise UserNotFoundError() + + # Get all roles for this organization + org_roles = {role.id: role for role in organization.roles} + + # Remove all current organization roles from the user + for role in list(target_user.roles): + if role.organization_id == organization_id: + target_user.roles.remove(role) + + # Add selected roles to the user + if roles: + for role_id in roles: + fetched_role = org_roles.get(role_id) + if fetched_role is not None: + target_user.roles.append(fetched_role) + + session.commit() + + return RedirectResponse( + url=organization_router.url_path_for("read_organization", org_id=organization_id), + status_code=303 + ) + + +@router.post("/organization/remove", response_class=RedirectResponse) +def remove_user_from_organization( + user_id: int = Form(...), + organization_id: int = Form(...), + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +) -> RedirectResponse: + """Remove a user from an organization by removing all their roles in that organization""" + # Check if the current user has permission to remove users + if not user.has_permission(ValidPermissions.REMOVE_USER, organization_id): + raise InsufficientPermissionsError() + + # Find the organization + organization = session.exec( + select(Organization) + .where(Organization.id == organization_id) + ).first() + + if not organization: + raise OrganizationNotFoundError() + + # Find the target user + target_user = session.exec( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles)) + ).first() + + if not target_user: + raise UserNotFoundError() + + # Prevent removing oneself + if target_user.id == user.id: + raise HTTPException( + status_code=400, + detail="You cannot remove yourself from the organization" + ) + + # Remove all organization roles from the user + for role in list(target_user.roles): + if role.organization_id == organization_id: + target_user.roles.remove(role) + + session.commit() + + return RedirectResponse( + url=organization_router.url_path_for("read_organization", org_id=organization_id), + status_code=303 + ) diff --git a/templates/auth_base.html b/templates/account/auth_base.html similarity index 100% rename from templates/auth_base.html rename to templates/account/auth_base.html diff --git a/templates/authentication/forgot_password.html b/templates/account/forgot_password.html similarity index 96% rename from templates/authentication/forgot_password.html rename to templates/account/forgot_password.html index c28f591..35aeac6 100644 --- a/templates/authentication/forgot_password.html +++ b/templates/account/forgot_password.html @@ -1,4 +1,4 @@ -{% extends "auth_base.html" %} +{% extends "account/auth_base.html" %} {% block title %}Forgot Password{% endblock %} diff --git a/templates/authentication/login.html b/templates/account/login.html similarity index 97% rename from templates/authentication/login.html rename to templates/account/login.html index fa4eff1..023da31 100644 --- a/templates/authentication/login.html +++ b/templates/account/login.html @@ -1,4 +1,4 @@ -{% extends "auth_base.html" %} +{% extends "account/auth_base.html" %} {% block title %}Login{% endblock %} diff --git a/templates/authentication/register.html b/templates/account/register.html similarity index 98% rename from templates/authentication/register.html rename to templates/account/register.html index 75c573a..c9b4a5d 100644 --- a/templates/authentication/register.html +++ b/templates/account/register.html @@ -1,4 +1,4 @@ -{% extends "auth_base.html" %} +{% extends "account/auth_base.html" %} {% block title %}Register{% endblock %} diff --git a/templates/authentication/reset_password.html b/templates/account/reset_password.html similarity index 98% rename from templates/authentication/reset_password.html rename to templates/account/reset_password.html index d7fb341..74d0c40 100644 --- a/templates/authentication/reset_password.html +++ b/templates/account/reset_password.html @@ -1,4 +1,4 @@ -{% extends "auth_base.html" %} +{% extends "account/auth_base.html" %} {% block title %}Reset Password{% endblock %} diff --git a/templates/base.html b/templates/base.html index efa4af3..7259491 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,7 +19,7 @@
- {% include 'components/header.html' %} + {% include 'base/partials/header.html' %}
@@ -30,7 +30,7 @@ diff --git a/templates/components/logo.html b/templates/base/macros/logo.html similarity index 100% rename from templates/components/logo.html rename to templates/base/macros/logo.html diff --git a/templates/components/silhouette.html b/templates/base/macros/silhouette.html similarity index 100% rename from templates/components/silhouette.html rename to templates/base/macros/silhouette.html diff --git a/templates/components/footer.html b/templates/base/partials/footer.html similarity index 100% rename from templates/components/footer.html rename to templates/base/partials/footer.html diff --git a/templates/components/header.html b/templates/base/partials/header.html similarity index 93% rename from templates/components/header.html rename to templates/base/partials/header.html index 9416fbe..6a05733 100644 --- a/templates/components/header.html +++ b/templates/base/partials/header.html @@ -1,5 +1,5 @@ -{% from 'components/logo.html' import render_logo %} -{% from 'components/silhouette.html' import render_silhouette %} +{% from 'base/macros/logo.html' import render_logo %} +{% from 'base/macros/silhouette.html' import render_silhouette %}