From 3c47292eaf338e16ad50eeae45ae65470b120f13 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 11 Feb 2026 23:45:23 -0500 Subject: [PATCH] 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 --- backend/app/api/endpoints/accounts.py | 84 +++++++++++++++++++ backend/app/api/endpoints/invite.py | 87 +++++++++++++++++++- backend/app/core/email.py | 86 +++++++++++++++++++ docs/plans/2026-02-12-resend-invite-codes.md | 74 +++++++++++++++++ frontend/src/api/accounts.ts | 5 ++ frontend/src/api/admin.ts | 2 + frontend/src/pages/AccountSettingsPage.tsx | 42 ++++++++-- frontend/src/pages/admin/InviteCodesPage.tsx | 17 +++- 8 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-02-12-resend-invite-codes.md diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index c2228584..b266f2b1 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -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)], diff --git a/backend/app/api/endpoints/invite.py b/backend/app/api/endpoints/invite.py index c44563bf..fdbc60a7 100644 --- a/backend/app/api/endpoints/invite.py +++ b/backend/app/api/endpoints/invite.py @@ -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( diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 1c66bd86..245d285a 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -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( """ + + +def _render_account_invite_html( + code: str, + account_name: str, + role_label: str, + signup_url: str, +) -> str: + return f""" + + + + +
+ + + + + + +
+

ResolutionFlow

+

Decision Tree Platform for MSP Professionals

+
+

+ You've been invited to join {account_name} as an {role_label}. +

+
+
+

Your Invite Code

+

{code}

+
+
+ + Create Your Account + +
+

+ Enter the code above during registration, or click the button to get started. +

+
+
+""" diff --git a/docs/plans/2026-02-12-resend-invite-codes.md b/docs/plans/2026-02-12-resend-invite-codes.md new file mode 100644 index 00000000..b5d3e34d --- /dev/null +++ b/docs/plans/2026-02-12-resend-invite-codes.md @@ -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 diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts index 07bffc9a..943d4481 100644 --- a/frontend/src/api/accounts.ts +++ b/frontend/src/api/accounts.ts @@ -43,6 +43,11 @@ export const accountsApi = { const response = await apiClient.get('/accounts/me/invites') return response.data }, + + async resendInvite(inviteId: string): Promise { + const response = await apiClient.post(`/accounts/me/invites/${inviteId}/resend`) + return response.data + }, } export default accountsApi diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 080d16e4..45ec92fc 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -56,6 +56,8 @@ export const adminApi = { api.post('/invites', data).then(r => r.data), deleteInviteCode: (code: string) => api.delete(`/invites/${code}`), + resendInviteCode: (code: string) => + api.post(`/invites/${code}/resend`).then(r => r.data), // Audit Logs listAuditLogs: (params?: Record) => diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 051ab6de..125e3c21 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -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(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() {

{invite.email}

- Expires {new Date(invite.expires_at).toLocaleDateString()} + {invite.expires_at + ? `Expires ${new Date(invite.expires_at).toLocaleDateString()}` + : 'No expiration'}

- - {invite.role} - +
+ + {invite.role} + + +
))} diff --git a/frontend/src/pages/admin/InviteCodesPage.tsx b/frontend/src/pages/admin/InviteCodesPage.tsx index c572aa14..02c100a5 100644 --- a/frontend/src/pages/admin/InviteCodesPage.tsx +++ b/frontend/src/pages/admin/InviteCodesPage.tsx @@ -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: , onClick: () => handleCopy(c.code), }, + ...(c.is_valid && c.email ? [{ + label: 'Resend', + icon: , + onClick: () => handleResend(c.code), + }] : []), ...(!c.is_used ? [{ label: 'Delete', icon: ,