feat(tenancy): add team invitation acceptance page
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 23:05:23 +02:00
parent 01f7add8dd
commit 11dcfdad73
3 changed files with 308 additions and 1 deletions

View File

@@ -10,7 +10,7 @@ Store pages for authentication and account management:
- Settings - Settings
""" """
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session 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) # AUTHENTICATED ROUTES (Store Users Only)
# ============================================================================ # ============================================================================

View File

@@ -228,6 +228,52 @@ class StoreTeamService:
logger.error(f"Error inviting team member: {str(e)}") logger.error(f"Error inviting team member: {str(e)}")
raise 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( def accept_invitation(
self, self,
db: Session, db: Session,

View File

@@ -0,0 +1,224 @@
{# app/modules/tenancy/templates/tenancy/store/invitation-accept.html #}
{# Standalone invitation acceptance page - does NOT extend store/base.html #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="invitationAccept()" lang="{{ current_language|default('en') }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Accept Invitation - {{ invitation.store_name if invitation else 'Store' }}</title>
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='store/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex flex-col overflow-y-auto md:flex-row">
<div class="h-32 md:h-auto md:w-1/2">
<img aria-hidden="true" class="object-cover w-full h-full dark:hidden"
src="{{ url_for('static', path='store/img/login-office.jpeg') }}" alt="Office" />
<img aria-hidden="true" class="hidden object-cover w-full h-full dark:block"
src="{{ url_for('static', path='store/img/login-office-dark.jpeg') }}" alt="Office" />
</div>
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
{% if not invitation or not invitation.valid %}
{# ==================== ERROR STATE ==================== #}
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-red-100 dark:bg-red-900">
<svg class="w-8 h-8 text-red-600 dark:text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
{% if invitation and invitation.error == 'expired' %}
<h2 class="mb-2 text-xl font-semibold text-gray-700 dark:text-gray-200">Invitation Expired</h2>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
This invitation has expired. Please ask <strong>{{ invitation.store_name }}</strong> to send you a new one.
</p>
{% elif invitation and invitation.error == 'already_accepted' %}
<h2 class="mb-2 text-xl font-semibold text-gray-700 dark:text-gray-200">Already Accepted</h2>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
This invitation has already been accepted. You can log in to <strong>{{ invitation.store_name }}</strong>.
</p>
{% else %}
<h2 class="mb-2 text-xl font-semibold text-gray-700 dark:text-gray-200">Invalid Invitation</h2>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
This invitation link is not valid. Please check your email for the correct link.
</p>
{% endif %}
<a href="/store/{{ store_code }}/login"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Go to Login
</a>
</div>
{% else %}
{# ==================== ACCEPTANCE FORM ==================== #}
{# Store info header #}
<div class="mb-6 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-purple-100 dark:bg-purple-600">
<span class="text-2xl font-bold text-purple-600 dark:text-purple-100">{{ invitation.store_name[:1] | upper }}</span>
</div>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-200">{{ invitation.store_name }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
You've been invited as <strong class="text-purple-600 dark:text-purple-400">{{ invitation.role_name }}</strong>
</p>
</div>
<h1 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Complete Your Account
</h1>
{# Alert Messages #}
<div x-show="error" x-text="error"
class="mb-4 p-3 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-900 dark:text-red-200" x-cloak></div>
<div x-show="success"
class="mb-4 p-3 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-900 dark:text-green-200" x-cloak>
<p class="font-medium">Account activated!</p>
<p>Redirecting to login...</p>
</div>
<form @submit.prevent="acceptInvitation()" x-show="!success">
{# Name fields #}
<div class="grid grid-cols-2 gap-3 mb-4">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">First Name</span>
<input type="text" x-model="form.first_name" required
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input" />
</label>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Last Name</span>
<input type="text" x-model="form.last_name" required
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input" />
</label>
</div>
{# Email (read-only) #}
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<input type="email" :value="email" readonly
class="block w-full mt-1 text-sm bg-gray-50 dark:bg-gray-600 dark:border-gray-600 dark:text-gray-300 form-input cursor-not-allowed" />
</label>
{# Password #}
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input type="password" x-model="form.password" required minlength="8"
placeholder="Min 8 chars, upper + lower + digit"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input" />
</label>
{# Confirm Password #}
<label class="block text-sm mb-6">
<span class="text-gray-700 dark:text-gray-400">Confirm Password</span>
<input type="password" x-model="confirmPassword" required
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input" />
<span x-show="confirmPassword && form.password !== confirmPassword"
class="text-xs text-red-600 mt-1">Passwords do not match</span>
</label>
{# Submit #}
<button type="submit"
:disabled="loading || form.password !== confirmPassword || !form.password"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Accept Invitation</span>
<span x-show="loading">Activating...</span>
</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script>
(function() {
var script = document.createElement('script');
script.defer = true;
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js';
script.onerror = function() {
var fallbackScript = document.createElement('script');
fallbackScript.defer = true;
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<script>
window.STORE_PLATFORM_CODE = {{ platform_code|tojson }};
</script>
<script>
function invitationAccept() {
return {
dark: localStorage.getItem('dark') === 'true',
token: '{{ token }}',
storeCode: '{{ store_code }}',
email: '{{ invitation.email if invitation and invitation.valid else "" }}',
form: {
first_name: '{{ invitation.first_name if invitation and invitation.valid and invitation.first_name else "" }}',
last_name: '{{ invitation.last_name if invitation and invitation.valid and invitation.last_name else "" }}',
password: '',
},
confirmPassword: '',
loading: false,
error: null,
success: false,
async acceptInvitation() {
if (this.form.password !== this.confirmPassword) {
this.error = 'Passwords do not match';
return;
}
if (this.form.password.length < 8) {
this.error = 'Password must be at least 8 characters';
return;
}
this.loading = true;
this.error = null;
try {
await apiClient.post('/store/team/accept-invitation', {
invitation_token: this.token,
password: this.form.password,
first_name: this.form.first_name,
last_name: this.form.last_name,
});
this.success = true;
// Build login URL (platform-aware)
const platformCode = window.STORE_PLATFORM_CODE;
const basePath = platformCode
? `/platforms/${platformCode}/store/${this.storeCode}`
: `/store/${this.storeCode}`;
setTimeout(() => {
window.location.href = `${basePath}/login`;
}, 2000);
} catch (err) {
this.error = err.message || 'Failed to accept invitation. Please try again.';
} finally {
this.loading = false;
}
}
};
}
</script>
</body>
</html>