Skip to content

Commit 05bd468

Browse files
rafizamankhanchriscarrollsmithcursoragent
committed
add invalidate old invites (#194)
* add invalidate old invites * Remove obsolete exception class * Improve invitation UX with resend, redirects, and pending visibility. Redirect inactive accept links to login/register with warnings, allow logged-in users to see those banners, add resend and expired-invite admin controls, and show all pending invites with status badges. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: chriscarrollsmith <chriscarrollsmith@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f85ba51 commit 05bd468

17 files changed

Lines changed: 863 additions & 194 deletions

File tree

exceptions/http_exceptions.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,6 @@ def __init__(self):
146146
)
147147

148148

149-
class ActiveInvitationExistsError(HTTPException):
150-
"""Raised when trying to invite a user for whom an active invitation already exists."""
151-
152-
def __init__(self):
153-
super().__init__(
154-
status_code=409,
155-
detail="An active invitation already exists for this email address in this organization.",
156-
)
157-
158-
159149
class InvalidRoleForOrganizationError(HTTPException):
160150
"""Raised when a role provided does not belong to the target organization.
161151
Note: If the role ID simply doesn't exist, a standard 404 RoleNotFoundError should be raised.
@@ -186,10 +176,31 @@ def __init__(self):
186176

187177

188178
class InvalidInvitationTokenError(HTTPException):
189-
"""Raised when an invitation token is invalid, expired, or not found."""
179+
"""Raised when an invitation token is missing, superseded, or already used."""
180+
181+
def __init__(self):
182+
super().__init__(
183+
status_code=404,
184+
detail=(
185+
"This invitation link is no longer valid. "
186+
"If you were invited again recently, use the link from the "
187+
"most recent invitation email."
188+
),
189+
)
190+
191+
192+
class ExpiredInvitationTokenError(HTTPException):
193+
"""Raised when an invitation token exists but has passed its expiry date."""
190194

191195
def __init__(self):
192-
super().__init__(status_code=404, detail="Invitation not found or expired")
196+
super().__init__(
197+
status_code=404,
198+
detail=(
199+
"This invitation link has expired. Ask your organization "
200+
"administrator to send a new invitation, then use the link in "
201+
"the latest email."
202+
),
203+
)
193204

194205

195206
class InvitationEmailMismatchError(HTTPException):

routers/core/account.py

Lines changed: 48 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
get_account_from_recovery_token,
4747
get_account_from_credentials,
4848
require_unauthenticated_client,
49+
require_unauthenticated_unless_invitation_warning,
4950
get_verified_account,
5051
)
5152
from exceptions.http_exceptions import (
@@ -55,14 +56,17 @@
5556
EmailNotVerifiedError,
5657
MaxEmailsReachedError,
5758
PasswordValidationError,
58-
InvalidInvitationTokenError,
5959
InvitationEmailMismatchError,
6060
InvitationProcessingError,
6161
)
6262
from routers.core.dashboard import router as dashboard_router
6363
from routers.core.user import router as user_router
6464
from routers.core.organization import router as org_router
65-
from utils.core.invitations import process_invitation
65+
from utils.core.invitations import (
66+
process_invitation,
67+
require_active_invitation_by_token,
68+
get_invitation_token_warning,
69+
)
6670
from utils.core.rate_limit import (
6771
check_login_ip_rate_limit,
6872
check_login_email_rate_limit,
@@ -152,38 +156,57 @@ def logout(
152156
@router.get("/login")
153157
async def read_login(
154158
request: Request,
155-
_: None = Depends(require_unauthenticated_client),
159+
_: None = Depends(require_unauthenticated_unless_invitation_warning),
156160
invitation_token: Optional[str] = Query(None),
161+
user: Optional[User] = Depends(get_optional_user),
162+
session: Session = Depends(get_session),
157163
):
158164
"""
159165
Render login page or redirect to dashboard if already logged in.
160166
"""
167+
invitation_token_warning = (
168+
get_invitation_token_warning(session, invitation_token)
169+
if invitation_token
170+
else None
171+
)
161172
return templates.TemplateResponse(
162173
request,
163174
"account/login.html",
164-
{"user": None, "invitation_token": invitation_token},
175+
{
176+
"user": user,
177+
"invitation_token": invitation_token,
178+
"invitation_token_warning": invitation_token_warning,
179+
},
165180
)
166181

167182

168183
@router.get("/register")
169184
async def read_register(
170185
request: Request,
171-
_: None = Depends(require_unauthenticated_client),
186+
_: None = Depends(require_unauthenticated_unless_invitation_warning),
172187
email: Optional[EmailStr] = Query(None),
173188
invitation_token: Optional[str] = Query(None),
189+
user: Optional[User] = Depends(get_optional_user),
190+
session: Session = Depends(get_session),
174191
):
175192
"""
176193
Render registration page or redirect to dashboard if already logged in.
177194
"""
195+
invitation_token_warning = (
196+
get_invitation_token_warning(session, invitation_token)
197+
if invitation_token
198+
else None
199+
)
178200
return templates.TemplateResponse(
179201
request,
180202
"account/register.html",
181203
{
182-
"user": None,
204+
"user": user,
183205
"password_pattern": HTML_PASSWORD_PATTERN,
184206
"email": email,
185207
"invitation_token": invitation_token,
186208
"host_name": os.getenv("HOST_NAME", "our platform"),
209+
"invitation_token_warning": invitation_token_warning,
187210
},
188211
)
189212

@@ -279,6 +302,18 @@ async def register(
279302
"""
280303
Register a new user account, optionally processing an invitation.
281304
"""
305+
pending_invitation: Optional[Invitation] = None
306+
if invitation_token:
307+
pending_invitation = require_active_invitation_by_token(
308+
session, invitation_token
309+
)
310+
if email != pending_invitation.invitee_email:
311+
logger.warning(
312+
f"Invitation email mismatch for token {invitation_token} during registration. "
313+
f"Account: {email}, Invitation: {pending_invitation.invitee_email}"
314+
)
315+
raise InvitationEmailMismatchError()
316+
282317
# Check if the email is already registered
283318
existing_account: Optional[Account] = session.exec(
284319
select(Account).where(Account.email == email)
@@ -326,46 +361,27 @@ async def register(
326361
redirect_url = dashboard_router.url_path_for("read_dashboard")
327362

328363
# Process invitation if token is provided (BEFORE final commit)
329-
if invitation_token:
364+
if pending_invitation:
330365
logger.info(
331366
f"Registration attempt with invitation token: {invitation_token} for email {email}"
332367
)
333-
# Fetch the invitation
334-
statement = select(Invitation).where(Invitation.token == invitation_token)
335-
invitation = session.exec(statement).first()
336-
337-
if not invitation or not invitation.is_active():
338-
logger.warning(
339-
f"Invalid or inactive invitation token provided during registration: {invitation_token}"
340-
)
341-
# Consider raising a more generic error to avoid exposing token validity
342-
raise InvalidInvitationTokenError()
343-
344-
# Verify email matches
345-
if email != invitation.invitee_email:
346-
logger.warning(
347-
f"Invitation email mismatch for token {invitation_token} during registration. "
348-
f"Account: {email}, Invitation: {invitation.invitee_email}"
349-
)
350-
# Consider raising a more generic error to avoid confirming email existence
351-
raise InvitationEmailMismatchError()
352368

353369
# Process the invitation (adds changes to the session)
354370
try:
355371
logger.info(
356-
f"Processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration."
372+
f"Processing invitation {pending_invitation.id} for new user {new_user.name} ({email}) during registration."
357373
)
358-
process_invitation(invitation, new_user, session)
374+
process_invitation(pending_invitation, new_user, session)
359375
# Set redirect to the organization page
360376
redirect_url = org_router.url_path_for(
361-
"read_organization", org_id=invitation.organization_id
377+
"read_organization", org_id=pending_invitation.organization_id
362378
)
363379
logger.info(
364-
f"Redirecting new user {new_user.name} to organization {invitation.organization_id} after accepting invitation {invitation.id}."
380+
f"Redirecting new user {new_user.name} to organization {pending_invitation.organization_id} after accepting invitation {pending_invitation.id}."
365381
)
366382
except Exception as e:
367383
logger.error(
368-
f"Error processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration: {e}",
384+
f"Error processing invitation {pending_invitation.id} for new user {new_user.name} ({email}) during registration: {e}",
369385
exc_info=True,
370386
)
371387
session.rollback()
@@ -447,15 +463,7 @@ async def login(
447463
logger.info(
448464
f"Login attempt with invitation token: {invitation_token} for account {account.email}"
449465
)
450-
# Fetch the invitation
451-
statement = select(Invitation).where(Invitation.token == invitation_token)
452-
invitation = session.exec(statement).first()
453-
454-
if not invitation or not invitation.is_active():
455-
logger.warning(
456-
f"Invalid or inactive invitation token provided during login: {invitation_token}"
457-
)
458-
raise InvalidInvitationTokenError()
466+
invitation = require_active_invitation_by_token(session, invitation_token)
459467

460468
# Verify email matches (check primary and any verified secondary emails)
461469
account_emails = session.exec(

0 commit comments

Comments
 (0)