Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions exceptions/http_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""

Expand Down
87 changes: 74 additions & 13 deletions routers/core/invitation.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand Down
28 changes: 4 additions & 24 deletions routers/core/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 ---


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 3 additions & 30 deletions routers/core/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ---


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions static/css/extras.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 8 additions & 17 deletions templates/organization/modals/members_card.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
<form method="POST" action="{{ url_for('remove_user_from_organization') }}" class="d-inline"
hx-post="{{ url_for('remove_user_from_organization') }}"
hx-target="#members-card-content"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-confirm="Remove {{ member.name }} from this organization?">
<input type="hidden" name="user_id" value="{{ member.id }}">
<input type="hidden" name="organization_id" value="{{ organization.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger" {% if member.id == user.id %}disabled{% endif %}>
Expand All @@ -76,23 +77,13 @@
</div>
{% endif %}

{# Pending Invitations Section #}
<hr class="my-4">
<h4>Pending Invitations</h4>
{% if active_invitations %}
<ul class="list-group list-group-flush" id="invitations-list">
{% for inv in active_invitations %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
<small class="text-muted">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
</li>
{% endfor %}
</ul>
{% else %}
<ul class="list-group list-group-flush" id="invitations-list">
<li class="list-group-item text-muted">No pending invitations.</li>
</ul>
{% endif %}
<h4 class="mb-3">Pending Invitations</h4>
<ul class="list-group list-group-flush invitation-list" id="invitations-list">
{% with show_invitation_cancel=true %}
{% include 'organization/partials/invitations_list.html' %}
{% endwith %}
</ul>
</div>
</div>

Expand Down
20 changes: 17 additions & 3 deletions templates/organization/partials/invitations_list.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
{# Partial: <li> items for pending invitations. Swapped into <ul id="invitations-list">. #}
{# Set show_invitation_cancel=true via {% with %} to render Cancel controls (members card only). #}
{% for inv in active_invitations %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
<small class="text-muted">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
<li class="list-group-item invitation-list-item">
<div class="d-flex align-items-center">
<span class="me-auto">{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
<small class="text-muted invitation-list-expiry me-3">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
{% if show_invitation_cancel is defined and show_invitation_cancel and ValidPermissions is defined and user_permissions is defined and ValidPermissions.INVITE_USER in user_permissions %}
<form method="POST" action="{{ url_for('delete_invitation') }}" class="invitation-cancel-form mb-0"
hx-post="{{ url_for('delete_invitation') }}"
hx-target="#members-card-content"
hx-swap="innerHTML"
hx-confirm="Cancel invitation for {{ inv.invitee_email }}?">
<input type="hidden" name="invitation_id" value="{{ inv.id }}">
<input type="hidden" name="organization_id" value="{{ organization.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Cancel</button>
</form>
{% endif %}
</div>
</li>
{% else %}
<li class="list-group-item text-muted">No pending invitations.</li>
Expand Down
3 changes: 2 additions & 1 deletion templates/organization/partials/member_row.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
<form method="POST" action="{{ url_for('remove_user_from_organization') }}" class="d-inline"
hx-post="{{ url_for('remove_user_from_organization') }}"
hx-target="#members-card-content"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-confirm="Remove {{ member.name }} from this organization?">
<input type="hidden" name="user_id" value="{{ member.id }}">
<input type="hidden" name="organization_id" value="{{ organization.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger" {% if member.id == user.id %}disabled{% endif %}>
Expand Down
Loading
Loading