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:
@@ -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)],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>"""
|
||||
|
||||
74
docs/plans/2026-02-12-resend-invite-codes.md
Normal file
74
docs/plans/2026-02-12-resend-invite-codes.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
Reference in New Issue
Block a user