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.
|
> **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.
|
> **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%)
|
### Backend (100%)
|
||||||
- ✅ FastAPI project structure
|
- FastAPI project structure with 25+ API endpoints
|
||||||
- ✅ PostgreSQL database with Docker
|
- PostgreSQL database with Docker, 29+ Alembic migrations
|
||||||
- ✅ User authentication (JWT, register, login, refresh)
|
- User authentication (JWT, register, login, refresh, logout, invite codes)
|
||||||
- ✅ Trees CRUD with full-text search
|
- Refresh token rotation with JTI-based revocation
|
||||||
- ✅ Sessions tracking with decisions
|
- Trees CRUD with full-text search (FTS index)
|
||||||
- ✅ Export API (Markdown, Text, HTML)
|
- Sessions tracking with decisions, outcomes, and variables
|
||||||
- ✅ Role-based access control foundation
|
- Export API (Markdown, Text, HTML)
|
||||||
- ✅ Production-ready logging with correlation IDs
|
- Role-based access control (super_admin, team_admin, engineer, viewer)
|
||||||
- ✅ 40+ integration tests
|
- Production-ready logging with correlation IDs
|
||||||
- ✅ DateTime timezone handling fixed
|
- 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)
|
### 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
|
### Security Hardening (Phases A-D Complete)
|
||||||
- ✅ Authentication UI (login, register)
|
- Registration role hardcoded to `engineer`
|
||||||
- ✅ Basic layout and navigation
|
- HTML export XSS fix (html.escape)
|
||||||
- ✅ Tree library/browsing page
|
- Secret key validator (rejects default when DEBUG=False)
|
||||||
- ✅ Tree navigation interface
|
- Role CHECK constraint on users table
|
||||||
- ✅ Session management
|
- Tree access check on session start
|
||||||
- ✅ Export functionality (download)
|
- Centralized permissions in `permissions.py`
|
||||||
- ✅ Responsive design
|
- `is_active` field on User model, enforced in auth
|
||||||
- ✅ Error boundaries
|
- Admin user management endpoints (6 endpoints)
|
||||||
- ✅ **Tree Editor** - Form-based with visual preview
|
- Refresh token rotation with JTI-based revocation
|
||||||
- ✅ Zustand store with immer (undo/redo via zundo)
|
- Password complexity validation (uppercase, lowercase, digit, min 10 chars)
|
||||||
- ✅ Split-view layout (editor left, preview right)
|
- Soft delete cascade cleanup (folder/tag junctions)
|
||||||
- ✅ Node CRUD (Decision, Action, Solution types)
|
- SQL wildcard escaping in tag search
|
||||||
- ✅ NodePicker with type-grouped dropdown
|
|
||||||
- ✅ Dynamic array fields (options, commands, steps)
|
### Backend Schema Features (Not Yet in Frontend)
|
||||||
- ✅ Visual tree preview with solution indicators
|
- **Tree Forking** (migration 022) — `parent_tree_id`, `root_tree_id`, `fork_depth`, `fork_reason`
|
||||||
- ✅ Shared node detection (multiple sources → same target)
|
- **Session Sharing** (migration 023) — `session_shares`, `session_share_views`, `allow_public_shares`
|
||||||
- ✅ Modal with scrollable content, fixed header/footer
|
- **Tree Sharing** (migration 024) — tree share links
|
||||||
- ✅ Markdown preview toggle in description fields
|
- **Tree Status** (migration 025) — status field on trees
|
||||||
- ✅ **Markdown Rendering** - Session player and node editor
|
- **Admin Panel Tables** (migration 026) — plan limits, feature flags
|
||||||
- ✅ `react-markdown` package installed
|
- **Session Variables** (migration 028) — variable tracking in sessions
|
||||||
- ✅ `MarkdownContent` component created
|
- **Session Outcomes** (migration 029) — outcome tracking
|
||||||
- ✅ 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
|
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
- CLAUDE.md (project context for Claude Code)
|
||||||
- ✅ Project overview and architecture docs
|
- LESSONS-LEARNED.md (bugs and fixes reference)
|
||||||
- ✅ Development roadmap through Phase 4
|
- Design system guide, component examples
|
||||||
- ✅ Feature specifications (including Phase 2.5)
|
- Feature specifications through Phase 4
|
||||||
- ✅ CLAUDE.md for Claude Code context
|
- Rebrand implementation guide
|
||||||
- ✅ LESSONS-LEARNED.md for avoiding past mistakes
|
|
||||||
- ✅ REBRAND-IMPLEMENTATION-GUIDE.md
|
|
||||||
- ✅ Permissions audit design doc
|
|
||||||
- ✅ Comprehensive project review report
|
|
||||||
- ✅ Subscription tier architecture plan
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What's In Progress 🔄
|
## What's In Progress
|
||||||
|
|
||||||
| Task | Status | Notes |
|
| Task | Status | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| Step Library Frontend | In Progress | Backend complete, frontend UI pending |
|
| Step Library Frontend | In Progress | Backend complete, frontend UI pending |
|
||||||
| Custom Step Flow | In Progress | Integration with tree navigation |
|
| Custom Step Flow | In Progress | Integration with tree navigation |
|
||||||
| Tree Forking | Planning | Backend schema complete, UI pending |
|
| Tree Forking UI | Planning | Backend schema complete (migration 022) |
|
||||||
| TypeScript strict mode | Warnings exist | tsconfig needs `strict: true` |
|
| Session Sharing UI | Planning | Backend schema complete (migration 023) |
|
||||||
| Deployment | **Production** | Deployed on Railway at resolutionflow.com |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What's Next (Priority Order)
|
## What's Next (Priority Order)
|
||||||
|
|
||||||
### Immediate (Phase 2.5 Completion)
|
### 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
|
### Soon (Phase 3)
|
||||||
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)
|
|
||||||
|
|
||||||
- File attachments for sessions
|
- File attachments for sessions
|
||||||
- Offline capability
|
- Offline capability
|
||||||
- Client context system
|
- Client context system
|
||||||
- Advanced analytics dashboard
|
- Advanced analytics dashboard
|
||||||
|
|
||||||
### Later (Phase 4)
|
### Later (Phase 4)
|
||||||
|
|
||||||
- PSA integrations (ConnectWise, Kaseya)
|
- PSA integrations (ConnectWise, Kaseya)
|
||||||
- PowerShell automation framework
|
- PowerShell automation framework
|
||||||
- Enterprise features (SSO, white-label)
|
- 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
|
## Environment Quick Reference
|
||||||
|
|
||||||
### Start Development
|
### Start Development
|
||||||
```powershell
|
```powershell
|
||||||
# Terminal 1: Database
|
|
||||||
docker start patherly_postgres
|
docker start patherly_postgres
|
||||||
|
cd backend && .\venv\Scripts\activate && uvicorn app.main:app --reload
|
||||||
# Terminal 2: Backend
|
cd frontend && npm run dev
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### URLs
|
### URLs
|
||||||
- Frontend: http://localhost:5173
|
- Frontend: http://localhost:5173
|
||||||
- Backend API: http://localhost:8000
|
- Backend API: http://localhost:8000
|
||||||
- API Docs: http://localhost:8000/docs
|
- API Docs: http://localhost:8000/api/docs
|
||||||
|
|
||||||
### Run Tests
|
### Run Tests
|
||||||
```powershell
|
```powershell
|
||||||
cd C:\Dev\Projects\patherly\backend
|
cd backend && pytest --override-ini="addopts="
|
||||||
.\venv\Scripts\activate
|
|
||||||
pytest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
## Blockers / Known Issues
|
||||||
|
|
||||||
| Issue | Workaround | Status |
|
| Issue | Workaround | Status |
|
||||||
|-------|------------|--------|
|
|-------|------------|--------|
|
||||||
| pytest-asyncio version conflict | Use 0.24.0 | Documented |
|
| pytest-asyncio version conflict | Use 0.24.0 | Documented |
|
||||||
| No local psql on Windows | Use `docker exec` | 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(
|
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:
|
) -> User:
|
||||||
"""Ensure user is active (not disabled)."""
|
"""Ensure user is active (not disabled). Auto-downgrades expired trials."""
|
||||||
if not current_user.is_active:
|
if not current_user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Account has been deactivated"
|
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
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.account import Account
|
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.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
||||||
from app.schemas.admin import MoveUserAccount
|
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
|
from app.api.deps import require_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
@@ -42,13 +53,13 @@ async def list_users(
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_id}", response_model=UserResponse)
|
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(require_admin)]
|
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))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -58,7 +69,104 @@ async def get_user(
|
|||||||
detail="User not found"
|
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)
|
@router.put("/users/{user_id}/role", response_model=UserResponse)
|
||||||
@@ -198,3 +306,69 @@ async def move_user_account(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return 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 secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
@@ -92,38 +92,39 @@ async def register(
|
|||||||
detail="Account invite code has expired"
|
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
|
invite_code_record = None
|
||||||
if not account_invite_record and settings.REQUIRE_INVITE_CODE:
|
if not account_invite_record:
|
||||||
if not user_data.invite_code:
|
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Invite code is required"
|
detail="Invite code is required"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Look up invite code (case-insensitive)
|
if user_data.invite_code:
|
||||||
result = await db.execute(
|
# Look up invite code (case-insensitive) — applies plan/trial regardless of REQUIRE_INVITE_CODE
|
||||||
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
|
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"
|
|
||||||
)
|
)
|
||||||
|
invite_code_record = result.scalar_one_or_none()
|
||||||
|
|
||||||
if invite_code_record.is_used:
|
if not invite_code_record:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Invite code has already been used"
|
detail="Invalid invite code"
|
||||||
)
|
)
|
||||||
|
|
||||||
if invite_code_record.is_expired:
|
if invite_code_record.is_used:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Invite code has expired"
|
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
|
# Check if email already exists
|
||||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
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
|
# Now set account owner and create subscription
|
||||||
new_account.owner_id = new_user.id
|
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(
|
new_subscription = Subscription(
|
||||||
account_id=new_account.id,
|
account_id=new_account.id,
|
||||||
plan="free",
|
plan=sub_plan,
|
||||||
status="active",
|
status=sub_status,
|
||||||
|
current_period_start=period_start,
|
||||||
|
current_period_end=period_end,
|
||||||
)
|
)
|
||||||
db.add(new_subscription)
|
db.add(new_subscription)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.rate_limit import limiter
|
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.user import User
|
||||||
from app.models.invite_code import InviteCode
|
from app.models.invite_code import InviteCode
|
||||||
from app.schemas.invite_code import InviteCodeCreate, InviteCodeResponse, InviteCodeValidation
|
from app.schemas.invite_code import InviteCodeCreate, InviteCodeResponse, InviteCodeValidation
|
||||||
@@ -23,9 +25,35 @@ async def create_invite_code(
|
|||||||
invite_code = InviteCode(
|
invite_code = InviteCode(
|
||||||
created_by_id=current_user.id,
|
created_by_id=current_user.id,
|
||||||
expires_at=invite_data.expires_at,
|
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)
|
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.commit()
|
||||||
await db.refresh(invite_code)
|
await db.refresh(invite_code)
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ class Settings(BaseSettings):
|
|||||||
# Registration
|
# Registration
|
||||||
REQUIRE_INVITE_CODE: bool = True # Set to False to allow open 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
|
||||||
STRIPE_SECRET_KEY: Optional[str] = None
|
STRIPE_SECRET_KEY: Optional[str] = None
|
||||||
STRIPE_PUBLISHABLE_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),
|
DateTime(timezone=True),
|
||||||
nullable=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)
|
note: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
@@ -84,3 +91,11 @@ class InviteCode(Base):
|
|||||||
def is_valid(self) -> bool:
|
def is_valid(self) -> bool:
|
||||||
"""Check if the invite code is valid (not used and not expired)."""
|
"""Check if the invite code is valid (not used and not expired)."""
|
||||||
return not self.is_used and not self.is_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 datetime import datetime
|
||||||
from typing import Optional
|
from typing import Literal, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, EmailStr, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class InviteCodeCreate(BaseModel):
|
class InviteCodeCreate(BaseModel):
|
||||||
"""Schema for creating a new invite code."""
|
"""Schema for creating a new invite code."""
|
||||||
expires_at: Optional[datetime] = Field(None, description="Optional expiration time")
|
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")
|
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):
|
class InviteCodeResponse(BaseModel):
|
||||||
@@ -23,6 +32,12 @@ class InviteCodeResponse(BaseModel):
|
|||||||
is_used: bool
|
is_used: bool
|
||||||
is_expired: bool
|
is_expired: bool
|
||||||
is_valid: 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:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -38,3 +38,15 @@ class SubscriptionDetails(BaseModel):
|
|||||||
subscription: SubscriptionResponse
|
subscription: SubscriptionResponse
|
||||||
limits: PlanLimitsResponse
|
limits: PlanLimitsResponse
|
||||||
usage: UsageResponse
|
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
|
# Payments
|
||||||
stripe==14.3.0
|
stripe==14.3.0
|
||||||
|
|
||||||
|
# Email
|
||||||
|
resend==2.21.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv==1.0.1
|
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,
|
AccountFeatureOverrideCreate,
|
||||||
AdminCategory,
|
AdminCategory,
|
||||||
GlobalCategoryCreate,
|
GlobalCategoryCreate,
|
||||||
|
InviteCodeResponse,
|
||||||
|
InviteCodeCreateRequest,
|
||||||
|
UserDetailResponse,
|
||||||
} from '@/types/admin'
|
} from '@/types/admin'
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
@@ -38,13 +41,21 @@ export const adminApi = {
|
|||||||
moveUserAccount: (id: string, display_code: string) =>
|
moveUserAccount: (id: string, display_code: string) =>
|
||||||
api.put(`/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
|
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>) =>
|
listInviteCodes: (params?: Record<string, unknown>) =>
|
||||||
api.get('/invite-codes', { params }).then(r => r.data),
|
api.get<InviteCodeResponse[]>('/invites', { params }).then(r => r.data),
|
||||||
createInviteCode: (data?: { expires_at?: string }) =>
|
createInviteCode: (data: InviteCodeCreateRequest = {}) =>
|
||||||
api.post('/invite-codes', data || {}).then(r => r.data),
|
api.post<InviteCodeResponse>('/invites', data).then(r => r.data),
|
||||||
deleteInviteCode: (id: string) =>
|
deleteInviteCode: (code: string) =>
|
||||||
api.delete(`/invite-codes/${id}`),
|
api.delete(`/invites/${code}`),
|
||||||
|
|
||||||
// Audit Logs
|
// Audit Logs
|
||||||
listAuditLogs: (params?: Record<string, unknown>) =>
|
listAuditLogs: (params?: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
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 { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
|
||||||
import type { Column } from '@/components/admin'
|
import type { Column } from '@/components/admin'
|
||||||
import { Modal } from '@/components/common/Modal'
|
import { Modal } from '@/components/common/Modal'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { InviteCodeResponse, InviteCodeCreateRequest } from '@/types/admin'
|
||||||
|
|
||||||
interface InviteCode {
|
const PLAN_OPTIONS = [
|
||||||
id: string
|
{ value: 'free', label: 'Free' },
|
||||||
code: string
|
{ value: 'pro', label: 'Pro' },
|
||||||
created_by_id: string
|
{ value: 'team', label: 'Team' },
|
||||||
used_by_id: string | null
|
] as const
|
||||||
is_active: boolean
|
|
||||||
expires_at: string | null
|
const planBadgeVariant = (plan: string): 'success' | 'destructive' | 'warning' | 'default' => {
|
||||||
created_at: string
|
switch (plan) {
|
||||||
|
case 'pro': return 'success'
|
||||||
|
case 'team': return 'warning'
|
||||||
|
default: return 'default'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InviteCodesPage() {
|
export function InviteCodesPage() {
|
||||||
const [codes, setCodes] = useState<InviteCode[]>([])
|
const [codes, setCodes] = useState<InviteCodeResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
const [expiresInDays, setExpiresInDays] = 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 () => {
|
const fetchCodes = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.listInviteCodes()
|
const data = await adminApi.listInviteCodes()
|
||||||
setCodes(Array.isArray(data) ? data : data.items || [])
|
setCodes(Array.isArray(data) ? data : [])
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load invite codes')
|
toast.error('Failed to load invite codes')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,18 +49,36 @@ export function InviteCodesPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchCodes() }, [fetchCodes])
|
useEffect(() => { fetchCodes() }, [fetchCodes])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEmail('')
|
||||||
|
setExpiresInDays('')
|
||||||
|
setAssignedPlan('free')
|
||||||
|
setTrialDays('')
|
||||||
|
setNote('')
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
setCreating(true)
|
||||||
try {
|
try {
|
||||||
const expiresAt = expiresInDays
|
const data: InviteCodeCreateRequest = {}
|
||||||
? new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString()
|
if (expiresInDays) {
|
||||||
: undefined
|
data.expires_at = new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString()
|
||||||
await adminApi.createInviteCode(expiresAt ? { expires_at: expiresAt } : undefined)
|
}
|
||||||
|
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')
|
toast.success('Invite code created')
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setExpiresInDays('')
|
resetForm()
|
||||||
fetchCodes()
|
fetchCodes()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to create invite code')
|
toast.error('Failed to create invite code')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +87,9 @@ export function InviteCodesPage() {
|
|||||||
toast.success('Code copied to clipboard')
|
toast.success('Code copied to clipboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (code: string) => {
|
||||||
try {
|
try {
|
||||||
await adminApi.deleteInviteCode(id)
|
await adminApi.deleteInviteCode(code)
|
||||||
toast.success('Invite code deleted')
|
toast.success('Invite code deleted')
|
||||||
fetchCodes()
|
fetchCodes()
|
||||||
} catch {
|
} 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',
|
key: 'code',
|
||||||
header: '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>
|
<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',
|
key: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
render: (c) => {
|
render: (c) => {
|
||||||
if (c.used_by_id) return <StatusBadge variant="default">Used</StatusBadge>
|
if (c.is_used) return <StatusBadge variant="default">Used</StatusBadge>
|
||||||
if (!c.is_active) return <StatusBadge variant="destructive">Inactive</StatusBadge>
|
if (c.is_expired) return <StatusBadge variant="warning">Expired</StatusBadge>
|
||||||
if (c.expires_at && new Date(c.expires_at) < new Date()) return <StatusBadge variant="warning">Expired</StatusBadge>
|
if (c.is_valid) return <StatusBadge variant="success">Active</StatusBadge>
|
||||||
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" />,
|
icon: <Copy className="h-4 w-4" />,
|
||||||
onClick: () => handleCopy(c.code),
|
onClick: () => handleCopy(c.code),
|
||||||
},
|
},
|
||||||
{
|
...(!c.is_used ? [{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
icon: <Trash2 className="h-4 w-4" />,
|
icon: <Trash2 className="h-4 w-4" />,
|
||||||
onClick: () => handleDelete(c.id),
|
onClick: () => handleDelete(c.code),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
},
|
}] : []),
|
||||||
]} />
|
]} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -129,7 +198,7 @@ export function InviteCodesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Invite Codes"
|
title="Invite Codes"
|
||||||
description="Manage registration invite codes"
|
description="Create and manage registration invite codes with plan assignment"
|
||||||
action={
|
action={
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
@@ -160,27 +229,73 @@ export function InviteCodesPage() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={createOpen}
|
isOpen={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => { setCreateOpen(false); resetForm() }}
|
||||||
title="Create Invite Code"
|
title="Create Invite Code"
|
||||||
size="sm"
|
size="sm"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<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"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-white">Expires in (days)</label>
|
<label className="mb-1 block text-sm font-medium text-white">Expires in (days)</label>
|
||||||
<input
|
<input
|
||||||
@@ -188,10 +303,18 @@ export function InviteCodesPage() {
|
|||||||
value={expiresInDays}
|
value={expiresInDays}
|
||||||
onChange={(e) => setExpiresInDays(e.target.value)}
|
onChange={(e) => setExpiresInDays(e.target.value)}
|
||||||
placeholder="Leave empty for no expiry"
|
placeholder="Leave empty for no expiry"
|
||||||
className={cn(
|
className={inputClass}
|
||||||
'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'
|
</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>
|
||||||
</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 { 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 { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
|
||||||
import type { Column } from '@/components/admin'
|
import type { Column } from '@/components/admin'
|
||||||
import { Modal } from '@/components/common/Modal'
|
import { Modal } from '@/components/common/Modal'
|
||||||
@@ -21,6 +22,7 @@ interface AdminUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -140,6 +142,11 @@ export function UsersPage() {
|
|||||||
className: 'w-12',
|
className: 'w-12',
|
||||||
render: (u) => (
|
render: (u) => (
|
||||||
<ActionMenu items={[
|
<ActionMenu items={[
|
||||||
|
{
|
||||||
|
label: 'View Detail',
|
||||||
|
icon: <ExternalLink className="h-4 w-4" />,
|
||||||
|
onClick: () => navigate(`/admin/users/${u.id}`),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Change Role',
|
label: 'Change Role',
|
||||||
icon: <Shield className="h-4 w-4" />,
|
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 AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage'))
|
const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage'))
|
||||||
const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage'))
|
const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage'))
|
||||||
|
const AdminUserDetailPage = lazy(() => import('@/pages/admin/UserDetailPage'))
|
||||||
const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage'))
|
const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage'))
|
||||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage'))
|
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage'))
|
||||||
const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage'))
|
const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage'))
|
||||||
@@ -143,6 +144,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'users/:userId',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<AdminUserDetailPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'invite-codes',
|
path: 'invite-codes',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@@ -128,3 +128,89 @@ export interface GlobalCategoryCreate {
|
|||||||
slug: string
|
slug: string
|
||||||
description?: string | null
|
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