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:
351
CURRENT-STATE.md
351
CURRENT-STATE.md
@@ -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)
|
||||
|
||||
61
backend/alembic/versions/030_enhance_invite_codes.py
Normal file
61
backend/alembic/versions/030_enhance_invite_codes.py
Normal 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')
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
105
backend/app/core/email.py
Normal 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>"""
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}]}}
|
||||
|
||||
72
backend/app/schemas/user_detail.py
Normal file
72
backend/app/schemas/user_detail.py
Normal 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
|
||||
@@ -25,5 +25,8 @@ slowapi==0.1.9
|
||||
# Payments
|
||||
stripe==14.3.0
|
||||
|
||||
# Email
|
||||
resend==2.21.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.1
|
||||
|
||||
227
backend/tests/test_invite_plan.py
Normal file
227
backend/tests/test_invite_plan.py
Normal 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
|
||||
173
docs/plans/2026-02-11-invite-codes-admin-panel.md
Normal file
173
docs/plans/2026-02-11-invite-codes-admin-panel.md
Normal 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
|
||||
@@ -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.
|
||||
390
docs/plans/2026-02-12-admin-invite-user-mgmt.md
Normal file
390
docs/plans/2026-02-12-admin-invite-user-mgmt.md
Normal 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 | 1–90 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 1–90)
|
||||
|
||||
`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 178–183)
|
||||
|
||||
- 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 ~178–183) |
|
||||
| `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 1–90 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).
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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">—</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">—</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>
|
||||
|
||||
422
frontend/src/pages/admin/UserDetailPage.tsx
Normal file
422
frontend/src/pages/admin/UserDetailPage.tsx
Normal 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
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user