feat: admin invite codes with plan assignment + user detail page

- Migration 030: add email, assigned_plan, trial_duration_days, email_sent_at
  to invite_codes with CHECK constraints
- Resend email integration (graceful degradation when API key not set)
- Invite codes now support plan assignment (free/pro/team) and trial duration (1-90 days)
- Registration applies invite code plan/trial to new subscription
- Auto-downgrade expired trials on authenticated access
- Enriched GET /admin/users/{id} with account, subscription, sessions, audit logs
- New endpoints: PUT /admin/users/{id}/subscription/plan and extend-trial
- Frontend: enhanced invite codes page with email, plan, trial fields
- Frontend: new user detail page at /admin/users/:userId
- Fixed API path drift: /invite-codes -> /invites
- 11 new backend tests, 416 total passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-11 21:42:58 -05:00
parent a466400c5b
commit 50cb0fc7f0
24 changed files with 2522 additions and 1121 deletions

908
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
> **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** February 2, 2026
> **Last Updated:** February 11, 2026
---
@@ -10,334 +10,135 @@
---
## What's Complete
## What's Complete
### Backend (100%)
- FastAPI project structure
- PostgreSQL database with Docker
- User authentication (JWT, register, login, refresh)
- ✅ Trees CRUD with full-text search
- ✅ Sessions tracking with decisions
- ✅ Export API (Markdown, Text, HTML)
- ✅ Role-based access control foundation
- ✅ Production-ready logging with correlation IDs
- ✅ 40+ integration tests
- ✅ DateTime timezone handling fixed
- FastAPI project structure with 25+ API endpoints
- PostgreSQL database with Docker, 29+ Alembic migrations
- User authentication (JWT, register, login, refresh, logout, invite codes)
- Refresh token rotation with JTI-based revocation
- Trees CRUD with full-text search (FTS index)
- Sessions tracking with decisions, outcomes, and variables
- Export API (Markdown, Text, HTML)
- Role-based access control (super_admin, team_admin, engineer, viewer)
- Production-ready logging with correlation IDs
- 100+ integration tests
- Rate limiting on auth endpoints (disabled in DEBUG)
- Audit log table with JSONB details
- Soft delete for trees with cascade cleanup
### Frontend (Phase 2 Complete)
- React 19 + Vite + TypeScript + Tailwind setup
- Authentication UI (login, register)
- Tree library/browsing page with grid/list/table views
- Tree navigation interface (session player)
- Session management with history and detail pages
- Export functionality (download)
- **Tree Editor** — Form-based with visual preview, Zustand + immer + zundo (undo/redo)
- **Markdown rendering** in session player and node editor
- **Monochrome Design System** — Dark-only, glass-morphism cards, Inter font, theme toggle removed
- **Tree Organization** — Categories, tags (autocomplete), user folders (3-level hierarchy), filters
- **RBAC & Permissions** — `usePermissions` hook, ProtectedRoute with role guards, permission-based UI hiding
- **Session Scratchpad** — Floating overlay (Ctrl+/), auto-save, markdown preview
- **Admin Panel** — 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories)
- **Session Quick Wins** (Issues #51-#55):
- Session timer (`useSessionTimer` hook, MM:SS / HH:MM:SS)
- Keyboard hints (Tab focuses notes)
- Repeat Last Session (prefills metadata from localStorage)
- Session auto-recovery (resume incomplete sessions)
- Copy step to clipboard
- Delete tree button in all view modes
- **Session Outcomes** — Outcome modal on session completion, step timing tracking
- **Settings page** at `/settings` — Default export format preference
- ✅ React + Vite + TypeScript + Tailwind setup
- ✅ Authentication UI (login, register)
- ✅ Basic layout and navigation
- ✅ Tree library/browsing page
- ✅ Tree navigation interface
- ✅ Session management
- ✅ Export functionality (download)
- ✅ Responsive design
- ✅ Error boundaries
- **Tree Editor** - Form-based with visual preview
- ✅ Zustand store with immer (undo/redo via zundo)
- ✅ Split-view layout (editor left, preview right)
- ✅ Node CRUD (Decision, Action, Solution types)
- ✅ NodePicker with type-grouped dropdown
- ✅ Dynamic array fields (options, commands, steps)
- ✅ Visual tree preview with solution indicators
- ✅ Shared node detection (multiple sources → same target)
- ✅ Modal with scrollable content, fixed header/footer
- ✅ Markdown preview toggle in description fields
- **Markdown Rendering** - Session player and node editor
- `react-markdown` package installed
- `MarkdownContent` component created
- ✅ Renders bold, italic, lists, code blocks, headers
-**User Preferences** - Settings page complete
- ✅ Dark/light/system theme toggle
- ✅ Default export format preference
- ✅ Persisted in localStorage
- ✅ Settings page at `/settings`
-**Tree Organization**
- ✅ Categories (global + team-specific)
- ✅ Tags with autocomplete
- ✅ User folders with subfolder hierarchy (max 3 levels)
- ✅ Right-click context menu for folder operations
- ✅ Filter trees by category, tags, and folders
-**RBAC & Permissions**
- ✅ Role hierarchy: super_admin > team_admin > engineer > viewer
- ✅ Permission checks in frontend (`usePermissions` hook)
- ✅ Protected routes with role guards
- ✅ Permission-based UI hiding (edit/delete/create actions)
-**Session Scratchpad**
- ✅ Floating overlay panel (Ctrl+/ to toggle)
- ✅ Auto-save with debounce
- ✅ Markdown preview
- ✅ Included in session exports
-**Mobile Responsiveness**
- ✅ Touch-friendly buttons and controls
- ✅ Optimized layouts for small screens
- ✅ Responsive navigation and forms
-**Design Consistency & Polish**
- ✅ Micro-interactions and transitions
- ✅ Global thin scrollbar styling
- ✅ Consistent brand colors and fonts
- ✅ Professional UI/UX polish
### Security Hardening (Phases A-D Complete)
- Registration role hardcoded to `engineer`
- HTML export XSS fix (html.escape)
- Secret key validator (rejects default when DEBUG=False)
- Role CHECK constraint on users table
- Tree access check on session start
- Centralized permissions in `permissions.py`
- `is_active` field on User model, enforced in auth
- Admin user management endpoints (6 endpoints)
- Refresh token rotation with JTI-based revocation
- Password complexity validation (uppercase, lowercase, digit, min 10 chars)
- Soft delete cascade cleanup (folder/tag junctions)
- SQL wildcard escaping in tag search
### Backend Schema Features (Not Yet in Frontend)
- **Tree Forking** (migration 022) — `parent_tree_id`, `root_tree_id`, `fork_depth`, `fork_reason`
- **Session Sharing** (migration 023) — `session_shares`, `session_share_views`, `allow_public_shares`
- **Tree Sharing** (migration 024) — tree share links
- **Tree Status** (migration 025) — status field on trees
- **Admin Panel Tables** (migration 026) — plan limits, feature flags
- **Session Variables** (migration 028) — variable tracking in sessions
- **Session Outcomes** (migration 029) — outcome tracking
### Documentation
- ✅ Project overview and architecture docs
- ✅ Development roadmap through Phase 4
- Feature specifications (including Phase 2.5)
- ✅ CLAUDE.md for Claude Code context
- ✅ LESSONS-LEARNED.md for avoiding past mistakes
- ✅ REBRAND-IMPLEMENTATION-GUIDE.md
- ✅ Permissions audit design doc
- ✅ Comprehensive project review report
- ✅ Subscription tier architecture plan
- CLAUDE.md (project context for Claude Code)
- LESSONS-LEARNED.md (bugs and fixes reference)
- Design system guide, component examples
- Feature specifications through Phase 4
- Rebrand implementation guide
---
## What's In Progress 🔄
## What's In Progress
| Task | Status | Notes |
|------|--------|-------|
| Step Library Frontend | In Progress | Backend complete, frontend UI pending |
| Custom Step Flow | In Progress | Integration with tree navigation |
| Tree Forking | Planning | Backend schema complete, UI pending |
| TypeScript strict mode | Warnings exist | tsconfig needs `strict: true` |
| Deployment | **Production** | Deployed on Railway at resolutionflow.com |
| Tree Forking UI | Planning | Backend schema complete (migration 022) |
| Session Sharing UI | Planning | Backend schema complete (migration 023) |
---
## What's Next (Priority Order)
### Immediate (Phase 2.5 Completion)
1. Step Library Frontend UI (browse, search, rate/review)
2. Custom Step Integration in tree navigation
3. Tree Forking UI and workflow
4. Session/Tree Sharing UI
1.~~Complete User Preferences~~ **COMPLETE** - Settings page with theme and export format
2.~~Tree Organization~~ **COMPLETE** - Categories, tags, folders with hierarchy
3.~~RBAC & Permissions~~ **COMPLETE** - Full permission system with role guards
4.~~Session Scratchpad~~ **COMPLETE** - Floating overlay with auto-save
5.~~Mobile Responsiveness~~ **COMPLETE** - Touch-friendly, responsive layouts
6. Step Library Frontend UI (browse, search, rate/review)
7. Custom Step Integration in tree navigation
8. Tree Forking UI and workflow
### Soon (Phase 3 Planning)
### Soon (Phase 3)
- File attachments for sessions
- Offline capability
- Client context system
- Advanced analytics dashboard
### Later (Phase 4)
- PSA integrations (ConnectWise, Kaseya)
- PowerShell automation framework
- Enterprise features (SSO, white-label)
---
## Key Files Reference
### Backend
```
backend/
├── app/
│ ├── main.py # FastAPI entry point
│ ├── api/v1/endpoints/ # API route handlers
│ │ ├── auth.py
│ │ ├── trees.py
│ │ └── sessions.py
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ └── core/
│ ├── config.py # Settings
│ ├── security.py # JWT handling
│ └── logging_config.py
├── alembic/ # Database migrations
├── tests/ # pytest tests
└── requirements.txt
```
### Frontend
```
frontend/
├── src/
│ ├── main.tsx # Entry point
│ ├── App.tsx # Router setup
│ ├── pages/ # Page components
│ │ └── TreeEditorPage.tsx
│ ├── components/
│ │ ├── common/ # Modal, etc.
│ │ ├── tree-editor/ # Tree Editor components
│ │ │ ├── TreeEditorLayout.tsx
│ │ │ ├── TreeMetadataForm.tsx
│ │ │ ├── NodeList.tsx
│ │ │ ├── NodeEditorModal.tsx
│ │ │ ├── NodeFormDecision.tsx
│ │ │ ├── NodeFormAction.tsx
│ │ │ ├── NodeFormResolution.tsx
│ │ │ ├── DynamicArrayField.tsx
│ │ │ └── NodePicker.tsx
│ │ └── tree-preview/ # Visual preview
│ │ ├── TreePreviewPanel.tsx
│ │ └── TreePreviewNode.tsx
│ ├── store/
│ │ ├── authStore.ts
│ │ └── treeEditorStore.ts # Zustand + immer + zundo
│ ├── contexts/ # React contexts (auth)
│ ├── hooks/ # Custom hooks
│ └── api/ # API client
├── tailwind.config.js
└── tsconfig.json
```
### Documentation
```
patherly/
├── CLAUDE-SETUP.md # Full context for Claude Code
├── CURRENT-STATE.md # This file - quick status
├── LESSONS-LEARNED.md # Bugs and fixes reference
├── 01-PROJECT-OVERVIEW.md
├── 02-TECHNICAL-ARCHITECTURE.md
├── 03-DEVELOPMENT-ROADMAP.md
├── 04-FEATURE-SPECIFICATIONS.md
└── PHASE-2.5-PERSONAL-BRANCHING.md # Detailed Phase 2.5 spec
```
---
## Environment Quick Reference
### Start Development
```powershell
# Terminal 1: Database
docker start patherly_postgres
# Terminal 2: Backend
cd C:\Dev\Projects\patherly\backend
.\venv\Scripts\activate
uvicorn app.main:app --reload
# Terminal 3: Frontend
cd C:\Dev\Projects\patherly\frontend
npm run dev
cd backend && .\venv\Scripts\activate && uvicorn app.main:app --reload
cd frontend && npm run dev
```
### URLs
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- API Docs: http://localhost:8000/docs
- API Docs: http://localhost:8000/api/docs
### Run Tests
```powershell
cd C:\Dev\Projects\patherly\backend
.\venv\Scripts\activate
pytest
cd backend && pytest --override-ini="addopts="
```
---
## Recent Changes (Feb 5-6, 2026)
1. **Mobile Responsiveness & Design Polish** (commit `90ff250`):
- Touch-friendly buttons and controls throughout the app
- Responsive layouts optimized for small screens
- Micro-interactions and smooth transitions
- Global thin scrollbar styling (6px, theme-aware)
- Consistent brand colors and professional UI polish
2. **Security Hardening** (Phases A-D complete):
- Registration role field removed (hardcoded to engineer)
- HTML export XSS fixes (all content escaped)
- Secret key validation (rejects default in production)
- Tree access checks on session start
- Refresh token rotation with JTI-based revocation
- Rate limiting on auth endpoints
- Password complexity validation
- Soft delete cascade cleanup
- SQL wildcard escaping in tag search
3. **Permissions & RBAC** (commits `34daa26`, `71ba0b9`, `3e0fb92`):
- Complete role hierarchy (super_admin > team_admin > engineer > viewer)
- Frontend `usePermissions` hook for permission checks
- Protected routes with role-based guards
- Permission-based UI hiding (edit/delete/create actions)
- Audit log table with JSONB details
- Soft delete for trees with `deleted_at` timestamp
- Super admin bypass in tree list filter
4. **Session Scratchpad** (commit `2733a00`):
- Refactored to floating overlay panel (420px wide, 55vh tall)
- Ctrl+/ keyboard shortcut to toggle
- Auto-save with 1s debounce
- Markdown preview support
- Included in session exports (markdown, text, HTML)
5. **Step Library Foundation** (Issues #5, #6, #7):
- Step categories table with 10 seeded categories
- Full step library schema (steps, ratings, usage log)
- Complete CRUD API at `/api/v1/steps`
- Full-text search and popular tags endpoints
- Rating/review system with verified use tracking
## Previous Changes (Jan 29, 2026)
1. **Comprehensive Seed Script** (`backend/scripts/seed_trees.py`):
- 7 complete troubleshooting decision trees with 10-20+ nodes each
- **Tier 1 (Help Desk)**: Password Reset, Outlook/Email, VPN Connection, Printer Problems
- **Tier 2 (Desktop Support)**: Slow Computer, Network Connectivity
- **Tier 3 (Systems)**: File Share Access Problems
- Real PowerShell commands in action nodes
- Professional ticket documentation in solution nodes
2. **Markdown Rendering** in Session Player and Node Editor:
- Installed `react-markdown` package
- Created `MarkdownContent` component (`frontend/src/components/ui/MarkdownContent.tsx`)
- Updated `TreeNavigationPage.tsx` to render descriptions with markdown
- Added markdown preview toggle in `NodeFormAction.tsx` and `NodeFormResolution.tsx`
- Supports: bold, italic, lists, code blocks, headers, blockquotes
3. Updated LESSONS-LEARNED.md with:
- httpx installation requirement for seed scripts
- Email validation rejecting `.local` TLD (RFC 6761)
## Previous Changes (Jan 28, 2026)
1. Fixed DateTime timezone bugs in all models
2. Added production logging system
3. Created 40+ integration tests
4. Added Phase 2.5 specifications (Personal Branching, Step Library)
5. Added User Preferences to MVP scope
6. Created LESSONS-LEARNED.md
7. Created CURRENT-STATE.md (this file)
8. **Tree Editor Implementation**:
- Zustand store with immer middleware and zundo for undo/redo
- Form-based node editing with type-specific forms
- NodePicker dropdown grouped by node type (Decision/Action/Solution)
- Visual tree preview with recursive rendering
- Solution connection indicators (green checkmark badges)
- Shared node detection showing when multiple nodes link to same target
- Modal component with scrollable body, fixed header/footer
---
## Blockers / Known Issues
| Issue | Workaround | Status |
|-------|------------|--------|
| pytest-asyncio version conflict | Use 0.24.0 | Documented |
| No local psql on Windows | Use `docker exec` | Documented |
---
## Session Handoff Notes
*Update this section at the end of each coding session:*
**Last Session (Feb 5-6, 2026):**
- Updated CURRENT-STATE.md to reflect Phase 2.5 progress
- All Phase 2 items complete (Tree Editor, RBAC, Permissions, UI polish)
- Step Library backend complete (schema, API, search, ratings)
- Mobile responsiveness and design consistency complete
- Security hardening (Phases A-D) complete
- Next: Step Library frontend UI, custom step flow integration, tree forking UI
**Previous Session (Feb 2-5, 2026):**
- Mobile responsiveness improvements (touch-friendly, responsive layouts)
- Security hardening phases A-D (permissions, token rotation, validation)
- RBAC system with audit logs and soft delete
- Session scratchpad refactored to floating overlay
- Global thin scrollbar styling
- Documentation updates (project review, subscription tiers)

View File

@@ -0,0 +1,61 @@
"""enhance invite codes with plan assignment and email
Revision ID: 030
Revises: 029
Create Date: 2026-02-12
Adds email, assigned_plan, trial_duration_days, and email_sent_at columns
to invite_codes table for plan-aware invite code creation.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '030'
down_revision: Union[str, None] = '029'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('invite_codes', sa.Column('email', sa.String(255), nullable=True))
op.add_column('invite_codes', sa.Column('assigned_plan', sa.String(50), nullable=False, server_default='free'))
op.add_column('invite_codes', sa.Column('trial_duration_days', sa.Integer(), nullable=True))
op.add_column('invite_codes', sa.Column('email_sent_at', sa.DateTime(timezone=True), nullable=True))
op.create_index('ix_invite_codes_email', 'invite_codes', ['email'])
# Plan must be free/pro/team
op.create_check_constraint(
'ck_invite_codes_assigned_plan',
'invite_codes',
"assigned_plan IN ('free', 'pro', 'team')"
)
# Trial duration 1-90 days or NULL
op.create_check_constraint(
'ck_invite_codes_trial_duration',
'invite_codes',
"trial_duration_days IS NULL OR (trial_duration_days >= 1 AND trial_duration_days <= 90)"
)
# Free plan cannot have trial duration
op.create_check_constraint(
'ck_invite_codes_free_no_trial',
'invite_codes',
"assigned_plan != 'free' OR trial_duration_days IS NULL"
)
def downgrade() -> None:
op.drop_constraint('ck_invite_codes_free_no_trial', 'invite_codes', type_='check')
op.drop_constraint('ck_invite_codes_trial_duration', 'invite_codes', type_='check')
op.drop_constraint('ck_invite_codes_assigned_plan', 'invite_codes', type_='check')
op.drop_index('ix_invite_codes_email', table_name='invite_codes')
op.drop_column('invite_codes', 'email_sent_at')
op.drop_column('invite_codes', 'trial_duration_days')
op.drop_column('invite_codes', 'assigned_plan')
op.drop_column('invite_codes', 'email')

View File

@@ -65,14 +65,36 @@ async def get_refresh_token_payload(
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)]
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Ensure user is active (not disabled)."""
"""Ensure user is active (not disabled). Auto-downgrades expired trials."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account has been deactivated"
)
# Lightweight trial expiry check
if current_user.account_id:
from app.models.subscription import Subscription
from datetime import datetime, timezone
result = await db.execute(
select(Subscription).where(Subscription.account_id == current_user.account_id)
)
subscription = result.scalar_one_or_none()
if (
subscription
and subscription.status == "trialing"
and subscription.current_period_end
and subscription.current_period_end < datetime.now(timezone.utc)
):
subscription.plan = "free"
subscription.status = "active"
subscription.current_period_end = None
subscription.current_period_start = None
await db.commit()
return current_user

View File

@@ -1,15 +1,26 @@
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.account import Account
from app.models.subscription import Subscription
from app.models.session import Session
from app.models.audit_log import AuditLog
from app.models.invite_code import InviteCode
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
from app.schemas.admin import MoveUserAccount
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
from app.schemas.user_detail import (
UserDetailResponse, AccountSummary, SubscriptionSummary,
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
)
from app.api.deps import require_admin
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -42,13 +53,13 @@ async def list_users(
return users
@router.get("/users/{user_id}", response_model=UserResponse)
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Get user details (super admin only)."""
"""Get enriched user details (super admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@@ -58,7 +69,104 @@ async def get_user(
detail="User not found"
)
return user
# Account + subscription
account_summary = None
subscription_summary = None
if user.account_id:
acc_result = await db.execute(select(Account).where(Account.id == user.account_id))
account = acc_result.scalar_one_or_none()
if account:
account_summary = AccountSummary(
id=account.id, name=account.name,
display_code=getattr(account, "display_code", None),
)
sub_result = await db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)
subscription = sub_result.scalar_one_or_none()
if subscription:
subscription_summary = SubscriptionSummary(
id=subscription.id, plan=subscription.plan, status=subscription.status,
current_period_start=subscription.current_period_start,
current_period_end=subscription.current_period_end,
)
# Recent sessions (latest 10 + total)
total_sessions_result = await db.execute(
select(func.count()).select_from(Session).where(Session.user_id == user_id)
)
total_sessions = total_sessions_result.scalar() or 0
sessions_result = await db.execute(
select(Session).options(selectinload(Session.tree))
.where(Session.user_id == user_id)
.order_by(Session.started_at.desc())
.limit(10)
)
sessions = sessions_result.scalars().all()
recent_sessions = [
SessionSummary(
id=s.id,
tree_name=s.tree.name if s.tree else None,
started_at=s.started_at,
completed_at=s.completed_at,
outcome=s.outcome,
)
for s in sessions
]
# Recent audit logs (latest 10 + total)
total_audits_result = await db.execute(
select(func.count()).select_from(AuditLog).where(AuditLog.user_id == user_id)
)
total_audit_logs = total_audits_result.scalar() or 0
audits_result = await db.execute(
select(AuditLog).where(AuditLog.user_id == user_id)
.order_by(AuditLog.created_at.desc())
.limit(10)
)
audits = audits_result.scalars().all()
recent_audit_logs = [
AuditLogSummary(
id=a.id, action=a.action, resource_type=a.resource_type,
resource_id=str(a.resource_id) if a.resource_id else None,
created_at=a.created_at, details=a.details,
)
for a in audits
]
# Invite code used
invite_code_used = None
if user.invite_code_id:
ic_result = await db.execute(
select(InviteCode).where(InviteCode.id == user.invite_code_id)
)
ic = ic_result.scalar_one_or_none()
if ic:
creator_email = None
if ic.created_by_id:
creator_result = await db.execute(
select(User.email).where(User.id == ic.created_by_id)
)
creator_email = creator_result.scalar_one_or_none()
invite_code_used = InviteCodeUsedSummary(
code=ic.code, assigned_plan=ic.assigned_plan,
trial_duration_days=ic.trial_duration_days,
created_by_email=creator_email,
)
return UserDetailResponse(
id=user.id, email=user.email, full_name=user.name,
role=user.role, is_active=user.is_active,
is_super_admin=user.is_super_admin,
is_team_admin=getattr(user, "is_team_admin", False),
created_at=user.created_at,
account=account_summary, subscription=subscription_summary,
invite_code_used=invite_code_used,
recent_sessions=recent_sessions, total_sessions=total_sessions,
recent_audit_logs=recent_audit_logs, total_audit_logs=total_audit_logs,
)
@router.put("/users/{user_id}/role", response_model=UserResponse)
@@ -198,3 +306,69 @@ async def move_user_account(
await db.commit()
await db.refresh(user)
return user
async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User, Subscription]:
"""Helper to load user and their subscription."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not user.account_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no account")
sub_result = await db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)
subscription = sub_result.scalar_one_or_none()
if not subscription:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found")
return user, subscription
@router.put("/users/{user_id}/subscription/plan")
async def update_user_plan(
user_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change a user's subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "team"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
user, subscription = await _get_user_subscription(user_id, db)
old_plan = subscription.plan
subscription.plan = data.plan
await log_audit(db, current_user.id, "subscription.plan_change", "subscription", subscription.id,
{"old_plan": old_plan, "new_plan": data.plan, "user_id": str(user_id)})
await db.commit()
return {"plan": subscription.plan, "status": subscription.status}
@router.put("/users/{user_id}/subscription/extend-trial")
async def extend_user_trial(
user_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Extend or start a trial for a user's subscription (super admin only)."""
if data.days < 1 or data.days > 90:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90")
user, subscription = await _get_user_subscription(user_id, db)
now = datetime.now(timezone.utc)
if subscription.status == "trialing" and subscription.current_period_end:
# Extend existing trial
new_end = subscription.current_period_end + timedelta(days=data.days)
else:
# Start new trial
subscription.status = "trialing"
subscription.current_period_start = now
new_end = now + timedelta(days=data.days)
subscription.current_period_end = new_end
await log_audit(db, current_user.id, "subscription.extend_trial", "subscription", subscription.id,
{"days": data.days, "new_end": new_end.isoformat(), "user_id": str(user_id)})
await db.commit()
return {"plan": subscription.plan, "status": subscription.status,
"current_period_end": subscription.current_period_end}

View File

@@ -1,6 +1,6 @@
import secrets
import string
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
@@ -92,38 +92,39 @@ async def register(
detail="Account invite code has expired"
)
# Validate platform invite code if required (skip if account invite was provided)
# Validate platform invite code (skip if account invite was provided)
invite_code_record = None
if not account_invite_record and settings.REQUIRE_INVITE_CODE:
if not user_data.invite_code:
if not account_invite_record:
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code is required"
)
# Look up invite code (case-insensitive)
result = await db.execute(
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
)
invite_code_record = result.scalar_one_or_none()
if not invite_code_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code"
if user_data.invite_code:
# Look up invite code (case-insensitive) — applies plan/trial regardless of REQUIRE_INVITE_CODE
result = await db.execute(
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
)
invite_code_record = result.scalar_one_or_none()
if invite_code_record.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has already been used"
)
if not invite_code_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code"
)
if invite_code_record.is_expired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has expired"
)
if invite_code_record.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has already been used"
)
if invite_code_record.is_expired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has expired"
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
@@ -175,10 +176,24 @@ async def register(
# Now set account owner and create subscription
new_account.owner_id = new_user.id
# Apply plan/trial from invite code if present
sub_plan = "free"
sub_status = "active"
period_start = None
period_end = None
if invite_code_record and invite_code_record.assigned_plan:
sub_plan = invite_code_record.assigned_plan
if invite_code_record.trial_duration_days:
sub_status = "trialing"
period_start = datetime.now(timezone.utc)
period_end = period_start + timedelta(days=invite_code_record.trial_duration_days)
new_subscription = Subscription(
account_id=new_account.id,
plan="free",
status="active",
plan=sub_plan,
status=sub_status,
current_period_start=period_start,
current_period_end=period_end,
)
db.add(new_subscription)

View File

@@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.core.audit import log_audit
from app.core.email import EmailService
from app.models.user import User
from app.models.invite_code import InviteCode
from app.schemas.invite_code import InviteCodeCreate, InviteCodeResponse, InviteCodeValidation
@@ -23,9 +25,35 @@ async def create_invite_code(
invite_code = InviteCode(
created_by_id=current_user.id,
expires_at=invite_data.expires_at,
note=invite_data.note
note=invite_data.note,
email=invite_data.email,
assigned_plan=invite_data.assigned_plan,
trial_duration_days=invite_data.trial_duration_days,
)
db.add(invite_code)
await db.flush()
# Send invite email if email provided
email_sent = False
if invite_data.email:
email_sent = await EmailService.send_invite_email(
to_email=invite_data.email,
code=invite_code.code,
plan=invite_data.assigned_plan,
trial_days=invite_data.trial_duration_days,
)
if email_sent:
invite_code.email_sent_at = datetime.now(timezone.utc)
await log_audit(
db, current_user.id, "invite.create", "invite_code", invite_code.id,
{
"code": invite_code.code,
"plan": invite_data.assigned_plan,
"email": invite_data.email,
"email_sent": email_sent,
},
)
await db.commit()
await db.refresh(invite_code)

View File

@@ -52,6 +52,15 @@ class Settings(BaseSettings):
# Registration
REQUIRE_INVITE_CODE: bool = True # Set to False to allow open registration
# Email (Resend)
RESEND_API_KEY: Optional[str] = None
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
@property
def email_enabled(self) -> bool:
"""Check if email sending is configured."""
return self.RESEND_API_KEY is not None
# Stripe
STRIPE_SECRET_KEY: Optional[str] = None
STRIPE_PUBLISHABLE_KEY: Optional[str] = None

105
backend/app/core/email.py Normal file
View File

@@ -0,0 +1,105 @@
import logging
from app.core.config import settings
logger = logging.getLogger(__name__)
class EmailService:
"""Best-effort email delivery via Resend. Never raises on failure."""
@staticmethod
async def send_invite_email(
to_email: str,
code: str,
plan: str,
trial_days: int | None = None,
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
plan_label = plan.capitalize()
trial_text = f" with a {trial_days}-day free trial" if trial_days else ""
subject = f"You're invited to ResolutionFlow ({plan_label} plan{trial_text})"
html = _render_invite_html(
code=code,
plan_label=plan_label,
trial_days=trial_days,
signup_url=signup_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Invite email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send invite email to %s", to_email)
return False
def _render_invite_html(
code: str,
plan_label: str,
trial_days: int | None,
signup_url: str,
) -> str:
trial_section = ""
if trial_days:
trial_section = f"""
<tr><td style="padding:0 40px 20px;">
<p style="margin:0;color:#a0a0a0;font-size:14px;">
Your <strong style="color:#fff;">{trial_days}-day free trial</strong> starts when you register.
After your trial ends, your account will revert to the Free plan.
</p>
</td></tr>"""
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 ResolutionFlow on the <strong style="color:#fff;">{plan_label}</strong> plan.
</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>
{trial_section}
<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

@@ -46,6 +46,13 @@ class InviteCode(Base):
DateTime(timezone=True),
nullable=True
)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
assigned_plan: Mapped[str] = mapped_column(String(50), nullable=False, server_default="free")
trial_duration_days: Mapped[Optional[int]] = mapped_column(nullable=True)
email_sent_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
note: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -84,3 +91,11 @@ class InviteCode(Base):
def is_valid(self) -> bool:
"""Check if the invite code is valid (not used and not expired)."""
return not self.is_used and not self.is_expired
@property
def has_trial(self) -> bool:
return self.trial_duration_days is not None and self.trial_duration_days > 0
@property
def email_sent(self) -> bool:
return self.email_sent_at is not None

View File

@@ -1,13 +1,22 @@
from datetime import datetime
from typing import Optional
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, EmailStr, Field, model_validator
class InviteCodeCreate(BaseModel):
"""Schema for creating a new invite code."""
expires_at: Optional[datetime] = Field(None, description="Optional expiration time")
note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for")
email: Optional[EmailStr] = Field(None, description="Recipient email for invite delivery")
assigned_plan: Literal["free", "pro", "team"] = Field("free", description="Plan to assign on registration")
trial_duration_days: Optional[int] = Field(None, ge=1, le=90, description="Trial duration in days (1-90)")
@model_validator(mode="after")
def free_plan_no_trial(self):
if self.assigned_plan == "free" and self.trial_duration_days is not None:
raise ValueError("Free plan cannot have a trial duration")
return self
class InviteCodeResponse(BaseModel):
@@ -23,6 +32,12 @@ class InviteCodeResponse(BaseModel):
is_used: bool
is_expired: bool
is_valid: bool
email: Optional[str] = None
assigned_plan: str = "free"
trial_duration_days: Optional[int] = None
email_sent_at: Optional[datetime] = None
has_trial: bool = False
email_sent: bool = False
class Config:
from_attributes = True

View File

@@ -38,3 +38,15 @@ class SubscriptionDetails(BaseModel):
subscription: SubscriptionResponse
limits: PlanLimitsResponse
usage: UsageResponse
class SubscriptionPlanUpdate(BaseModel):
plan: str # free, pro, team
model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}}
class ExtendTrialRequest(BaseModel):
days: int # 1-90
model_config = {"json_schema_extra": {"examples": [{"days": 14}]}}

View File

@@ -0,0 +1,72 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class AccountSummary(BaseModel):
id: UUID
name: str
display_code: Optional[str] = None
model_config = {"from_attributes": True}
class SubscriptionSummary(BaseModel):
id: UUID
plan: str
status: str
current_period_start: Optional[datetime] = None
current_period_end: Optional[datetime] = None
model_config = {"from_attributes": True}
class SessionSummary(BaseModel):
id: UUID
tree_name: Optional[str] = None
started_at: datetime
completed_at: Optional[datetime] = None
outcome: Optional[str] = None
model_config = {"from_attributes": True}
class AuditLogSummary(BaseModel):
id: UUID
action: str
resource_type: Optional[str] = None
resource_id: Optional[str] = None
created_at: datetime
details: Optional[dict] = None
model_config = {"from_attributes": True}
class InviteCodeUsedSummary(BaseModel):
code: str
assigned_plan: str
trial_duration_days: Optional[int] = None
created_by_email: Optional[str] = None
model_config = {"from_attributes": True}
class UserDetailResponse(BaseModel):
id: UUID
email: str
full_name: Optional[str] = None
role: str
is_active: bool
is_super_admin: bool
is_team_admin: bool
created_at: datetime
account: Optional[AccountSummary] = None
subscription: Optional[SubscriptionSummary] = None
invite_code_used: Optional[InviteCodeUsedSummary] = None
recent_sessions: list[SessionSummary] = []
total_sessions: int = 0
recent_audit_logs: list[AuditLogSummary] = []
total_audit_logs: int = 0

View File

@@ -25,5 +25,8 @@ slowapi==0.1.9
# Payments
stripe==14.3.0
# Email
resend==2.21.0
# Utilities
python-dotenv==1.0.1

View File

@@ -0,0 +1,227 @@
"""Tests for enhanced invite codes with plan assignment and trial durations."""
import pytest
from datetime import datetime, timezone, timedelta
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.invite_code import InviteCode
from app.models.subscription import Subscription
from app.models.user import User
class TestInviteCodeCreation:
"""Test invite code creation with plan/trial fields."""
@pytest.mark.asyncio
async def test_create_invite_with_plan(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "note": "Beta tester"},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["assigned_plan"] == "pro"
assert data["has_trial"] is False
assert data["trial_duration_days"] is None
@pytest.mark.asyncio
async def test_create_invite_with_trial(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["assigned_plan"] == "pro"
assert data["trial_duration_days"] == 14
assert data["has_trial"] is True
@pytest.mark.asyncio
async def test_create_invite_with_email(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "team", "email": "beta@example.com"},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "beta@example.com"
# Email not sent because RESEND_API_KEY not configured
assert data["email_sent"] is False
@pytest.mark.asyncio
async def test_free_plan_rejects_trial(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "free", "trial_duration_days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_trial_duration_bounds(
self, client: AsyncClient, admin_auth_headers: dict
):
# Too low
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 0},
headers=admin_auth_headers,
)
assert response.status_code == 422
# Too high
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 91},
headers=admin_auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_default_plan_is_free(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={},
headers=admin_auth_headers,
)
assert response.status_code == 201
assert response.json()["assigned_plan"] == "free"
class TestRegistrationWithInvitePlan:
"""Test that registration applies invite code plan/trial to subscription."""
@pytest.mark.asyncio
async def test_register_with_pro_trial_invite(
self, client: AsyncClient, admin_auth_headers: dict, test_db: AsyncSession
):
# Create a pro trial invite
resp = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 14},
headers=admin_auth_headers,
)
code = resp.json()["code"]
# Register with the invite code
reg_resp = await client.post(
"/api/v1/auth/register",
json={
"email": "trial_user@example.com",
"password": "SecurePass1",
"name": "Trial User",
"invite_code": code,
},
)
assert reg_resp.status_code == 201
user_id = reg_resp.json()["id"]
# Check subscription
user = (await test_db.execute(
select(User).where(User.id == user_id)
)).scalar_one()
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)).scalar_one()
assert sub.plan == "pro"
assert sub.status == "trialing"
assert sub.current_period_end is not None
assert sub.current_period_end > datetime.now(timezone.utc)
@pytest.mark.asyncio
async def test_register_with_team_no_trial(
self, client: AsyncClient, admin_auth_headers: dict, test_db: AsyncSession
):
# Create team invite without trial
resp = await client.post(
"/api/v1/invites",
json={"assigned_plan": "team"},
headers=admin_auth_headers,
)
code = resp.json()["code"]
reg_resp = await client.post(
"/api/v1/auth/register",
json={
"email": "team_user@example.com",
"password": "SecurePass1",
"name": "Team User",
"invite_code": code,
},
)
assert reg_resp.status_code == 201
user_id = reg_resp.json()["id"]
user = (await test_db.execute(
select(User).where(User.id == user_id)
)).scalar_one()
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)).scalar_one()
assert sub.plan == "team"
assert sub.status == "active"
class TestAdminSubscriptionManagement:
"""Test admin subscription plan change and trial extension endpoints."""
@pytest.mark.asyncio
async def test_change_user_plan(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
user_id = test_user["user_data"]["id"]
response = await client.put(
f"/api/v1/admin/users/{user_id}/subscription/plan",
json={"plan": "pro"},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["plan"] == "pro"
@pytest.mark.asyncio
async def test_extend_trial(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
user_id = test_user["user_data"]["id"]
response = await client.put(
f"/api/v1/admin/users/{user_id}/subscription/extend-trial",
json={"days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "trialing"
assert data["current_period_end"] is not None
@pytest.mark.asyncio
async def test_enriched_user_detail(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
user_id = test_user["user_data"]["id"]
response = await client.get(
f"/api/v1/admin/users/{user_id}",
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
# Should have enriched fields
assert "subscription" in data
assert "account" in data
assert "recent_sessions" in data
assert "total_sessions" in data
assert "recent_audit_logs" in data
assert "total_audit_logs" in data

View File

@@ -0,0 +1,173 @@
# Admin Panel: Invite Codes + User Management Enhancement
## Context
The admin panel has basic invite code CRUD and user listing, but lacks:
- **Plan assignment on invite codes** — all registrations get "free" plan
- **Email delivery** — admin must manually copy/send codes
- **Trial duration** — no time-limited plan access for beta testers
- **User detail page** — no way to view/manage a user's subscription, activity, or trial
This change enables the admin to create invite codes tied to specific plans (free/pro/team) with optional trial durations, send branded invite emails via Resend, and manage user subscriptions from a detailed user page.
---
## Phase 1: Database Migration (030)
**New file:** `backend/alembic/versions/030_enhance_invite_codes.py`
Add columns to `invite_codes`:
- `email` (String(255), nullable, indexed)
- `assigned_plan` (String(50), nullable, default `'free'`, CHECK `free/pro/team`)
- `trial_duration_days` (Integer, nullable)
- `email_sent_at` (DateTime(timezone=True), nullable)
**Update:** `backend/app/models/invite_code.py` — add fields + `has_trial` and `email_sent` properties
---
## Phase 2: Resend Email Integration
**New file:** `backend/app/core/email.py`
- `EmailService` class with `send_invite_email(to, code, plan, trial_days)`
- Graceful degradation: if `RESEND_API_KEY` not set, log warning, skip sending
- Email failure doesn't block invite creation (best-effort)
**New file:** `backend/app/templates/invite_email.html`
- Branded HTML email: monochrome design, ResolutionFlow logo, CTA button
- Shows invite code, plan name, trial duration if applicable, signup link
**Update:** `backend/app/core/config.py` — add `RESEND_API_KEY`, `FROM_EMAIL`, `email_enabled` property
**Update:** `backend/requirements.txt` — add `resend`
**Env vars:** `RESEND_API_KEY`, `FROM_EMAIL=ResolutionFlow <invites@resolutionflow.com>`
---
## Phase 3: Backend API Changes
### Invite code enhancements
**Update:** `backend/app/schemas/invite_code.py`
- `InviteCodeCreate`: add `email`, `assigned_plan`, `trial_duration_days`
- `InviteCodeResponse`: add new fields + computed `has_trial`, `email_sent`
**Update:** `backend/app/api/endpoints/invite.py`
- `create_invite_code`: accept new fields, send email if email provided, set `email_sent_at`, audit log
### Registration plan assignment
**Update:** `backend/app/api/endpoints/auth.py` (lines 178-183)
- When `invite_code_record` has `assigned_plan`/`trial_duration_days`, apply to new subscription
- Set `plan=invite_code_record.assigned_plan`, `status='trialing'` if trial, calculate `current_period_end`
### Subscription management endpoints
**Update:** `backend/app/api/endpoints/admin.py`
- `PUT /admin/users/{id}/subscription/plan` — change plan
- `PUT /admin/users/{id}/subscription/extend-trial` — add days to trial
- `GET /admin/users/{id}/detail` — enhanced user detail with account, subscription, sessions, audit logs, invite code used
**New file:** `backend/app/schemas/subscription.py``SubscriptionPlanUpdate`, `ExtendTrialRequest`, `SubscriptionResponse`
**New file:** `backend/app/schemas/user_detail.py``UserDetailResponse`, `SessionSummary`, `AuditLogSummary`, `AccountSummary`
### Trial expiry on login (lightweight)
**Update:** `backend/app/api/deps.py` — in `get_current_active_user`, check if subscription is trialing and expired → auto-downgrade to free
---
## Phase 4: Frontend Types & API Client
**Update:** `frontend/src/types/admin.ts`
- Enhanced `InviteCodeResponse` with email/plan/trial fields
- New: `UserDetail`, `SubscriptionDetail`, `SessionSummary`, `AuditLogSummary`, `AccountSummary`
**Update:** `frontend/src/api/admin.ts`
- Enhanced `createInviteCode` with new fields
- New: `getUserDetail`, `updateUserSubscriptionPlan`, `extendUserTrial`
---
## Phase 5: Frontend — Enhanced Invite Codes Page
**Update:** `frontend/src/pages/admin/InviteCodesPage.tsx`
Create form additions:
- Email input (optional, validated)
- Plan selector dropdown (Free / Pro / Team)
- Trial duration input (number of days, shown only if plan != free)
Table additions:
- "Recipient" column (email or "—")
- "Plan" column with badge
- "Trial" column (days or "—")
- "Email Sent" indicator
---
## Phase 6: Frontend — User Detail Page
**New file:** `frontend/src/pages/admin/UserDetailPage.tsx`
Sections:
1. **Header** — name, email, role badges, active status
2. **Account & Subscription card** — plan, status, trial end date, account display code
3. **Admin Actions card** — Change Role, Change Plan, Extend Trial, Activate/Deactivate (modal-based)
4. **Recent Sessions tab** — tree name, started, completed, outcome
5. **Audit Logs tab** — action, resource, timestamp, expandable details
6. **Invite Code card** — code used, plan assigned, who created it
**Update:** `frontend/src/router.tsx` — add route `admin/users/:userId`
**Update:** `frontend/src/pages/admin/UsersPage.tsx` — make user rows clickable → navigate to detail page
---
## Implementation Order
1. Migration 030 (invite code fields)
2. Model update (invite_code.py)
3. Resend integration (email.py, config.py, template, requirements.txt)
4. Backend schemas (invite_code, subscription, user_detail)
5. Backend API (invite.py, auth.py, admin.py, deps.py)
6. Backend tests
7. Frontend types + API client
8. Frontend invite codes page enhancement
9. Frontend user detail page
10. End-to-end testing
---
## Files to Create
- `backend/alembic/versions/030_enhance_invite_codes.py`
- `backend/app/core/email.py`
- `backend/app/templates/invite_email.html`
- `backend/app/schemas/subscription.py`
- `backend/app/schemas/user_detail.py`
- `frontend/src/pages/admin/UserDetailPage.tsx`
## Files to Modify
- `backend/app/models/invite_code.py`
- `backend/app/schemas/invite_code.py`
- `backend/app/api/endpoints/invite.py`
- `backend/app/api/endpoints/auth.py` (lines 178-183)
- `backend/app/api/endpoints/admin.py`
- `backend/app/api/deps.py`
- `backend/app/core/config.py`
- `backend/requirements.txt`
- `frontend/src/types/admin.ts`
- `frontend/src/api/admin.ts`
- `frontend/src/pages/admin/InviteCodesPage.tsx`
- `frontend/src/pages/admin/UsersPage.tsx`
- `frontend/src/router.tsx`
---
## Verification
1. **Backend tests:** Create invite with plan+trial → register with code → verify subscription has correct plan/status/period_end
2. **Email test:** Mock Resend, verify template renders, verify email_sent_at set on success
3. **Trial expiry:** Create expired trial → login → verify auto-downgrade to free
4. **Admin UI:** Create invite with email+plan+trial → verify email sent → register → verify in user detail page → change plan → extend trial
5. **Build:** `cd frontend && npm run build` passes
6. **Full test suite:** `cd backend && pytest --override-ini="addopts="` passes

View File

@@ -0,0 +1,253 @@
# Admin Panel: Invite Codes + User Management Enhancement
Date: 2026-02-12
Status: Proposed
## Summary
Enhance admin capabilities to:
1. Create invite codes tied to plans (`free`, `pro`, `team`) with optional trial durations.
2. Send invite emails via Resend (best-effort, non-blocking).
3. Apply invite-assigned plan/trial on registration.
4. Give admins a detailed user management view with subscription/session/audit context.
5. Support admin subscription actions (change plan, extend/start trial).
6. Auto-downgrade expired trials during authenticated access checks.
## Goals
- Remove manual invite-code sharing workflow.
- Support controlled beta onboarding with plan + trial at invite level.
- Enable operational admin workflows for account/subscription lifecycle.
- Keep backward compatibility where practical and avoid unsafe breaking changes.
## Non-Goals
- Stripe billing workflow redesign.
- Full historical pagination for user-detail sessions/audits in this iteration.
- Rework of account invite (`/accounts/me/invites`) flow.
## Key Decisions Locked
- Invite API path standardization: use `/invites` (frontend and backend aligned).
- User detail endpoint: enrich existing `GET /admin/users/{id}`.
- Invite `email` is advisory only (no strict email-match enforcement at registration).
- Invite plan/trial applies whenever a valid invite code is provided, even if `REQUIRE_INVITE_CODE=false`.
- Trial duration bounds: `1..90` days.
- Extend trial endpoint may convert non-trialing subscriptions to `trialing`.
- User detail payload includes recent summaries (latest 10 sessions + latest 10 audit logs) plus total counts.
## Scope by Phase
## Phase 1: Database Migration (`030`)
Create `backend/alembic/versions/030_enhance_invite_codes.py` (down revision `029`).
Add to `invite_codes`:
- `email`: `String(255)`, nullable, indexed.
- `assigned_plan`: `String(50)`, non-null, server default `'free'`.
- `trial_duration_days`: `Integer`, nullable.
- `email_sent_at`: `DateTime(timezone=True)`, nullable.
Constraints:
- `assigned_plan IN ('free','pro','team')`.
- `trial_duration_days IS NULL OR trial_duration_days BETWEEN 1 AND 90`.
- Optional consistency guard: `assigned_plan='free'` implies `trial_duration_days IS NULL`.
Update model `backend/app/models/invite_code.py`:
- Add mapped columns above.
- Add computed properties:
- `has_trial: bool` (`trial_duration_days is not None and > 0`)
- `email_sent: bool` (`email_sent_at is not None`)
## Phase 2: Resend Email Integration
Create `backend/app/core/email.py`:
- `EmailService.send_invite_email(to_email, code, plan, trial_days, signup_url) -> bool`.
- Returns `False` if `RESEND_API_KEY` missing.
- Catches provider failures and returns `False` (logs warning/error).
- Never blocks invite creation.
Create `backend/app/templates/invite_email.html`:
- Monochrome branded HTML.
- Invite code, plan, optional trial text, signup CTA button.
Update `backend/app/core/config.py`:
- `RESEND_API_KEY: Optional[str] = None`
- `FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"`
- `email_enabled` property.
Update `backend/requirements.txt`:
- Add `resend` package.
## Phase 3: Backend Schemas + Endpoints
### Invite code schemas
Update `backend/app/schemas/invite_code.py`:
- `InviteCodeCreate` adds:
- `email: Optional[EmailStr]`
- `assigned_plan: Literal['free','pro','team'] = 'free'`
- `trial_duration_days: Optional[int]` (1..90)
- `InviteCodeResponse` adds:
- `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`
- computed flags `has_trial`, `email_sent`.
### Invite endpoints
Update `backend/app/api/endpoints/invite.py`:
- `POST /invites` accepts new fields.
- Creates invite with plan/trial/email metadata.
- If email provided, attempts send:
- on success: set `email_sent_at`.
- on failure: invite still returns 201.
- Add audit log for invite creation with delivery result.
- Keep `GET /invites`, `DELETE /invites/{code}`, `GET /invites/validate/{code}` behavior compatible.
### Registration plan assignment
Update `backend/app/api/endpoints/auth.py`:
- If invite code is supplied and valid, load it and apply invite plan/trial regardless of `REQUIRE_INVITE_CODE`.
- For non-account-invite registrations:
- create subscription `plan=invite_code.assigned_plan` (fallback `free`).
- if `trial_duration_days` set:
- `status='trialing'`
- `current_period_start=now`
- `current_period_end=now + trial_duration_days`.
- else `status='active'`.
- Preserve account-invite join flow behavior.
- Mark invite as used post user creation.
### Admin subscription + detail endpoints
Update `backend/app/api/endpoints/admin.py`:
- Enrich `GET /admin/users/{id}` response:
- base user fields
- account summary
- subscription summary
- recent sessions (10) + total count
- recent audit logs (10) + total count
- invite code used summary
- Add:
- `PUT /admin/users/{id}/subscription/plan`
- `PUT /admin/users/{id}/subscription/extend-trial`
### Trial expiry check
Update `backend/app/api/deps.py`:
- In `get_current_active_user`, check account subscription.
- If `status='trialing'` and expired, auto-downgrade:
- `plan='free'`, `status='active'`
- clear/normalize trial period fields
- commit before returning user.
## Phase 4: Backend Schema Additions
Use existing file `backend/app/schemas/subscription.py` (do not duplicate):
- Add `SubscriptionPlanUpdate`.
- Add `ExtendTrialRequest`.
- Keep/extend `SubscriptionResponse` as needed.
Create `backend/app/schemas/user_detail.py`:
- `AccountSummary`
- `SessionSummary`
- `AuditLogSummary`
- `InviteCodeUsedSummary`
- `UserDetailResponse` (superset for enriched `/admin/users/{id}`).
## Phase 5: Frontend Types + API Client
Update `frontend/src/types/admin.ts`:
- Invite response fields for email/plan/trial/email-sent metadata.
- New detail types:
- `UserDetail`
- `SubscriptionDetail`
- `SessionSummary`
- `AuditLogSummary`
- `AccountSummary`.
Update `frontend/src/api/admin.ts`:
- Switch invite endpoints to `/invites`.
- Enhance `createInviteCode` payload.
- Add:
- `getUserDetail(userId)`
- `updateUserSubscriptionPlan(userId, plan)`
- `extendUserTrial(userId, days)`.
## Phase 6: Frontend Invite Codes Page
Update `frontend/src/pages/admin/InviteCodesPage.tsx`:
- Create form fields:
- optional email
- plan selector (Free/Pro/Team)
- trial days input when plan != free
- Table additions:
- recipient
- plan badge
- trial column
- email sent indicator
- Preserve existing create/copy/delete actions and status badges.
## Phase 7: Frontend User Detail Page
Create `frontend/src/pages/admin/UserDetailPage.tsx`:
- Header: name/email/role/active.
- Account & subscription card.
- Admin actions:
- change role
- change plan
- extend/start trial
- activate/deactivate
- Tabs:
- recent sessions
- audit logs
- Invite code card:
- code, assigned plan, creator.
Update `frontend/src/router.tsx`:
- Add route `admin/users/:userId`.
Update `frontend/src/pages/admin/UsersPage.tsx`:
- Make rows navigate to detail.
- Ensure action menu clicks do not trigger row navigation.
## API / Interface Changes
### Modified
- `POST /invites`
- new request fields: `email`, `assigned_plan`, `trial_duration_days`.
- `GET /invites`
- new response fields: `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`, `has_trial`, `email_sent`.
- `GET /admin/users/{id}`
- enriched response with account/subscription/recent activity details.
### Added
- `PUT /admin/users/{id}/subscription/plan`
- `PUT /admin/users/{id}/subscription/extend-trial`
## Test Plan
## Backend tests
1. Invite create with `assigned_plan + trial_duration_days` persists correctly.
2. Invite create with email:
- Resend success sets `email_sent_at`.
- Resend failure still returns 201 and does not set `email_sent_at`.
3. Registration with invite applies correct subscription plan/status/period fields.
4. Registration with optional invite (`REQUIRE_INVITE_CODE=false`) still applies plan/trial.
5. Expired trial auto-downgrades on authenticated request.
6. Admin plan update endpoint updates subscription + audit logs.
7. Admin extend-trial endpoint converts/extends correctly + audit logs.
8. Enriched `GET /admin/users/{id}` returns expected shape and list-size caps.
## Frontend verification
1. Create invite with email + plan + trial from admin UI.
2. Confirm invite table renders recipient/plan/trial/email-sent.
3. Open user detail from users table.
4. Change plan and extend trial from detail page.
5. Confirm updated values refresh in UI.
6. `npm run build` passes.
## Commands
- `cd backend && pytest --override-ini="addopts="`
- `cd frontend && npm run build`
## Risks and Mitigations
- Endpoint drift (`/invite-codes` vs `/invites`): update admin API client and validate all admin invite calls.
- Subscription side-effects in auth/deps: centralize trial-expiry logic and cover with tests.
- Payload growth for user detail: cap related arrays at 10 and include totals.
- Email provider outages: best-effort send with logging, no invite creation failure.
## Rollout
1. Deploy migration and backend changes.
2. Validate admin invite creation and registration path in staging.
3. Deploy frontend with new invite/user-detail UI.
4. Monitor audit logs and invite email delivery behavior post-release.
## Assumptions
- Existing admin access control (`require_admin`) remains unchanged.
- Plan limits for `free/pro/team` are already configured in `plan_limits`.
- No mandatory template engine addition is required for this email template rendering path.

View File

@@ -0,0 +1,390 @@
# Admin Panel: Invite Codes + User Management Enhancement
**Date:** 2026-02-12
**Status:** Proposed — Combined Plan
---
## Summary
Enhance admin capabilities to:
1. Create invite codes tied to plans (`free`, `pro`, `team`) with optional trial durations.
2. Send invite emails via Resend (best-effort, non-blocking).
3. Apply invite-assigned plan/trial on registration.
4. Give admins a detailed user management view with subscription, session, and audit context.
5. Support admin subscription actions (change plan, extend/start trial).
6. Auto-downgrade expired trials during authenticated access checks.
---
## Goals
- Remove manual invite-code sharing workflow.
- Support controlled beta onboarding with plan + trial at invite level.
- Enable operational admin workflows for account/subscription lifecycle.
- Keep backward compatibility where practical and avoid unsafe breaking changes.
---
## Non-Goals
- Stripe billing workflow redesign.
- Full historical pagination for user-detail sessions/audits in this iteration.
- Rework of account invite (`/accounts/me/invites`) flow.
---
## Key Decisions Locked
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Invite API path | Standardize on `/invites` | Already in use (`router = APIRouter(prefix="/invites")`). Update CLAUDE.md which incorrectly references `/invite-codes`. |
| User detail endpoint | Enrich existing `GET /admin/users/{id}` | One endpoint, richer response. No reason for admin to get a "lite" version. |
| Invite email matching | Advisory only (no strict enforcement) | The invite code itself is the security gate. Email is for admin tracking. Strict matching creates friction during beta. |
| Invite plan/trial application | Applies whenever a valid invite code is provided, even if `REQUIRE_INVITE_CODE=false` | Ensures plan/trial always carries through regardless of registration policy. |
| Trial duration bounds | 190 days | 90 days covers any realistic beta period. Protects against typos. Admin can always extend after expiry. |
| Extend trial behavior | May convert non-trialing subscriptions to `trialing` | Admin should have maximum control. Covers scenarios like forgotten trial assignment or second chances. |
| User detail payload | Recent summaries (latest 10 sessions + 10 audit logs) + total counts | Balances useful at-a-glance admin view with response performance. Full history via future paginated endpoints. |
---
## Phase 1: Database Migration (030)
**New file:** `backend/alembic/versions/030_enhance_invite_codes.py` (down revision `029`)
Add columns to `invite_codes`:
- `email`: `String(255)`, nullable, indexed.
- `assigned_plan`: `String(50)`, non-null, server default `'free'`.
- `trial_duration_days`: `Integer`, nullable.
- `email_sent_at`: `DateTime(timezone=True)`, nullable.
Database constraints:
- `assigned_plan IN ('free', 'pro', 'team')`.
- `trial_duration_days IS NULL OR trial_duration_days BETWEEN 1 AND 90`.
- Consistency guard: `assigned_plan = 'free'` implies `trial_duration_days IS NULL`.
**Update:** `backend/app/models/invite_code.py`
- Add mapped columns for all new fields.
- Add computed properties:
- `has_trial: bool``trial_duration_days is not None and > 0`
- `email_sent: bool``email_sent_at is not None`
---
## Phase 2: Resend Email Integration
**New file:** `backend/app/core/email.py`
- `EmailService` class with `send_invite_email(to_email, code, plan, trial_days, signup_url) -> bool`.
- Returns `False` if `RESEND_API_KEY` not set (graceful degradation).
- Catches provider failures, returns `False`, logs warning/error.
- Never blocks invite creation (best-effort delivery).
**New file:** `backend/app/templates/invite_email.html`
- Branded HTML email: monochrome design, ResolutionFlow logo, CTA button.
- Shows invite code, plan name, trial duration if applicable, signup link.
**Update:** `backend/app/core/config.py`
- Add `RESEND_API_KEY: Optional[str] = None`
- Add `FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"`
- Add `email_enabled` computed property.
**Update:** `backend/requirements.txt` — add `resend` package.
**Env vars required:** `RESEND_API_KEY`, `FROM_EMAIL` (has default).
**Prerequisite:** DNS records (SPF, DKIM) must be configured in Resend for `resolutionflow.com` domain before production email delivery will work.
---
## Phase 3: Backend Schemas + Endpoints
### 3a. Invite Code Schemas
**Update:** `backend/app/schemas/invite_code.py`
`InviteCodeCreate` — add fields:
- `email: Optional[EmailStr]`
- `assigned_plan: Literal['free', 'pro', 'team'] = 'free'`
- `trial_duration_days: Optional[int]` (validated 190)
`InviteCodeResponse` — add fields:
- `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`
- Computed flags: `has_trial`, `email_sent`
### 3b. Invite Endpoints
**Update:** `backend/app/api/endpoints/invite.py`
- `POST /invites` — accept new fields (email, assigned_plan, trial_duration_days).
- Create invite with plan/trial/email metadata.
- If email provided, attempt send via EmailService.
- On send success: set `email_sent_at`.
- On send failure: invite still returns 201.
- Add audit log entry for invite creation with delivery result.
- Keep `GET /invites`, `DELETE /invites/{code}`, `GET /invites/validate/{code}` behavior compatible.
### 3c. Registration Plan Assignment
**Update:** `backend/app/api/endpoints/auth.py` (registration endpoint, around lines 178183)
- If invite code is supplied and valid, load it and apply invite plan/trial **regardless of `REQUIRE_INVITE_CODE` setting**.
- For non-account-invite registrations:
- Create subscription with `plan = invite_code.assigned_plan` (fallback `'free'`).
- If `trial_duration_days` is set:
- `status = 'trialing'`
- `current_period_start = now`
- `current_period_end = now + trial_duration_days`
- Else: `status = 'active'`.
- Preserve existing account-invite join flow behavior.
- Mark invite as used after user creation.
### 3d. Admin Subscription + User Detail Endpoints
**Update:** `backend/app/api/endpoints/admin.py`
Enrich `GET /admin/users/{id}` response to include:
- Base user fields (name, email, role, active status).
- Account summary (account name, display code).
- Subscription summary (plan, status, trial end date).
- Recent sessions: latest 10 + total count.
- Recent audit logs: latest 10 + total count.
- Invite code used summary (code, assigned plan, who created it).
Add new endpoints:
- `PUT /admin/users/{id}/subscription/plan` — change user's plan.
- `PUT /admin/users/{id}/subscription/extend-trial` — add days to trial, or convert to trialing if not already.
Both endpoints should create audit log entries.
### 3e. Trial Expiry Check
**Update:** `backend/app/api/deps.py` — in `get_current_active_user`
- Check account subscription status.
- If `status = 'trialing'` and `current_period_end < now`:
- Set `plan = 'free'`, `status = 'active'`.
- Clear/normalize trial period fields.
- Commit before returning user.
**Note:** This is a lightweight login-time check. Users with active JWT sessions will retain access until token refresh. Acceptable for beta; revisit if stricter enforcement needed later.
---
## Phase 4: Backend Schema Additions
**Check first:** Verify whether `backend/app/schemas/subscription.py` already exists. If it does, extend it. If not, create it.
Schemas needed in `backend/app/schemas/subscription.py`:
- `SubscriptionPlanUpdate` — for plan change requests.
- `ExtendTrialRequest` — for trial extension requests.
- `SubscriptionResponse` — for subscription state in responses.
**New file:** `backend/app/schemas/user_detail.py`
- `AccountSummary`
- `SessionSummary`
- `AuditLogSummary`
- `InviteCodeUsedSummary`
- `UserDetailResponse` (superset response for enriched `/admin/users/{id}`)
---
## Phase 5: Frontend Types + API Client
**Update:** `frontend/src/types/admin.ts`
- Enhanced `InviteCodeResponse` with email, plan, trial, email-sent fields.
- New types: `UserDetail`, `SubscriptionDetail`, `SessionSummary`, `AuditLogSummary`, `AccountSummary`.
**Update:** `frontend/src/api/admin.ts`
- Ensure invite endpoints target `/invites` (not `/invite-codes`).
- Enhance `createInviteCode` payload with new fields.
- Add: `getUserDetail(userId)`, `updateUserSubscriptionPlan(userId, plan)`, `extendUserTrial(userId, days)`.
---
## Phase 6: Frontend — Enhanced Invite Codes Page
**Update:** `frontend/src/pages/admin/InviteCodesPage.tsx`
Create form additions:
- Email input (optional, validated).
- Plan selector dropdown (Free / Pro / Team).
- Trial duration input (number of days, shown only when plan ≠ free).
Table additions:
- "Recipient" column (email or "—").
- "Plan" column with badge.
- "Trial" column (days or "—").
- "Email Sent" indicator.
Preserve existing create/copy/delete actions and status badges.
---
## Phase 7: Frontend — User Detail Page
**New file:** `frontend/src/pages/admin/UserDetailPage.tsx`
Sections:
- **Header** — name, email, role badges, active status.
- **Account & Subscription card** — plan, status, trial end date, account display code.
- **Admin Actions card** — Change Role, Change Plan, Extend/Start Trial, Activate/Deactivate (modal-based).
- **Recent Sessions tab** — tree name, started, completed, outcome.
- **Audit Logs tab** — action, resource, timestamp, expandable details.
- **Invite Code card** — code used, plan assigned, who created it.
**Update:** `frontend/src/router.tsx` — add route `admin/users/:userId`.
**Update:** `frontend/src/pages/admin/UsersPage.tsx` — make user rows clickable to navigate to detail page. Ensure action menu clicks (dropdowns, buttons) don't trigger row navigation.
---
## File Inventory
### Files to Create
| File | Phase |
|------|-------|
| `backend/alembic/versions/030_enhance_invite_codes.py` | 1 |
| `backend/app/core/email.py` | 2 |
| `backend/app/templates/invite_email.html` | 2 |
| `backend/app/schemas/subscription.py` (verify doesn't exist first) | 4 |
| `backend/app/schemas/user_detail.py` | 4 |
| `frontend/src/pages/admin/UserDetailPage.tsx` | 7 |
### Files to Modify
| File | Phase | What Changes |
|------|-------|-------------|
| `backend/app/models/invite_code.py` | 1 | Add new columns + computed properties |
| `backend/app/core/config.py` | 2 | Add RESEND_API_KEY, FROM_EMAIL, email_enabled |
| `backend/requirements.txt` | 2 | Add resend package |
| `backend/app/schemas/invite_code.py` | 3a | Add email, plan, trial fields to create/response |
| `backend/app/api/endpoints/invite.py` | 3b | Accept new fields, send email, audit log |
| `backend/app/api/endpoints/auth.py` | 3c | Apply invite plan/trial on registration (lines ~178183) |
| `backend/app/api/endpoints/admin.py` | 3d | Enrich user detail, add subscription endpoints |
| `backend/app/api/deps.py` | 3e | Trial expiry check in get_current_active_user |
| `frontend/src/types/admin.ts` | 5 | Enhanced invite + new detail types |
| `frontend/src/api/admin.ts` | 5 | New API functions, fix invite path |
| `frontend/src/pages/admin/InviteCodesPage.tsx` | 6 | Form + table enhancements |
| `frontend/src/pages/admin/UsersPage.tsx` | 7 | Clickable rows → detail page |
| `frontend/src/router.tsx` | 7 | Add user detail route |
### Also Update (Housekeeping)
| File | What Changes |
|------|-------------|
| `CLAUDE.md` | Fix invite codes endpoint reference from `/invite-codes` to `/invites` |
---
## API / Interface Changes
### Modified Endpoints
- `POST /invites` — new request fields: `email`, `assigned_plan`, `trial_duration_days`.
- `GET /invites` — new response fields: `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`, `has_trial`, `email_sent`.
- `GET /admin/users/{id}` — enriched response with account/subscription/recent activity details.
### New Endpoints
- `PUT /admin/users/{id}/subscription/plan`
- `PUT /admin/users/{id}/subscription/extend-trial`
---
## Implementation Order
1. Migration 030 (invite code fields)
2. Model update (invite_code.py)
3. Resend integration (email.py, config.py, template, requirements.txt)
4. Backend schemas (invite_code, subscription, user_detail)
5. Backend API (invite.py, auth.py, admin.py, deps.py)
6. Backend tests
7. Frontend types + API client
8. Frontend invite codes page enhancement
9. Frontend user detail page
10. End-to-end testing
---
## Test Plan
### Backend Tests
1. Invite create with `assigned_plan` + `trial_duration_days` persists correctly.
2. Invite create with email — Resend success sets `email_sent_at`.
3. Invite create with email — Resend failure still returns 201, does not set `email_sent_at`.
4. Registration with invite applies correct subscription plan/status/period fields.
5. Registration with optional invite (`REQUIRE_INVITE_CODE=false`) still applies plan/trial when code provided.
6. Expired trial auto-downgrades on authenticated request.
7. Admin plan update endpoint updates subscription + creates audit log.
8. Admin extend-trial endpoint converts/extends correctly + creates audit log.
9. Enriched `GET /admin/users/{id}` returns expected shape and list-size caps (10 sessions, 10 audit logs).
10. Trial duration validation rejects values outside 190 range.
11. Free plan invite rejects trial_duration_days (consistency guard).
### Frontend Verification
1. Create invite with email + plan + trial from admin UI.
2. Confirm invite table renders recipient/plan/trial/email-sent columns.
3. Open user detail from users table (click row).
4. Change plan and extend trial from detail page.
5. Confirm updated values refresh in UI.
6. `cd frontend && npm run build` passes.
### Commands
```
cd backend && pytest --override-ini="addopts="
cd frontend && npm run build
```
---
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| Endpoint drift (`/invite-codes` vs `/invites`) | Update CLAUDE.md and admin API client. Verify all admin invite calls use `/invites`. |
| Subscription side-effects in auth/deps | Centralize trial-expiry logic. Cover with targeted tests. |
| Payload growth for user detail | Cap related arrays at 10 items, include total counts. |
| Email provider outages | Best-effort send with logging. Invite creation never fails due to email. |
| DNS not configured for Resend | Document as prerequisite. Email gracefully degrades when API key missing. |
| `subscription.py` may already exist | Verify before creating. Extend if present, create if not. |
| JWT session outlives trial expiry | Acceptable for beta. Document as known limitation. |
---
## Rollout
1. Deploy migration and backend changes.
2. Validate admin invite creation and registration path in staging.
3. Deploy frontend with new invite/user-detail UI.
4. Monitor audit logs and invite email delivery behavior post-release.
---
## Assumptions
- Existing admin access control (`require_admin`) remains unchanged.
- Plan limits for `free/pro/team` are already configured in `plan_limits`.
- No mandatory template engine addition is required for email template rendering.
- Alembic `env.py` already imports `InviteCode` model (per LESSONS-LEARNED.md).

View File

@@ -13,6 +13,9 @@ import type {
AccountFeatureOverrideCreate,
AdminCategory,
GlobalCategoryCreate,
InviteCodeResponse,
InviteCodeCreateRequest,
UserDetailResponse,
} from '@/types/admin'
export const adminApi = {
@@ -38,13 +41,21 @@ export const adminApi = {
moveUserAccount: (id: string, display_code: string) =>
api.put(`/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
// Invite Codes (existing endpoints)
// Users - detail + subscription
getUserDetail: (id: string) =>
api.get<UserDetailResponse>(`/admin/users/${id}`).then(r => r.data),
updateUserSubscriptionPlan: (id: string, plan: string) =>
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
extendUserTrial: (id: string, days: number) =>
api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data),
// Invite Codes
listInviteCodes: (params?: Record<string, unknown>) =>
api.get('/invite-codes', { params }).then(r => r.data),
createInviteCode: (data?: { expires_at?: string }) =>
api.post('/invite-codes', data || {}).then(r => r.data),
deleteInviteCode: (id: string) =>
api.delete(`/invite-codes/${id}`),
api.get<InviteCodeResponse[]>('/invites', { params }).then(r => r.data),
createInviteCode: (data: InviteCodeCreateRequest = {}) =>
api.post<InviteCodeResponse>('/invites', data).then(r => r.data),
deleteInviteCode: (code: string) =>
api.delete(`/invites/${code}`),
// Audit Logs
listAuditLogs: (params?: Record<string, unknown>) =>

View File

@@ -1,33 +1,45 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Copy, Trash2, Ticket } from 'lucide-react'
import { Plus, Copy, Trash2, Ticket, Mail, MailCheck } from 'lucide-react'
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { InviteCodeResponse, InviteCodeCreateRequest } from '@/types/admin'
interface InviteCode {
id: string
code: string
created_by_id: string
used_by_id: string | null
is_active: boolean
expires_at: string | null
created_at: string
const PLAN_OPTIONS = [
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
{ value: 'team', label: 'Team' },
] as const
const planBadgeVariant = (plan: string): 'success' | 'destructive' | 'warning' | 'default' => {
switch (plan) {
case 'pro': return 'success'
case 'team': return 'warning'
default: return 'default'
}
}
export function InviteCodesPage() {
const [codes, setCodes] = useState<InviteCode[]>([])
const [codes, setCodes] = useState<InviteCodeResponse[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
// Form state
const [email, setEmail] = useState('')
const [expiresInDays, setExpiresInDays] = useState('')
const [assignedPlan, setAssignedPlan] = useState<'free' | 'pro' | 'team'>('free')
const [trialDays, setTrialDays] = useState('')
const [note, setNote] = useState('')
const fetchCodes = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listInviteCodes()
setCodes(Array.isArray(data) ? data : data.items || [])
setCodes(Array.isArray(data) ? data : [])
} catch {
toast.error('Failed to load invite codes')
} finally {
@@ -37,18 +49,36 @@ export function InviteCodesPage() {
useEffect(() => { fetchCodes() }, [fetchCodes])
const resetForm = () => {
setEmail('')
setExpiresInDays('')
setAssignedPlan('free')
setTrialDays('')
setNote('')
}
const handleCreate = async () => {
setCreating(true)
try {
const expiresAt = expiresInDays
? new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString()
: undefined
await adminApi.createInviteCode(expiresAt ? { expires_at: expiresAt } : undefined)
const data: InviteCodeCreateRequest = {}
if (expiresInDays) {
data.expires_at = new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString()
}
if (email.trim()) data.email = email.trim()
if (note.trim()) data.note = note.trim()
data.assigned_plan = assignedPlan
if (assignedPlan !== 'free' && trialDays) {
data.trial_duration_days = parseInt(trialDays)
}
await adminApi.createInviteCode(data)
toast.success('Invite code created')
setCreateOpen(false)
setExpiresInDays('')
resetForm()
fetchCodes()
} catch {
toast.error('Failed to create invite code')
} finally {
setCreating(false)
}
}
@@ -57,9 +87,9 @@ export function InviteCodesPage() {
toast.success('Code copied to clipboard')
}
const handleDelete = async (id: string) => {
const handleDelete = async (code: string) => {
try {
await adminApi.deleteInviteCode(id)
await adminApi.deleteInviteCode(code)
toast.success('Invite code deleted')
fetchCodes()
} catch {
@@ -67,7 +97,12 @@ export function InviteCodesPage() {
}
}
const columns: Column<InviteCode>[] = [
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'
)
const columns: Column<InviteCodeResponse>[] = [
{
key: 'code',
header: 'Code',
@@ -75,14 +110,48 @@ export function InviteCodesPage() {
<code className="rounded bg-white/10 px-2 py-1 text-sm font-mono text-white/70">{c.code}</code>
),
},
{
key: 'email',
header: 'Recipient',
render: (c) => c.email ? (
<div className="flex items-center gap-1.5">
{c.email_sent ? (
<MailCheck className="h-3.5 w-3.5 text-emerald-400" />
) : (
<Mail className="h-3.5 w-3.5 text-white/30" />
)}
<span className="text-sm text-white/60">{c.email}</span>
</div>
) : (
<span className="text-sm text-white/30">&mdash;</span>
),
},
{
key: 'plan',
header: 'Plan',
render: (c) => (
<StatusBadge variant={planBadgeVariant(c.assigned_plan)}>
{c.assigned_plan.charAt(0).toUpperCase() + c.assigned_plan.slice(1)}
</StatusBadge>
),
},
{
key: 'trial',
header: 'Trial',
render: (c) => c.has_trial ? (
<span className="text-sm text-white/60">{c.trial_duration_days}d</span>
) : (
<span className="text-sm text-white/30">&mdash;</span>
),
},
{
key: 'status',
header: 'Status',
render: (c) => {
if (c.used_by_id) return <StatusBadge variant="default">Used</StatusBadge>
if (!c.is_active) return <StatusBadge variant="destructive">Inactive</StatusBadge>
if (c.expires_at && new Date(c.expires_at) < new Date()) return <StatusBadge variant="warning">Expired</StatusBadge>
return <StatusBadge variant="success">Active</StatusBadge>
if (c.is_used) return <StatusBadge variant="default">Used</StatusBadge>
if (c.is_expired) return <StatusBadge variant="warning">Expired</StatusBadge>
if (c.is_valid) return <StatusBadge variant="success">Active</StatusBadge>
return <StatusBadge variant="destructive">Inactive</StatusBadge>
},
},
{
@@ -114,12 +183,12 @@ export function InviteCodesPage() {
icon: <Copy className="h-4 w-4" />,
onClick: () => handleCopy(c.code),
},
{
...(!c.is_used ? [{
label: 'Delete',
icon: <Trash2 className="h-4 w-4" />,
onClick: () => handleDelete(c.id),
onClick: () => handleDelete(c.code),
destructive: true,
},
}] : []),
]} />
),
},
@@ -129,7 +198,7 @@ export function InviteCodesPage() {
<div className="space-y-6">
<PageHeader
title="Invite Codes"
description="Manage registration invite codes"
description="Create and manage registration invite codes with plan assignment"
action={
<button
onClick={() => setCreateOpen(true)}
@@ -160,27 +229,73 @@ export function InviteCodesPage() {
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
onClose={() => { setCreateOpen(false); resetForm() }}
title="Create Invite Code"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setCreateOpen(false)}
onClick={() => { setCreateOpen(false); resetForm() }}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
>
Cancel
</button>
<button
onClick={handleCreate}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
disabled={creating}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
Create
{creating ? 'Creating...' : 'Create'}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-white">Recipient Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Optional — will send invite email"
className={inputClass}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Plan</label>
<select
aria-label="Plan"
value={assignedPlan}
onChange={(e) => {
const plan = e.target.value as 'free' | 'pro' | 'team'
setAssignedPlan(plan)
if (plan === 'free') setTrialDays('')
}}
className={inputClass}
>
{PLAN_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{assignedPlan !== 'free' && (
<div>
<label className="mb-1 block text-sm font-medium text-white">Trial Duration (days)</label>
<input
type="number"
value={trialDays}
onChange={(e) => setTrialDays(e.target.value)}
placeholder="e.g. 14 (1-90)"
min={1}
max={90}
className={inputClass}
/>
<p className="mt-1 text-xs text-white/40">Leave empty for no trial account gets full plan immediately.</p>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-white">Expires in (days)</label>
<input
@@ -188,10 +303,18 @@ export function InviteCodesPage() {
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
placeholder="Leave empty for no expiry"
className={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'
)}
className={inputClass}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Note</label>
<input
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Optional note (e.g. who this is for)"
className={inputClass}
/>
</div>
</div>

View File

@@ -0,0 +1,422 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket } from 'lucide-react'
import { StatusBadge } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { UserDetailResponse } from '@/types/admin'
const PLAN_OPTIONS = ['free', 'pro', 'team'] as const
export function UserDetailPage() {
const { userId } = useParams<{ userId: string }>()
const navigate = useNavigate()
const [user, setUser] = useState<UserDetailResponse | null>(null)
const [loading, setLoading] = useState(true)
// Modal state
const [planModalOpen, setPlanModalOpen] = useState(false)
const [selectedPlan, setSelectedPlan] = useState('')
const [trialModalOpen, setTrialModalOpen] = useState(false)
const [trialDays, setTrialDays] = useState('14')
const [activeTab, setActiveTab] = useState<'sessions' | 'audit'>('sessions')
const fetchUser = useCallback(async () => {
if (!userId) return
setLoading(true)
try {
const data = await adminApi.getUserDetail(userId)
setUser(data)
} catch {
toast.error('Failed to load user details')
} finally {
setLoading(false)
}
}, [userId])
useEffect(() => { fetchUser() }, [fetchUser])
const handleChangePlan = async () => {
if (!userId || !selectedPlan) return
try {
await adminApi.updateUserSubscriptionPlan(userId, selectedPlan)
toast.success(`Plan changed to ${selectedPlan}`)
setPlanModalOpen(false)
fetchUser()
} catch {
toast.error('Failed to change plan')
}
}
const handleExtendTrial = async () => {
if (!userId || !trialDays) return
try {
await adminApi.extendUserTrial(userId, parseInt(trialDays))
toast.success(`Trial extended by ${trialDays} days`)
setTrialModalOpen(false)
fetchUser()
} catch {
toast.error('Failed to extend trial')
}
}
const handleToggleActive = async () => {
if (!userId || !user) return
try {
if (user.is_active) {
await adminApi.deactivateUser(userId)
toast.success('User deactivated')
} else {
await adminApi.activateUser(userId)
toast.success('User activated')
}
fetchUser()
} catch {
toast.error('Failed to update user status')
}
}
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'
)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
)
}
if (!user) {
return (
<div className="py-20 text-center text-white/40">User not found</div>
)
}
const fmt = (d: string | null) => d ? new Date(d).toLocaleDateString() : '—'
const fmtFull = (d: string | null) => d ? new Date(d).toLocaleString() : '—'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/users')}
className="rounded-md border border-white/10 p-2 text-white/60 hover:bg-white/10 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex-1">
<h1 className="text-xl font-semibold text-white">
{user.full_name || user.email}
</h1>
<p className="text-sm text-white/40">{user.email}</p>
</div>
<div className="flex items-center gap-2">
{user.is_super_admin && (
<StatusBadge variant="warning">
<Crown className="mr-1 h-3 w-3" /> Super Admin
</StatusBadge>
)}
<StatusBadge variant={user.is_active ? 'success' : 'destructive'}>
{user.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
<StatusBadge variant="default">{user.role}</StatusBadge>
</div>
</div>
{/* Account & Subscription */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="glass-card rounded-2xl p-6">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-white/40">
Account & Subscription
</h2>
<dl className="space-y-3">
{user.account && (
<>
<div className="flex justify-between">
<dt className="text-sm text-white/60">Account</dt>
<dd className="text-sm text-white">{user.account.name}</dd>
</div>
{user.account.display_code && (
<div className="flex justify-between">
<dt className="text-sm text-white/60">Display Code</dt>
<dd className="text-sm font-mono text-white/70">{user.account.display_code}</dd>
</div>
)}
</>
)}
{user.subscription ? (
<>
<div className="flex justify-between">
<dt className="text-sm text-white/60">Plan</dt>
<dd className="text-sm font-semibold text-white">
{user.subscription.plan.charAt(0).toUpperCase() + user.subscription.plan.slice(1)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-white/60">Status</dt>
<dd>
<StatusBadge variant={user.subscription.status === 'trialing' ? 'warning' : 'success'}>
{user.subscription.status}
</StatusBadge>
</dd>
</div>
{user.subscription.current_period_end && (
<div className="flex justify-between">
<dt className="text-sm text-white/60">Period End</dt>
<dd className="text-sm text-white/70">{fmt(user.subscription.current_period_end)}</dd>
</div>
)}
</>
) : (
<div className="text-sm text-white/40">No subscription</div>
)}
<div className="flex justify-between">
<dt className="text-sm text-white/60">Joined</dt>
<dd className="text-sm text-white/70">{fmt(user.created_at)}</dd>
</div>
</dl>
</div>
{/* Admin Actions */}
<div className="glass-card rounded-2xl p-6">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-white/40">
Admin Actions
</h2>
<div className="space-y-3">
<button
onClick={() => {
setSelectedPlan(user.subscription?.plan || 'free')
setPlanModalOpen(true)
}}
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
>
<Shield className="h-4 w-4 text-white/40" />
Change Plan
</button>
<button
onClick={() => setTrialModalOpen(true)}
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
>
<Clock className="h-4 w-4 text-white/40" />
{user.subscription?.status === 'trialing' ? 'Extend Trial' : 'Start Trial'}
</button>
<button
onClick={handleToggleActive}
className={cn(
'flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left text-sm',
user.is_active
? 'border-red-500/20 text-red-400 hover:bg-red-500/5'
: 'border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/5'
)}
>
{user.is_active ? (
<><UserX className="h-4 w-4" /> Deactivate User</>
) : (
<><UserCheck className="h-4 w-4" /> Activate User</>
)}
</button>
</div>
</div>
</div>
{/* Invite Code Used */}
{user.invite_code_used && (
<div className="glass-card rounded-2xl p-6">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-white/40">
<Ticket className="mr-2 inline h-4 w-4" />
Invite Code Used
</h2>
<dl className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<dt className="text-xs text-white/40">Code</dt>
<dd className="mt-1 font-mono text-sm text-white/70">{user.invite_code_used.code}</dd>
</div>
<div>
<dt className="text-xs text-white/40">Plan Assigned</dt>
<dd className="mt-1 text-sm text-white/70">
{user.invite_code_used.assigned_plan.charAt(0).toUpperCase() + user.invite_code_used.assigned_plan.slice(1)}
</dd>
</div>
<div>
<dt className="text-xs text-white/40">Trial Days</dt>
<dd className="mt-1 text-sm text-white/70">{user.invite_code_used.trial_duration_days ?? '—'}</dd>
</div>
<div>
<dt className="text-xs text-white/40">Created By</dt>
<dd className="mt-1 text-sm text-white/70">{user.invite_code_used.created_by_email ?? '—'}</dd>
</div>
</dl>
</div>
)}
{/* Tabs: Sessions / Audit Logs */}
<div className="glass-card rounded-2xl">
<div className="flex border-b border-white/[0.06]">
<button
onClick={() => setActiveTab('sessions')}
className={cn(
'px-6 py-3 text-sm font-medium',
activeTab === 'sessions' ? 'border-b-2 border-white text-white' : 'text-white/40 hover:text-white/60'
)}
>
Sessions ({user.total_sessions})
</button>
<button
onClick={() => setActiveTab('audit')}
className={cn(
'px-6 py-3 text-sm font-medium',
activeTab === 'audit' ? 'border-b-2 border-white text-white' : 'text-white/40 hover:text-white/60'
)}
>
Audit Logs ({user.total_audit_logs})
</button>
</div>
<div className="p-6">
{activeTab === 'sessions' && (
user.recent_sessions.length > 0 ? (
<table className="w-full">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs text-white/40">
<th className="pb-2 font-medium">Tree</th>
<th className="pb-2 font-medium">Started</th>
<th className="pb-2 font-medium">Completed</th>
<th className="pb-2 font-medium">Outcome</th>
</tr>
</thead>
<tbody>
{user.recent_sessions.map(s => (
<tr key={s.id} className="border-b border-white/[0.03]">
<td className="py-3 text-sm text-white/70">{s.tree_name ?? '—'}</td>
<td className="py-3 text-sm text-white/40">{fmtFull(s.started_at)}</td>
<td className="py-3 text-sm text-white/40">{fmtFull(s.completed_at)}</td>
<td className="py-3">
{s.outcome ? (
<StatusBadge variant={s.outcome === 'resolved' ? 'success' : 'default'}>
{s.outcome}
</StatusBadge>
) : (
<span className="text-sm text-white/30"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-8 text-center text-sm text-white/40">No sessions yet</div>
)
)}
{activeTab === 'audit' && (
user.recent_audit_logs.length > 0 ? (
<table className="w-full">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs text-white/40">
<th className="pb-2 font-medium">Action</th>
<th className="pb-2 font-medium">Resource</th>
<th className="pb-2 font-medium">Time</th>
</tr>
</thead>
<tbody>
{user.recent_audit_logs.map(a => (
<tr key={a.id} className="border-b border-white/[0.03]">
<td className="py-3 text-sm text-white/70">{a.action}</td>
<td className="py-3 text-sm text-white/40">{a.resource_type ?? '—'}</td>
<td className="py-3 text-sm text-white/40">{fmtFull(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-8 text-center text-sm text-white/40">No audit logs yet</div>
)
)}
</div>
</div>
{/* Change Plan Modal */}
<Modal
isOpen={planModalOpen}
onClose={() => setPlanModalOpen(false)}
title="Change Subscription Plan"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setPlanModalOpen(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10"
>
Cancel
</button>
<button
onClick={handleChangePlan}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
>
Update Plan
</button>
</div>
}
>
<div>
<label className="mb-1 block text-sm font-medium text-white">Plan</label>
<select
aria-label="Subscription plan"
value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)}
className={inputClass}
>
{PLAN_OPTIONS.map(p => (
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
))}
</select>
</div>
</Modal>
{/* Extend Trial Modal */}
<Modal
isOpen={trialModalOpen}
onClose={() => setTrialModalOpen(false)}
title={user.subscription?.status === 'trialing' ? 'Extend Trial' : 'Start Trial'}
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setTrialModalOpen(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10"
>
Cancel
</button>
<button
onClick={handleExtendTrial}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
>
{user.subscription?.status === 'trialing' ? 'Extend' : 'Start Trial'}
</button>
</div>
}
>
<div>
<label className="mb-1 block text-sm font-medium text-white">Days to add</label>
<input
type="number"
value={trialDays}
onChange={(e) => setTrialDays(e.target.value)}
min={1}
max={90}
className={inputClass}
/>
<p className="mt-1 text-xs text-white/40">1-90 days. Will convert to trialing status if not already.</p>
</div>
</Modal>
</div>
)
}
export default UserDetailPage

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { UserCheck, UserX, Shield, ArrowRightLeft } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink } from 'lucide-react'
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
@@ -21,6 +22,7 @@ interface AdminUser {
}
export function UsersPage() {
const navigate = useNavigate()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
@@ -140,6 +142,11 @@ export function UsersPage() {
className: 'w-12',
render: (u) => (
<ActionMenu items={[
{
label: 'View Detail',
icon: <ExternalLink className="h-4 w-4" />,
onClick: () => navigate(`/admin/users/${u.id}`),
},
{
label: 'Change Role',
icon: <Shield className="h-4 w-4" />,

View File

@@ -21,6 +21,7 @@ const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage'))
const AdminUserDetailPage = lazy(() => import('@/pages/admin/UserDetailPage'))
const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage'))
const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage'))
@@ -143,6 +144,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'users/:userId',
element: (
<Suspense fallback={<PageLoader />}>
<AdminUserDetailPage />
</Suspense>
),
},
{
path: 'invite-codes',
element: (

View File

@@ -128,3 +128,89 @@ export interface GlobalCategoryCreate {
slug: string
description?: string | null
}
// Invite code types (enhanced)
export interface InviteCodeResponse {
id: string
code: string
created_by_id: string
used_by_id: string | null
expires_at: string | null
note: string | null
created_at: string
used_at: string | null
is_used: boolean
is_expired: boolean
is_valid: boolean
email: string | null
assigned_plan: string
trial_duration_days: number | null
email_sent_at: string | null
has_trial: boolean
email_sent: boolean
}
export interface InviteCodeCreateRequest {
expires_at?: string | null
note?: string | null
email?: string | null
assigned_plan?: 'free' | 'pro' | 'team'
trial_duration_days?: number | null
}
// User detail types
export interface AccountSummary {
id: string
name: string
display_code: string | null
}
export interface SubscriptionSummary {
id: string
plan: string
status: string
current_period_start: string | null
current_period_end: string | null
}
export interface SessionSummary {
id: string
tree_name: string | null
started_at: string
completed_at: string | null
outcome: string | null
}
export interface AuditLogSummary {
id: string
action: string
resource_type: string | null
resource_id: string | null
created_at: string
details: Record<string, unknown> | null
}
export interface InviteCodeUsedSummary {
code: string
assigned_plan: string
trial_duration_days: number | null
created_by_email: string | null
}
export interface UserDetailResponse {
id: string
email: string
full_name: string | null
role: string
is_active: boolean
is_super_admin: boolean
is_team_admin: boolean
created_at: string
account: AccountSummary | null
subscription: SubscriptionSummary | null
invite_code_used: InviteCodeUsedSummary | null
recent_sessions: SessionSummary[]
total_sessions: number
recent_audit_logs: AuditLogSummary[]
total_audit_logs: number
}