feat: add resend capability for platform and account invite codes

Revoke-and-recreate flow for both invite systems with email delivery
via Resend API. Includes account invite email template and audit logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-11 23:45:23 -05:00
parent a1f5127e98
commit 3c47292eaf
8 changed files with 390 additions and 7 deletions

View File

@@ -9,6 +9,8 @@ from sqlalchemy import select
from app.core.database import get_db
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
from app.core.audit import log_audit
from app.core.email import EmailService
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.subscription import Subscription
@@ -222,6 +224,88 @@ async def create_invite(
return invite
@router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse)
async def resend_invite(
invite_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Revoke an existing account invite and create a new one, then email it."""
result = await db.execute(
select(AccountInvite).where(
AccountInvite.id == invite_id,
AccountInvite.account_id == current_user.account_id,
)
)
old_invite = result.scalar_one_or_none()
if not old_invite:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite not found"
)
if old_invite.is_used:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot resend a used invite"
)
# Recalculate expiration from now if the old one had an expiration
new_expires_at = None
if old_invite.expires_at and old_invite.created_at:
original_duration = old_invite.expires_at - old_invite.created_at
new_expires_at = datetime.now(timezone.utc) + original_duration
elif old_invite.expires_at:
new_expires_at = old_invite.expires_at
# Capture properties before deleting
email = old_invite.email
role = old_invite.role
await db.delete(old_invite)
await db.flush()
# Create new invite
code = secrets.token_urlsafe(16)
new_invite = AccountInvite(
account_id=current_user.account_id,
invited_by_id=current_user.id,
email=email,
code=code,
role=role,
expires_at=new_expires_at,
)
db.add(new_invite)
await db.flush()
# Get account name for email
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one()
email_sent = await EmailService.send_account_invite_email(
to_email=email,
code=code,
account_name=account.name,
role=role,
)
await log_audit(
db, current_user.id, "account_invite.resend", "account_invite", new_invite.id,
{
"email": email,
"role": role,
"email_sent": email_sent,
},
)
await db.commit()
await db.refresh(new_invite)
return new_invite
@router.get("/me/invites", response_model=list[AccountInviteResponse])
async def list_invites(
db: Annotated[AsyncSession, Depends(get_db)],

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
@@ -101,6 +101,91 @@ async def revoke_invite_code(
await db.commit()
@router.post("/{code}/resend", response_model=InviteCodeResponse)
async def resend_invite_code(
code: str,
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Revoke an existing invite code and create a new one with the same properties, then email it."""
result = await db.execute(
select(InviteCode).where(InviteCode.code == code)
)
old_invite = result.scalar_one_or_none()
if not old_invite:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite code not found"
)
if old_invite.is_used:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot resend a used invite code"
)
if not old_invite.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot resend an invite code without a recipient email"
)
# Recalculate expiration from now if the old one had an expiration
new_expires_at = None
if old_invite.expires_at and old_invite.created_at:
original_duration = old_invite.expires_at - old_invite.created_at
new_expires_at = datetime.now(timezone.utc) + original_duration
elif old_invite.expires_at:
new_expires_at = old_invite.expires_at
# Capture properties before deleting
email = old_invite.email
assigned_plan = old_invite.assigned_plan
trial_duration_days = old_invite.trial_duration_days
note = old_invite.note
old_code = old_invite.code
await db.delete(old_invite)
await db.flush()
# Create new invite with same properties
new_invite = InviteCode(
created_by_id=current_user.id,
expires_at=new_expires_at,
note=note,
email=email,
assigned_plan=assigned_plan,
trial_duration_days=trial_duration_days,
)
db.add(new_invite)
await db.flush()
# Send email
email_sent = await EmailService.send_invite_email(
to_email=email,
code=new_invite.code,
plan=assigned_plan,
trial_days=trial_duration_days,
)
if email_sent:
new_invite.email_sent_at = datetime.now(timezone.utc)
await log_audit(
db, current_user.id, "invite.resend", "invite_code", new_invite.id,
{
"old_code": old_code,
"new_code": new_invite.code,
"email": email,
"email_sent": email_sent,
},
)
await db.commit()
await db.refresh(new_invite)
return new_invite
@router.get("/validate/{code}", response_model=InviteCodeValidation)
@limiter.limit("5/minute")
async def validate_invite_code(

View File

@@ -51,6 +51,49 @@ class EmailService:
return False
@staticmethod
async def send_account_invite_email(
to_email: str,
code: str,
account_name: str,
role: str,
signup_url: str = "https://resolutionflow.com/register",
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
role_label = role.capitalize()
subject = f"You've been invited to join {account_name} on ResolutionFlow"
html = _render_account_invite_html(
code=code,
account_name=account_name,
role_label=role_label,
signup_url=signup_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Account invite email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send account invite email to %s", to_email)
return False
def _render_invite_html(
code: str,
plan_label: str,
@@ -103,3 +146,46 @@ def _render_invite_html(
</td></tr>
</table>
</body></html>"""
def _render_account_invite_html(
code: str,
account_name: str,
role_label: str,
signup_url: str,
) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
You've been invited to join <strong style="color:#fff;">{account_name}</strong> as an <strong style="color:#fff;">{role_label}</strong>.
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:20px;">
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your Invite Code</p>
<p style="margin:0;color:#fff;font-size:28px;font-weight:700;letter-spacing:4px;">{code}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{signup_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Create Your Account
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
Enter the code above during registration, or click the button to get started.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""

View File

@@ -0,0 +1,74 @@
# Resend Invite Codes — Design
**Date**: 2026-02-12
## Summary
Add a "Resend" capability to both invite systems (platform invite codes and account invites). Resending revokes the old code and generates a fresh one, then emails it to the recipient.
## Backend
### Platform Invite Codes
**New endpoint**: `POST /api/v1/invites/{code}/resend` (admin-only)
1. Look up existing invite by code
2. Reject if already used (409 Conflict)
3. Delete the old invite
4. Create a new invite with the same properties (email, plan, trial_days, note, expiration recalculated from now)
5. Send email via `EmailService.send_invite_email()`
6. Log audit event
7. Return the new invite
### Account Invites
**New endpoint**: `POST /api/v1/accounts/me/invites/{invite_id}/resend` (owner-only)
1. Look up existing invite by ID
2. Reject if already used (409 Conflict)
3. Delete the old invite
4. Create a new invite with the same email, role, and fresh expiration
5. Send email via new `EmailService.send_account_invite_email()`
6. Log audit event
7. Return the new invite
### New Email Method: `send_account_invite_email()`
Added to `EmailService` in `backend/app/core/email.py`.
- **Parameters**: `to_email`, `code`, `account_name`, `role`, `signup_url`
- **Subject**: "You've been invited to join [Account Name] on ResolutionFlow"
- **Body**: Same dark monochrome HTML template as platform invites, with:
- "You've been invited to join **[Account Name]** as an **Engineer/Viewer**"
- Prominent invite code display (same style)
- No plan/trial section
- "Create Your Account" CTA button
- **Returns** `bool` — best-effort, never raises
## Frontend
### Platform Invite Codes (`InviteCodesPage.tsx`)
- Resend button in actions column (next to copy/delete)
- Only visible for unused, non-expired codes with an email address
- Calls `POST /api/v1/invites/{code}/resend`
- Shows success toast with new code, refreshes list
- Loading state on button during API call
### Account Invites (`AccountSettingsPage.tsx`)
- Resend button next to each pending invite
- Only visible for unused, non-expired invites
- Calls `POST /api/v1/accounts/me/invites/{invite_id}/resend`
- Shows success toast, refreshes list
- Loading state on button during API call
## Files to Modify
- `backend/app/core/email.py` — add `send_account_invite_email()` + HTML template
- `backend/app/api/endpoints/invite.py` — add resend endpoint for platform codes
- `backend/app/api/endpoints/accounts.py` — add resend endpoint for account invites
- `frontend/src/pages/admin/InviteCodesPage.tsx` — add resend button
- `frontend/src/pages/AccountSettingsPage.tsx` — add resend button
- `frontend/src/api/admin.ts` — add resend API call
- `frontend/src/api/accounts.ts` — add resend API call

View File

@@ -43,6 +43,11 @@ export const accountsApi = {
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data
},
async resendInvite(inviteId: string): Promise<AccountInvite> {
const response = await apiClient.post<AccountInvite>(`/accounts/me/invites/${inviteId}/resend`)
return response.data
},
}
export default accountsApi

View File

@@ -56,6 +56,8 @@ export const adminApi = {
api.post<InviteCodeResponse>('/invites', data).then(r => r.data),
deleteInviteCode: (code: string) =>
api.delete(`/invites/${code}`),
resendInviteCode: (code: string) =>
api.post<InviteCodeResponse>(`/invites/${code}/resend`).then(r => r.data),
// Audit Logs
listAuditLogs: (params?: Record<string, unknown>) =>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree } from 'lucide-react'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, RefreshCw } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
import { cn } from '@/lib/utils'
@@ -102,6 +102,22 @@ export function AccountSettingsPage() {
}
}
const [resendingId, setResendingId] = useState<string | null>(null)
const handleResendInvite = async (inviteId: string) => {
setResendingId(inviteId)
try {
await accountsApi.resendInvite(inviteId)
toast.success('Invite resent with a new code')
const invitesData = await accountsApi.getInvites()
setInvites(invitesData)
} catch {
toast.error('Failed to resend invite')
} finally {
setResendingId(null)
}
}
const handleRemoveMember = async (userId: string) => {
try {
await accountsApi.removeMember(userId)
@@ -433,12 +449,28 @@ export function AccountSettingsPage() {
<div>
<p className="text-sm text-white">{invite.email}</p>
<p className="text-xs text-white/40">
Expires {new Date(invite.expires_at).toLocaleDateString()}
{invite.expires_at
? `Expires ${new Date(invite.expires_at).toLocaleDateString()}`
: 'No expiration'}
</p>
</div>
<span className="rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/70">
{invite.role}
</span>
<div className="flex items-center gap-2">
<span className="rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/70">
{invite.role}
</span>
<button
onClick={() => handleResendInvite(invite.id)}
disabled={resendingId === invite.id}
className="text-white/40 hover:text-white disabled:opacity-50"
title="Resend invite"
>
{resendingId === invite.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
))}
</div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Copy, Trash2, Ticket, Mail, MailCheck } from 'lucide-react'
import { Plus, Copy, Trash2, Ticket, Mail, MailCheck, RefreshCw } from 'lucide-react'
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
@@ -97,6 +97,16 @@ export function InviteCodesPage() {
}
}
const handleResend = async (code: string) => {
try {
const newInvite = await adminApi.resendInviteCode(code)
toast.success(`New code ${newInvite.code} sent to ${newInvite.email}`)
fetchCodes()
} catch {
toast.error('Failed to resend invite code')
}
}
const inputClass = cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
@@ -183,6 +193,11 @@ export function InviteCodesPage() {
icon: <Copy className="h-4 w-4" />,
onClick: () => handleCopy(c.code),
},
...(c.is_valid && c.email ? [{
label: 'Resend',
icon: <RefreshCw className="h-4 w-4" />,
onClick: () => handleResend(c.code),
}] : []),
...(!c.is_used ? [{
label: 'Delete',
icon: <Trash2 className="h-4 w-4" />,