diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index 1aabdc9..f745596 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -146,16 +146,6 @@ def __init__(self): ) -class ActiveInvitationExistsError(HTTPException): - """Raised when trying to invite a user for whom an active invitation already exists.""" - - def __init__(self): - super().__init__( - status_code=409, - detail="An active invitation already exists for this email address in this organization.", - ) - - class InvalidRoleForOrganizationError(HTTPException): """Raised when a role provided does not belong to the target organization. Note: If the role ID simply doesn't exist, a standard 404 RoleNotFoundError should be raised. @@ -186,10 +176,31 @@ def __init__(self): class InvalidInvitationTokenError(HTTPException): - """Raised when an invitation token is invalid, expired, or not found.""" + """Raised when an invitation token is missing, superseded, or already used.""" + + def __init__(self): + super().__init__( + status_code=404, + detail=( + "This invitation link is no longer valid. " + "If you were invited again recently, use the link from the " + "most recent invitation email." + ), + ) + + +class ExpiredInvitationTokenError(HTTPException): + """Raised when an invitation token exists but has passed its expiry date.""" def __init__(self): - super().__init__(status_code=404, detail="Invitation not found or expired") + super().__init__( + status_code=404, + detail=( + "This invitation link has expired. Ask your organization " + "administrator to send a new invitation, then use the link in " + "the latest email." + ), + ) class InvitationEmailMismatchError(HTTPException): diff --git a/routers/core/account.py b/routers/core/account.py index ee4f047..7d93e7c 100644 --- a/routers/core/account.py +++ b/routers/core/account.py @@ -45,6 +45,7 @@ get_account_from_recovery_token, get_account_from_credentials, require_unauthenticated_client, + require_unauthenticated_unless_invitation_warning, get_verified_account, ) from exceptions.http_exceptions import ( @@ -54,14 +55,17 @@ EmailNotVerifiedError, MaxEmailsReachedError, PasswordValidationError, - InvalidInvitationTokenError, InvitationEmailMismatchError, InvitationProcessingError, ) from routers.core.dashboard import router as dashboard_router from routers.core.user import router as user_router from routers.core.organization import router as org_router -from utils.core.invitations import process_invitation +from utils.core.invitations import ( + process_invitation, + require_active_invitation_by_token, + get_invitation_token_warning, +) from utils.core.rate_limit import ( check_login_ip_rate_limit, check_login_email_rate_limit, @@ -147,37 +151,56 @@ def logout( @router.get("/login") async def read_login( request: Request, - _: None = Depends(require_unauthenticated_client), + _: None = Depends(require_unauthenticated_unless_invitation_warning), invitation_token: Optional[str] = Query(None), + user: Optional[User] = Depends(get_optional_user), + session: Session = Depends(get_session), ): """ Render login page or redirect to dashboard if already logged in. """ + invitation_token_warning = ( + get_invitation_token_warning(session, invitation_token) + if invitation_token + else None + ) return templates.TemplateResponse( request, "account/login.html", - {"user": None, "invitation_token": invitation_token}, + { + "user": user, + "invitation_token": invitation_token, + "invitation_token_warning": invitation_token_warning, + }, ) @router.get("/register") async def read_register( request: Request, - _: None = Depends(require_unauthenticated_client), + _: None = Depends(require_unauthenticated_unless_invitation_warning), email: Optional[EmailStr] = Query(None), invitation_token: Optional[str] = Query(None), + user: Optional[User] = Depends(get_optional_user), + session: Session = Depends(get_session), ): """ Render registration page or redirect to dashboard if already logged in. """ + invitation_token_warning = ( + get_invitation_token_warning(session, invitation_token) + if invitation_token + else None + ) return templates.TemplateResponse( request, "account/register.html", { - "user": None, + "user": user, "password_pattern": HTML_PASSWORD_PATTERN, "email": email, "invitation_token": invitation_token, + "invitation_token_warning": invitation_token_warning, }, ) @@ -270,6 +293,18 @@ async def register( """ Register a new user account, optionally processing an invitation. """ + pending_invitation: Optional[Invitation] = None + if invitation_token: + pending_invitation = require_active_invitation_by_token( + session, invitation_token + ) + if email != pending_invitation.invitee_email: + logger.warning( + f"Invitation email mismatch for token {invitation_token} during registration. " + f"Account: {email}, Invitation: {pending_invitation.invitee_email}" + ) + raise InvitationEmailMismatchError() + # Check if the email is already registered existing_account: Optional[Account] = session.exec( select(Account).where(Account.email == email) @@ -313,46 +348,27 @@ async def register( redirect_url = dashboard_router.url_path_for("read_dashboard") # Process invitation if token is provided (BEFORE final commit) - if invitation_token: + if pending_invitation: logger.info( f"Registration attempt with invitation token: {invitation_token} for email {email}" ) - # Fetch the invitation - statement = select(Invitation).where(Invitation.token == invitation_token) - invitation = session.exec(statement).first() - - if not invitation or not invitation.is_active(): - logger.warning( - f"Invalid or inactive invitation token provided during registration: {invitation_token}" - ) - # Consider raising a more generic error to avoid exposing token validity - raise InvalidInvitationTokenError() - - # Verify email matches - if email != invitation.invitee_email: - logger.warning( - f"Invitation email mismatch for token {invitation_token} during registration. " - f"Account: {email}, Invitation: {invitation.invitee_email}" - ) - # Consider raising a more generic error to avoid confirming email existence - raise InvitationEmailMismatchError() # Process the invitation (adds changes to the session) try: logger.info( - f"Processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration." + f"Processing invitation {pending_invitation.id} for new user {new_user.name} ({email}) during registration." ) - process_invitation(invitation, new_user, session) + process_invitation(pending_invitation, new_user, session) # Set redirect to the organization page redirect_url = org_router.url_path_for( - "read_organization", org_id=invitation.organization_id + "read_organization", org_id=pending_invitation.organization_id ) logger.info( - f"Redirecting new user {new_user.name} to organization {invitation.organization_id} after accepting invitation {invitation.id}." + f"Redirecting new user {new_user.name} to organization {pending_invitation.organization_id} after accepting invitation {pending_invitation.id}." ) except Exception as e: logger.error( - f"Error processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration: {e}", + f"Error processing invitation {pending_invitation.id} for new user {new_user.name} ({email}) during registration: {e}", exc_info=True, ) session.rollback() @@ -434,15 +450,7 @@ async def login( logger.info( f"Login attempt with invitation token: {invitation_token} for account {account.email}" ) - # Fetch the invitation - statement = select(Invitation).where(Invitation.token == invitation_token) - invitation = session.exec(statement).first() - - if not invitation or not invitation.is_active(): - logger.warning( - f"Invalid or inactive invitation token provided during login: {invitation_token}" - ) - raise InvalidInvitationTokenError() + invitation = require_active_invitation_by_token(session, invitation_token) # Verify email matches (check primary and any verified secondary emails) account_emails = session.exec( diff --git a/routers/core/invitation.py b/routers/core/invitation.py index 07f4554..473ec42 100644 --- a/routers/core/invitation.py +++ b/routers/core/invitation.py @@ -1,3 +1,4 @@ +from datetime import timedelta from uuid import uuid4 from typing import Optional from fastapi import APIRouter, Depends, Form, Query, Request, status @@ -13,17 +14,19 @@ get_optional_user, get_session, ) -from utils.core.models import User, Role, Account, Invitation, Organization +from utils.core.models import User, Role, Account, Invitation, Organization, utc_now from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions -from utils.core.invitations import send_invitation_email, process_invitation +from utils.core.invitations import ( + send_invitation_email, + process_invitation, + require_active_invitation_by_token, +) from exceptions.http_exceptions import ( UserIsAlreadyMemberError, - ActiveInvitationExistsError, InvalidRoleForOrganizationError, OrganizationNotFoundError, InvitationEmailSendError, - InvalidInvitationTokenError, InvitationNotFoundError, InsufficientPermissionsError, RoleNotFoundError, @@ -34,7 +37,6 @@ from routers.core.account import router as account_router from routers.core.organization import router as org_router -# Setup logger logger = getLogger("uvicorn.error") templates = Jinja2Templates(directory="templates") @@ -45,16 +47,64 @@ ) -# Dependency to get a valid invitation def get_valid_invitation( token: str = Query(...), session: Session = Depends(get_session) ) -> Invitation: """Dependency to retrieve a valid, active invitation based on the token.""" - statement = select(Invitation).where(Invitation.token == token) - invitation = session.exec(statement).first() - if not invitation or not invitation.is_active(): - raise InvalidInvitationTokenError() - return invitation + return require_active_invitation_by_token(session, token) + + +def _redirect_for_inactive_invitation( + invitation: Optional[Invitation], + token: str, + session: Session, +) -> RedirectResponse: + """Send user to register/login so invitation_token_warning banners can display.""" + if invitation: + existing_account = session.exec( + select(Account).where(Account.email == invitation.invitee_email) + ).first() + if existing_account: + login_url = account_router.url_path_for("read_login") + redirect_url = f"{login_url}?invitation_token={token}" + else: + register_url = account_router.url_path_for("read_register") + redirect_url = ( + f"{register_url}?email={invitation.invitee_email}" + f"&invitation_token={token}" + ) + else: + login_url = account_router.url_path_for("read_login") + redirect_url = f"{login_url}?invitation_token={token}" + + return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER) + + +def _members_table_response( + request: Request, + session: Session, + organization_id: int, + current_user: User, + toast_message: str | None = None, +) -> Response: + organization, user_permissions, pending_invitations = load_org_for_members_partial( + session, organization_id, current_user + ) + response = templates.TemplateResponse( + request, + "organization/partials/members_table.html", + { + "organization": organization, + "pending_invitations": pending_invitations, + "user": current_user, + "user_permissions": user_permissions, + "ValidPermissions": ValidPermissions, + "all_permissions": list(ValidPermissions) + list(AppPermissions), + }, + ) + if toast_message: + response = append_toast(response, request, templates, toast_message) + return response @router.post("/", name="create_invitation") @@ -74,48 +124,37 @@ async def create_invitation( description="ID of the organization to invite the user to", ), ): - # Fetch the organization organization = session.get(Organization, organization_id) if not organization: raise OrganizationNotFoundError() - # Check if the current user has permission to invite users to this organization if not current_user.has_permission(ValidPermissions.INVITE_USER, 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 RoleNotFoundError() if role.organization_id != organization_id: raise InvalidRoleForOrganizationError() - # Check if invitee is already a member of the organization existing_account = session.exec( select(Account).where(Account.email == invitee_email) ).first() if existing_account: - # Check if any user with this account is already a member existing_user = session.exec( select(User).where(User.account_id == existing_account.id) ).first() if existing_user: - # Check if user has any role in this organization if any( role.organization_id == organization_id for role in existing_user.roles ): raise UserIsAlreadyMemberError() - # Check for active invitations with the same email - active_invitations = Invitation.get_active_for_org(session, organization_id) - if any( - invitation.invitee_email == invitee_email for invitation in active_invitations - ): - raise ActiveInvitationExistsError() + Invitation.invalidate_pending_for_email(session, organization_id, invitee_email) + session.flush() - # Create the invitation token = str(uuid4()) invitation = Invitation( organization_id=organization_id, @@ -127,30 +166,23 @@ async def create_invitation( session.add(invitation) try: - # Refresh to ensure relationships are loaded *before* sending email - session.flush() # Ensure invitation gets an ID if needed by email sender, flush changes + session.flush() session.refresh(invitation) - # Ensure organization is loaded before passing to email function - # (May already be loaded, but explicit refresh is safer) if not invitation.organization: - session.refresh(organization) # Refresh the org object fetched earlier - invitation.organization = organization # Assign if needed + session.refresh(organization) + invitation.organization = organization - # Send email synchronously BEFORE committing send_invitation_email(invitation, session) - - # Commit *only* if email sending was successful session.commit() - session.refresh(invitation) # Refresh again after commit if needed elsewhere + session.refresh(invitation) except EmailSendFailedError as e: logger.error( f"Invitation email failed for {invitee_email} in org {organization_id}: {e}" ) - session.rollback() # Rollback the invitation creation - raise InvitationEmailSendError() # Raise HTTP 500 + session.rollback() + raise InvitationEmailSendError() except Exception as e: - # Catch any other unexpected errors during flush/refresh/email/commit logger.error( f"Unexpected error during invitation creation/sending for {invitee_email} " f"in org {organization_id}: {e}", @@ -159,26 +191,87 @@ async def create_invitation( session.rollback() raise HTTPException(status_code=500, detail="An unexpected error occurred.") - # HTMX: return partial; non-HTMX: PRG redirect if is_htmx_request(request): - organization, user_permissions, active_invitations = ( - load_org_for_members_partial(session, organization_id, current_user) - ) - response = templates.TemplateResponse( + response = _members_table_response( 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), - }, + session, + organization_id, + current_user, + "Invitation sent successfully.", ) response.headers["HX-Trigger"] = "modalDismiss" - return append_toast( - response, request, templates, "Invitation sent successfully." + return response + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) + + +@router.post("/resend", name="resend_invitation", response_class=RedirectResponse) +async def resend_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 resend" + ), + 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 resend invitations for this organization" + ) + + invitation = session.get(Invitation, invitation_id) + if ( + not invitation + or invitation.organization_id != organization_id + or invitation.used + ): + raise InvitationNotFoundError() + + invitation.token = str(uuid4()) + invitation.expires_at = utc_now() + timedelta(days=7) + + try: + session.flush() + session.refresh(invitation) + if not invitation.organization: + session.refresh(organization) + invitation.organization = organization + + send_invitation_email(invitation, session) + session.commit() + session.refresh(invitation) + + except EmailSendFailedError as e: + logger.error( + f"Invitation resend failed for {invitation.invitee_email} " + f"in org {organization_id}: {e}" + ) + session.rollback() + raise InvitationEmailSendError() + except Exception as e: + logger.error( + f"Unexpected error during invitation resend for {invitation.invitee_email} " + f"in org {organization_id}: {e}", + exc_info=True, + ) + session.rollback() + raise HTTPException(status_code=500, detail="An unexpected error occurred.") + + if is_htmx_request(request): + return _members_table_response( + request, + session, + organization_id, + current_user, + "Invitation resent.", ) return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) @@ -214,46 +307,38 @@ async def 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( + return _members_table_response( 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." + session, + organization_id, + current_user, + "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), + token: str = Query(...), current_user: Optional[User] = Depends(get_optional_user), session: Session = Depends(get_session), ): """Handles the acceptance of an invitation via the link in the email.""" - # Check if an account exists for the invitee email + invitation = session.exec( + select(Invitation).where(Invitation.token == token) + ).first() + + if not invitation or not invitation.is_active(): + return _redirect_for_inactive_invitation(invitation, token, session) + account_statement = select(Account).where(Account.email == invitation.invitee_email) existing_account = session.exec(account_statement).first() if existing_account: - # Account exists - check if user is logged in and matches the invitation if current_user and current_user.account_id == existing_account.id: - # Ensure the account relationship is loaded before accessing its email if not current_user.account: session.refresh(current_user, attribute_names=["account"]) - # Check if refreshed account has an email (should always exist, but good practice) if not current_user.account or not current_user.account.email: logger.error( f"User {current_user.id} is missing account details after refresh." @@ -263,14 +348,12 @@ async def accept_invitation( detail="Internal server error retrieving user account.", ) - # Logged in as the correct user, process the invitation directly logger.info( f"User {current_user.id} ({current_user.account.email}) accepting invitation {invitation.id} directly." ) try: process_invitation(invitation, current_user, session) session.commit() - # Redirect to the organization page redirect_url = org_router.url_path_for( "read_organization", org_id=invitation.organization_id ) @@ -283,13 +366,10 @@ async def accept_invitation( exc_info=True, ) session.rollback() - # Re-raise or return a generic error response raise HTTPException( status_code=500, detail="Failed to process invitation." ) else: - # Account exists, but user is not logged in or is the wrong user - # Redirect to login, passing the token logger.info( f"Invitation {invitation.id} requires login for {invitation.invitee_email}. Redirecting." ) @@ -299,7 +379,6 @@ async def accept_invitation( url=redirect_url_with_token, status_code=status.HTTP_303_SEE_OTHER ) else: - # Account does not exist - redirect to registration logger.info( f"Invitation {invitation.id} requires registration for {invitation.invitee_email}. Redirecting." ) diff --git a/routers/core/organization.py b/routers/core/organization.py index 9c30180..4c79312 100644 --- a/routers/core/organization.py +++ b/routers/core/organization.py @@ -69,8 +69,8 @@ async def read_organization( ) ).first() - # Fetch active invitations for the organization - active_invitations = Invitation.get_active_for_org(session, org_id) + # Fetch pending invitations for the organization + pending_invitations = Invitation.get_pending_for_org(session, org_id) # Pass all required context to the template return templates.TemplateResponse( @@ -82,7 +82,7 @@ async def read_organization( "user_permissions": user_permissions, "ValidPermissions": ValidPermissions, "all_permissions": list(ValidPermissions) + list(AppPermissions), - "active_invitations": active_invitations, + "pending_invitations": pending_invitations, }, ) diff --git a/routers/core/user.py b/routers/core/user.py index 119b2a5..fffebfe 100644 --- a/routers/core/user.py +++ b/routers/core/user.py @@ -239,7 +239,7 @@ def update_user_role( session.commit() if is_htmx_request(request): - organization, user_permissions, active_invitations = ( + organization, user_permissions, pending_invitations = ( load_org_for_members_partial(session, organization_id, user) ) response = templates.TemplateResponse( @@ -247,7 +247,7 @@ def update_user_role( "organization/partials/members_table.html", { "organization": organization, - "active_invitations": active_invitations, + "pending_invitations": pending_invitations, "user": user, "user_permissions": user_permissions, "ValidPermissions": ValidPermissions, @@ -313,7 +313,7 @@ def remove_user_from_organization( session.commit() if is_htmx_request(request): - organization, user_permissions, active_invitations = ( + organization, user_permissions, pending_invitations = ( load_org_for_members_partial(session, organization_id, user) ) response = templates.TemplateResponse( @@ -321,7 +321,7 @@ def remove_user_from_organization( "organization/partials/members_table.html", { "organization": organization, - "active_invitations": active_invitations, + "pending_invitations": pending_invitations, "user": user, "user_permissions": user_permissions, "ValidPermissions": ValidPermissions, diff --git a/static/css/extras.css b/static/css/extras.css index 7822548..ca7a5db 100644 --- a/static/css/extras.css +++ b/static/css/extras.css @@ -18,6 +18,11 @@ input:invalid:not(:placeholder-shown) + .invalid-feedback { padding-right: var(--bs-card-spacer-x); } +.invitation-cancel-form .btn { + min-width: 6.75rem; +} + +.invitation-resend-form .btn-invitation-resend, .invitation-cancel-form .btn { min-width: 6.75rem; } \ No newline at end of file diff --git a/templates/account/login.html b/templates/account/login.html index 234e50e..168f8cd 100644 --- a/templates/account/login.html +++ b/templates/account/login.html @@ -6,6 +6,15 @@ {% block auth_content %}