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.database import get_db
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage 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 import Account
from app.models.account_invite import AccountInvite from app.models.account_invite import AccountInvite
from app.models.subscription import Subscription from app.models.subscription import Subscription
@@ -222,6 +224,88 @@ async def create_invite(
return 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]) @router.get("/me/invites", response_model=list[AccountInviteResponse])
async def list_invites( async def list_invites(
db: Annotated[AsyncSession, Depends(get_db)], 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 typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -101,6 +101,91 @@ async def revoke_invite_code(
await db.commit() 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) @router.get("/validate/{code}", response_model=InviteCodeValidation)
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def validate_invite_code( async def validate_invite_code(

View File

@@ -51,6 +51,49 @@ class EmailService:
return False 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( def _render_invite_html(
code: str, code: str,
plan_label: str, plan_label: str,
@@ -103,3 +146,46 @@ def _render_invite_html(
</td></tr> </td></tr>
</table> </table>
</body></html>""" </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') const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data 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 export default accountsApi

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' 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 { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types' import type { Account, AccountMember, AccountInvite } from '@/types'
import { cn } from '@/lib/utils' 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) => { const handleRemoveMember = async (userId: string) => {
try { try {
await accountsApi.removeMember(userId) await accountsApi.removeMember(userId)
@@ -433,12 +449,28 @@ export function AccountSettingsPage() {
<div> <div>
<p className="text-sm text-white">{invite.email}</p> <p className="text-sm text-white">{invite.email}</p>
<p className="text-xs text-white/40"> <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> </p>
</div> </div>
<span className="rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/70"> <div className="flex items-center gap-2">
{invite.role} <span className="rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/70">
</span> {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>
))} ))}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' 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 { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin' import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal' 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( const inputClass = cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white', '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' '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" />, icon: <Copy className="h-4 w-4" />,
onClick: () => handleCopy(c.code), 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 ? [{ ...(!c.is_used ? [{
label: 'Delete', label: 'Delete',
icon: <Trash2 className="h-4 w-4" />, icon: <Trash2 className="h-4 w-4" />,