diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index 6f4dcf1..1aabdc9 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -43,10 +43,11 @@ def __init__(self, field: str, message: str): class InsufficientPermissionsError(HTTPException): - def __init__(self): - super().__init__( - status_code=403, detail="You don't have permission to perform this action" - ) + def __init__( + self, + message: str = "You don't have permission to perform this action", + ): + super().__init__(status_code=403, detail=message) class OrganizationSetupError(HTTPException): @@ -177,6 +178,13 @@ def __init__(self): ) +class InvitationNotFoundError(HTTPException): + """Raised when an invitation ID does not exist.""" + + def __init__(self): + super().__init__(status_code=404, detail="Invitation not found") + + class InvalidInvitationTokenError(HTTPException): """Raised when an invitation token is invalid, expired, or not found.""" diff --git a/routers/core/invitation.py b/routers/core/invitation.py index da85913..07f4554 100644 --- a/routers/core/invitation.py +++ b/routers/core/invitation.py @@ -1,7 +1,7 @@ from uuid import uuid4 from typing import Optional from fastapi import APIRouter, Depends, Form, Query, Request, status -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, Response from fastapi.templating import Jinja2Templates from fastapi.exceptions import HTTPException from pydantic import EmailStr @@ -15,6 +15,7 @@ ) from utils.core.models import User, Role, Account, Invitation, Organization from utils.core.enums import ValidPermissions +from utils.app.enums import AppPermissions from utils.core.invitations import send_invitation_email, process_invitation from exceptions.http_exceptions import ( UserIsAlreadyMemberError, @@ -23,15 +24,15 @@ OrganizationNotFoundError, InvitationEmailSendError, InvalidInvitationTokenError, + InvitationNotFoundError, + InsufficientPermissionsError, + RoleNotFoundError, ) from exceptions.exceptions import EmailSendFailedError from utils.core.htmx import is_htmx_request, append_toast - -# Import the account router to generate URLs for login/register +from utils.core.organizations import load_org_for_members_partial from routers.core.account import router as account_router -from routers.core.organization import ( - router as org_router, -) # Already imported, check usage +from routers.core.organization import router as org_router # Setup logger logger = getLogger("uvicorn.error") @@ -80,15 +81,14 @@ async def create_invitation( # Check if the current user has permission to invite users to this organization if not current_user.has_permission(ValidPermissions.INVITE_USER, organization): - raise HTTPException( - status_code=403, - detail="You don't have permission to invite users to this organization", + raise InsufficientPermissionsError( + "You don't have permission to invite users to this organization" ) # Verify the role exists and belongs to this organization role = session.get(Role, role_id) if not role: - raise HTTPException(status_code=404, detail="Role not found") + raise RoleNotFoundError() if role.organization_id != organization_id: raise InvalidRoleForOrganizationError() @@ -161,11 +161,20 @@ async def create_invitation( # HTMX: return partial; non-HTMX: PRG redirect if is_htmx_request(request): - active_invitations = Invitation.get_active_for_org(session, organization_id) + organization, user_permissions, active_invitations = ( + load_org_for_members_partial(session, organization_id, current_user) + ) response = templates.TemplateResponse( request, - "organization/partials/invitations_list.html", - {"active_invitations": active_invitations}, + "organization/partials/members_table.html", + { + "organization": organization, + "active_invitations": active_invitations, + "user": current_user, + "user_permissions": user_permissions, + "ValidPermissions": ValidPermissions, + "all_permissions": list(ValidPermissions) + list(AppPermissions), + }, ) response.headers["HX-Trigger"] = "modalDismiss" return append_toast( @@ -174,6 +183,58 @@ async def create_invitation( return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) +@router.post("/delete", name="delete_invitation", response_class=RedirectResponse) +async def delete_invitation( + request: Request, + current_user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session), + invitation_id: int = Form( + ..., title="Invitation ID", description="ID of the invitation to delete" + ), + organization_id: int = Form( + ..., + title="Organization ID", + description="ID of the organization the invitation belongs to", + ), +) -> Response: + organization = session.get(Organization, organization_id) + if not organization: + raise OrganizationNotFoundError() + + if not current_user.has_permission(ValidPermissions.INVITE_USER, organization): + raise InsufficientPermissionsError( + "You don't have permission to cancel invitations for this organization" + ) + + invitation = session.get(Invitation, invitation_id) + if not invitation or invitation.organization_id != organization_id: + raise InvitationNotFoundError() + + session.delete(invitation) + session.commit() + + if is_htmx_request(request): + organization, user_permissions, active_invitations = ( + load_org_for_members_partial(session, organization_id, current_user) + ) + response = templates.TemplateResponse( + request, + "organization/partials/members_table.html", + { + "organization": organization, + "active_invitations": active_invitations, + "user": current_user, + "user_permissions": user_permissions, + "ValidPermissions": ValidPermissions, + "all_permissions": list(ValidPermissions) + list(AppPermissions), + }, + ) + return append_toast( + response, request, templates, "Invitation cancelled successfully." + ) + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) + + @router.get("/accept", name="accept_invitation") async def accept_invitation( invitation: Invitation = Depends(get_valid_invitation), diff --git a/routers/core/role.py b/routers/core/role.py index 89c038c..dd57649 100644 --- a/routers/core/role.py +++ b/routers/core/role.py @@ -15,8 +15,8 @@ utc_now, User, DataIntegrityError, - Organization, ) +from utils.core.organizations import load_org_for_roles_partial from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions from exceptions.http_exceptions import ( @@ -36,26 +36,6 @@ templates = Jinja2Templates(directory="templates") -def _load_org_for_roles_partial( - session: Session, organization_id: int, user: User -) -> tuple: - """Re-query org with roles/users/permissions and compute user_permissions.""" - organization = session.exec( - select(Organization) - .where(Organization.id == organization_id) - .options( - selectinload(Organization.roles).selectinload(Role.users), - selectinload(Organization.roles).selectinload(Role.permissions), - ) - ).first() - user_permissions = set() - for role in user.roles: - if role.organization_id == organization_id: - for permission in role.permissions: - user_permissions.add(permission.name) - return organization, user_permissions - - # --- Routes --- @@ -114,7 +94,7 @@ def create_role( raise RoleAlreadyExistsError() if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial( + organization, user_permissions = load_org_for_roles_partial( session, organization_id, user ) response = templates.TemplateResponse( @@ -221,7 +201,7 @@ def update_role( session.refresh(db_role) if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial( + organization, user_permissions = load_org_for_roles_partial( session, organization_id, user ) response = templates.TemplateResponse( @@ -282,7 +262,7 @@ def delete_role( session.commit() if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial( + organization, user_permissions = load_org_for_roles_partial( session, organization_id, user ) response = templates.TemplateResponse( diff --git a/routers/core/user.py b/routers/core/user.py index 1d04b93..119b2a5 100644 --- a/routers/core/user.py +++ b/routers/core/user.py @@ -10,9 +10,8 @@ AccountEmail, DataIntegrityError, Organization, - Role, - Invitation, ) +from utils.core.organizations import load_org_for_members_partial from utils.core.auth import MAX_EMAILS_PER_ACCOUNT from utils.core.dependencies import ( get_authenticated_user, @@ -40,32 +39,6 @@ templates = Jinja2Templates(directory="templates") -def _load_org_for_members_partial( - session: Session, organization_id: int, user: User -) -> tuple: - """Re-query org with members fully loaded and compute user_permissions.""" - organization = session.exec( - select(Organization) - .where(Organization.id == organization_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() - user_permissions = set() - for role in user.roles: - if role.organization_id == organization_id: - for permission in role.permissions: - user_permissions.add(permission.name) - active_invitations = Invitation.get_active_for_org(session, organization_id) - return organization, user_permissions, active_invitations - - # --- Routes --- @@ -267,7 +240,7 @@ def update_user_role( if is_htmx_request(request): organization, user_permissions, active_invitations = ( - _load_org_for_members_partial(session, organization_id, user) + load_org_for_members_partial(session, organization_id, user) ) response = templates.TemplateResponse( request, @@ -341,7 +314,7 @@ def remove_user_from_organization( if is_htmx_request(request): organization, user_permissions, active_invitations = ( - _load_org_for_members_partial(session, organization_id, user) + load_org_for_members_partial(session, organization_id, user) ) response = templates.TemplateResponse( request, diff --git a/static/css/extras.css b/static/css/extras.css index b126461..7822548 100644 --- a/static/css/extras.css +++ b/static/css/extras.css @@ -5,4 +5,19 @@ input:invalid:not(:placeholder-shown) + .invalid-feedback { display: block; +} + +/* Align pending-invitation Cancel buttons with card-header actions (e.g. Invite Member). */ +.card-body .invitation-list.list-group-flush { + margin-left: calc(-1 * var(--bs-card-spacer-x)); + margin-right: calc(-1 * var(--bs-card-spacer-x)); +} + +.card-body .invitation-list .invitation-list-item { + padding-left: var(--bs-card-spacer-x); + padding-right: var(--bs-card-spacer-x); +} + +.invitation-cancel-form .btn { + min-width: 6.75rem; } \ No newline at end of file diff --git a/templates/organization/modals/members_card.html b/templates/organization/modals/members_card.html index dd91bf5..76fa7a9 100644 --- a/templates/organization/modals/members_card.html +++ b/templates/organization/modals/members_card.html @@ -59,7 +59,8 @@