From 11dcfdad73bb84c332f59d481c704ff8ca53d1b0 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 29 Mar 2026 23:05:23 +0200 Subject: [PATCH] feat(tenancy): add team invitation acceptance page New standalone page at /store/{store_code}/invitation/accept?token=xxx where invited team members can: - Review their name and email (pre-filled from invitation) - Set their password - Accept the invitation Page handles all routing modes (dev path, platform path, prod subdomain, custom domain) via store context middleware. After acceptance, redirects to the platform-aware store login page. New service method get_invitation_info() validates the token and returns invitation details without modifying anything. Error states: expired token, already accepted, invalid token. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/tenancy/routes/pages/store.py | 39 ++- .../tenancy/services/store_team_service.py | 46 ++++ .../tenancy/store/invitation-accept.html | 224 ++++++++++++++++++ 3 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 app/modules/tenancy/templates/tenancy/store/invitation-accept.html diff --git a/app/modules/tenancy/routes/pages/store.py b/app/modules/tenancy/routes/pages/store.py index 909bbf3b..9c3196ee 100644 --- a/app/modules/tenancy/routes/pages/store.py +++ b/app/modules/tenancy/routes/pages/store.py @@ -10,7 +10,7 @@ Store pages for authentication and account management: - Settings """ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session @@ -100,6 +100,43 @@ async def store_login_page( ) +@router.get( + "/invitation/accept", response_class=HTMLResponse, include_in_schema=False +) +async def store_invitation_accept_page( + request: Request, + token: str = Query(..., description="Invitation token from email"), + store_code: str = Depends(get_resolved_store_code), + db: Session = Depends(get_db), +): + """ + Render invitation acceptance page. + + Public route — no auth required. Validates the token and shows a form + to review name and set password. + """ + from app.modules.tenancy.services.store_team_service import store_team_service + + language = getattr(request.state, "language", "en") + platform_code = getattr(request.state, "platform_code", None) + + # Get invitation info (without accepting it) + info = store_team_service.get_invitation_info(db, token) + + return templates.TemplateResponse( + "tenancy/store/invitation-accept.html", + { + "request": request, + "store_code": store_code, + "platform_code": platform_code, + "frontend_type": "store", + "token": token, + "invitation": info, + **get_jinja2_globals(language), + }, + ) + + # ============================================================================ # AUTHENTICATED ROUTES (Store Users Only) # ============================================================================ diff --git a/app/modules/tenancy/services/store_team_service.py b/app/modules/tenancy/services/store_team_service.py index 7f4c9ca2..e6aa3f94 100644 --- a/app/modules/tenancy/services/store_team_service.py +++ b/app/modules/tenancy/services/store_team_service.py @@ -228,6 +228,52 @@ class StoreTeamService: logger.error(f"Error inviting team member: {str(e)}") raise + def get_invitation_info(self, db: Session, token: str) -> dict[str, Any] | None: + """ + Get invitation details without accepting it. + + Used by the acceptance page to pre-fill the form. + Returns None if token is invalid. + """ + store_user = ( + db.query(StoreUser) + .execution_options(include_deleted=True) + .filter(StoreUser.invitation_token == token) + .first() + ) + + if not store_user: + return None + + user = store_user.user + store = store_user.store + role_name = store_user.role.name if store_user.role else "member" + + # Check if already accepted + if store_user.invitation_accepted_at is not None: + return { + "valid": False, + "error": "already_accepted", + "store_name": store.name, + } + + # Check expiration (7 days) + is_expired = False + if store_user.invitation_sent_at: + elapsed = datetime.utcnow() - store_user.invitation_sent_at + is_expired = elapsed > timedelta(days=7) + + return { + "valid": not is_expired, + "error": "expired" if is_expired else None, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "store_name": store.name, + "store_code": store.store_code, + "role_name": role_name, + } + def accept_invitation( self, db: Session, diff --git a/app/modules/tenancy/templates/tenancy/store/invitation-accept.html b/app/modules/tenancy/templates/tenancy/store/invitation-accept.html new file mode 100644 index 00000000..fb938a82 --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/store/invitation-accept.html @@ -0,0 +1,224 @@ +{# app/modules/tenancy/templates/tenancy/store/invitation-accept.html #} +{# Standalone invitation acceptance page - does NOT extend store/base.html #} + + + + + + Accept Invitation - {{ invitation.store_name if invitation else 'Store' }} + + + + + + + +
+
+
+
+ + +
+
+
+ + {% if not invitation or not invitation.valid %} + {# ==================== ERROR STATE ==================== #} +
+
+ + + +
+ {% if invitation and invitation.error == 'expired' %} +

Invitation Expired

+

+ This invitation has expired. Please ask {{ invitation.store_name }} to send you a new one. +

+ {% elif invitation and invitation.error == 'already_accepted' %} +

Already Accepted

+

+ This invitation has already been accepted. You can log in to {{ invitation.store_name }}. +

+ {% else %} +

Invalid Invitation

+

+ This invitation link is not valid. Please check your email for the correct link. +

+ {% endif %} + + Go to Login + +
+ {% else %} + {# ==================== ACCEPTANCE FORM ==================== #} + + {# Store info header #} +
+
+ {{ invitation.store_name[:1] | upper }} +
+

{{ invitation.store_name }}

+

+ You've been invited as {{ invitation.role_name }} +

+
+ +

+ Complete Your Account +

+ + {# Alert Messages #} +
+ +
+

Account activated!

+

Redirecting to login...

+
+ +
+ {# Name fields #} +
+ + +
+ + {# Email (read-only) #} + + + {# Password #} + + + {# Confirm Password #} + + + {# Submit #} + +
+ {% endif %} +
+
+
+
+
+ + + + + + + + + + + + +