38 Commits

Author SHA1 Message Date
chihlasm
0bda590537 fix: use get_admin_db for all new admin account endpoints
All admin endpoints query across tenants without a tenant context.
get_db (app-role, subject to RLS) was never imported and would crash
at runtime — replace all 6 occurrences with get_admin_db (BYPASSRLS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 07:47:42 +00:00
chihlasm
2dbb8b6abf refactor: design critique fixes for account pages
- Admin accounts: replace dense card grid with compact DataTable
- Account settings: remove redundant hero card, stat grid, header pills
- Fix bg-accent (orange) misuse on decorative elements across 7 files
- Add ConfirmButton for destructive actions (deactivate, remove member)
- Replace single-field modals with inline editing (plan, trial)
- Add contextual help: display code tooltip, improved empty states
- Non-owner aside explanation for hidden owner-only sections
- Admin sidebar: group 11 items into 5 labeled sections
- Rename UsersPage.tsx → AccountsPage.tsx to match route
- Fix border radius consistency, hide zero-count badges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 04:54:26 +00:00
chihlasm
9452e5d408 fix: remove unused admin account icon import 2026-04-12 04:54:26 +00:00
chihlasm
e002fe4969 feat: add admin account detail management 2026-04-12 04:54:26 +00:00
chihlasm
7cbc9fe224 feat: expand admin customer account controls 2026-04-12 04:54:26 +00:00
chihlasm
70242ad037 feat: reorganize admin panel around accounts 2026-04-12 04:54:26 +00:00
chihlasm
f54d7ecd78 docs: update current state after Phase 4 merge 2026-04-12 04:35:30 +00:00
chihlasm
46593ba8ca Merge PR #136: feat: tenant isolation Phase 4 — RLS on all remaining tables 2026-04-12 04:35:01 +00:00
chihlasm
52553d62d2 fix(tests): update expectations for RLS-correct behavior
- test_rls_isolation: add pytestmark for module-scoped event loop to fix
  "Future attached to a different loop" with pytest-asyncio 0.23 + asyncpg
  module-scoped fixtures
- test_admin_categories_global: global categories use PLATFORM_ACCOUNT_ID
  not NULL; update stale assertion
- test_permissions_account: with RLS, cross-tenant tree access returns 404
  (invisible) not 403 (forbidden) — update to match actual behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:48:30 +00:00
chihlasm
a48660700a fix: background jobs and lifespan must use BYPASSRLS sessions
All code that runs outside a request context (APScheduler jobs,
lifespan startup) has no app.current_account_id set, so the
app-role session returns 0 rows from every RLS-protected table.

Changed to _admin_session_factory (BYPASSRLS) in:
- knowledge_flywheel_scheduler.py — queries ai_sessions
- psa_retry_scheduler.py — queries psa_post_log
- retention_cleanup.py — queries assistant_chats
- scheduler.py (_fire_maintenance_schedule, _cleanup_expired_ai_conversations)
- main.py (archive_stale_ai_sessions, _process_notification_retries,
  load_all_schedules at startup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:44:23 +00:00
chihlasm
3ff886363c fix: use BYPASSRLS session for all auth deps and user-mutation endpoints
Phase 4 enabled RLS on the users table. All code paths that touch users
(or other RLS-protected tables) before require_tenant_context sets
app.current_account_id must use get_admin_db (BYPASSRLS):

- deps.py: get_current_user and get_current_active_user → get_admin_db
- auth.py: all endpoints → get_admin_db (login, register, refresh, etc.
  run before tenant context exists; mutation endpoints also need session
  consistency since current_user is in the admin session)
- accounts.py: transfer_ownership, leave_account, delete_account
  → get_admin_db (these mutate current_user directly)
- onboarding.py: dismiss_onboarding → get_admin_db (same reason)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:25:18 +00:00
chihlasm
501442e5f0 fix: seed_test_users must use ADMIN_DATABASE_URL after Phase 4 RLS on users
RLS is now enabled on the users table. The seed script was using the
app-role connection (DATABASE_URL) which has no tenant context at seed
time — all SELECTs return 0 rows and INSERTs are blocked by FORCE RLS.

Falls back to DATABASE_URL if ADMIN_DATABASE_URL is not set (local dev
without roles configured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:12:46 +00:00
chihlasm
6f53ec06f5 docs: add lessons 107-109 — RLS startup, global tables, tree_shares account_id
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 02:58:12 +00:00
chihlasm
ec322f7cdf fix: bootstrap service account with BYPASSRLS session 2026-04-12 02:44:36 +00:00
chihlasm
f9248aeaa8 fix: remove platform_steps and template_trees from Phase 4 RLS
Both tables have no account_id column — they are globally readable
by all authenticated users and must not have RLS policies.

Also removes the corresponding test cases that assumed these tables
had account_id-based policies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:48:50 +00:00
chihlasm
c6da4ebee5 fix: remove script_categories from Phase 4 RLS — no account_id column
script_categories is a global lookup table (shared across all tenants).
The account_id column belongs to ScriptTemplate in the same model file,
not ScriptCategory. The Python scan matched the file, not the class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:32:42 +00:00
chihlasm
64f004a62c feat: tenant isolation Phase 4 — RLS on 31 remaining tables + script_builder fix
Enable RLS on all remaining tenant-scoped tables (31 tables):

Standard policy (tenant sees own rows):
  users, account_invites, account_limit_overrides, account_feature_overrides,
  subscriptions, ai_chat_sessions, ai_conversations, ai_session_steps,
  ai_session_embeddings, ai_suggestions, ai_usage, assistant_chats,
  attachments, copilot_conversations, feedback, file_uploads, fork_points,
  kb_imports, notifications, notification_configs, notification_logs,
  psa_activity_logs, psa_member_mappings, script_builder_sessions,
  script_categories, session_ratings, tree_embeddings, user_folders,
  user_pinned_trees

Platform-visibility policy (own rows OR PLATFORM_ACCOUNT_ID):
  platform_steps, template_trees

Intentionally skipped:
  accounts (IS the root table, no account_id column)
  plan_feature_defaults (platform config, no account_id column)

Also fixes script_builder_service.create_session() which was missing
account_id= on ScriptBuilderSession construction, causing 500s on all
script builder endpoints (pre-existing CI failure).

Adds Phase 4 RLS isolation tests covering: users, script_builder_sessions,
ai_session_steps, notifications, platform_steps, template_trees.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:25:28 +00:00
Claude
ba36e37dab docs: update CHANGELOG with Tenant Isolation Phase 2 and Phase 3 details
- Document Phase 2: PostgreSQL RLS on 11 session tables, account_id NOT NULL enforcement, Alembic migration support
- Document Phase 3: RLS on audit_logs and tree_shares, cross-tenant session access for public shares, complete account_id propagation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:43:10 +00:00
chihlasm
9e6965512b Merge pull request #135 from resolutionflow/feat/tenant-isolation-phase-3
feat: tenant isolation Phase 3 — audit_logs, tree_shares, remaining RLS
2026-04-11 04:28:47 -04:00
chihlasm
893b8a5008 fix: tree_shares.account_id must come from tree owner, not the actor
- trees.py: change account_id=current_user.account_id →
  account_id=tree.account_id so super-admin cross-account shares land in
  the tree's tenant where RLS will see them.

- migration a05e1a1bea7c: fix backfill to join tree_shares → trees instead
  of tree_shares → users(created_by). Same logic: historical shares belong
  to the tree's tenant.

- test_tree_sharing.py: add test_share_account_id_matches_tree_not_actor
  to assert share.account_id == tree.account_id after POST /share; also
  add missing account_id to all direct TreeShare(...) constructors in
  existing tests.

- test_phase1_migrations.py: remove team_id= from TargetList constructor
  (column dropped in Phase 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 07:02:35 +00:00
chihlasm
e05472615b feat: tenant isolation Phase 3 — audit_logs, tree_shares, remaining RLS
P3-A: Add account_id to audit_logs model + migration (backfill via user_id →
  users.account_id). log_audit() gains optional account_id param with fallback
  SELECT to avoid churn across 40 call sites.

P3-B: Add account_id to tree_shares model + migration (backfill via created_by
  → users.account_id). TreeShare constructor updated in trees.py.

P3-C: Enable RLS on 6 remaining tables: step_ratings, step_usage_log,
  target_lists, session_shares, audit_logs, tree_shares.

P3-D: Drop team_id from target_lists — endpoint, schema, and model now use
  account_id as the sole isolation key.

P3-E: Append Phase 3 RLS isolation tests for all 6 tables.

test_target_lists.py: fix cross-account test to use Account model (not Team)
and set account_id on new User.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 07:02:35 +00:00
chihlasm
00fdd663bc Merge pull request #134 from resolutionflow/feat/tenant-isolation-phase-2
feat: Phase 2 tenant isolation — RLS on 11 session tables
2026-04-11 03:02:25 -04:00
chihlasm
8cf58add22 fix: use valid confidence_tier value in RLS test ai_sessions INSERT
'medium' is not a valid value for ck_ai_sessions_confidence_tier.
Valid values are 'guided' | 'exploring' | 'discovery'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 05:28:52 +00:00
chihlasm
6c231ef1c6 fix: use started_at (not created_at) in RLS test session INSERT
sessions table has started_at as the timestamp column, not created_at.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:53:35 +00:00
chihlasm
758cd61621 fix: propagate account_id through all write paths missing NOT NULL coverage
Service layer (production code):
- branch_manager: set account_id on SessionBranch (root + fork) and ForkPoint
  from session.account_id; load session in create_fork for this purpose
- handoff_manager: set account_id on SessionHandoff from session.account_id
- ai_suggestions endpoint: set account_id on AISuggestion from current_user
- steps endpoint (/feedback): set account_id on StepRating from current_user
- ratings endpoint: set account_id on StepRating from current_user

Test infrastructure:
- conftest.py: seed PLATFORM_ACCOUNT_ID (00000000-...-0001) account after
  Base.metadata.create_all so global categories and gallery items have a valid FK
- test_rls_isolation: add _ensure_rls_schema fixture that runs
  'alembic upgrade head' before module tests — previous function-scoped
  test_db fixtures drop the schema, leaving the RLS tests with no tables
- test_branding: create Account before User in helper functions
- test_admin_gallery: set account_id=PLATFORM_ACCOUNT_ID on Tree/ScriptTemplate
- test_public_templates: set account_id=PLATFORM_ACCOUNT_ID on Tree,
  ScriptTemplate, TreeCategory
- test_resolution_outputs: set account_id=session.account_id on
  SessionResolutionOutput
- test_analytics_phase5: set account_id on PsaPostLog
- test_draft_trees: replace account_id=None with PLATFORM_ACCOUNT_ID in
  migration default test (NOT NULL now enforced)
- test_maintenance_schedules: set account_id on other_tree
- test_save_session_as_tree: set account_id on all 5 Session() constructors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:24:36 +00:00
chihlasm
b9fcdd5d73 fix: use DATABASE_URL_SYNC (Railway reference var) as primary Alembic URL
DATABASE_URL_SYNC is now set as a Railway reference variable pointing to
${{pgvector.DATABASE_URL}}, which resolves to the correct postgres superuser
credentials per environment (production, PR preview, fresh DBs). This handles
the bootstrap case where resolutionflow_admin doesn't exist yet.

Falls back to ADMIN_DATABASE_URL (sync-converted) for local dev only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:42:07 +00:00
chihlasm
4273ed0e5c fix: use Railway native PG env vars for Alembic migrations
Prior approach (ADMIN_DATABASE_URL first) broke PR preview environments: fresh
Railway PostgreSQL instances have no resolutionflow_admin role yet, so the admin
URL fails before the create_db_roles migration can run (bootstrap deadlock).

New priority order in _alembic_sync_url():
1. PGHOST/PGUSER/PGPASSWORD/PGDATABASE — Railway auto-links these from the
   PostgreSQL service per-environment, giving correct superuser creds for every
   env including fresh PR preview DBs where no custom roles exist yet.
2. ADMIN_DATABASE_URL (resolutionflow_admin, BYPASSRLS, asyncpg→sync) — local
   dev and stable envs where the role already exists.
3. DATABASE_URL_SYNC — legacy fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:35:04 +00:00
chihlasm
0107d2d896 fix: use resolutionflow_admin for Alembic migrations (avoid postgres superuser)
DATABASE_URL_SYNC uses the postgres superuser whose password is unavailable
in Railway after Phase 1 switched runtime to the app role. resolutionflow_admin
(BYPASSRLS) is the correct role for migrations. Derive a psycopg2 sync URL from
ADMIN_DATABASE_URL; fall back to DATABASE_URL_SYNC for local dev environments
where ADMIN_DATABASE_URL is not set separately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:23:32 +00:00
chihlasm
79ae34108a fix: add Alembic migrations step + RLS env vars to CI
- Run alembic upgrade head before tests so DB roles and RLS policies exist
- Set TEST_DB_NAME=resolutionflow_test so test_rls_isolation.py connects to
  the correct database (was defaulting to patherly_test which doesn't exist in CI)
- Set DB_APP_ROLE_PASSWORD so create_db_roles migration creates resolutionflow_app
  with a known password that the RLS isolation tests can connect with

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:55:10 +00:00
chihlasm
bd29f590a2 fix: set account_id on all Session constructors; fix 3 ESLint errors in CI
Backend: start_session, prepare_session, batch_launch_sessions all missing
account_id=current_user.account_id — Phase 1 NOT NULL constraint made these
500 in test suite (test_ratings.py fixture couldn't create sessions).

Frontend ESLint:
- TaskLane.tsx: suppress react-refresh/only-export-components for clearTaskState
- TeamSummary.tsx: init loading from isAccountOwner to avoid sync setState in effect
- ScriptBodyEditor.tsx: move lastValueRef.current assignment into useEffect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:41:42 +00:00
chihlasm
ce4cfc3240 fix: set account_id on PsaPostLog in psa_post_to_ticket (missed third write path); fix get_admin_db docstring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:12:45 +00:00
chihlasm
82ee177d9b fix: harden Phase 2 RLS tests — try/finally cleanup, assert guards, seed B-data for isolation checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:07:26 +00:00
chihlasm
ed8de92c52 test: add Phase 2 RLS isolation tests for 11 session tables (incl. step_library visibility regression)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:00:09 +00:00
chihlasm
5bd331ca92 fix: clarify step_library RLS comment; remove unused sqlalchemy import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:57:41 +00:00
chihlasm
87fac02e9b feat: migration — enable RLS on 11 Phase 2 session tables (tenant-only + step_library visibility policy)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:55:25 +00:00
chihlasm
4f4bc435da docs: broaden admin_database docstring to cover non-admin BYPASSRLS use cases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:51:53 +00:00
chihlasm
ac2b193909 fix: use get_admin_db in access_share to handle cross-tenant session reads (public shares)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:50:00 +00:00
chihlasm
b641ac6c55 fix: set account_id on session_supporting_data, session_resolution_outputs, maintenance_schedules, psa_post_log constructors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:44:17 +00:00
80 changed files with 4709 additions and 1359 deletions

View File

@@ -31,6 +31,8 @@ jobs:
SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true"
APP_NAME: ResolutionFlow
TEST_DB_NAME: resolutionflow_test
DB_APP_ROLE_PASSWORD: app_secret_ci
steps:
- uses: actions/checkout@v5
@@ -47,6 +49,9 @@ jobs:
- name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run Alembic migrations
run: cd backend && alembic upgrade head
- name: Check tenant filter enforcement
run: cd backend && python scripts/check_tenant_filters.py
# Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears.

View File

@@ -9,7 +9,8 @@ All notable changes to ResolutionFlow are documented here.
- Recurring Issue Detection — client-specific pattern alerts (#60)
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
- **Tenant Isolation Phase 1** — PostgreSQL Row-Level Security (RLS) enforcement across all core tables (trees, tags, categories, psa_connections, flow_proposals) with database role separation (`resolutionflow_app` for user operations, `resolutionflow_admin` with BYPASSRLS for admin endpoints), admin database engine isolation, tenant context via `ContextVar` with automatic transaction-scoped enforcement, `account_id` column backfill on 35+ tables (sessions, AI branching, PSA, notifications, scripts, targets, folders), global content separation via platform account, fresh-DB migration order fixes
- **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
@@ -24,7 +25,6 @@ All notable changes to ResolutionFlow are documented here.
- **Assistant Chat session actions** — moved Pause/Resume/Close actions from action bar to page header for consistency with FlowPilot
- **Design system token normalization** — unified FlowPilot, AssistantChat, and ScriptBuilder components to use consistent design tokens
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
- **Admin database routing** — privileged operations (analytics, user management) now bypass RLS via dedicated admin engine
### Fixed
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
@@ -43,7 +43,6 @@ All notable changes to ResolutionFlow are documented here.
- Task Lane stale data when creating new chat or resuming from concluded session
- Chat ref invalidation race condition between handleNewChat and async data loads
- Images now properly display in chat message history instead of blank placeholders
- Non-default, no-team trees now properly handled in global content migration
---

View File

@@ -375,6 +375,12 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
**107. Startup routines must use `_admin_session_factory()` after Phase 4 RLS:** Any code that runs at startup (lifespan, `ensure_service_account`, seed scripts) and touches tenant-isolated tables (`users`, etc.) must use `_admin_session_factory()` — not `get_db()`. Phase 4 enabled RLS on `users`; a tenant-scoped session has no `app.current_account_id` set at startup, so all queries return 0 rows or fail. `get_service_account_id` in `deps.py` is safe — it reads from `app.state` cached at startup, never hits the DB per-request.
**108. Tables with no `account_id` column (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts` — global/platform tables documented with "No account_id. No RLS." in their model files. When writing RLS migrations, scan at the class level (check for `account_id: Mapped` within the class block), not the file level — multiple classes in one `.py` file can have different columns (e.g. `ScriptCategory` vs `ScriptTemplate` in `script_template.py`).
**109. `tree_shares.account_id` must equal `tree.account_id`, not the actor's account:** When creating a `TreeShare`, always use `account_id=tree.account_id` (tree owner's tenant). A super admin in tenant A sharing tenant B's tree must produce a share row in tenant B's RLS context — using `current_user.account_id` instead makes the share invisible to the tree owner after RLS is enforced.
## RBAC & Permissions
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
@@ -522,7 +528,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **resolutionflow** (16703 symbols, 35922 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -2,7 +2,7 @@
> **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** March 23, 2026
> **Last Updated:** April 12, 2026
---
@@ -163,6 +163,13 @@
- SQL wildcard escaping in tag search
- PSA credentials encrypted at rest (Fernet)
### Tenant Isolation (Phases 1-4 Complete)
- PostgreSQL RLS enabled across tenant-scoped tables in phased rollout
- `account_id` propagation completed across core content, sessions, analytics, notifications, shares, and remaining Phase 4 tables
- Global platform tables correctly excluded from tenant RLS where they have no `account_id` (`script_categories`, `platform_steps`, `template_trees`)
- Runtime bootstrap paths updated to use BYPASSRLS/admin sessions where needed (auth/user mutations, startup service account, background jobs, seed scripts)
- Preview Railway backend and frontend deployments green for PR 136 after the Phase 4 fixes
### Copilot-First Dashboard (March 2026)
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)

View File

@@ -29,13 +29,37 @@ from app.models.session_branch import SessionBranch # noqa: F401
from app.models.fork_point import ForkPoint # noqa: F401
from app.models.session_handoff import SessionHandoff # noqa: F401
from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401
from app.core.config import settings
def _alembic_sync_url() -> str:
"""Return a psycopg2-compatible sync URL for Alembic.
Priority order:
1. DATABASE_URL_SYNC — in Railway this is set as a reference variable
(${{pgvector.DATABASE_URL}}) that resolves to the correct postgres
superuser credentials for the current environment (production, PR preview,
etc.). This always works even on fresh databases before any custom roles
have been created, because it uses the postgres superuser.
2. ADMIN_DATABASE_URL (resolutionflow_admin, BYPASSRLS) converted to a sync
driver — fallback for local dev where DATABASE_URL_SYNC may not be set.
"""
if settings.DATABASE_URL_SYNC:
return settings.DATABASE_URL_SYNC
admin_url = settings.ADMIN_DATABASE_URL
if admin_url and "+asyncpg" in admin_url:
return admin_url.replace("postgresql+asyncpg://", "postgresql://")
return settings.DATABASE_URL_SYNC
# this is the Alembic Config object
config = context.config
# Override sqlalchemy.url with the sync version for migrations
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
config.set_main_option("sqlalchemy.url", _alembic_sync_url())
# Interpret the config file for Python logging.
if config.config_file_name is not None:
@@ -86,7 +110,7 @@ def run_migrations_online() -> None:
from sqlalchemy import create_engine
connectable = create_engine(
settings.DATABASE_URL_SYNC,
_alembic_sync_url(),
poolclass=pool.NullPool,
)

View File

@@ -0,0 +1,59 @@
"""Enable RLS on Phase 3 tables.
Tables covered:
- step_ratings (account_id NOT NULL since migration 7167e9374b0c)
- step_usage_log (account_id NOT NULL since migration 7167e9374b0c)
- target_lists (account_id NOT NULL since migration 2c6aabd89bc6)
- session_shares (account_id NOT NULL since session_share model)
- audit_logs (account_id NOT NULL since migration 2a9056eddd90)
- tree_shares (account_id NOT NULL since migration a05e1a1bea7c)
All use a standard intra-tenant isolation policy.
Token-based access to session_shares and tree_shares goes through
endpoints that use get_admin_db (BYPASSRLS), so a strict tenant
policy here is correct.
Revision ID: 04f013768235
Revises: a05e1a1bea7c
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = '04f013768235'
down_revision: Union[str, None] = 'a05e1a1bea7c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_CURRENT_ACCOUNT = (
"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000')::uuid"
)
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
_PHASE3_TABLES = [
"step_ratings",
"step_usage_log",
"target_lists",
"session_shares",
"audit_logs",
"tree_shares",
]
def upgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_STANDARD_USING})
""")
def downgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -0,0 +1,32 @@
"""Drop team_id from target_lists.
account_id (NOT NULL) is now the tenant isolation key; team_id is redundant.
All reads/writes use account_id via RLS + application filter.
Revision ID: 172ad76d7d20
Revises: 04f013768235
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '172ad76d7d20'
down_revision: Union[str, None] = '04f013768235'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index('ix_target_lists_team_id', table_name='target_lists', if_exists=True)
op.drop_constraint('target_lists_team_id_fkey', 'target_lists', type_='foreignkey')
op.drop_column('target_lists', 'team_id')
def downgrade() -> None:
op.add_column('target_lists', sa.Column('team_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'target_lists_team_id_fkey', 'target_lists', 'teams',
['team_id'], ['id'], ondelete='CASCADE',
)
op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id'])

View File

@@ -0,0 +1,51 @@
"""Add account_id to audit_logs and backfill via user_id.
Revision ID: 2a9056eddd90
Revises: 70a5dd746e83
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2a9056eddd90'
down_revision: Union[str, None] = '70a5dd746e83'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('audit_logs', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_audit_logs_account_id', 'audit_logs', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: derive from the acting user's account
op.execute("""
UPDATE audit_logs al
SET account_id = u.account_id
FROM users u
WHERE al.user_id = u.id
AND u.account_id IS NOT NULL
AND al.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM audit_logs WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} audit_logs rows have NULL account_id after backfill. "
"All audit log entries must have an associated user with an account."
)
op.alter_column('audit_logs', 'account_id', nullable=False)
op.create_index('ix_audit_logs_account_id', 'audit_logs', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_audit_logs_account_id', table_name='audit_logs')
op.drop_constraint('fk_audit_logs_account_id', 'audit_logs', type_='foreignkey')
op.drop_column('audit_logs', 'account_id')

View File

@@ -0,0 +1,90 @@
"""Enable RLS on Phase 2 session and supporting tables.
10 tables use a standard tenant-only policy.
step_library uses a visibility-aware policy — public steps visible to all tenants.
NOTE: session_messages does not exist in this codebase (removed from plan).
script_generations is the correct table name (not script_template_generations).
sessions and ai_sessions are two separate tables, both in scope.
Prerequisites:
- Phase 1 migration must have run (resolutionflow_app role exists, Phase 1 tables have RLS)
- NOT NULL write-path bugs fixed (P2-A commits b641ac6)
- shares.py cross-tenant session fix deployed (P2-B commit ac2b193)
Revision ID: 70a5dd746e83
Revises: c5f48b9890f9
Create Date: 2026-04-10 06:54:49.431817
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '70a5dd746e83'
down_revision: Union[str, None] = 'c5f48b9890f9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
# Standard tenant-only policy — account_id must match the current tenant.
# When no tenant context is set, COALESCE returns the nil UUID so zero rows
# are visible (fail-closed).
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
# Visibility-aware policy for step_library — public steps (visibility='public')
# must be visible to ALL tenants regardless of account_id. This covers the
# visibility='public' arm of build_step_visibility_filter() in app/core/filters.py.
# The created_by arm (private steps visible to their author) is covered
# transitively: private steps share account_id with their creator, so the
# account_id match handles it. This relies on account_id NOT NULL on step_library.
_STEP_LIBRARY_USING = f"account_id = {_CURRENT_ACCOUNT} OR visibility = 'public'"
# Standard tables: strict tenant isolation, no cross-tenant visibility.
_STANDARD_TABLES = [
"sessions",
"ai_sessions",
"session_branches",
"session_supporting_data",
"session_resolution_outputs",
"session_handoffs",
"script_templates",
"script_generations",
"maintenance_schedules",
"psa_post_log",
]
def upgrade() -> None:
# ── Standard tenant-isolation tables ────────────────────────────────────
for table in _STANDARD_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_STANDARD_USING})
""")
# ── step_library ────────────────────────────────────────────────────────
# Public steps (visibility='public') must be readable by all tenants so
# the Solutions Library browsing experience works without tenant context.
# Private/team steps remain tenant-scoped.
op.execute("ALTER TABLE step_library ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE step_library FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON step_library
USING ({_STEP_LIBRARY_USING})
""")
def downgrade() -> None:
for table in _STANDARD_TABLES + ["step_library"]:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -0,0 +1,57 @@
"""Add account_id to tree_shares and backfill via tree owner's account.
The share belongs to the tree's tenant, not the actor who created it.
A super admin in account A can share a tree owned by account B; that share
must land in account B so account B's RLS filter sees it.
Revision ID: a05e1a1bea7c
Revises: 2a9056eddd90
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a05e1a1bea7c'
down_revision: Union[str, None] = '2a9056eddd90'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('tree_shares', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_tree_shares_account_id', 'tree_shares', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: derive from the tree's account, not the creator's account.
# A share lives in the same tenant as its tree so that the tree owner's
# RLS context covers their own shares regardless of who created them.
op.execute("""
UPDATE tree_shares ts
SET account_id = t.account_id
FROM trees t
WHERE ts.tree_id = t.id
AND t.account_id IS NOT NULL
AND ts.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM tree_shares WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} tree_shares rows have NULL account_id after backfill. "
"All share entries must have a creating user with an account."
)
op.alter_column('tree_shares', 'account_id', nullable=False)
op.create_index('ix_tree_shares_account_id', 'tree_shares', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_tree_shares_account_id', table_name='tree_shares')
op.drop_constraint('fk_tree_shares_account_id', 'tree_shares', type_='foreignkey')
op.drop_column('tree_shares', 'account_id')

View File

@@ -0,0 +1,85 @@
"""Enable RLS on Phase 4 tables — all remaining tenant-scoped tables.
All tables in this migration already have account_id NOT NULL (enforced by
earlier migrations). This migration adds ENABLE ROW LEVEL SECURITY,
FORCE ROW LEVEL SECURITY, and the appropriate tenant isolation policy to each.
Policy variants used:
- Standard: account_id = current_setting(app.current_account_id)::uuid
- Platform: standard OR account_id = PLATFORM_ACCOUNT_ID
(for global content tables readable by all tenants)
Skipped intentionally:
- accounts — IS the root table; no account_id column
- plan_feature_defaults — platform config; no account_id column
- script_categories — global lookup table; no account_id column
- platform_steps — global content; no account_id column (readable by all)
- template_trees — global content; no account_id column (readable by all)
Revision ID: b3c7e9f2a1d8
Revises: 172ad76d7d20
Create Date: 2026-04-12
"""
from typing import Union
from alembic import op
revision: str = "b3c7e9f2a1d8"
down_revision: Union[str, None] = "172ad76d7d20"
branch_labels = None
depends_on = None
# Standard policy — tenant sees only own rows.
_STANDARD_TABLES = [
"users",
"account_invites",
"account_limit_overrides",
"account_feature_overrides",
"subscriptions",
"ai_chat_sessions",
"ai_conversations",
"ai_session_steps",
"ai_session_embeddings",
"ai_suggestions",
"ai_usage",
"assistant_chats",
"attachments",
"copilot_conversations",
"feedback",
"file_uploads",
"fork_points",
"kb_imports",
"notifications",
"notification_configs",
"notification_logs",
"psa_activity_logs",
"psa_member_mappings",
"script_builder_sessions",
"session_ratings",
"tree_embeddings",
"user_folders",
"user_pinned_trees",
]
_POLICY_EXPR = (
"account_id = COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
def upgrade() -> None:
for table in _STANDARD_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_POLICY_EXPR})
""")
def downgrade() -> None:
for table in _STANDARD_TABLES:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")

View File

@@ -24,10 +24,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
token: Annotated[str, Depends(oauth2_scheme)]
) -> User:
"""Get current authenticated user from JWT token."""
"""Get current authenticated user from JWT token.
Must use get_admin_db (BYPASSRLS): this dep runs before require_tenant_context
sets app.current_account_id, so the users table RLS would block the lookup.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
@@ -77,10 +81,14 @@ async def get_refresh_token_payload(
async def get_current_active_user(
request: Request,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> User:
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
Enforces must_change_password — blocks all routes except allowlist."""
Enforces must_change_password — blocks all routes except allowlist.
Uses get_admin_db: runs before require_tenant_context sets the ContextVar,
so tenant-scoped tables (subscriptions) would return 0 rows via app role.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from pydantic import BaseModel
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
from app.core.audit import log_audit
from app.models.refresh_token import RefreshToken
@@ -148,7 +149,7 @@ async def update_member_role(
@router.post("/me/transfer-ownership", response_model=AccountResponse)
async def transfer_ownership(
data: TransferOwnershipRequest,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Transfer account ownership to another member (owner only)."""
@@ -377,7 +378,7 @@ async def list_invites(
@router.post("/me/leave")
async def leave_account(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Leave the current account (non-owners only). Creates a personal account."""
@@ -423,7 +424,7 @@ class DeleteAccountRequest(BaseModel):
@router.delete("/me")
async def delete_account(
data: DeleteAccountRequest,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Delete the current account and soft-delete the user (owner only, no other members)."""

View File

@@ -5,8 +5,8 @@ from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload, aliased
from app.core.admin_database import get_admin_db
from app.core.audit import log_audit
@@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode
from app.models.account_invite import AccountInvite
from app.models.tree import Tree
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse
from app.schemas.admin import (
MoveUserAccount,
AdminUserCreate,
AdminUserCreateResponse,
AdminPasswordReset,
AdminPasswordResetResponse,
HardDeleteCheckResponse,
AdminUserListItem,
AdminUserListResponse,
AdminAccountMember,
AdminAccountListItem,
AdminAccountListResponse,
AdminAccountOwnerSummary,
AdminAccountSubscriptionSummary,
AdminAccountUsageSummary,
AdminAccountDetailResponse,
AdminAccountInviteSummary,
AdminAccountCreate,
AdminAccountUpdate,
)
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.core.subscriptions import get_account_usage
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=list[UserResponse])
@router.get("/users", response_model=AdminUserListResponse)
async def list_users(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
page: Optional[int] = Query(None, ge=1),
size: Optional[int] = Query(None, ge=1, le=100),
search: Optional[str] = Query(None, description="Search by user or account fields"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
@@ -46,23 +69,240 @@ async def list_users(
account_id: Optional[UUID] = Query(None, description="Filter by account"),
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
):
"""List all users (super admin only)."""
query = select(User)
"""List users for super admin global people search."""
resolved_limit = size or limit
resolved_skip = skip
current_page = 1
if page is not None:
resolved_skip = (page - 1) * resolved_limit
current_page = page
elif resolved_limit > 0:
current_page = (resolved_skip // resolved_limit) + 1
count_query = (
select(func.count())
.select_from(User)
.outerjoin(Account, User.account_id == Account.id)
)
query = (
select(
User,
Account.name.label("account_name"),
Account.display_code.label("account_display_code"),
)
.outerjoin(Account, User.account_id == Account.id)
)
if not include_archived:
query = query.where(User.deleted_at.is_(None))
count_query = count_query.where(User.deleted_at.is_(None))
if is_active is not None:
query = query.where(User.is_active == is_active)
count_query = count_query.where(User.is_active == is_active)
if role:
query = query.where(User.role == role)
count_query = count_query.where(User.role == role)
if account_id:
query = query.where(User.account_id == account_id)
count_query = count_query.where(User.account_id == account_id)
if search:
search_term = f"%{search.strip()}%"
search_filter = or_(
User.name.ilike(search_term),
User.email.ilike(search_term),
Account.name.ilike(search_term),
Account.display_code.ilike(search_term),
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit)
result = await db.execute(query)
users = result.scalars().all()
return users
rows = result.all()
items = [
AdminUserListItem(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
is_super_admin=user.is_super_admin,
is_active=user.is_active,
account_id=user.account_id,
account_role=user.account_role,
account_name=account_name,
account_display_code=account_display_code,
created_at=user.created_at,
last_login=user.last_login,
deleted_at=user.deleted_at,
)
for user, account_name, account_display_code in rows
]
return AdminUserListResponse(
items=items,
total=total,
page=current_page,
per_page=resolved_limit,
)
@router.get("/accounts", response_model=AdminAccountListResponse)
async def list_accounts(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
page: int = Query(1, ge=1),
size: int = Query(12, ge=1, le=100),
search: Optional[str] = Query(None, description="Search by account, display code, or owner"),
plan: Optional[str] = Query(None, description="Filter by subscription plan"),
status: Optional[str] = Query(None, description="Filter by subscription status"),
include_archived: bool = Query(False, description="Include archived users in account member lists"),
):
"""List accounts with embedded members for the admin panel."""
owner_user = aliased(User)
count_query = (
select(func.count(func.distinct(Account.id)))
.select_from(Account)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
)
accounts_query = (
select(
Account,
owner_user.id.label("owner_user_id"),
owner_user.name.label("owner_name"),
owner_user.email.label("owner_email"),
Subscription.id.label("subscription_id"),
Subscription.plan.label("subscription_plan"),
Subscription.status.label("subscription_status"),
Subscription.billing_interval.label("subscription_billing_interval"),
Subscription.current_period_end.label("subscription_current_period_end"),
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
)
if search:
search_term = f"%{search.strip()}%"
search_filter = or_(
Account.name.ilike(search_term),
Account.display_code.ilike(search_term),
owner_user.name.ilike(search_term),
owner_user.email.ilike(search_term),
)
count_query = count_query.where(search_filter)
accounts_query = accounts_query.where(search_filter)
if plan:
count_query = count_query.where(Subscription.plan == plan)
accounts_query = accounts_query.where(Subscription.plan == plan)
if status:
count_query = count_query.where(Subscription.status == status)
accounts_query = accounts_query.where(Subscription.status == status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
accounts_result = await db.execute(
accounts_query
.order_by(Account.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
rows = accounts_result.all()
accounts = [row.Account for row in rows]
account_ids = [account.id for account in accounts]
members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids}
pending_invites_by_account: dict[UUID, int] = {account_id: 0 for account_id in account_ids}
usage_by_account: dict[UUID, AdminAccountUsageSummary] = {}
if account_ids:
members_query = select(User).where(User.account_id.in_(account_ids))
if not include_archived:
members_query = members_query.where(User.deleted_at.is_(None))
members_query = members_query.order_by(User.created_at.asc())
members_result = await db.execute(members_query)
for member in members_result.scalars().all():
members_by_account.setdefault(member.account_id, []).append(
AdminAccountMember(
id=member.id,
email=member.email,
name=member.name,
role=member.role,
is_super_admin=member.is_super_admin,
is_active=member.is_active,
account_role=member.account_role,
created_at=member.created_at,
last_login=member.last_login,
deleted_at=member.deleted_at,
)
)
pending_invites_result = await db.execute(
select(AccountInvite.account_id, func.count(AccountInvite.id))
.where(
AccountInvite.account_id.in_(account_ids),
AccountInvite.used_at.is_(None),
)
.group_by(AccountInvite.account_id)
)
pending_invites_by_account.update({row[0]: row[1] for row in pending_invites_result.all()})
for account_id in account_ids:
usage = await get_account_usage(account_id, db)
usage_by_account[account_id] = AdminAccountUsageSummary(
tree_count=usage.get("tree_count", 0),
session_count_this_month=usage.get("session_count_this_month", 0),
)
items = [
AdminAccountListItem(
id=row.Account.id,
name=row.Account.name,
display_code=row.Account.display_code,
created_at=row.Account.created_at,
owner_id=row.Account.owner_id,
owner=(
AdminAccountOwnerSummary(
id=row.owner_user_id,
name=row.owner_name,
email=row.owner_email,
) if row.owner_user_id and row.owner_name and row.owner_email else None
),
subscription=(
AdminAccountSubscriptionSummary(
id=row.subscription_id,
plan=row.subscription_plan,
status=row.subscription_status,
billing_interval=row.subscription_billing_interval,
current_period_end=row.subscription_current_period_end,
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
),
usage=usage_by_account.get(row.Account.id, AdminAccountUsageSummary()),
member_count=len(members_by_account.get(row.Account.id, [])),
active_member_count=sum(1 for member in members_by_account.get(row.Account.id, []) if member.is_active),
pending_invite_count=pending_invites_by_account.get(row.Account.id, 0),
sso_enabled=row.Account.sso_enabled,
branding_company_name=row.Account.branding_company_name,
members=members_by_account.get(row.Account.id, []),
)
for row in rows
]
return AdminAccountListResponse(
items=items,
total=total,
page=page,
per_page=size,
)
def _generate_display_code() -> str:
@@ -71,6 +311,183 @@ def _generate_display_code() -> str:
return ''.join(secrets.choice(chars) for _ in range(8))
async def _generate_unique_display_code(db: AsyncSession) -> str:
"""Generate a unique display code for a new account."""
while True:
display_code = _generate_display_code()
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
if existing.scalar_one_or_none() is None:
return display_code
async def _get_account_detail_payload(
account_id: UUID,
db: AsyncSession,
include_archived: bool = False,
) -> AdminAccountDetailResponse:
owner_user = aliased(User)
result = await db.execute(
select(
Account,
owner_user.id.label("owner_user_id"),
owner_user.name.label("owner_name"),
owner_user.email.label("owner_email"),
Subscription.id.label("subscription_id"),
Subscription.plan.label("subscription_plan"),
Subscription.status.label("subscription_status"),
Subscription.billing_interval.label("subscription_billing_interval"),
Subscription.current_period_end.label("subscription_current_period_end"),
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
.where(Account.id == account_id)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
if not include_archived:
members_query = members_query.where(User.deleted_at.is_(None))
members_result = await db.execute(members_query)
members = [
AdminAccountMember(
id=member.id,
email=member.email,
name=member.name,
role=member.role,
is_super_admin=member.is_super_admin,
is_active=member.is_active,
account_role=member.account_role,
created_at=member.created_at,
last_login=member.last_login,
deleted_at=member.deleted_at,
)
for member in members_result.scalars().all()
]
invites_result = await db.execute(
select(AccountInvite)
.where(AccountInvite.account_id == account_id)
.order_by(AccountInvite.created_at.desc())
)
invites = [
AdminAccountInviteSummary(
id=invite.id,
email=invite.email,
role=invite.role,
expires_at=invite.expires_at,
created_at=invite.created_at,
used_at=invite.used_at,
)
for invite in invites_result.scalars().all()
if invite.used_at is None
]
usage = await get_account_usage(account_id, db)
return AdminAccountDetailResponse(
id=row.Account.id,
name=row.Account.name,
display_code=row.Account.display_code,
created_at=row.Account.created_at,
owner_id=row.Account.owner_id,
owner=(
AdminAccountOwnerSummary(
id=row.owner_user_id,
name=row.owner_name,
email=row.owner_email,
) if row.owner_user_id and row.owner_name and row.owner_email else None
),
subscription=(
AdminAccountSubscriptionSummary(
id=row.subscription_id,
plan=row.subscription_plan,
status=row.subscription_status,
billing_interval=row.subscription_billing_interval,
current_period_end=row.subscription_current_period_end,
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
),
usage=AdminAccountUsageSummary(
tree_count=usage.get("tree_count", 0),
session_count_this_month=usage.get("session_count_this_month", 0),
),
member_count=len(members),
active_member_count=sum(1 for member in members if member.is_active),
pending_invite_count=len(invites),
sso_enabled=row.Account.sso_enabled,
branding_company_name=row.Account.branding_company_name,
members=members,
invites=invites,
)
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_account(
data: AdminAccountCreate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new account without requiring an initial user."""
display_code = await _generate_unique_display_code(db)
new_account = Account(
name=data.name.strip(),
display_code=display_code,
)
db.add(new_account)
await db.flush()
new_subscription = Subscription(
account_id=new_account.id,
plan=data.plan,
status="active",
)
db.add(new_subscription)
await log_audit(
db, current_user.id, "account.create_admin", "account", new_account.id,
{"name": new_account.name, "plan": data.plan},
)
await db.commit()
return await _get_account_detail_payload(new_account.id, db)
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
async def get_account_detail(
account_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
include_archived: bool = Query(False),
):
"""Get detailed account information for admin management."""
return await _get_account_detail_payload(account_id, db, include_archived=include_archived)
@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
async def update_account(
account_id: UUID,
data: AdminAccountUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update account settings from the admin panel."""
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
old_name = account.name
account.name = data.name.strip()
await log_audit(
db, current_user.id, "account.update_admin", "account", account.id,
{"old_name": old_name, "new_name": account.name},
)
await db.commit()
return await _get_account_detail_payload(account.id, db)
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreate,
@@ -516,6 +933,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
return user, subscription
async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]:
"""Helper to load account and its subscription."""
account_result = await db.execute(select(Account).where(Account.id == account_id))
account = account_result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
sub_result = await db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)
subscription = sub_result.scalar_one_or_none()
if not subscription:
subscription = Subscription(
account_id=account.id,
plan="free",
status="active",
)
db.add(subscription)
await db.flush()
return account, subscription
@router.put("/users/{user_id}/subscription/plan")
async def update_user_plan(
user_id: UUID,
@@ -535,6 +974,31 @@ async def update_user_plan(
return {"plan": subscription.plan, "status": subscription.status}
@router.put("/accounts/{account_id}/subscription/plan")
async def update_account_plan(
account_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change an account 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")
account, subscription = await _get_account_subscription(account_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, "account_id": str(account_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,
@@ -565,6 +1029,43 @@ async def extend_user_trial(
"current_period_end": subscription.current_period_end}
@router.put("/accounts/{account_id}/subscription/extend-trial")
async def extend_account_trial(
account_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Extend or start a trial for an account 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")
account, subscription = await _get_account_subscription(account_id, db)
now = datetime.now(timezone.utc)
if subscription.status == "trialing" and subscription.current_period_end:
new_end = subscription.current_period_end + timedelta(days=data.days)
else:
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(), "account_id": str(account.id)},
)
await db.commit()
return {
"plan": subscription.plan,
"status": subscription.status,
"current_period_end": subscription.current_period_end,
}
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
async def admin_reset_password(
user_id: UUID,

View File

@@ -43,6 +43,7 @@ async def create_suggestion(
suggestion = AISuggestion(
tree_id=data.tree_id,
user_id=current_user.id,
account_id=current_user.account_id,
session_id=data.session_id,
action_type=data.action_type,
target_node_id=data.target_node_id,

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update
from app.core.config import settings
from app.core.settings_manager import SettingsManager
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.rate_limit import limiter
from app.core.security import (
verify_password,
@@ -67,7 +67,7 @@ def _generate_display_code() -> str:
async def register(
request: Request,
user_data: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Register a new user.
@@ -232,7 +232,7 @@ async def register(
async def login(
request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Login and get access token."""
# Find user by email
@@ -270,7 +270,7 @@ async def login(
async def login_json(
request: Request,
credentials: UserLogin,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Login with JSON body (alternative to form data)."""
result = await db.execute(select(User).where(User.email == credentials.email))
@@ -304,7 +304,7 @@ async def login_json(
async def refresh_token(
request: Request,
payload: Annotated[dict, Depends(get_refresh_token_payload)],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Refresh access token using refresh token (rotation: old token is revoked)."""
user_id = payload.get("sub")
@@ -368,7 +368,7 @@ async def get_me(
async def update_me(
data: UserUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Update current user's profile (name, email)."""
update_fields = data.model_fields_set - {"current_password"}
@@ -415,7 +415,7 @@ async def update_me(
@router.post("/logout")
async def logout(
payload: Annotated[dict, Depends(get_refresh_token_payload)],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Logout user by revoking the refresh token."""
jti = payload.get("jti")
@@ -438,7 +438,7 @@ async def change_password(
request: Request,
data: ChangePasswordRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Change the current user's password."""
if not verify_password(data.current_password, current_user.password_hash):
@@ -478,7 +478,7 @@ async def change_password(
async def forgot_password(
request: Request,
data: ForgotPasswordRequest,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Request a password reset email. Always returns success (anti-enumeration)."""
result = await db.execute(select(User).where(User.email == data.email))
@@ -513,7 +513,7 @@ async def forgot_password(
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
async def verify_reset_token(
data: VerifyResetTokenRequest,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Verify a password reset token is valid."""
payload = decode_token(data.token)
@@ -544,7 +544,7 @@ async def verify_reset_token(
async def reset_password(
request: Request,
data: ResetPasswordRequest,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Reset password using a valid reset token."""
payload = decode_token(data.token)
@@ -611,7 +611,7 @@ async def reset_password(
@router.get("/email/verification-status")
async def get_verification_status(
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Check if email verification is enabled on the platform."""
enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -623,7 +623,7 @@ async def get_verification_status(
async def send_verification_email(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Send an email verification link to the current user."""
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -662,7 +662,7 @@ async def send_verification_email(
@router.post("/email/verify")
async def verify_email(
data: dict,
db: Annotated[AsyncSession, Depends(get_db)]
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Verify an email using a token. Public endpoint."""
token = data.get("token")

View File

@@ -69,6 +69,7 @@ async def create_schedule(
schedule = MaintenanceSchedule(
tree_id=data.tree_id,
account_id=current_user.account_id,
created_by=current_user.id,
cron_expression=data.cron_expression,
timezone=data.timezone,

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.assistant_chat import AssistantChat
from app.models.psa_connection import PsaConnection
from app.models.session import Session
@@ -98,7 +99,7 @@ async def get_onboarding_status(
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
async def dismiss_onboarding(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> OnboardingStatus:
"""Dismiss the onboarding checklist for the current user."""

View File

@@ -91,6 +91,7 @@ async def submit_step_feedback(
new_rating = StepRating(
step_id=step_id,
user_id=current_user.id,
account_id=current_user.account_id,
session_id=session_uuid,
was_helpful=data.was_helpful,
# rating is nullable now — thumbs-only mode

View File

@@ -85,6 +85,7 @@ async def create_session(
session = await script_builder_service.create_session(
db=db,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
language=data.language,
)

View File

@@ -196,6 +196,7 @@ async def start_session(
new_session = Session(
tree_id=tree.id,
user_id=current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
@@ -693,6 +694,7 @@ async def prepare_session(
new_session = Session(
tree_id=tree.id,
user_id=data.assigned_to_id or current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
@@ -770,6 +772,7 @@ async def batch_launch_sessions(
session = Session(
tree_id=tree.id,
user_id=current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
@@ -1102,6 +1105,7 @@ async def psa_post_to_ticket(
# Log to audit trail
log_entry = PsaPostLog(
session_id=session.id,
account_id=session.account_id,
psa_connection_id=psa_connection.id if psa_connection else None,
ticket_id=session.psa_ticket_id,
note_type=data.note_type,

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import joinedload
from sqlalchemy.exc import IntegrityError
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.session import Session
from app.models.session_share import SessionShare, SessionShareView
from app.models.user import User
@@ -210,7 +211,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
async def access_share(
share_token: str,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
"""Access a shared session via share token.

View File

@@ -460,6 +460,7 @@ async def rate_step(
rating = StepRating(
step_id=step_id,
user_id=current_user.id,
account_id=current_user.account_id,
rating=rating_data.rating,
was_helpful=rating_data.was_helpful,
review_text=rating_data.review_text,

View File

@@ -103,6 +103,7 @@ async def create_supporting_data(
item = SessionSupportingData(
session_id=session_id,
account_id=session.account_id,
label=data.label,
data_type=data.data_type,
content=data.content,

View File

@@ -18,12 +18,10 @@ async def list_target_lists(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all target lists for the current user's team."""
if not current_user.team_id:
return []
"""List all target lists for the current user's account."""
result = await db.execute(
select(TargetList)
.where(TargetList.team_id == current_user.team_id)
.where(TargetList.account_id == current_user.account_id)
.order_by(TargetList.name)
)
return result.scalars().all()
@@ -36,11 +34,9 @@ async def create_target_list(
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Create a new target list for the current team."""
if not current_user.team_id:
raise HTTPException(status_code=400, detail="User must belong to a team")
"""Create a new target list for the current account."""
target_list = TargetList(
team_id=current_user.team_id,
account_id=current_user.account_id,
created_by=current_user.id,
name=data.name,
description=data.description,
@@ -61,7 +57,7 @@ async def get_target_list(
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
TargetList.account_id == current_user.account_id,
)
)
target_list = result.scalar_one_or_none()
@@ -81,7 +77,7 @@ async def update_target_list(
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
TargetList.account_id == current_user.account_id,
)
)
target_list = result.scalar_one_or_none()
@@ -91,7 +87,7 @@ async def update_target_list(
if "name" in update_fields and data.name is not None:
target_list.name = data.name
if "description" in update_fields:
target_list.description = data.description # allow setting to None
target_list.description = data.description
if "targets" in update_fields and data.targets is not None:
target_list.targets = [t.model_dump() for t in data.targets]
await db.commit()
@@ -109,7 +105,7 @@ async def delete_target_list(
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
TargetList.account_id == current_user.account_id,
)
)
target_list = result.scalar_one_or_none()

View File

@@ -1048,6 +1048,7 @@ async def create_tree_share(
# Create share
tree_share = TreeShare(
tree_id=tree.id,
account_id=tree.account_id, # share belongs to the tree's tenant, not the actor
share_token=share_token,
created_by=current_user.id,
allow_forking=share_data.allow_forking,

View File

@@ -2,8 +2,10 @@
"""
Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
Use ONLY for /admin/* endpoints and internal tooling.
Never use this engine from user-facing endpoints.
Use ONLY where explicit application-level access control makes database-layer
tenant filtering unnecessary: /admin/* endpoints, internal tooling, and public
endpoints that enforce their own authorization before returning data (e.g.
share access via opaque token + visibility check).
"""
from collections.abc import AsyncGenerator
@@ -25,7 +27,7 @@ _admin_session_factory = async_sessionmaker(
async def get_admin_db() -> AsyncGenerator[AsyncSession, None]:
"""Yield an admin DB session (BYPASSRLS). Use only on /admin/* endpoints."""
"""Yield an admin DB session (BYPASSRLS). See module docstring for approved use cases."""
async with _admin_session_factory() as session:
try:
yield session

View File

@@ -12,10 +12,19 @@ async def log_audit(
resource_type: str,
resource_id: Optional[UUID] = None,
details: Optional[dict] = None,
account_id: Optional[UUID] = None,
) -> None:
"""Record an audit log entry. Does not commit — piggybacks on the caller's commit."""
if account_id is None:
# Derive from the acting user's account as a fallback (one extra query).
from sqlalchemy import select
from app.models.user import User
result = await db.execute(select(User.account_id).where(User.id == user_id))
account_id = result.scalar_one()
entry = AuditLog(
user_id=user_id,
account_id=account_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,

View File

@@ -21,7 +21,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
"""Create batch sessions for a scheduled maintenance run."""
# Import all models first to ensure SQLAlchemy mapper relationships resolve
import app.models # noqa: F401
from app.core.database import async_session_maker
from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.session import Session
from app.models.target_list import TargetList
@@ -118,7 +118,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
async def _cleanup_expired_ai_conversations() -> None:
"""Delete expired AI wizard conversations."""
import app.models # noqa: F401
from app.core.database import async_session_maker
from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.ai_conversation import AIConversation
async with async_session_maker() as db:

View File

@@ -14,6 +14,8 @@ import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.admin_database import _admin_session_factory
logger = logging.getLogger(__name__)
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
@@ -52,40 +54,45 @@ async def _ensure_system_account(db: AsyncSession) -> uuid.UUID:
async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
"""Ensure the ResolutionFlow service account exists and return its ID.
Idempotent — safe to call on every startup. Creates the account if it
does not exist. The account has no usable password and is_service_account=True
so it can never log in via normal auth flows.
Idempotent — safe to call on every startup. This lookup must bypass RLS
because startup runs before any request-scoped tenant context exists and
the users table is tenant-isolated in Phase 4. The service account is
normally created by Alembic migration 1490781700bc; the runtime create path
remains as a self-healing fallback for environments that predate that seed.
"""
_ = db # Retained for call-site compatibility in app lifespan startup.
from app.models.user import User
result = await db.execute(
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
)
user = result.scalar_one_or_none()
async with _admin_session_factory() as admin_db:
result = await admin_db.execute(
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
)
user = result.scalar_one_or_none()
if user is not None:
if not user.is_service_account:
user.is_service_account = True
await db.commit()
return user.id
if user is not None:
if not user.is_service_account:
user.is_service_account = True
await admin_db.commit()
return user.id
account_id = await _ensure_system_account(db)
account_id = await _ensure_system_account(admin_db)
new_user = User(
id=uuid.uuid4(),
email=SERVICE_ACCOUNT_EMAIL,
name=SERVICE_ACCOUNT_NAME,
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
role="engineer",
is_super_admin=False,
is_team_admin=False,
is_active=True,
is_service_account=True,
must_change_password=False,
account_id=account_id,
account_role="engineer",
)
db.add(new_user)
await db.commit()
logger.info(f"[service_account] Created service account (id={new_user.id})")
return new_user.id
new_user = User(
id=uuid.uuid4(),
email=SERVICE_ACCOUNT_EMAIL,
name=SERVICE_ACCOUNT_NAME,
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
role="engineer",
is_super_admin=False,
is_team_admin=False,
is_active=True,
is_service_account=True,
must_change_password=False,
account_id=account_id,
account_role="engineer",
)
admin_db.add(new_user)
await admin_db.commit()
logger.info(f"[service_account] Created service account (id={new_user.id})")
return new_user.id

View File

@@ -25,7 +25,8 @@ if settings.SENTRY_DSN:
),
)
from app.core.database import init_db, async_session_maker
from app.core.database import init_db
from app.core.admin_database import _admin_session_factory as async_session_maker
from app.core.logging_config import setup_logging
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
from app.core.security_headers import SecurityHeadersMiddleware

View File

@@ -21,6 +21,12 @@ class AuditLog(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(

View File

@@ -8,7 +8,6 @@ from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.account import Account
@@ -18,10 +17,6 @@ class TargetList(Base):
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
team_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
nullable=False, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),

View File

@@ -25,6 +25,12 @@ class TreeShare(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
share_token: Mapped[str] = mapped_column(
String(64),
unique=True,

View File

@@ -28,6 +28,110 @@ class ActivityEntry(BaseModel):
from_attributes = True
# --- Admin Accounts & People Search ---
class AdminUserListItem(BaseModel):
id: UUID
email: EmailStr
name: str
role: str
is_super_admin: bool = False
is_active: bool = True
account_id: Optional[UUID] = None
account_role: Optional[str] = None
account_name: Optional[str] = None
account_display_code: Optional[str] = None
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class AdminUserListResponse(BaseModel):
items: list[AdminUserListItem]
total: int
page: int
per_page: int
class AdminAccountMember(BaseModel):
id: UUID
email: EmailStr
name: str
role: str
is_super_admin: bool = False
is_active: bool = True
account_role: Optional[str] = None
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class AdminAccountOwnerSummary(BaseModel):
id: UUID
name: str
email: EmailStr
class AdminAccountSubscriptionSummary(BaseModel):
id: UUID
plan: str
status: str
billing_interval: Optional[str] = None
current_period_end: Optional[datetime] = None
cancel_at_period_end: bool = False
class AdminAccountUsageSummary(BaseModel):
tree_count: int = 0
session_count_this_month: int = 0
class AdminAccountInviteSummary(BaseModel):
id: UUID
email: EmailStr
role: str
expires_at: Optional[datetime] = None
created_at: datetime
used_at: Optional[datetime] = None
class AdminAccountListItem(BaseModel):
id: UUID
name: str
display_code: str
created_at: datetime
owner_id: Optional[UUID] = None
owner: Optional[AdminAccountOwnerSummary] = None
subscription: Optional[AdminAccountSubscriptionSummary] = None
usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary)
member_count: int = 0
active_member_count: int = 0
pending_invite_count: int = 0
sso_enabled: bool = False
branding_company_name: Optional[str] = None
members: list[AdminAccountMember] = Field(default_factory=list)
class AdminAccountListResponse(BaseModel):
items: list[AdminAccountListItem]
total: int
page: int
per_page: int
class AdminAccountDetailResponse(AdminAccountListItem):
invites: list[AdminAccountInviteSummary] = Field(default_factory=list)
class AdminAccountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
plan: Literal["free", "pro", "team"] = "free"
class AdminAccountUpdate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
# --- Audit Logs ---
class AuditLogEntry(BaseModel):

View File

@@ -23,7 +23,7 @@ class TargetListUpdate(BaseModel):
class TargetListResponse(BaseModel):
id: UUID
team_id: UUID
account_id: UUID
created_by: Optional[UUID]
name: str
description: Optional[str]

View File

@@ -34,6 +34,7 @@ class BranchManager:
root = SessionBranch(
id=uuid.uuid4(),
session_id=session_id,
account_id=session.account_id,
parent_branch_id=None,
branch_order=1,
label="Root",
@@ -68,9 +69,17 @@ class BranchManager:
"status": "untried",
})
# Load session to get account_id for FK constraints
session_result = await self.db.execute(
select(AISession).where(AISession.id == session_id)
)
session = session_result.scalar_one_or_none()
account_id = session.account_id if session else None
fork_point = ForkPoint(
id=uuid.uuid4(),
session_id=session_id,
account_id=account_id,
parent_branch_id=parent_branch_id,
trigger_step_id=trigger_step_id,
fork_reason=fork_reason,
@@ -90,6 +99,7 @@ class BranchManager:
branch = SessionBranch(
id=branch_ids[i],
session_id=session_id,
account_id=account_id,
parent_branch_id=parent_branch_id,
fork_point_step_id=trigger_step_id,
branch_order=i + 1,

View File

@@ -56,6 +56,7 @@ class HandoffManager:
handoff = SessionHandoff(
session_id=session_id,
account_id=session.account_id,
handed_off_by=user_id,
intent=intent,
source_branch_id=session.active_branch_id,

View File

@@ -10,7 +10,7 @@ import logging
from sqlalchemy import select
from app.core.database import async_session_maker
from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.ai_session import AISession
from app.services.knowledge_flywheel import analyze_session

View File

@@ -371,6 +371,7 @@ async def push_documentation(
# Log success
log_entry = PsaPostLog(
id=uuid.uuid4(),
account_id=session.account_id,
ai_session_id=session.id,
psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id,
@@ -394,6 +395,7 @@ async def push_documentation(
# Log failure with retry scheduling
log_entry = PsaPostLog(
id=uuid.uuid4(),
account_id=session.account_id,
ai_session_id=session.id,
psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id,

View File

@@ -9,7 +9,7 @@ from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import async_session_maker
from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.psa_post_log import PsaPostLog
from app.services.psa_documentation_service import retry_failed_push

View File

@@ -45,6 +45,7 @@ class ResolutionOutputGenerator:
output = SessionResolutionOutput(
session_id=session_id,
account_id=session.account_id,
output_type=output_type,
generated_content=content,
status="draft",

View File

@@ -9,7 +9,7 @@ from datetime import datetime, timezone, timedelta
from sqlalchemy import select, delete, func
from app.core.database import async_session_maker
from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.account import Account
from app.models.assistant_chat import AssistantChat

View File

@@ -144,6 +144,7 @@ def _extract_script_from_response(content: str, language: str) -> tuple[str | No
async def create_session(
db: AsyncSession,
user_id: UUID,
account_id: UUID,
team_id: UUID | None,
language: str,
initial_prompt: str | None = None,
@@ -151,6 +152,7 @@ async def create_session(
"""Create a new Script Builder session."""
session = ScriptBuilderSession(
user_id=user_id,
account_id=account_id,
team_id=team_id,
language=language,
)

View File

@@ -80,7 +80,10 @@ def _display_code() -> str:
async def main() -> None:
engine = create_async_engine(settings.DATABASE_URL, echo=False)
# Must use ADMIN_DATABASE_URL (BYPASSRLS) — Phase 4 enabled RLS on users.
# The app-role connection has no tenant context at seed time and would see 0 rows.
admin_url = getattr(settings, "ADMIN_DATABASE_URL", None) or settings.DATABASE_URL
engine = create_async_engine(admin_url, echo=False)
password_hash = get_password_hash(SHARED_PASSWORD)
now = datetime.now(timezone.utc)
team_account_id: uuid.UUID | None = None

View File

@@ -75,6 +75,19 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
"""))
# Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by
# global categories, gallery items, and other platform-owned content.
await conn.execute(sa.text("""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES (
'00000000-0000-0000-0000-000000000001',
'ResolutionFlow System',
'RF-SYS-1',
NOW(), NOW()
)
ON CONFLICT (id) DO NOTHING
"""))
# Create async session maker
async_session_maker = async_sessionmaker(
engine,

View File

@@ -19,8 +19,116 @@ class TestAdminEndpoints:
"/api/v1/admin/users", headers=admin_auth_headers
)
assert response.status_code == 200
users = response.json()
assert len(users) >= 2 # admin + test_user
payload = response.json()
assert payload["total"] >= 2 # admin + test_user
assert len(payload["items"]) >= 2
@pytest.mark.asyncio
async def test_list_users_supports_search(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test admin people search by user email."""
response = await client.get(
"/api/v1/admin/users",
params={"search": test_user["email"]},
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] >= 1
assert any(item["email"] == test_user["email"] for item in payload["items"])
@pytest.mark.asyncio
async def test_list_accounts_as_admin(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Test listing accounts with member data."""
response = await client.get(
"/api/v1/admin/accounts", headers=admin_auth_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] >= 1
assert len(payload["items"]) >= 1
assert "members" in payload["items"][0]
assert "subscription" in payload["items"][0]
@pytest.mark.asyncio
async def test_create_account_as_admin(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Test creating an empty account from admin."""
response = await client.post(
"/api/v1/admin/accounts",
json={"name": "Acme Customer", "plan": "pro"},
headers=admin_auth_headers,
)
assert response.status_code == 201
payload = response.json()
assert payload["name"] == "Acme Customer"
assert payload["subscription"]["plan"] == "pro"
assert payload["display_code"]
@pytest.mark.asyncio
async def test_get_account_detail_as_admin(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test fetching account detail for management view."""
account_id = test_user["user_data"]["account_id"]
response = await client.get(
f"/api/v1/admin/accounts/{account_id}",
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == account_id
assert "members" in payload
assert "invites" in payload
@pytest.mark.asyncio
async def test_update_account_name_as_admin(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test renaming an account from admin detail view."""
account_id = test_user["user_data"]["account_id"]
response = await client.put(
f"/api/v1/admin/accounts/{account_id}",
json={"name": "Renamed Customer Account"},
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == account_id
assert payload["name"] == "Renamed Customer Account"
@pytest.mark.asyncio
async def test_update_account_plan(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test changing an account's subscription plan."""
account_id = test_user["user_data"]["account_id"]
response = await client.put(
f"/api/v1/admin/accounts/{account_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_account_trial(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test starting or extending an account trial."""
account_id = test_user["user_data"]["account_id"]
response = await client.put(
f"/api/v1/admin/accounts/{account_id}/subscription/extend-trial",
json={"days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["status"] == "trialing"
assert response.json()["current_period_end"] is not None
@pytest.mark.asyncio
async def test_list_users_as_non_admin(

View File

@@ -29,7 +29,7 @@ class TestAdminGlobalCategories:
data = response.json()
assert data["name"] == "Test Category"
assert data["slug"] == "test-category"
assert data["account_id"] is None
assert data["account_id"] == "00000000-0000-0000-0000-000000000001" # PLATFORM_ACCOUNT_ID
@pytest.mark.asyncio
async def test_update_global_category(

View File

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tree import Tree
from app.models.script_template import ScriptTemplate, ScriptCategory
_PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
# ---------------------------------------------------------------------------
# Helpers
@@ -22,6 +23,7 @@ async def _create_tree(db: AsyncSession, admin_user_id: str) -> Tree:
name="Gallery Test Flow",
tree_type="troubleshooting",
visibility="public",
account_id=_PLATFORM_ACCOUNT_ID,
is_gallery_featured=False,
gallery_sort_order=0,
tree_structure={
@@ -53,6 +55,7 @@ async def _create_script(db: AsyncSession, admin_user_id: str) -> ScriptTemplate
script = ScriptTemplate(
id=uuid.uuid4(),
category_id=category.id,
account_id=_PLATFORM_ACCOUNT_ID,
name="Gallery Test Script",
slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}",
script_body="Write-Host 'Test'",

View File

@@ -594,6 +594,7 @@ class TestPsaMetrics:
post_log = PsaPostLog(
id=uuid.uuid4(),
ai_session_id=push_session_id,
account_id=account_id,
ticket_id="TICKET-123",
note_type="internal",
content_posted="Session summary",

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.security import get_password_hash
from app.models.account import Account
from app.models.team import Team
from app.models.user import User
@@ -23,6 +24,8 @@ async def _create_team_with_admin(
team_name: str = "Branding Test Team",
) -> tuple[dict, str, Team]:
"""Create a team + team admin user. Returns (auth_headers, team_id_str, team)."""
account = Account(name=team_name, display_code=uuid.uuid4().hex[:8].upper())
test_db.add(account)
team = Team(name=team_name)
test_db.add(team)
await test_db.flush()
@@ -36,6 +39,8 @@ async def _create_team_with_admin(
team_id=team.id,
is_team_admin=True,
role="engineer",
account_id=account.id,
account_role="engineer",
)
test_db.add(user)
await test_db.commit()
@@ -58,6 +63,15 @@ async def _create_team_member(
is_team_admin: bool = False,
) -> dict:
"""Create a regular team member. Returns auth_headers."""
# Look up the account associated with this team via an existing member
from sqlalchemy import select as _select
from app.models.user import User as _User
result = await test_db.execute(
_select(_User).where(_User.team_id == team.id).limit(1)
)
team_member = result.scalar_one_or_none()
member_account_id = team_member.account_id if team_member else None
email = f"member_{uuid.uuid4().hex[:8]}@test.com"
user = User(
email=email,
@@ -67,6 +81,8 @@ async def _create_team_member(
team_id=team.id,
is_team_admin=is_team_admin,
role="engineer",
account_id=member_account_id,
account_role="engineer",
)
test_db.add(user)
await test_db.commit()

View File

@@ -334,12 +334,13 @@ class TestDraftTreesAPI:
"""Test that migration defaults existing trees to published status."""
# Create a tree without specifying status (relies on DB default)
from uuid import UUID, uuid4
_platform_id = UUID("00000000-0000-0000-0000-000000000001")
tree = Tree(
name="Legacy Tree",
description="Created before status field",
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
author_id=None,
account_id=None
account_id=_platform_id,
)
test_db.add(tree)
await test_db.commit()

View File

@@ -127,10 +127,12 @@ async def test_cannot_schedule_other_teams_tree(client: AsyncClient, auth_header
test_db.add(other_team)
await test_db.flush()
from uuid import UUID as _UUID
other_tree = Tree(
name="Other Team Tree",
tree_type="maintenance",
team_id=other_team.id,
account_id=_UUID("00000000-0000-0000-0000-000000000001"),
tree_structure={
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",

View File

@@ -200,6 +200,7 @@ class TestAccountPermissions:
})
outsider_headers = {"Authorization": f"Bearer {outsider_login.json()['access_token']}"}
# Outsider should NOT see the private tree
# Outsider should NOT see the private tree.
# With RLS, the tree is invisible to other tenants — 404 not 403.
response = await client.get(f"/api/v1/trees/{tree_id}", headers=outsider_headers)
assert response.status_code == 403
assert response.status_code == 404

View File

@@ -464,7 +464,6 @@ async def test_target_list_account_id_from_team_admin(test_db: AsyncSession):
await test_db.flush()
target_list = TargetList(
team_id=team.id,
account_id=account.id,
created_by=user.id,
name="Server Targets",

View File

@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.script_template import ScriptCategory, ScriptTemplate
from app.models.tree import Tree
_PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
# ---------------------------------------------------------------------------
# Helpers
@@ -41,6 +43,7 @@ async def _create_featured_tree(db: AsyncSession, name: str = "Featured Flow", f
description="A featured flow for the gallery",
tree_type="troubleshooting",
tree_structure=_make_tree_structure(4),
account_id=_PLATFORM_ACCOUNT_ID,
is_gallery_featured=featured,
is_active=True,
usage_count=42,
@@ -74,6 +77,7 @@ async def _create_featured_script(
) -> ScriptTemplate:
script = ScriptTemplate(
category_id=category.id,
account_id=_PLATFORM_ACCOUNT_ID,
name=name,
slug=name.lower().replace(" ", "-"),
description="A gallery-featured script",
@@ -312,7 +316,7 @@ class TestCategoriesEndpoint:
from app.models.category import TreeCategory
# Create a category and a featured tree in that category
cat = TreeCategory(name="Networking", slug="networking", is_active=True)
cat = TreeCategory(name="Networking", slug="networking", is_active=True, account_id=_PLATFORM_ACCOUNT_ID)
test_db.add(cat)
await test_db.commit()
await test_db.refresh(cat)
@@ -321,6 +325,7 @@ class TestCategoriesEndpoint:
name="Router Diagnostics",
tree_type="troubleshooting",
tree_structure=_make_tree_structure(2),
account_id=_PLATFORM_ACCOUNT_ID,
is_gallery_featured=True,
is_active=True,
usage_count=5,

View File

@@ -62,6 +62,7 @@ async def test_edit_output(client: AsyncClient, test_user, auth_headers, test_db
output = SessionResolutionOutput(
session_id=session.id,
account_id=session.account_id,
output_type="psa_ticket_notes",
generated_content="Original notes",
status="draft",

View File

@@ -16,11 +16,20 @@ Run with:
The test DB is patherly_test (matches conftest.py default).
"""
import os
import subprocess
import sys
import uuid
from pathlib import Path
import asyncpg
import pytest
# All tests in this module use module-scoped async fixtures (admin_conn,
# seed_rls_test_data) which run on the module event loop. Without this marker,
# pytest-asyncio 0.23+ defaults tests to function-scoped loops, causing
# "Future attached to a different loop" errors on the asyncpg connections.
pytestmark = pytest.mark.asyncio(loop_scope="module")
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
@@ -37,7 +46,25 @@ ACCOUNT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
async def admin_conn():
def _ensure_rls_schema():
"""Re-apply Alembic migrations before the module runs.
Function-scoped test_db fixtures in other modules drop and recreate the
public schema using Base.metadata.create_all, which does not enable RLS
or create DB roles. This fixture re-runs 'alembic upgrade head' so that
the full migration-managed schema (including RLS policies) is in place.
"""
backend_dir = Path(__file__).parent.parent
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=backend_dir,
check=True,
capture_output=True,
)
@pytest.fixture(scope="module")
async def admin_conn(_ensure_rls_schema):
"""Superuser asyncpg connection for fixture setup and teardown."""
conn = await asyncpg.connect(_ADMIN_DSN)
yield conn
@@ -170,7 +197,6 @@ async def conn_no_context():
# trees
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -178,7 +204,6 @@ async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
assert len(rows) == 0, "Account A should not see Account B trees"
@pytest.mark.asyncio
async def test_trees_account_a_can_see_own_rows(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'"
@@ -186,7 +211,6 @@ async def test_trees_account_a_can_see_own_rows(conn_a):
assert len(rows) >= 1, "Account A should see its own trees"
@pytest.mark.asyncio
async def test_trees_no_context_sees_no_private_trees(conn_no_context):
rows = await conn_no_context.fetch(
"SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE"
@@ -198,7 +222,6 @@ async def test_trees_no_context_sees_no_private_trees(conn_no_context):
# tree_tags — platform visibility
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -206,7 +229,6 @@ async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
assert len(rows) == 0
@pytest.mark.asyncio
async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
rows_a = await conn_a.fetch(
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
@@ -222,7 +244,6 @@ async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
# tree_categories — platform visibility
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -234,7 +255,6 @@ async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
# step_categories — platform visibility
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_step_categories_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -246,7 +266,6 @@ async def test_step_categories_account_a_cannot_see_account_b(conn_a):
# psa_connections — tenant-only
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -258,9 +277,782 @@ async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
# flow_proposals — tenant-only
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0
# ---------------------------------------------------------------------------
# Phase 2 fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
async def session_row_ids(admin_conn):
"""
Insert one `sessions` row and one `ai_sessions` row for each of
ACCOUNT_A and ACCOUNT_B using the superuser connection (BYPASSRLS).
Returns a dict with the inserted IDs for use in tests.
Cleans up on exit.
"""
# Resolve a valid tree_id and user_id for each account
tree_a = await admin_conn.fetchrow(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}' LIMIT 1"
)
tree_b = await admin_conn.fetchrow(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
)
user_a = await admin_conn.fetchrow(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_A_ID}' LIMIT 1"
)
user_b = await admin_conn.fetchrow(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
)
assert tree_a is not None, f"No tree found for ACCOUNT_A ({ACCOUNT_A_ID}) — seed_rls_test_data must run first"
assert tree_b is not None, f"No tree found for ACCOUNT_B ({ACCOUNT_B_ID}) — seed_rls_test_data must run first"
assert user_a is not None, f"No user found for ACCOUNT_A ({ACCOUNT_A_ID}) — seed_rls_test_data must run first"
assert user_b is not None, f"No user found for ACCOUNT_B ({ACCOUNT_B_ID}) — seed_rls_test_data must run first"
tree_a_id = str(tree_a["id"])
tree_b_id = str(tree_b["id"])
user_a_id = str(user_a["id"])
user_b_id = str(user_b["id"])
session_a_id = str(uuid.uuid4())
session_b_id = str(uuid.uuid4())
ai_session_a_id = str(uuid.uuid4())
ai_session_b_id = str(uuid.uuid4())
# Insert sessions rows (sessions uses started_at not created_at)
await admin_conn.execute(f"""
INSERT INTO sessions (
id, tree_id, user_id, account_id, tree_snapshot,
path_taken, decisions, custom_steps, started_at
) VALUES
('{session_a_id}', '{tree_a_id}', '{user_a_id}', '{ACCOUNT_A_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()),
('{session_b_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW())
""")
# Insert ai_sessions rows
# confidence_tier valid values: 'guided' | 'exploring' | 'discovery'
await admin_conn.execute(f"""
INSERT INTO ai_sessions (
id, user_id, account_id, session_type, intake_type,
intake_content, status, confidence_tier, confidence_score,
created_at, updated_at
) VALUES
('{ai_session_a_id}', '{user_a_id}', '{ACCOUNT_A_ID}',
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
NOW(), NOW()),
('{ai_session_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
NOW(), NOW())
""")
# -------------------------------------------------------------------------
# Seed Account B rows for every "cannot-see" table that would otherwise be
# empty. Without these, isolation tests pass vacuously even when RLS is off.
# -------------------------------------------------------------------------
# session_branches (FK: ai_sessions.id)
branch_b_row = await admin_conn.fetchrow("""
INSERT INTO session_branches (
id, session_id, account_id, branch_order, label, status,
conversation_messages, created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 1, 'test-branch', 'active',
'[]'::jsonb, NOW(), NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID)
branch_b_id = str(branch_b_row["id"])
# session_supporting_data (FK: sessions.id)
supporting_data_b_row = await admin_conn.fetchrow("""
INSERT INTO session_supporting_data (
id, session_id, account_id, label, data_type, content,
sort_order, created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 'test-data', 'text_snippet',
'test content', 0, NOW(), NOW()
) RETURNING id
""", session_b_id, ACCOUNT_B_ID)
supporting_data_b_id = str(supporting_data_b_row["id"])
# session_resolution_outputs (FK: ai_sessions.id)
resolution_output_b_row = await admin_conn.fetchrow("""
INSERT INTO session_resolution_outputs (
id, session_id, account_id, output_type, generated_content,
status, generated_by_model, created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 'psa_ticket_notes',
'test content', 'draft', 'test-model', NOW(), NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID)
resolution_output_b_id = str(resolution_output_b_row["id"])
# session_handoffs (FK: ai_sessions.id, users.id)
handoff_b_row = await admin_conn.fetchrow("""
INSERT INTO session_handoffs (
id, session_id, account_id, handed_off_by, intent, snapshot,
priority, psa_note_pushed, notification_sent, created_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, $3::uuid, 'park',
'{}'::jsonb, 'normal', false, false, NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID, user_b_id)
handoff_b_id = str(handoff_b_row["id"])
# maintenance_schedules (FK: trees.id)
maintenance_b_row = await admin_conn.fetchrow("""
INSERT INTO maintenance_schedules (
id, tree_id, account_id, cron_expression, timezone,
created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, '0 9 * * 1', 'UTC',
NOW(), NOW()
) RETURNING id
""", tree_b_id, ACCOUNT_B_ID)
maintenance_b_id = str(maintenance_b_row["id"])
# psa_post_log (FK: ai_sessions.id, users.id)
psa_log_b_row = await admin_conn.fetchrow("""
INSERT INTO psa_post_log (
id, ai_session_id, account_id, ticket_id, note_type,
content_posted, status, posted_by, posted_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 'TEST-0001', 'internal',
'test note', 'success', $3::uuid, NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID, user_b_id)
psa_log_b_id = str(psa_log_b_row["id"])
# script_templates requires a script_categories row — insert a temporary one
script_category_b_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO script_categories (id, name, slug, sort_order, is_active, created_at, updated_at)
VALUES ('{script_category_b_id}', 'RLS Test Category', 'rls-test-category-{script_category_b_id[:8]}',
0, true, NOW(), NOW())
""")
script_template_b_row = await admin_conn.fetchrow(f"""
INSERT INTO script_templates (
id, category_id, account_id, name, slug, script_body,
complexity, is_active, created_at, updated_at
) VALUES (
gen_random_uuid(), '{script_category_b_id}'::uuid, $1::uuid,
'RLS Test Template', 'rls-test-template-b-' || gen_random_uuid()::text,
'Write-Host "test"', 'beginner', true, NOW(), NOW()
) RETURNING id
""", ACCOUNT_B_ID)
script_template_b_id = str(script_template_b_row["id"])
# script_generations (FK: script_templates.id, users.id)
script_gen_b_row = await admin_conn.fetchrow("""
INSERT INTO script_generations (
id, template_id, user_id, account_id, parameters_used,
generated_script, created_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, $3::uuid, '{}'::jsonb,
'test script', NOW()
) RETURNING id
""", script_template_b_id, user_b_id, ACCOUNT_B_ID)
script_gen_b_id = str(script_gen_b_row["id"])
try:
yield {
"session_a": session_a_id,
"session_b": session_b_id,
"ai_session_a": ai_session_a_id,
"ai_session_b": ai_session_b_id,
}
finally:
# Cleanup in reverse FK order (children before parents)
await admin_conn.execute(
f"DELETE FROM script_generations WHERE id = '{script_gen_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_branches WHERE id = '{branch_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_supporting_data WHERE id = '{supporting_data_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_resolution_outputs WHERE id = '{resolution_output_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_handoffs WHERE id = '{handoff_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM maintenance_schedules WHERE id = '{maintenance_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM psa_post_log WHERE id = '{psa_log_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM script_templates WHERE id = '{script_template_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM script_categories WHERE id = '{script_category_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM sessions WHERE id IN ('{session_a_id}', '{session_b_id}')"
)
await admin_conn.execute(
f"DELETE FROM ai_sessions WHERE id IN ('{ai_session_a_id}', '{ai_session_b_id}')"
)
# ---------------------------------------------------------------------------
# sessions
# ---------------------------------------------------------------------------
async def test_sessions_account_a_cannot_see_account_b_sessions(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_b']}'"
)
assert len(rows) == 0, "Account A should not see Account B sessions"
async def test_sessions_account_a_can_see_own_sessions(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_a']}'"
)
assert len(rows) == 1, "Account A should see its own sessions"
async def test_sessions_no_context_sees_nothing(conn_no_context, session_row_ids):
rows = await conn_no_context.fetch(
f"SELECT id FROM sessions WHERE id IN "
f"('{session_row_ids['session_a']}', '{session_row_ids['session_b']}')"
)
assert len(rows) == 0, "No-context connection should see no sessions"
# ---------------------------------------------------------------------------
# ai_sessions
# ---------------------------------------------------------------------------
async def test_ai_sessions_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_b']}'"
)
assert len(rows) == 0, "Account A should not see Account B ai_sessions"
async def test_ai_sessions_account_a_can_see_own(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_a']}'"
)
assert len(rows) == 1, "Account A should see its own ai_sessions"
# ---------------------------------------------------------------------------
# session_branches
# ---------------------------------------------------------------------------
async def test_session_branches_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_branches WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_branches"
# ---------------------------------------------------------------------------
# session_supporting_data
# ---------------------------------------------------------------------------
async def test_session_supporting_data_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_supporting_data WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_supporting_data"
# ---------------------------------------------------------------------------
# session_resolution_outputs
# ---------------------------------------------------------------------------
async def test_session_resolution_outputs_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_resolution_outputs WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_resolution_outputs"
# ---------------------------------------------------------------------------
# session_handoffs
# ---------------------------------------------------------------------------
async def test_session_handoffs_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_handoffs WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_handoffs"
# ---------------------------------------------------------------------------
# script_templates
# ---------------------------------------------------------------------------
async def test_script_templates_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM script_templates WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B script_templates"
# ---------------------------------------------------------------------------
# script_generations
# ---------------------------------------------------------------------------
async def test_script_generations_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM script_generations WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B script_generations"
# ---------------------------------------------------------------------------
# maintenance_schedules
# ---------------------------------------------------------------------------
async def test_maintenance_schedules_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM maintenance_schedules WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B maintenance_schedules"
# ---------------------------------------------------------------------------
# psa_post_log
# ---------------------------------------------------------------------------
async def test_psa_post_log_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM psa_post_log WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B psa_post_log"
# ---------------------------------------------------------------------------
# step_library — visibility-aware policy
# ---------------------------------------------------------------------------
async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_conn, conn_a):
"""Private/non-public steps owned by Account B must not be visible to Account A."""
private_step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{private_step_id}', '{ACCOUNT_B_ID}', 'RLS Private Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_library "
f"WHERE id = '{private_step_id}' AND visibility != 'public'"
)
assert len(rows) == 0, "Account A should not see Account B's private step_library rows"
finally:
await admin_conn.execute(
f"DELETE FROM step_library WHERE id = '{private_step_id}'"
)
async def test_step_library_account_a_can_see_account_b_public_steps(admin_conn, conn_a):
"""Public steps owned by Account B MUST be visible to Account A (cross-tenant visibility)."""
public_step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{public_step_id}', '{ACCOUNT_B_ID}', 'RLS Public Step', 'action',
'{{}}'::jsonb, 'public', TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_library WHERE id = '{public_step_id}'"
)
assert len(rows) == 1, (
"Account A should see public steps owned by Account B "
"(cross-tenant public visibility policy)"
)
finally:
await admin_conn.execute(
f"DELETE FROM step_library WHERE id = '{public_step_id}'"
)
# ===========================================================================
# Phase 3 RLS isolation tests
# Tables: step_ratings, step_usage_log, target_lists,
# session_shares, audit_logs, tree_shares
# ===========================================================================
# ---------------------------------------------------------------------------
# Helpers shared by Phase 3 fixtures
# ---------------------------------------------------------------------------
async def _get_user_b_id(admin_conn) -> str:
row = await admin_conn.fetchrow(
"SELECT id FROM users WHERE email = 'rls-user-b@example.com'"
)
return str(row["id"])
async def _get_tree_b_id(admin_conn) -> str:
row = await admin_conn.fetchrow(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
)
return str(row["id"])
# ---------------------------------------------------------------------------
# step_ratings
# ---------------------------------------------------------------------------
async def test_step_ratings_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see step ratings belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
# Need a step_library row as FK target
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 RLS Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
rating_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_ratings (
id, step_id, user_id, account_id, is_verified_use, is_visible,
created_at, updated_at
) VALUES (
'{rating_id}', '{step_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
FALSE, TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_ratings WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B step_ratings"
finally:
await admin_conn.execute(f"DELETE FROM step_ratings WHERE id = '{rating_id}'")
await admin_conn.execute(f"DELETE FROM step_library WHERE id = '{step_id}'")
# ---------------------------------------------------------------------------
# step_usage_log
# ---------------------------------------------------------------------------
async def test_step_usage_log_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see step usage logs belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 Usage Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
# Need a sessions row as FK for usage log
session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO sessions (
id, tree_id, user_id, account_id, tree_snapshot,
path_taken, decisions, custom_steps, started_at
) VALUES (
'{session_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()
)
""")
log_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_usage_log (
id, step_id, user_id, account_id, session_id, used_at
) VALUES (
'{log_id}', '{step_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'{session_id}', NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_usage_log WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B step_usage_log"
finally:
await admin_conn.execute(f"DELETE FROM step_usage_log WHERE id = '{log_id}'")
await admin_conn.execute(f"DELETE FROM sessions WHERE id = '{session_id}'")
await admin_conn.execute(f"DELETE FROM step_library WHERE id = '{step_id}'")
# ---------------------------------------------------------------------------
# target_lists
# ---------------------------------------------------------------------------
async def test_target_lists_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see target lists belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tl_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO target_lists (
id, account_id, created_by, name, targets, created_at, updated_at
) VALUES (
'{tl_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
'Phase3 RLS Target List', '[]'::jsonb, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM target_lists WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B target_lists"
finally:
await admin_conn.execute(f"DELETE FROM target_lists WHERE id = '{tl_id}'")
# ---------------------------------------------------------------------------
# session_shares
# ---------------------------------------------------------------------------
async def test_session_shares_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see session shares belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
# Need a sessions row as FK
session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO sessions (
id, tree_id, user_id, account_id, tree_snapshot,
path_taken, decisions, custom_steps, started_at
) VALUES (
'{session_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()
)
""")
share_id = str(uuid.uuid4())
share_token = f"phase3-rls-test-{share_id[:8]}"
await admin_conn.execute(f"""
INSERT INTO session_shares (
id, session_id, account_id, share_token, visibility,
created_by, view_count, is_active, created_at, updated_at
) VALUES (
'{share_id}', '{session_id}', '{ACCOUNT_B_ID}',
'{share_token}', 'account', '{user_b_id}',
0, TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM session_shares WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_shares"
finally:
await admin_conn.execute(f"DELETE FROM session_shares WHERE id = '{share_id}'")
await admin_conn.execute(f"DELETE FROM sessions WHERE id = '{session_id}'")
# ---------------------------------------------------------------------------
# audit_logs
# ---------------------------------------------------------------------------
async def test_audit_logs_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see audit logs belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
log_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO audit_logs (
id, user_id, account_id, action, resource_type, created_at
) VALUES (
'{log_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'test.action', 'test_resource', NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM audit_logs WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B audit_logs"
finally:
await admin_conn.execute(f"DELETE FROM audit_logs WHERE id = '{log_id}'")
# ---------------------------------------------------------------------------
# tree_shares
# ---------------------------------------------------------------------------
async def test_tree_shares_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see tree shares belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
share_id = str(uuid.uuid4())
share_token = f"phase3-tree-rls-{share_id[:8]}"
await admin_conn.execute(f"""
INSERT INTO tree_shares (
id, tree_id, account_id, share_token, created_by,
allow_forking, created_at
) VALUES (
'{share_id}', '{tree_b_id}', '{ACCOUNT_B_ID}',
'{share_token}', '{user_b_id}', TRUE, NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM tree_shares WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B tree_shares"
finally:
await admin_conn.execute(f"DELETE FROM tree_shares WHERE id = '{share_id}'")
# ===========================================================================
# Phase 4 RLS isolation tests
# Tables: users, script_builder_sessions, ai_session_steps, notifications
#
# Note: platform_steps and template_trees have no account_id column and no RLS —
# they are globally readable by all authenticated users.
# ===========================================================================
# ---------------------------------------------------------------------------
# users
# ---------------------------------------------------------------------------
async def test_users_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see users belonging to Account B."""
rows = await conn_a.fetch(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B users"
async def test_users_account_a_can_see_own(admin_conn, conn_a):
"""Account A must be able to see its own users."""
rows = await conn_a.fetch(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_A_ID}'"
)
assert len(rows) > 0, "Account A should see its own users"
# ---------------------------------------------------------------------------
# script_builder_sessions
# ---------------------------------------------------------------------------
async def test_script_builder_sessions_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see script builder sessions belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO script_builder_sessions (
id, user_id, account_id, language, created_at, updated_at
) VALUES (
'{session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'powershell', NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM script_builder_sessions WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B script_builder_sessions"
finally:
await admin_conn.execute(
f"DELETE FROM script_builder_sessions WHERE id = '{session_id}'"
)
# ---------------------------------------------------------------------------
# ai_session_steps
# ---------------------------------------------------------------------------
async def test_ai_session_steps_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see ai_session_steps belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
# Need an ai_sessions row as FK
ai_session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO ai_sessions (
id, user_id, account_id, flow_type, status, confidence_tier,
created_at, updated_at
) VALUES (
'{ai_session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'troubleshooting', 'active', 'guided', NOW(), NOW()
)
""")
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO ai_session_steps (
id, session_id, account_id, step_type, content,
created_at
) VALUES (
'{step_id}', '{ai_session_id}', '{ACCOUNT_B_ID}',
'question', 'Phase4 RLS test step', NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM ai_session_steps WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B ai_session_steps"
finally:
await admin_conn.execute(f"DELETE FROM ai_session_steps WHERE id = '{step_id}'")
await admin_conn.execute(f"DELETE FROM ai_sessions WHERE id = '{ai_session_id}'")
# ---------------------------------------------------------------------------
# notifications
# ---------------------------------------------------------------------------
async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see notifications belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
notif_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO notifications (
id, user_id, account_id, type, title, message,
is_read, created_at
) VALUES (
'{notif_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'info', 'Phase4 RLS Test', 'RLS isolation test notification',
FALSE, NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM notifications WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B notifications"
finally:
await admin_conn.execute(f"DELETE FROM notifications WHERE id = '{notif_id}'")

View File

@@ -155,6 +155,7 @@ class TestSaveSessionAsTreeAPI:
session = Session(
tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure,
path_taken=["root"],
decisions=[{"node_id": "root", "timestamp": datetime.now(timezone.utc).isoformat()}],
@@ -199,6 +200,7 @@ class TestSaveSessionAsTreeAPI:
session = Session(
tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure,
path_taken=["root"],
decisions=[],
@@ -239,6 +241,7 @@ class TestSaveSessionAsTreeAPI:
session = Session(
tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure,
path_taken=["root"],
decisions=[],
@@ -279,6 +282,7 @@ class TestSaveSessionAsTreeAPI:
session = Session(
tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure,
path_taken=["root"],
decisions=[],
@@ -352,6 +356,7 @@ class TestSaveSessionAsTreeAPI:
session = Session(
tree_id=tree.id,
user_id=other_user.id,
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure,
path_taken=["root"],
decisions=[],

View File

@@ -0,0 +1,89 @@
import pytest
from sqlalchemy import select
from app.core import service_account as service_account_module
from app.core.service_account import (
SERVICE_ACCOUNT_EMAIL,
SYSTEM_ACCOUNT_DISPLAY_CODE,
ensure_service_account,
)
from app.models.account import Account
from app.models.user import User
class _SessionFactoryOverride:
def __init__(self, session):
self._session = session
def __call__(self):
return self
async def __aenter__(self):
return self._session
async def __aexit__(self, exc_type, exc, tb):
return False
@pytest.mark.asyncio
async def test_ensure_service_account_creates_and_reuses_seeded_user(test_db, monkeypatch):
monkeypatch.setattr(
service_account_module,
"_admin_session_factory",
_SessionFactoryOverride(test_db),
)
service_account_id = await ensure_service_account(test_db)
created_user = (
await test_db.execute(select(User).where(User.id == service_account_id))
).scalar_one()
assert created_user.email == SERVICE_ACCOUNT_EMAIL
assert created_user.is_service_account is True
system_account = (
await test_db.execute(
select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE)
)
).scalar_one()
assert created_user.account_id == system_account.id
second_id = await ensure_service_account(test_db)
assert second_id == service_account_id
@pytest.mark.asyncio
async def test_ensure_service_account_marks_existing_user_as_service_account(test_db, monkeypatch):
monkeypatch.setattr(
service_account_module,
"_admin_session_factory",
_SessionFactoryOverride(test_db),
)
system_account = (
await test_db.execute(
select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE)
)
).scalar_one()
existing_user = User(
email=SERVICE_ACCOUNT_EMAIL,
name="ResolutionFlow",
password_hash="!service-account-no-login",
role="engineer",
is_super_admin=False,
is_team_admin=False,
is_active=True,
is_service_account=False,
must_change_password=False,
account_id=system_account.id,
account_role="engineer",
)
test_db.add(existing_user)
await test_db.commit()
resolved_id = await ensure_service_account(test_db)
await test_db.refresh(existing_user)
assert resolved_id == existing_user.id
assert existing_user.is_service_account is True

View File

@@ -3,37 +3,10 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.models.user import User
from sqlalchemy import select
@pytest.fixture
async def auth_headers(client: AsyncClient, test_db: AsyncSession, test_user: dict):
"""Override auth_headers to ensure the test user has a team_id assigned."""
# Fetch the user from DB and assign a team
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
user = result.scalar_one()
# Create a team and assign the user to it
team = Team(name="Test Team")
test_db.add(team)
await test_db.flush()
user.team_id = team.id
await test_db.commit()
# Re-login to get a fresh token
login_data = {
"email": test_user["email"],
"password": test_user["password"],
}
resp = await client.post("/api/v1/auth/login/json", json=login_data)
assert resp.status_code == 200
token_data = resp.json()
return {"Authorization": f"Bearer {token_data['access_token']}"}
@pytest.mark.asyncio
async def test_create_target_list(client: AsyncClient, auth_headers: dict):
resp = await client.post(
@@ -107,25 +80,28 @@ async def test_delete_target_list(client: AsyncClient, auth_headers: dict):
assert get.status_code == 404
@pytest.mark.asyncio
async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers: dict, test_db):
"""User from team B cannot access team A's list."""
async def test_cannot_access_other_accounts_list(client: AsyncClient, auth_headers: dict, test_db):
"""User from account B cannot access account A's target list."""
import uuid
from app.models.team import Team
from app.models.account import Account
from app.models.user import User
from app.core.security import get_password_hash
# Create team A list using existing auth_headers
# Create account A list using existing auth_headers
create = await client.post(
"/api/v1/target-lists/",
json={"name": "Team A List", "targets": [{"label": "SRV-A"}]},
json={"name": "Account A List", "targets": [{"label": "SRV-A"}]},
headers=auth_headers,
)
assert create.status_code == 201
list_id = create.json()["id"]
# Create a separate team B with its own user
team_b = Team(name=f"Team B {uuid.uuid4()}")
test_db.add(team_b)
# Create a separate account B with its own user
account_b = Account(
name=f"Account B {uuid.uuid4()}",
display_code=f"AB{str(uuid.uuid4())[:6].upper()}",
)
test_db.add(account_b)
await test_db.flush()
user_b = User(
@@ -133,11 +109,13 @@ async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers:
password_hash=get_password_hash("password123"),
name="User B",
is_active=True,
team_id=team_b.id,
account_id=account_b.id,
account_role="engineer",
role="engineer",
)
test_db.add(user_b)
await test_db.flush()
await test_db.commit()
# Get auth token for user B
login = await client.post(
@@ -148,6 +126,6 @@ async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers:
token_b = login.json()["access_token"]
headers_b = {"Authorization": f"Bearer {token_b}"}
# Team B cannot access Team A's list
# Account B cannot access Account A's list
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b)
assert resp.status_code == 404

View File

@@ -117,6 +117,7 @@ class TestTreeSharing:
for i in range(3):
share = TreeShare(
tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token=f"token_{i}_" + "x" * 56,
created_by=sample_tree.author_id,
allow_forking=i % 2 == 0
@@ -162,6 +163,7 @@ class TestTreeSharing:
# Create a share
share = TreeShare(
tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token="public_test_token" + "x" * 47,
created_by=UUID(test_user["user_data"]["id"]),
allow_forking=True
@@ -192,6 +194,7 @@ class TestTreeSharing:
# Create expired share
share = TreeShare(
tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token="expired_token" + "x" * 50,
created_by=UUID(test_user["user_data"]["id"]),
allow_forking=True,
@@ -209,6 +212,7 @@ class TestTreeSharing:
from uuid import UUID
share = TreeShare(
tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token="inactive_tree_token" + "x" * 44,
created_by=UUID(test_user["user_data"]["id"]),
allow_forking=True
@@ -248,6 +252,37 @@ class TestTreeSharing:
tokens.add(token)
assert len(tokens) == 5
async def test_share_account_id_matches_tree_not_actor(
self, client: AsyncClient, sample_tree, auth_headers, test_db
):
"""Share account_id must equal tree.account_id, not the actor's account_id.
A super admin in a different account can share any tree. The resulting
TreeShare row must live in the tree-owner's account so that the tree
owner's RLS context covers it. If account_id were derived from the
actor instead, the share would vanish from the tree owner's view once
RLS is enabled.
"""
from uuid import UUID
from sqlalchemy import select
response = await client.post(
f"/api/v1/trees/{sample_tree.id}/share",
json={"allow_forking": True},
headers=auth_headers,
)
assert response.status_code == 201
share_token = response.json()["share_token"]
result = await test_db.execute(
select(TreeShare).where(TreeShare.share_token == share_token)
)
share = result.scalar_one()
assert share.account_id == sample_tree.account_id, (
"TreeShare.account_id must equal tree.account_id, not the actor's account. "
"Shares must live in the tree owner's tenant for RLS to cover them."
)
@pytest.mark.asyncio
async def test_migration_defaults_visibility_to_team(test_db):

View File

@@ -2,6 +2,11 @@ import api from './client'
import type {
DashboardMetrics,
ActivityEntry,
AdminUserListResponse,
AdminAccountListResponse,
AdminAccountDetailResponse,
AdminAccountCreate,
AdminAccountUpdate,
AuditLogListResponse,
PlanLimitConfig,
AccountOverrideResponse,
@@ -78,7 +83,15 @@ export const adminApi = {
createUser: (data: AdminUserCreate) =>
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
listUsers: (params?: Record<string, unknown>) =>
api.get('/admin/users', { params }).then(r => r.data),
api.get<AdminUserListResponse>('/admin/users', { params }).then(r => r.data),
listAccounts: (params?: Record<string, unknown>) =>
api.get<AdminAccountListResponse>('/admin/accounts', { params }).then(r => r.data),
createAccount: (data: AdminAccountCreate) =>
api.post<AdminAccountDetailResponse>('/admin/accounts', data).then(r => r.data),
getAccountDetail: (id: string, params?: Record<string, unknown>) =>
api.get<AdminAccountDetailResponse>(`/admin/accounts/${id}`, { params }).then(r => r.data),
updateAccount: (id: string, data: AdminAccountUpdate) =>
api.put<AdminAccountDetailResponse>(`/admin/accounts/${id}`, data).then(r => r.data),
getUser: (id: string) =>
api.get(`/admin/users/${id}`).then(r => r.data),
updateUserRole: (id: string, role: string) =>
@@ -119,6 +132,10 @@ export const adminApi = {
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),
updateAccountSubscriptionPlan: (id: string, plan: string) =>
api.put(`/admin/accounts/${id}/subscription/plan`, { plan }).then(r => r.data),
extendAccountTrial: (id: string, days: number) =>
api.put(`/admin/accounts/${id}/subscription/extend-trial`, { days }).then(r => r.data),
// Invite Codes
listInviteCodes: (params?: Record<string, unknown>) =>

View File

@@ -54,7 +54,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
onClick={() => setOpen(!open)}
className={cn(
'rounded-md p-1.5 text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground'
'hover:bg-elevated hover:text-foreground'
)}
>
<MoreHorizontal className="h-4 w-4" />
@@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
'disabled:opacity-50 disabled:pointer-events-none',
item.destructive
? 'text-red-400 hover:bg-red-400/10'
: 'text-muted-foreground hover:bg-accent'
: 'text-muted-foreground hover:bg-elevated'
)}
>
{item.icon}

View File

@@ -1,7 +1,7 @@
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Users,
Building2,
Ticket,
FileText,
Gauge,
@@ -15,18 +15,54 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ path: '/admin/users', label: 'Users', icon: Users },
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
{ path: '/admin/settings', label: 'Settings', icon: Settings },
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
interface NavItem {
path: string
label: string
icon: typeof LayoutDashboard
end?: boolean
}
interface NavSection {
label?: string
items: NavItem[]
}
const navSections: NavSection[] = [
{
items: [
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ path: '/admin/accounts', label: 'Accounts', icon: Building2 },
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
],
},
{
label: 'Platform',
items: [
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
{ path: '/admin/settings', label: 'Settings', icon: Settings },
],
},
{
label: 'Content',
items: [
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
],
},
{
label: 'Feedback',
items: [
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
],
},
{
label: 'Audit',
items: [
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
],
},
]
interface AdminSidebarProps {
@@ -47,22 +83,33 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
<div className="p-4">
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
</div>
<nav className="flex-1 space-y-1 px-3">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path, item.end)
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
<nav className="flex-1 space-y-4 overflow-y-auto px-3">
{navSections.map((section, i) => (
<div key={i}>
{section.label && (
<p className="mb-1 px-3 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{section.label}
</p>
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
<div className="space-y-0.5">
{section.items.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path, item.end)
? 'bg-elevated text-foreground'
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</div>
</div>
))}
</nav>
<div className="border-t border-border p-3">
@@ -71,7 +118,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
'text-muted-foreground hover:bg-accent hover:text-foreground'
'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
<ArrowLeft className="h-4 w-4" />

View File

@@ -53,7 +53,7 @@ export function DataTable<T>({
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-accent">
<tr className="border-b border-border bg-elevated">
{columns.map((col) => (
<th
key={col.key}
@@ -90,7 +90,7 @@ export function DataTable<T>({
<tr key={i} className="border-b border-border last:border-0">
{columns.map((col) => (
<td key={col.key} className="px-4 py-3">
<div className="h-4 w-3/4 animate-pulse rounded bg-accent" />
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
</td>
))}
</tr>
@@ -107,7 +107,7 @@ export function DataTable<T>({
data.map((item) => (
<tr
key={keyExtractor(item)}
className="border-b border-border last:border-0 hover:bg-accent transition-colors"
className="border-b border-border last:border-0 hover:bg-elevated transition-colors"
>
{columns.map((col) => (
<td key={col.key} className={cn('px-4 py-3', col.className)}>

View File

@@ -43,7 +43,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
>
<ChevronLeft className="h-4 w-4" />
</button>
@@ -59,7 +59,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
'px-2',
p === page
? 'bg-primary text-white'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
{p}
@@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
>
<ChevronRight className="h-4 w-4" />
</button>

View File

@@ -6,22 +6,26 @@ interface StatusBadgeProps {
variant?: BadgeVariant
children: React.ReactNode
className?: string
title?: string
}
const variantClasses: Record<BadgeVariant, string> = {
success: 'bg-emerald-400/10 text-emerald-400',
destructive: 'bg-red-400/10 text-red-400',
warning: 'bg-yellow-400/10 text-yellow-400',
default: 'bg-accent text-muted-foreground',
default: 'bg-muted text-muted-foreground',
}
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
export function StatusBadge({ variant = 'default', children, className, title }: StatusBadgeProps) {
return (
<span className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}
title={title}
>
{children}
</span>
)

View File

@@ -57,6 +57,7 @@ function loadTaskState(sessionId: string): TaskResponse[] | null {
} catch { return null }
}
// eslint-disable-next-line react-refresh/only-export-components
export function clearTaskState(sessionId: string) {
try { sessionStorage.removeItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) } catch { /* ignore */ }
}

View File

@@ -0,0 +1,69 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface ConfirmButtonProps {
onConfirm: () => void
children: React.ReactNode
confirmLabel?: string
className?: string
confirmClassName?: string
timeoutMs?: number
'aria-label'?: string
}
/**
* Two-click inline confirm button.
* First click arms the button (shows confirm state).
* Second click executes the action.
* Auto-resets after timeoutMs (default 3000ms).
*/
export function ConfirmButton({
onConfirm,
children,
confirmLabel = 'Confirm?',
className,
confirmClassName,
timeoutMs = 3000,
'aria-label': ariaLabel,
}: ConfirmButtonProps) {
const [armed, setArmed] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reset = useCallback(() => {
setArmed(false)
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const handleClick = () => {
if (armed) {
reset()
onConfirm()
} else {
setArmed(true)
timerRef.current = setTimeout(reset, timeoutMs)
}
}
return (
<button
type="button"
onClick={handleClick}
onBlur={reset}
aria-label={ariaLabel}
className={cn(armed ? confirmClassName : className)}
>
{armed ? confirmLabel : children}
</button>
)
}
export default ConfirmButton

View File

@@ -9,10 +9,10 @@ export function TeamSummary() {
const { isAccountOwner } = usePermissions()
const navigate = useNavigate()
const [escalationCount, setEscalationCount] = useState(0)
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(!!isAccountOwner)
useEffect(() => {
if (!isAccountOwner) { setLoading(false); return }
if (!isAccountOwner) return
aiSessionsApi.getEscalationQueue()
.then((esc) => setEscalationCount(esc.length))
.catch(() => {})

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import Editor, { type BeforeMount } from '@monaco-editor/react'
import { resolutionFlowTheme, THEME_ID } from '@/components/tree-editor/code-mode/resolutionFlowTheme'
import { Spinner } from '@/components/common/Spinner'
@@ -11,7 +11,9 @@ interface Props {
export function ScriptBodyEditor({ value, onChange, disabled }: Props) {
const lastValueRef = useRef(value)
lastValueRef.current = value
useEffect(() => {
lastValueRef.current = value
}, [value])
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
// Register our dark theme if not already defined

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,633 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
ArrowLeft,
Building2,
CalendarClock,
Check,
Copy,
Crown,
Loader2,
Mail,
Pencil,
UserCheck,
UserPlus,
UserX,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/common/Modal'
import { EmptyState, StatusBadge } from '@/components/admin'
import { ConfirmButton } from '@/components/common/ConfirmButton'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { AdminAccountDetailResponse, AdminAccountMember } from '@/types/admin'
function formatDate(value: string | null) {
if (!value) return 'Never'
return new Date(value).toLocaleDateString()
}
export function AccountDetailPage() {
const { accountId } = useParams<{ accountId: string }>()
const navigate = useNavigate()
const [account, setAccount] = useState<AdminAccountDetailResponse | null>(null)
const [loading, setLoading] = useState(true)
const [isEditingName, setIsEditingName] = useState(false)
const [editedName, setEditedName] = useState('')
const [savingName, setSavingName] = useState(false)
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copiedPassword, setCopiedPassword] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({
email: '',
role: 'engineer' as 'engineer' | 'viewer',
})
const [inviteLoading, setInviteLoading] = useState(false)
const [editingPlan, setEditingPlan] = useState(false)
const [selectedPlan, setSelectedPlan] = useState('free')
const [planSaving, setPlanSaving] = useState(false)
const [editingTrial, setEditingTrial] = useState(false)
const [trialDays, setTrialDays] = useState('14')
const [trialSaving, setTrialSaving] = useState(false)
const loadAccount = useCallback(async () => {
if (!accountId) return
setLoading(true)
try {
const data = await adminApi.getAccountDetail(accountId)
setAccount(data)
setEditedName(data.name)
setSelectedPlan(data.subscription?.plan ?? 'free')
} catch {
toast.error('Failed to load account')
} finally {
setLoading(false)
}
}, [accountId])
useEffect(() => {
loadAccount()
}, [loadAccount])
const handleSaveName = async () => {
if (!account || !editedName.trim() || editedName.trim() === account.name) {
setIsEditingName(false)
return
}
setSavingName(true)
try {
const updated = await adminApi.updateAccount(account.id, { name: editedName.trim() })
setAccount(updated)
setEditedName(updated.name)
setIsEditingName(false)
toast.success('Account updated')
} catch {
toast.error('Failed to update account')
} finally {
setSavingName(false)
}
}
const handleCreateUser = async () => {
if (!account || !createForm.email || !createForm.name) return
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: 'existing',
account_display_code: account.display_code,
account_role: createForm.account_role,
send_email: createForm.send_email,
})
setShowCreateUserModal(false)
setCreateForm({ email: '', name: '', account_role: 'engineer', send_email: true })
setTempPassword(result.temporary_password)
setCopiedPassword(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
loadAccount()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
} else {
toast.error('Failed to create user')
}
} finally {
setCreateLoading(false)
}
}
const handleInviteUser = async () => {
if (!account || !inviteForm.email) return
setInviteLoading(true)
try {
await adminApi.createInvite({
email: inviteForm.email,
account_display_code: account.display_code,
role: inviteForm.role,
})
toast.success('Invite sent')
setInviteForm({ email: '', role: 'engineer' })
setShowInviteModal(false)
loadAccount()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
} else {
toast.error('Failed to send invite')
}
} finally {
setInviteLoading(false)
}
}
const handleUpdateMemberRole = async (member: AdminAccountMember, nextRole: string) => {
try {
await adminApi.updateAccountRole(member.id, nextRole)
toast.success(`Updated ${member.name}`)
loadAccount()
} catch {
toast.error('Failed to update account role')
}
}
const handleToggleActive = async (member: AdminAccountMember) => {
try {
if (member.is_active) {
await adminApi.deactivateUser(member.id)
toast.success('User deactivated')
} else {
await adminApi.activateUser(member.id)
toast.success('User activated')
}
loadAccount()
} catch {
toast.error('Failed to update user status')
}
}
const handleUpdatePlan = async () => {
if (!account) return
setPlanSaving(true)
try {
await adminApi.updateAccountSubscriptionPlan(account.id, selectedPlan)
toast.success(`Plan updated to ${selectedPlan}`)
setEditingPlan(false)
loadAccount()
} catch {
toast.error('Failed to update plan')
} finally {
setPlanSaving(false)
}
}
const handleExtendTrial = async () => {
if (!account || !trialDays) return
setTrialSaving(true)
try {
await adminApi.extendAccountTrial(account.id, parseInt(trialDays, 10))
toast.success(`Trial updated by ${trialDays} days`)
setEditingTrial(false)
loadAccount()
} catch {
toast.error('Failed to update trial')
} finally {
setTrialSaving(false)
}
}
const copyDisplayCode = async () => {
if (!account) return
await navigator.clipboard.writeText(account.display_code)
toast.success('Display code copied')
}
const copyTempPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopiedPassword(true)
setTimeout(() => setCopiedPassword(false), 2000)
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!account) {
return (
<EmptyState
title="Account not found"
description="This account may have been removed or is unavailable."
action={<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>Back to Accounts</Button>}
/>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/accounts')}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-elevated hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<Building2 className="h-6 w-6 text-muted-foreground" />
<h1 className="truncate text-2xl font-bold text-foreground">{account.name}</h1>
<StatusBadge variant="default" title="Unique code for joining this account">{account.display_code}</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Manage account settings, subscription, invites, and users from one place.
</p>
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
<Mail className="h-4 w-4" />
Invite User
</Button>
<Button onClick={() => setShowCreateUserModal(true)}>
<UserPlus className="h-4 w-4" />
Create User
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<section className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-foreground">Account Settings</h2>
<Button variant="secondary" size="sm" onClick={copyDisplayCode}>
<Copy className="h-4 w-4" />
Copy Code
</Button>
</div>
<div className="mt-5 space-y-5">
<div>
<label className="block text-sm font-medium text-foreground">Account Name</label>
{isEditingName ? (
<div className="mt-2 flex items-center gap-2">
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} />
<Button onClick={handleSaveName} loading={savingName} size="icon-sm">
<Check className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon-sm"
onClick={() => {
setEditedName(account.name)
setIsEditingName(false)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-foreground">{account.name}</span>
<button
onClick={() => setIsEditingName(true)}
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Owner</p>
<p className="mt-2 text-sm text-foreground">{account.owner?.name ?? 'Unassigned'}</p>
<p className="text-xs text-muted-foreground">{account.owner?.email ?? 'No owner user yet'}</p>
</div>
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Created</p>
<p className="mt-2 text-sm text-foreground">{formatDate(account.created_at)}</p>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-foreground">Users</h2>
<StatusBadge variant="default">{account.member_count} members</StatusBadge>
</div>
<div className="mt-4 space-y-3">
{account.members.length > 0 ? (
account.members.map((member) => (
<div key={member.id} className="rounded-xl border border-border bg-card/50 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="font-medium text-foreground">{member.name}</p>
<p className="text-sm text-muted-foreground">{member.email}</p>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge variant="default">{member.role}</StatusBadge>
{member.account_role && <StatusBadge variant="default">{member.account_role}</StatusBadge>}
<StatusBadge variant={member.is_active ? 'success' : 'destructive'}>
{member.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={member.account_role ?? 'engineer'}
onChange={(e) => handleUpdateMemberRole(member, e.target.value)}
className={cn(
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
{member.is_active ? (
<ConfirmButton
onConfirm={() => handleToggleActive(member)}
confirmLabel="Confirm deactivate?"
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-elevated"
confirmClassName="inline-flex items-center rounded-md border border-danger/30 bg-danger-dim px-3 py-1.5 text-sm font-medium text-danger transition-colors"
>
<UserX className="h-4 w-4" />
Deactivate
</ConfirmButton>
) : (
<Button variant="secondary" size="sm" onClick={() => handleToggleActive(member)}>
<UserCheck className="h-4 w-4" />
Activate
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => navigate(`/admin/users/${member.id}`)}>
View User
</Button>
</div>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
<p>No users yet.</p>
<p className="mt-1">Use <strong className="text-foreground">Create User</strong> or <strong className="text-foreground">Invite User</strong> above to add members.</p>
</div>
)}
</div>
</div>
</section>
<aside className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Subscription</h2>
{account.subscription ? (
<div className="flex gap-2">
<StatusBadge variant="default">{account.subscription.plan}</StatusBadge>
<StatusBadge variant={account.subscription.status === 'active' ? 'success' : account.subscription.status === 'canceled' ? 'destructive' : 'warning'}>
{account.subscription.status}
</StatusBadge>
</div>
) : (
<StatusBadge variant="warning">No subscription</StatusBadge>
)}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Renewal</p>
<p className="mt-2 text-sm text-foreground">{formatDate(account.subscription?.current_period_end ?? null)}</p>
</div>
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Usage</p>
<p className="mt-2 text-sm text-foreground">{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions</p>
</div>
</div>
{editingPlan ? (
<div className="mt-4 flex items-center gap-2">
<select
value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)}
className={cn(
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<Button size="sm" onClick={handleUpdatePlan} loading={planSaving}>Save</Button>
<Button variant="secondary" size="sm" onClick={() => setEditingPlan(false)}>Cancel</Button>
</div>
) : editingTrial ? (
<div className="mt-4 flex items-center gap-2">
<Input
type="number"
min={1}
max={90}
value={trialDays}
onChange={(e) => setTrialDays(e.target.value)}
className="w-24"
placeholder="Days"
/>
<Button size="sm" onClick={handleExtendTrial} loading={trialSaving}>Save</Button>
<Button variant="secondary" size="sm" onClick={() => setEditingTrial(false)}>Cancel</Button>
</div>
) : (
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setSelectedPlan(account.subscription?.plan ?? 'free')
setEditingPlan(true)
}}
>
<Crown className="h-4 w-4" />
Change Plan
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
setTrialDays('14')
setEditingTrial(true)
}}
>
<CalendarClock className="h-4 w-4" />
Extend Trial
</Button>
</div>
)}
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Pending Invites</h2>
{account.pending_invite_count > 0 && (
<StatusBadge variant="warning">{account.pending_invite_count} pending</StatusBadge>
)}
</div>
<div className="mt-4 space-y-3">
{account.invites.length > 0 ? (
account.invites.map((invite) => (
<div key={invite.id} className="rounded-xl border border-border bg-card/50 p-4">
<p className="font-medium text-foreground">{invite.email}</p>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge variant="default">{invite.role}</StatusBadge>
<StatusBadge variant="default">Expires {formatDate(invite.expires_at)}</StatusBadge>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
<p>No pending invites.</p>
<p className="mt-1">Use <strong className="text-foreground">Invite User</strong> above to send an invitation.</p>
</div>
)}
</div>
</div>
</aside>
</div>
<Modal
isOpen={showCreateUserModal}
onClose={() => setShowCreateUserModal(false)}
title="Create User in Account"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateUserModal(false)}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
{createLoading ? 'Creating...' : 'Create User'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<Input value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input type="email" value={createForm.email} onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
<select
value={createForm.account_role}
onChange={(e) => setCreateForm((f) => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={createForm.send_email}
onChange={(e) => setCreateForm((f) => ({ ...f, send_email: e.target.checked }))}
className="rounded border-border bg-card"
/>
<label className="text-sm text-muted-foreground">Send welcome email with temporary password</label>
</div>
</div>
</Modal>
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User to Account"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
<Button onClick={handleInviteUser} disabled={!inviteForm.email} loading={inviteLoading}>
{inviteLoading ? 'Sending...' : 'Send Invite'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input type="email" value={inviteForm.email} onChange={(e) => setInviteForm((f) => ({ ...f, email: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm((f) => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
</Modal>
<Modal
isOpen={!!tempPassword}
onClose={() => setTempPassword(null)}
title="User Created"
size="sm"
footer={<div className="flex justify-end"><Button onClick={() => setTempPassword(null)}>Done</Button></div>}
>
<div className="space-y-4">
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
This password will not be shown again. Copy it now.
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
{tempPassword}
</code>
<button
onClick={copyTempPassword}
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
>
{copiedPassword ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
</Modal>
</div>
)
}
export default AccountDetailPage

View File

@@ -0,0 +1,821 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Building2,
Check,
Copy,
ExternalLink,
Loader2,
Mail,
Plus,
Search,
Sparkles,
UserPlus,
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
DataTable,
EmptyState,
PageHeader,
Pagination,
SearchInput,
StatusBadge,
ActionMenu,
type Column,
} from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type {
AdminAccountListItem,
AdminUserListItem,
} from '@/types/admin'
function formatDate(value: string | null) {
if (!value) return 'Never'
return new Date(value).toLocaleDateString()
}
function planBadgeVariant(status: string | undefined): 'success' | 'warning' | 'destructive' | 'default' {
switch (status) {
case 'active': return 'success'
case 'trialing': return 'warning'
case 'past_due': return 'warning'
case 'canceled': return 'destructive'
default: return 'default'
}
}
export function UsersPage() {
const navigate = useNavigate()
const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
const [accountsLoading, setAccountsLoading] = useState(true)
const [accountSearch, setAccountSearch] = useState('')
const [planFilter, setPlanFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const accountPageSize = 12
const [showArchived, setShowArchived] = useState(false)
const [people, setPeople] = useState<AdminUserListItem[]>([])
const [peopleLoading, setPeopleLoading] = useState(false)
const [peopleSearch, setPeopleSearch] = useState('')
const [peoplePage, setPeoplePage] = useState(1)
const [peopleTotal, setPeopleTotal] = useState(0)
const peoplePageSize = 12
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_mode: 'personal' as 'existing' | 'personal',
account_display_code: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({
email: '',
account_display_code: '',
role: 'engineer' as 'engineer' | 'viewer',
})
const [inviteLoading, setInviteLoading] = useState(false)
const [showCreateAccountModal, setShowCreateAccountModal] = useState(false)
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team' })
const [createAccountLoading, setCreateAccountLoading] = useState(false)
const fetchAccounts = useCallback(async () => {
setAccountsLoading(true)
try {
const accountsData = await adminApi.listAccounts({
page,
size: accountPageSize,
search: accountSearch || undefined,
plan: planFilter !== 'all' ? planFilter : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
include_archived: showArchived || undefined,
})
setAccounts(accountsData.items)
setTotal(accountsData.total)
} catch {
toast.error('Failed to load accounts')
} finally {
setAccountsLoading(false)
}
}, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter])
const fetchPeople = useCallback(async () => {
if (!peopleSearch.trim()) {
setPeopleLoading(false)
setPeople([])
setPeopleTotal(0)
return
}
setPeopleLoading(true)
try {
const data = await adminApi.listUsers({
page: peoplePage,
size: peoplePageSize,
search: peopleSearch || undefined,
include_archived: showArchived || undefined,
})
setPeople(data.items)
setPeopleTotal(data.total)
} catch {
toast.error('Failed to load people search')
} finally {
setPeopleLoading(false)
}
}, [peoplePage, peoplePageSize, peopleSearch, showArchived])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
useEffect(() => {
fetchPeople()
}, [fetchPeople])
const handleCreateUser = async () => {
if (!createForm.email || !createForm.name) return
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
toast.error('Account display code is required')
return
}
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: createForm.account_mode,
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
send_email: createForm.send_email,
})
setShowCreateModal(false)
setTempPassword(result.temporary_password)
setCopied(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
setCreateForm({
email: '',
name: '',
account_mode: 'personal',
account_display_code: '',
account_role: 'engineer',
send_email: true,
})
fetchAccounts()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
} else {
toast.error('Failed to create user')
}
} finally {
setCreateLoading(false)
}
}
const handleCopyPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleInviteUser = async () => {
if (!inviteForm.email || !inviteForm.account_display_code) return
setInviteLoading(true)
try {
const result = await adminApi.createInvite({
email: inviteForm.email,
account_display_code: inviteForm.account_display_code,
role: inviteForm.role,
})
setShowInviteModal(false)
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
fetchAccounts()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
} else {
toast.error('Failed to send invite')
}
} finally {
setInviteLoading(false)
}
}
const handleCreateAccount = async () => {
if (!createAccountForm.name.trim()) return
setCreateAccountLoading(true)
try {
const created = await adminApi.createAccount({
name: createAccountForm.name.trim(),
plan: createAccountForm.plan,
})
toast.success('Account created')
setShowCreateAccountModal(false)
setCreateAccountForm({ name: '', plan: 'free' })
navigate(`/admin/accounts/${created.id}`)
} catch {
toast.error('Failed to create account')
} finally {
setCreateAccountLoading(false)
}
}
const accountColumns: Column<AdminAccountListItem>[] = [
{
key: 'name',
header: 'Account',
render: (account) => (
<div className="min-w-0">
<button
type="button"
onClick={() => navigate(`/admin/accounts/${account.id}`)}
className="text-sm font-medium text-foreground hover:underline"
>
{account.name}
</button>
<p className="mt-0.5 text-xs text-muted-foreground">
{account.display_code}
{account.owner ? ` · ${account.owner.name}` : ''}
</p>
</div>
),
},
{
key: 'plan',
header: 'Plan',
render: (account) => (
<StatusBadge variant="default">
{account.subscription?.plan ?? 'free'}
</StatusBadge>
),
className: 'w-[100px]',
},
{
key: 'status',
header: 'Status',
render: (account) => {
if (!account.subscription) {
return <StatusBadge variant="warning">No subscription</StatusBadge>
}
return (
<StatusBadge variant={planBadgeVariant(account.subscription.status)}>
{account.subscription.status}
</StatusBadge>
)
},
className: 'w-[120px]',
},
{
key: 'members',
header: 'Members',
render: (account) => (
<span className="text-sm text-foreground">
{account.active_member_count}
<span className="text-muted-foreground"> / {account.member_count}</span>
</span>
),
className: 'w-[100px]',
},
{
key: 'usage',
header: 'Usage',
render: (account) => (
<span className="text-sm text-muted-foreground">
{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions
</span>
),
className: 'w-[160px]',
},
{
key: 'created',
header: 'Created',
render: (account) => (
<span className="text-sm text-muted-foreground">{formatDate(account.created_at)}</span>
),
className: 'w-[100px]',
},
{
key: 'actions',
header: '',
render: (account) => (
<ActionMenu
items={[
{
label: 'Manage Account',
icon: <Building2 className="h-4 w-4" />,
onClick: () => navigate(`/admin/accounts/${account.id}`),
},
...(account.owner ? [{
label: 'View Owner',
icon: <ExternalLink className="h-4 w-4" />,
onClick: () => navigate(`/admin/users/${account.owner?.id}`),
}] : []),
]}
/>
),
className: 'w-[48px]',
},
]
const peopleColumns: Column<AdminUserListItem>[] = [
{
key: 'name',
header: 'Name',
render: (user) => (
<div className="min-w-0">
<button
type="button"
onClick={() => navigate(`/admin/users/${user.id}`)}
className="text-sm font-medium text-foreground hover:underline"
>
{user.name}
</button>
<p className="mt-0.5 text-xs text-muted-foreground">{user.email}</p>
</div>
),
},
{
key: 'role',
header: 'Role',
render: (user) => (
<div className="flex flex-wrap gap-1">
{user.is_super_admin && <StatusBadge variant="destructive">Super Admin</StatusBadge>}
<StatusBadge variant="default">{user.role}</StatusBadge>
</div>
),
className: 'w-[140px]',
},
{
key: 'account',
header: 'Account',
render: (user) => (
<span className="text-sm text-muted-foreground">
{user.account_name || 'No account'}
{user.account_display_code && (
<span className="ml-1 text-xs opacity-60">{user.account_display_code}</span>
)}
</span>
),
},
{
key: 'status',
header: 'Status',
render: (user) => (
<div className="flex gap-1">
<StatusBadge variant={user.is_active ? 'success' : 'destructive'}>
{user.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
{user.deleted_at && <StatusBadge variant="warning">Archived</StatusBadge>}
</div>
),
className: 'w-[140px]',
},
{
key: 'last_login',
header: 'Last Login',
render: (user) => (
<span className="text-sm text-muted-foreground">{formatDate(user.last_login)}</span>
),
className: 'w-[100px]',
},
{
key: 'actions',
header: '',
render: (user) => (
<ActionMenu
items={[
{
label: 'View Detail',
icon: <ExternalLink className="h-4 w-4" />,
onClick: () => navigate(`/admin/users/${user.id}`),
},
]}
/>
),
className: 'w-[48px]',
},
]
const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize))
const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize))
return (
<div className="space-y-6">
<PageHeader
title="Accounts"
description="Manage customer accounts, subscriptions, and users."
action={
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => setShowCreateAccountModal(true)}>
<Plus className="h-4 w-4" />
Create Account
</Button>
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
<Mail className="h-4 w-4" />
Invite User
</Button>
<Button onClick={() => setShowCreateModal(true)}>
<UserPlus className="h-4 w-4" />
Create User
</Button>
</div>
}
/>
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SearchInput
value={accountSearch}
onSearch={(value) => {
setAccountSearch(value)
setPage(1)
}}
placeholder="Search accounts, owners, or codes..."
className="w-full sm:max-w-sm"
/>
<div className="flex flex-wrap items-center gap-3">
<select
value={planFilter}
onChange={(e) => {
setPlanFilter(e.target.value)
setPage(1)
}}
className={cn(
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="all">All plans</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className={cn(
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="all">All statuses</option>
<option value="active">Active</option>
<option value="trialing">Trialing</option>
<option value="past_due">Past due</option>
<option value="canceled">Canceled</option>
<option value="orphaned">Orphaned</option>
</select>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => {
setShowArchived(e.target.checked)
setPage(1)
setPeoplePage(1)
}}
className="rounded border-border bg-card"
/>
Archived
</label>
</div>
</div>
{/* Accounts table */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground">
{accountsLoading ? 'Loading...' : `${total} accounts`}
</h2>
</div>
<DataTable
columns={accountColumns}
data={accounts}
keyExtractor={(a) => a.id}
isLoading={accountsLoading}
skeletonRows={6}
emptyState={
<EmptyState
icon={<Building2 className="h-8 w-8" />}
title="No accounts found"
description="Adjust the filters or clear the search."
/>
}
/>
<Pagination
page={page}
totalPages={accountTotalPages}
total={total}
pageSize={accountPageSize}
onPageChange={setPage}
/>
</section>
{/* Global people search */}
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<div>
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<h2 className="text-base font-semibold text-foreground">Global People Search</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Find a user across all accounts by name or email.
</p>
</div>
<SearchInput
value={peopleSearch}
onSearch={(value) => {
setPeopleSearch(value)
setPeoplePage(1)
}}
placeholder="Search by name, email, or account..."
className="max-w-sm"
/>
{peopleSearch.trim() ? (
people.length > 0 ? (
<div className="space-y-3">
<DataTable
columns={peopleColumns}
data={people}
keyExtractor={(p) => p.id}
isLoading={peopleLoading}
skeletonRows={4}
emptyState={
<EmptyState
icon={<Sparkles className="h-8 w-8" />}
title="No matching people"
description="Try another name or email."
/>
}
/>
<Pagination
page={peoplePage}
totalPages={peopleTotalPages}
total={peopleTotal}
pageSize={peoplePageSize}
onPageChange={setPeoplePage}
/>
</div>
) : !peopleLoading ? (
<EmptyState
icon={<Sparkles className="h-8 w-8" />}
title="No matching people"
description="Try another name or email."
/>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching...
</div>
)
) : (
<p className="text-sm text-muted-foreground">Type a name or email to search.</p>
)}
</section>
{/* Create Account modal */}
<Modal
isOpen={showCreateAccountModal}
onClose={() => setShowCreateAccountModal(false)}
title="Create Account"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateAccountModal(false)}>Cancel</Button>
<Button onClick={handleCreateAccount} disabled={!createAccountForm.name.trim()} loading={createAccountLoading}>
{createAccountLoading ? 'Creating...' : 'Create Account'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Name</label>
<Input
value={createAccountForm.name}
onChange={(e) => setCreateAccountForm((form) => ({ ...form, name: e.target.value }))}
placeholder="Acme MSP"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Initial Plan</label>
<select
value={createAccountForm.plan}
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'team' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
</div>
</div>
</Modal>
{/* Create User modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create User"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
{createLoading ? 'Creating...' : 'Create User'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<Input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm((form) => ({ ...form, name: e.target.value }))}
placeholder="Full name"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((form) => ({ ...form, email: e.target.value }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
<select
value={createForm.account_mode}
onChange={(e) => setCreateForm((form) => ({ ...form, account_mode: e.target.value as 'existing' | 'personal' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="personal">Personal (new account)</option>
<option value="existing">Join existing account</option>
</select>
</div>
{createForm.account_mode === 'existing' && (
<>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={createForm.account_display_code}
onChange={(e) => setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
<select
value={createForm.account_role}
onChange={(e) => setCreateForm((form) => ({ ...form, account_role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="send-email"
checked={createForm.send_email}
onChange={(e) => setCreateForm((form) => ({ ...form, send_email: e.target.checked }))}
className="rounded border-border bg-card"
/>
<label htmlFor="send-email" className="text-sm text-muted-foreground">
Send welcome email with temporary password
</label>
</div>
</div>
</Modal>
{/* Temp password modal */}
<Modal
isOpen={!!tempPassword}
onClose={() => setTempPassword(null)}
title="User Created"
size="sm"
footer={(
<div className="flex justify-end">
<Button onClick={() => setTempPassword(null)}>Done</Button>
</div>
)}
>
<div className="space-y-4">
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
This password will not be shown again. Copy it now.
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
{tempPassword}
</code>
<button
onClick={handleCopyPassword}
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
title="Copy password"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
The user will be required to change this password on first login.
</p>
</div>
</Modal>
{/* Invite User modal */}
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}>
{inviteLoading ? 'Sending...' : 'Send Invite'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm((form) => ({ ...form, email: e.target.value }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={inviteForm.account_display_code}
onChange={(e) => setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm((form) => ({ ...form, role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
</Modal>
</div>
)
}
export default UsersPage

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react'
import { Users, TreePine, CreditCard, Activity, TrendingUp, Building2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { PageHeader } from '@/components/admin'
import { adminApi } from '@/api/admin'
@@ -43,7 +43,7 @@ export function DashboardPage() {
}, [])
const quickLinks = [
{ to: '/admin/users', label: 'Manage Users', icon: Users },
{ to: '/admin/accounts', label: 'Manage Accounts', icon: Building2 },
{ to: '/admin/plan-limits', label: 'Plan Limits', icon: TrendingUp },
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },

View File

@@ -177,7 +177,7 @@ export function UserDetailPage() {
try {
await adminApi.hardDeleteUser(userId)
toast.success('User permanently deleted')
navigate('/admin/users')
navigate('/admin/accounts')
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
@@ -207,8 +207,8 @@ export function UserDetailPage() {
title="User not found"
description="This user may have been removed or is unavailable."
action={(
<Button variant="secondary" onClick={() => navigate('/admin/users')}>
Back to Users
<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>
Back to Accounts
</Button>
)}
/>
@@ -223,7 +223,7 @@ export function UserDetailPage() {
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/users')}
onClick={() => navigate('/admin/accounts')}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />

View File

@@ -1,556 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink, UserPlus, Copy, Check, Mail } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface AdminUser {
id: string
email: string
name: string
role: string
is_super_admin: boolean
is_active: boolean
account_id: string | null
account_role: string | null
created_at: string
last_login: string | null
deleted_at: string | null
}
export function UsersPage() {
const navigate = useNavigate()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 20
const [showArchived, setShowArchived] = useState(false)
// Role change modal
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
const [newRole, setNewRole] = useState('')
// Move account modal
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
const [displayCode, setDisplayCode] = useState('')
// Create user modal
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_mode: 'personal' as 'existing' | 'personal',
account_display_code: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
// Temp password display modal
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Invite user modal
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({ email: '', account_display_code: '', role: 'engineer' as 'engineer' | 'viewer' })
const [inviteLoading, setInviteLoading] = useState(false)
const fetchUsers = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined, include_archived: showArchived || undefined })
setUsers(data.items || data)
setTotal(data.total || (data.items ? data.items.length : data.length))
} catch {
toast.error('Failed to load users')
} finally {
setLoading(false)
}
}, [page, search, showArchived])
useEffect(() => { fetchUsers() }, [fetchUsers])
const handleRoleChange = async () => {
if (!roleModalUser || !newRole) return
try {
await adminApi.updateUserRole(roleModalUser.id, newRole)
toast.success('Role updated')
setRoleModalUser(null)
fetchUsers()
} catch {
toast.error('Failed to update role')
}
}
const handleToggleActive = async (user: AdminUser) => {
try {
if (user.is_active) {
await adminApi.deactivateUser(user.id)
toast.success('User deactivated')
} else {
await adminApi.activateUser(user.id)
toast.success('User activated')
}
fetchUsers()
} catch {
toast.error('Failed to update user status')
}
}
const handleMoveAccount = async () => {
if (!moveModalUser || !displayCode) return
try {
await adminApi.moveUserAccount(moveModalUser.id, displayCode)
toast.success('User moved to account')
setMoveModalUser(null)
setDisplayCode('')
fetchUsers()
} catch {
toast.error('Failed to move user')
}
}
const handleCreateUser = async () => {
if (!createForm.email || !createForm.name) return
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
toast.error('Account display code is required')
return
}
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: createForm.account_mode,
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
send_email: createForm.send_email,
})
setShowCreateModal(false)
setTempPassword(result.temporary_password)
setCopied(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
setCreateForm({ email: '', name: '', account_mode: 'personal', account_display_code: '', account_role: 'engineer', send_email: true })
fetchUsers()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
} else {
toast.error('Failed to create user')
}
} finally {
setCreateLoading(false)
}
}
const handleCopyPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleInviteUser = async () => {
if (!inviteForm.email || !inviteForm.account_display_code) return
setInviteLoading(true)
try {
const result = await adminApi.createInvite({
email: inviteForm.email,
account_display_code: inviteForm.account_display_code,
role: inviteForm.role,
})
setShowInviteModal(false)
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
} else {
toast.error('Failed to send invite')
}
} finally {
setInviteLoading(false)
}
}
const columns: Column<AdminUser>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (u) => (
<div>
<div className="font-medium text-foreground">{u.name}</div>
<div className="text-xs text-muted-foreground">{u.email}</div>
</div>
),
},
{
key: 'role',
header: 'Role',
render: (u) => (
<div className="flex items-center gap-2">
<span className="text-sm">{u.role}</span>
{u.is_super_admin && (
<StatusBadge variant="destructive">Super Admin</StatusBadge>
)}
</div>
),
},
{
key: 'status',
header: 'Status',
render: (u) => (
<div className="flex items-center gap-1">
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
{u.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
{u.deleted_at && (
<StatusBadge variant="warning">Archived</StatusBadge>
)}
</div>
),
},
{
key: 'created_at',
header: 'Joined',
sortable: true,
render: (u) => (
<span className="text-sm text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-12',
render: (u) => (
<ActionMenu items={[
{
label: 'View Detail',
icon: <ExternalLink className="h-4 w-4" />,
onClick: () => navigate(`/admin/users/${u.id}`),
},
{
label: 'Change Role',
icon: <Shield className="h-4 w-4" />,
onClick: () => { setRoleModalUser(u); setNewRole(u.role) },
},
{
label: u.is_active ? 'Deactivate' : 'Activate',
icon: u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
onClick: () => handleToggleActive(u),
destructive: u.is_active,
},
{
label: 'Move Account',
icon: <ArrowRightLeft className="h-4 w-4" />,
onClick: () => { setMoveModalUser(u); setDisplayCode('') },
},
]} />
),
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<PageHeader title="Users" description="Manage platform users and roles" />
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
<Mail className="h-4 w-4" />
Invite User
</Button>
<Button onClick={() => setShowCreateModal(true)}>
<UserPlus className="h-4 w-4" />
Create User
</Button>
</div>
</div>
<div className="flex items-center gap-4">
<SearchInput
value={search}
onSearch={(v) => { setSearch(v); setPage(1) }}
placeholder="Search by name or email..."
className="max-w-sm"
/>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => { setShowArchived(e.target.checked); setPage(1) }}
className="rounded border-border bg-card"
/>
Show archived
</label>
</div>
<DataTable
columns={columns}
data={users}
keyExtractor={(u) => u.id}
isLoading={loading}
/>
<Pagination
page={page}
totalPages={Math.ceil(total / pageSize)}
total={total}
pageSize={pageSize}
onPageChange={setPage}
/>
{/* Role Change Modal */}
<Modal
isOpen={!!roleModalUser}
onClose={() => setRoleModalUser(null)}
title="Change Role"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setRoleModalUser(null)}>Cancel</Button>
<Button onClick={handleRoleChange}>Save</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Changing role for <span className="font-medium text-foreground">{roleModalUser?.name}</span>
</p>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</Modal>
{/* Move Account Modal */}
<Modal
isOpen={!!moveModalUser}
onClose={() => setMoveModalUser(null)}
title="Move User to Account"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setMoveModalUser(null)}>Cancel</Button>
<Button onClick={handleMoveAccount} disabled={!displayCode}>Move</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Moving <span className="font-medium text-foreground">{moveModalUser?.name}</span> to a new account.
</p>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={displayCode}
onChange={(e) => setDisplayCode(e.target.value)}
placeholder="e.g. ABC-1234"
/>
</div>
</div>
</Modal>
{/* Create User Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
{createLoading ? 'Creating...' : 'Create User'}
</Button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<Input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
placeholder="Full name"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
<select
value={createForm.account_mode}
onChange={(e) => setCreateForm(f => ({ ...f, account_mode: e.target.value as 'existing' | 'personal' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="personal">Personal (new account)</option>
<option value="existing">Join existing account</option>
</select>
</div>
{createForm.account_mode === 'existing' && (
<>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={createForm.account_display_code}
onChange={(e) => setCreateForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
<select
value={createForm.account_role}
onChange={(e) => setCreateForm(f => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="send-email"
checked={createForm.send_email}
onChange={(e) => setCreateForm(f => ({ ...f, send_email: e.target.checked }))}
className="rounded border-border bg-card"
/>
<label htmlFor="send-email" className="text-sm text-muted-foreground">
Send welcome email with temporary password
</label>
</div>
</div>
</Modal>
{/* Temporary Password Modal */}
<Modal
isOpen={!!tempPassword}
onClose={() => setTempPassword(null)}
title="User Created"
size="sm"
footer={
<div className="flex justify-end">
<Button onClick={() => setTempPassword(null)}>Done</Button>
</div>
}
>
<div className="space-y-4">
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
This password will not be shown again. Copy it now.
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground font-mono">
{tempPassword}
</code>
<button
onClick={handleCopyPassword}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
title="Copy password"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
The user will be required to change this password on first login.
</p>
</div>
</Modal>
{/* Invite User Modal */}
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}>
{inviteLoading ? 'Sending...' : 'Send Invite'}
</Button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm(f => ({ ...f, email: e.target.value }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={inviteForm.account_display_code}
onChange={(e) => setInviteForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm(f => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
</Modal>
</div>
)
}
export default UsersPage

View File

@@ -63,7 +63,8 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP
// Admin pages
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
const AdminUsersPage = lazyWithRetry(() => import('@/pages/admin/UsersPage'))
const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/AccountsPage'))
const AdminAccountDetailPage = lazyWithRetry(() => import('@/pages/admin/AccountDetailPage'))
const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage'))
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
@@ -222,7 +223,9 @@ export const router = sentryCreateBrowserRouter([
),
children: [
{ index: true, element: page(AdminDashboardPage) },
{ path: 'users', element: page(AdminUsersPage) },
{ path: 'accounts', element: page(AdminAccountsPage) },
{ path: 'accounts/:accountId', element: page(AdminAccountDetailPage) },
{ path: 'users', element: page(AdminAccountsPage) },
{ path: 'users/:userId', element: page(AdminUserDetailPage) },
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },

View File

@@ -18,6 +18,108 @@ export interface ActivityEntry {
created_at: string
}
export interface AdminUserListItem {
id: string
email: string
name: string
role: string
is_super_admin: boolean
is_active: boolean
account_id: string | null
account_role: string | null
account_name: string | null
account_display_code: string | null
created_at: string
last_login: string | null
deleted_at: string | null
}
export interface AdminUserListResponse {
items: AdminUserListItem[]
total: number
page: number
per_page: number
}
export interface AdminAccountMember {
id: string
email: string
name: string
role: string
is_super_admin: boolean
is_active: boolean
account_role: string | null
created_at: string
last_login: string | null
deleted_at: string | null
}
export interface AdminAccountOwnerSummary {
id: string
name: string
email: string
}
export interface AdminAccountSubscriptionSummary {
id: string
plan: string
status: string
billing_interval: string | null
current_period_end: string | null
cancel_at_period_end: boolean
}
export interface AdminAccountUsageSummary {
tree_count: number
session_count_this_month: number
}
export interface AdminAccountListItem {
id: string
name: string
display_code: string
created_at: string
owner_id: string | null
owner: AdminAccountOwnerSummary | null
subscription: AdminAccountSubscriptionSummary | null
usage: AdminAccountUsageSummary
member_count: number
active_member_count: number
pending_invite_count: number
sso_enabled: boolean
branding_company_name: string | null
members: AdminAccountMember[]
}
export interface AdminAccountListResponse {
items: AdminAccountListItem[]
total: number
page: number
per_page: number
}
export interface AdminAccountInviteSummary {
id: string
email: string
role: string
expires_at: string | null
created_at: string
used_at: string | null
}
export interface AdminAccountDetailResponse extends AdminAccountListItem {
invites: AdminAccountInviteSummary[]
}
export interface AdminAccountCreate {
name: string
plan: 'free' | 'pro' | 'team'
}
export interface AdminAccountUpdate {
name: string
}
export interface AuditLogEntry {
id: string
user_id: string