feat(tenancy): add team invitation acceptance page
Some checks failed
Some checks failed
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:
@@ -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)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user