Skip to content

Commit 423bc39

Browse files
Merge remote-tracking branch 'origin/main' into hetzner
2 parents 8487f5a + 8c3fed8 commit 423bc39

19 files changed

Lines changed: 865 additions & 196 deletions

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):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "0.1.25"
3+
version = "0.1.26"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

routers/core/account.py

Lines changed: 48 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
get_account_from_recovery_token,
4646
get_account_from_credentials,
4747
require_unauthenticated_client,
48+
require_unauthenticated_unless_invitation_warning,
4849
get_verified_account,
4950
)
5051
from exceptions.http_exceptions import (
@@ -54,14 +55,17 @@
5455
EmailNotVerifiedError,
5556
MaxEmailsReachedError,
5657
PasswordValidationError,
57-
InvalidInvitationTokenError,
5858
InvitationEmailMismatchError,
5959
InvitationProcessingError,
6060
)
6161
from routers.core.dashboard import router as dashboard_router
6262
from routers.core.user import router as user_router
6363
from routers.core.organization import router as org_router
64-
from utils.core.invitations import process_invitation
64+
from utils.core.invitations import (
65+
process_invitation,
66+
require_active_invitation_by_token,
67+
get_invitation_token_warning,
68+
)
6569
from utils.core.rate_limit import (
6670
check_login_ip_rate_limit,
6771
check_login_email_rate_limit,
@@ -147,37 +151,56 @@ def logout(
147151
@router.get("/login")
148152
async def read_login(
149153
request: Request,
150-
_: None = Depends(require_unauthenticated_client),
154+
_: None = Depends(require_unauthenticated_unless_invitation_warning),
151155
invitation_token: Optional[str] = Query(None),
156+
user: Optional[User] = Depends(get_optional_user),
157+
session: Session = Depends(get_session),
152158
):
153159
"""
154160
Render login page or redirect to dashboard if already logged in.
155161
"""
162+
invitation_token_warning = (
163+
get_invitation_token_warning(session, invitation_token)
164+
if invitation_token
165+
else None
166+
)
156167
return templates.TemplateResponse(
157168
request,
158169
"account/login.html",
159-
{"user": None, "invitation_token": invitation_token},
170+
{
171+
"user": user,
172+
"invitation_token": invitation_token,
173+
"invitation_token_warning": invitation_token_warning,
174+
},
160175
)
161176

162177

163178
@router.get("/register")
164179
async def read_register(
165180
request: Request,
166-
_: None = Depends(require_unauthenticated_client),
181+
_: None = Depends(require_unauthenticated_unless_invitation_warning),
167182
email: Optional[EmailStr] = Query(None),
168183
invitation_token: Optional[str] = Query(None),
184+
user: Optional[User] = Depends(get_optional_user),
185+
session: Session = Depends(get_session),
169186
):
170187
"""
171188
Render registration page or redirect to dashboard if already logged in.
172189
"""
190+
invitation_token_warning = (
191+
get_invitation_token_warning(session, invitation_token)
192+
if invitation_token
193+
else None
194+
)
173195
return templates.TemplateResponse(
174196
request,
175197
"account/register.html",
176198
{
177-
"user": None,
199+
"user": user,
178200
"password_pattern": HTML_PASSWORD_PATTERN,
179201
"email": email,
180202
"invitation_token": invitation_token,
203+
"invitation_token_warning": invitation_token_warning,
181204
},
182205
)
183206

@@ -270,6 +293,18 @@ async def register(
270293
"""
271294
Register a new user account, optionally processing an invitation.
272295
"""
296+
pending_invitation: Optional[Invitation] = None
297+
if invitation_token:
298+
pending_invitation = require_active_invitation_by_token(
299+
session, invitation_token
300+
)
301+
if email != pending_invitation.invitee_email:
302+
logger.warning(
303+
f"Invitation email mismatch for token {invitation_token} during registration. "
304+
f"Account: {email}, Invitation: {pending_invitation.invitee_email}"
305+
)
306+
raise InvitationEmailMismatchError()
307+
273308
# Check if the email is already registered
274309
existing_account: Optional[Account] = session.exec(
275310
select(Account).where(Account.email == email)
@@ -313,46 +348,27 @@ async def register(
313348
redirect_url = dashboard_router.url_path_for("read_dashboard")
314349

315350
# Process invitation if token is provided (BEFORE final commit)
316-
if invitation_token:
351+
if pending_invitation:
317352
logger.info(
318353
f"Registration attempt with invitation token: {invitation_token} for email {email}"
319354
)
320-
# Fetch the invitation
321-
statement = select(Invitation).where(Invitation.token == invitation_token)
322-
invitation = session.exec(statement).first()
323-
324-
if not invitation or not invitation.is_active():
325-
logger.warning(
326-
f"Invalid or inactive invitation token provided during registration: {invitation_token}"
327-
)
328-
# Consider raising a more generic error to avoid exposing token validity
329-
raise InvalidInvitationTokenError()
330-
331-
# Verify email matches
332-
if email != invitation.invitee_email:
333-
logger.warning(
334-
f"Invitation email mismatch for token {invitation_token} during registration. "
335-
f"Account: {email}, Invitation: {invitation.invitee_email}"
336-
)
337-
# Consider raising a more generic error to avoid confirming email existence
338-
raise InvitationEmailMismatchError()
339355

340356
# Process the invitation (adds changes to the session)
341357
try:
342358
logger.info(
343-
f"Processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration."
359+
f"Processing invitation {pending_invitation.id} for new user {new_user.name} ({email}) during registration."
344360
)
345-
process_invitation(invitation, new_user, session)
361+
process_invitation(pending_invitation, new_user, session)
346362
# Set redirect to the organization page
347363
redirect_url = org_router.url_path_for(
348-
"read_organization", org_id=invitation.organization_id
364+
"read_organization", org_id=pending_invitation.organization_id
349365
)
350366
logger.info(
351-
f"Redirecting new user {new_user.name} to organization {invitation.organization_id} after accepting invitation {invitation.id}."
367+
f"Redirecting new user {new_user.name} to organization {pending_invitation.organization_id} after accepting invitation {pending_invitation.id}."
352368
)
353369
except Exception as e:
354370
logger.error(
355-
f"Error processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration: {e}",
371+
f"Error processing invitation {pending_invitation.id} for new user {new_user.name} ({email}) during registration: {e}",
356372
exc_info=True,
357373
)
358374
session.rollback()
@@ -434,15 +450,7 @@ async def login(
434450
logger.info(
435451
f"Login attempt with invitation token: {invitation_token} for account {account.email}"
436452
)
437-
# Fetch the invitation
438-
statement = select(Invitation).where(Invitation.token == invitation_token)
439-
invitation = session.exec(statement).first()
440-
441-
if not invitation or not invitation.is_active():
442-
logger.warning(
443-
f"Invalid or inactive invitation token provided during login: {invitation_token}"
444-
)
445-
raise InvalidInvitationTokenError()
453+
invitation = require_active_invitation_by_token(session, invitation_token)
446454

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

0 commit comments

Comments
 (0)