Compare commits
6 Commits
main
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bda590537 | ||
|
|
2dbb8b6abf | ||
|
|
9452e5d408 | ||
|
|
e002fe4969 | ||
|
|
7cbc9fe224 | ||
|
|
70242ad037 |
@@ -5,8 +5,8 @@ from typing import Annotated, Optional
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload, aliased
|
||||||
|
|
||||||
from app.core.admin_database import get_admin_db
|
from app.core.admin_database import get_admin_db
|
||||||
from app.core.audit import log_audit
|
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.account_invite import AccountInvite
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
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.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||||
from app.schemas.user_detail import (
|
from app.schemas.user_detail import (
|
||||||
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
||||||
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
||||||
)
|
)
|
||||||
from app.api.deps import require_admin
|
from app.api.deps import require_admin
|
||||||
|
from app.core.subscriptions import get_account_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[UserResponse])
|
@router.get("/users", response_model=AdminUserListResponse)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
current_user: Annotated[User, Depends(require_admin)],
|
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),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=100),
|
limit: int = Query(100, ge=1, le=100),
|
||||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
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"),
|
account_id: Optional[UUID] = Query(None, description="Filter by account"),
|
||||||
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
||||||
):
|
):
|
||||||
"""List all users (super admin only)."""
|
"""List users for super admin global people search."""
|
||||||
query = select(User)
|
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:
|
if not include_archived:
|
||||||
query = query.where(User.deleted_at.is_(None))
|
query = query.where(User.deleted_at.is_(None))
|
||||||
|
count_query = count_query.where(User.deleted_at.is_(None))
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
query = query.where(User.is_active == is_active)
|
query = query.where(User.is_active == is_active)
|
||||||
|
count_query = count_query.where(User.is_active == is_active)
|
||||||
if role:
|
if role:
|
||||||
query = query.where(User.role == role)
|
query = query.where(User.role == role)
|
||||||
|
count_query = count_query.where(User.role == role)
|
||||||
if account_id:
|
if account_id:
|
||||||
query = query.where(User.account_id == 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)
|
result = await db.execute(query)
|
||||||
users = result.scalars().all()
|
rows = result.all()
|
||||||
return users
|
|
||||||
|
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:
|
def _generate_display_code() -> str:
|
||||||
@@ -71,6 +311,183 @@ def _generate_display_code() -> str:
|
|||||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
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)
|
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
data: AdminUserCreate,
|
data: AdminUserCreate,
|
||||||
@@ -516,6 +933,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
|
|||||||
return user, subscription
|
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")
|
@router.put("/users/{user_id}/subscription/plan")
|
||||||
async def update_user_plan(
|
async def update_user_plan(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
@@ -535,6 +974,31 @@ async def update_user_plan(
|
|||||||
return {"plan": subscription.plan, "status": subscription.status}
|
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")
|
@router.put("/users/{user_id}/subscription/extend-trial")
|
||||||
async def extend_user_trial(
|
async def extend_user_trial(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
@@ -565,6 +1029,43 @@ async def extend_user_trial(
|
|||||||
"current_period_end": subscription.current_period_end}
|
"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)
|
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
|
||||||
async def admin_reset_password(
|
async def admin_reset_password(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
|||||||
@@ -28,6 +28,110 @@ class ActivityEntry(BaseModel):
|
|||||||
from_attributes = True
|
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 ---
|
# --- Audit Logs ---
|
||||||
|
|
||||||
class AuditLogEntry(BaseModel):
|
class AuditLogEntry(BaseModel):
|
||||||
|
|||||||
@@ -19,8 +19,116 @@ class TestAdminEndpoints:
|
|||||||
"/api/v1/admin/users", headers=admin_auth_headers
|
"/api/v1/admin/users", headers=admin_auth_headers
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
users = response.json()
|
payload = response.json()
|
||||||
assert len(users) >= 2 # admin + test_user
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_list_users_as_non_admin(
|
async def test_list_users_as_non_admin(
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import api from './client'
|
|||||||
import type {
|
import type {
|
||||||
DashboardMetrics,
|
DashboardMetrics,
|
||||||
ActivityEntry,
|
ActivityEntry,
|
||||||
|
AdminUserListResponse,
|
||||||
|
AdminAccountListResponse,
|
||||||
|
AdminAccountDetailResponse,
|
||||||
|
AdminAccountCreate,
|
||||||
|
AdminAccountUpdate,
|
||||||
AuditLogListResponse,
|
AuditLogListResponse,
|
||||||
PlanLimitConfig,
|
PlanLimitConfig,
|
||||||
AccountOverrideResponse,
|
AccountOverrideResponse,
|
||||||
@@ -78,7 +83,15 @@ export const adminApi = {
|
|||||||
createUser: (data: AdminUserCreate) =>
|
createUser: (data: AdminUserCreate) =>
|
||||||
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
|
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
|
||||||
listUsers: (params?: Record<string, unknown>) =>
|
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) =>
|
getUser: (id: string) =>
|
||||||
api.get(`/admin/users/${id}`).then(r => r.data),
|
api.get(`/admin/users/${id}`).then(r => r.data),
|
||||||
updateUserRole: (id: string, role: string) =>
|
updateUserRole: (id: string, role: string) =>
|
||||||
@@ -119,6 +132,10 @@ export const adminApi = {
|
|||||||
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
|
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
|
||||||
extendUserTrial: (id: string, days: number) =>
|
extendUserTrial: (id: string, days: number) =>
|
||||||
api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data),
|
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
|
// Invite Codes
|
||||||
listInviteCodes: (params?: Record<string, unknown>) =>
|
listInviteCodes: (params?: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
|||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
'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" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
@@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
|||||||
'disabled:opacity-50 disabled:pointer-events-none',
|
'disabled:opacity-50 disabled:pointer-events-none',
|
||||||
item.destructive
|
item.destructive
|
||||||
? 'text-red-400 hover:bg-red-400/10'
|
? 'text-red-400 hover:bg-red-400/10'
|
||||||
: 'text-muted-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:bg-elevated'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Building2,
|
||||||
Ticket,
|
Ticket,
|
||||||
FileText,
|
FileText,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -15,18 +15,54 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const navItems = [
|
interface NavItem {
|
||||||
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
path: string
|
||||||
{ path: '/admin/users', label: 'Users', icon: Users },
|
label: string
|
||||||
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
|
icon: typeof LayoutDashboard
|
||||||
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
|
end?: boolean
|
||||||
{ 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 },
|
interface NavSection {
|
||||||
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
label?: string
|
||||||
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
|
items: NavItem[]
|
||||||
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
|
}
|
||||||
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
|
|
||||||
|
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 {
|
interface AdminSidebarProps {
|
||||||
@@ -47,22 +83,33 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
|
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-1 px-3">
|
<nav className="flex-1 space-y-4 overflow-y-auto px-3">
|
||||||
{navItems.map((item) => (
|
{navSections.map((section, i) => (
|
||||||
<Link
|
<div key={i}>
|
||||||
key={item.path}
|
{section.label && (
|
||||||
to={item.path}
|
<p className="mb-1 px-3 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
onClick={onNavigate}
|
{section.label}
|
||||||
className={cn(
|
</p>
|
||||||
'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'
|
|
||||||
)}
|
)}
|
||||||
>
|
<div className="space-y-0.5">
|
||||||
<item.icon className="h-4 w-4" />
|
{section.items.map((item) => (
|
||||||
{item.label}
|
<Link
|
||||||
</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>
|
</nav>
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-3">
|
||||||
@@ -71,7 +118,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
|||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
|
'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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function DataTable<T>({
|
|||||||
<div className="overflow-x-auto rounded-lg border border-border">
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-accent">
|
<tr className="border-b border-border bg-elevated">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -90,7 +90,7 @@ export function DataTable<T>({
|
|||||||
<tr key={i} className="border-b border-border last:border-0">
|
<tr key={i} className="border-b border-border last:border-0">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td key={col.key} className="px-4 py-3">
|
<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>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -107,7 +107,7 @@ export function DataTable<T>({
|
|||||||
data.map((item) => (
|
data.map((item) => (
|
||||||
<tr
|
<tr
|
||||||
key={keyExtractor(item)}
|
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) => (
|
{columns.map((col) => (
|
||||||
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
|||||||
<button
|
<button
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
disabled={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" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -59,7 +59,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
|||||||
'px-2',
|
'px-2',
|
||||||
p === page
|
p === page
|
||||||
? 'bg-primary text-white'
|
? 'bg-primary text-white'
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
|||||||
<button
|
<button
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
disabled={page >= totalPages}
|
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" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,22 +6,26 @@ interface StatusBadgeProps {
|
|||||||
variant?: BadgeVariant
|
variant?: BadgeVariant
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantClasses: Record<BadgeVariant, string> = {
|
const variantClasses: Record<BadgeVariant, string> = {
|
||||||
success: 'bg-emerald-400/10 text-emerald-400',
|
success: 'bg-emerald-400/10 text-emerald-400',
|
||||||
destructive: 'bg-red-400/10 text-red-400',
|
destructive: 'bg-red-400/10 text-red-400',
|
||||||
warning: 'bg-yellow-400/10 text-yellow-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 (
|
return (
|
||||||
<span className={cn(
|
<span
|
||||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
className={cn(
|
||||||
variantClasses[variant],
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
className
|
variantClasses[variant],
|
||||||
)}>
|
className
|
||||||
|
)}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
69
frontend/src/components/common/ConfirmButton.tsx
Normal file
69
frontend/src/components/common/ConfirmButton.tsx
Normal 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
|
||||||
File diff suppressed because it is too large
Load Diff
633
frontend/src/pages/admin/AccountDetailPage.tsx
Normal file
633
frontend/src/pages/admin/AccountDetailPage.tsx
Normal 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
|
||||||
821
frontend/src/pages/admin/AccountsPage.tsx
Normal file
821
frontend/src/pages/admin/AccountsPage.tsx
Normal 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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 { cn } from '@/lib/utils'
|
||||||
import { PageHeader } from '@/components/admin'
|
import { PageHeader } from '@/components/admin'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
@@ -43,7 +43,7 @@ export function DashboardPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const quickLinks = [
|
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/plan-limits', label: 'Plan Limits', icon: TrendingUp },
|
||||||
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
|
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
|
||||||
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
|
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function UserDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await adminApi.hardDeleteUser(userId)
|
await adminApi.hardDeleteUser(userId)
|
||||||
toast.success('User permanently deleted')
|
toast.success('User permanently deleted')
|
||||||
navigate('/admin/users')
|
navigate('/admin/accounts')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err && typeof err === 'object' && 'response' in err) {
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||||
@@ -207,8 +207,8 @@ export function UserDetailPage() {
|
|||||||
title="User not found"
|
title="User not found"
|
||||||
description="This user may have been removed or is unavailable."
|
description="This user may have been removed or is unavailable."
|
||||||
action={(
|
action={(
|
||||||
<Button variant="secondary" onClick={() => navigate('/admin/users')}>
|
<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>
|
||||||
Back to Users
|
Back to Accounts
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -223,7 +223,7 @@ export function UserDetailPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<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"
|
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -63,7 +63,8 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP
|
|||||||
// Admin pages
|
// Admin pages
|
||||||
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
|
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
|
||||||
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
|
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 AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage'))
|
||||||
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
|
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
|
||||||
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
|
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
|
||||||
@@ -222,7 +223,9 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: page(AdminDashboardPage) },
|
{ 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: 'users/:userId', element: page(AdminUserDetailPage) },
|
||||||
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },
|
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },
|
||||||
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },
|
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },
|
||||||
|
|||||||
@@ -18,6 +18,108 @@ export interface ActivityEntry {
|
|||||||
created_at: string
|
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 {
|
export interface AuditLogEntry {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user