diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index 38bd52fc..6a3d73a0 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -39,6 +39,10 @@ from app.schemas.admin import ( AdminAccountOwnerSummary, AdminAccountSubscriptionSummary, AdminAccountUsageSummary, + AdminAccountDetailResponse, + AdminAccountInviteSummary, + AdminAccountCreate, + AdminAccountUpdate, ) from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest from app.schemas.user_detail import ( @@ -307,6 +311,183 @@ def _generate_display_code() -> str: return ''.join(secrets.choice(chars) for _ in range(8)) +async def _generate_unique_display_code(db: AsyncSession) -> str: + """Generate a unique display code for a new account.""" + while True: + display_code = _generate_display_code() + existing = await db.execute(select(Account.id).where(Account.display_code == display_code)) + if existing.scalar_one_or_none() is None: + return display_code + + +async def _get_account_detail_payload( + account_id: UUID, + db: AsyncSession, + include_archived: bool = False, +) -> AdminAccountDetailResponse: + owner_user = aliased(User) + result = await db.execute( + select( + Account, + owner_user.id.label("owner_user_id"), + owner_user.name.label("owner_name"), + owner_user.email.label("owner_email"), + Subscription.id.label("subscription_id"), + Subscription.plan.label("subscription_plan"), + Subscription.status.label("subscription_status"), + Subscription.billing_interval.label("subscription_billing_interval"), + Subscription.current_period_end.label("subscription_current_period_end"), + Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"), + ) + .outerjoin(owner_user, Account.owner_id == owner_user.id) + .outerjoin(Subscription, Subscription.account_id == Account.id) + .where(Account.id == account_id) + ) + row = result.one_or_none() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc()) + if not include_archived: + members_query = members_query.where(User.deleted_at.is_(None)) + members_result = await db.execute(members_query) + members = [ + AdminAccountMember( + id=member.id, + email=member.email, + name=member.name, + role=member.role, + is_super_admin=member.is_super_admin, + is_active=member.is_active, + account_role=member.account_role, + created_at=member.created_at, + last_login=member.last_login, + deleted_at=member.deleted_at, + ) + for member in members_result.scalars().all() + ] + + invites_result = await db.execute( + select(AccountInvite) + .where(AccountInvite.account_id == account_id) + .order_by(AccountInvite.created_at.desc()) + ) + invites = [ + AdminAccountInviteSummary( + id=invite.id, + email=invite.email, + role=invite.role, + expires_at=invite.expires_at, + created_at=invite.created_at, + used_at=invite.used_at, + ) + for invite in invites_result.scalars().all() + if invite.used_at is None + ] + + usage = await get_account_usage(account_id, db) + + return AdminAccountDetailResponse( + id=row.Account.id, + name=row.Account.name, + display_code=row.Account.display_code, + created_at=row.Account.created_at, + owner_id=row.Account.owner_id, + owner=( + AdminAccountOwnerSummary( + id=row.owner_user_id, + name=row.owner_name, + email=row.owner_email, + ) if row.owner_user_id and row.owner_name and row.owner_email else None + ), + subscription=( + AdminAccountSubscriptionSummary( + id=row.subscription_id, + plan=row.subscription_plan, + status=row.subscription_status, + billing_interval=row.subscription_billing_interval, + current_period_end=row.subscription_current_period_end, + cancel_at_period_end=row.subscription_cancel_at_period_end or False, + ) if row.subscription_id and row.subscription_plan and row.subscription_status else None + ), + usage=AdminAccountUsageSummary( + tree_count=usage.get("tree_count", 0), + session_count_this_month=usage.get("session_count_this_month", 0), + ), + member_count=len(members), + active_member_count=sum(1 for member in members if member.is_active), + pending_invite_count=len(invites), + sso_enabled=row.Account.sso_enabled, + branding_company_name=row.Account.branding_company_name, + members=members, + invites=invites, + ) + + +@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED) +async def create_account( + data: AdminAccountCreate, + db: Annotated[AsyncSession, Depends(get_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_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_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update account settings from the admin panel.""" + result = await db.execute(select(Account).where(Account.id == account_id)) + account = result.scalar_one_or_none() + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + old_name = account.name + account.name = data.name.strip() + await log_audit( + db, current_user.id, "account.update_admin", "account", account.id, + {"old_name": old_name, "new_name": account.name}, + ) + await db.commit() + return await _get_account_detail_payload(account.id, db) + + @router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED) async def create_user( data: AdminUserCreate, diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index c72a24cf..438ae387 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -86,6 +86,15 @@ class AdminAccountUsageSummary(BaseModel): 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 @@ -110,6 +119,19 @@ class AdminAccountListResponse(BaseModel): per_page: int +class AdminAccountDetailResponse(AdminAccountListItem): + invites: list[AdminAccountInviteSummary] = Field(default_factory=list) + + +class AdminAccountCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + plan: Literal["free", "pro", "team"] = "free" + + +class AdminAccountUpdate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + + # --- Audit Logs --- class AuditLogEntry(BaseModel): diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index f5f433b4..d244c523 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -53,6 +53,54 @@ class TestAdminEndpoints: 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 diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index e3b28d68..15a05b5f 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -4,6 +4,9 @@ import type { ActivityEntry, AdminUserListResponse, AdminAccountListResponse, + AdminAccountDetailResponse, + AdminAccountCreate, + AdminAccountUpdate, AuditLogListResponse, PlanLimitConfig, AccountOverrideResponse, @@ -83,6 +86,12 @@ export const adminApi = { api.get('/admin/users', { params }).then(r => r.data), listAccounts: (params?: Record) => api.get('/admin/accounts', { params }).then(r => r.data), + createAccount: (data: AdminAccountCreate) => + api.post('/admin/accounts', data).then(r => r.data), + getAccountDetail: (id: string, params?: Record) => + api.get(`/admin/accounts/${id}`, { params }).then(r => r.data), + updateAccount: (id: string, data: AdminAccountUpdate) => + api.put(`/admin/accounts/${id}`, data).then(r => r.data), getUser: (id: string) => api.get(`/admin/users/${id}`).then(r => r.data), updateUserRole: (id: string, role: string) => diff --git a/frontend/src/pages/admin/AccountDetailPage.tsx b/frontend/src/pages/admin/AccountDetailPage.tsx new file mode 100644 index 00000000..887d5afc --- /dev/null +++ b/frontend/src/pages/admin/AccountDetailPage.tsx @@ -0,0 +1,620 @@ +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, + Users, + 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 { 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(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(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 [planModalOpen, setPlanModalOpen] = useState(false) + const [selectedPlan, setSelectedPlan] = useState('free') + const [planSaving, setPlanSaving] = useState(false) + + const [trialModalOpen, setTrialModalOpen] = 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}`) + setPlanModalOpen(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`) + setTrialModalOpen(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 ( +
+ +
+ ) + } + + if (!account) { + return ( + navigate('/admin/accounts')}>Back to Accounts} + /> + ) + } + + return ( +
+
+ +
+
+ +

{account.name}

+ {account.display_code} +
+

+ Manage account settings, subscription, invites, and users from one place. +

+
+
+ + +
+
+ +
+
+
+
+

Account Settings

+ +
+ +
+
+ + {isEditingName ? ( +
+ setEditedName(e.target.value)} /> + + +
+ ) : ( +
+ {account.name} + +
+ )} +
+ +
+
+

Owner

+

{account.owner?.name ?? 'Unassigned'}

+

{account.owner?.email ?? 'No owner user yet'}

+
+
+

Created

+

{formatDate(account.created_at)}

+
+
+
+
+ +
+
+

Users

+ {account.member_count} members +
+ +
+ {account.members.length > 0 ? ( + account.members.map((member) => ( +
+
+
+

{member.name}

+

{member.email}

+
+ {member.role} + {member.account_role && {member.account_role}} + + {member.is_active ? 'Active' : 'Inactive'} + +
+
+
+ + + +
+
+
+ )) + ) : ( +
+ No users yet. Create or invite someone into this account. +
+ )} +
+
+
+ + +
+ + setShowCreateUserModal(false)} + title="Create User in Account" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setCreateForm((f) => ({ ...f, name: e.target.value }))} /> +
+
+ + setCreateForm((f) => ({ ...f, email: e.target.value }))} /> +
+
+ + +
+
+ setCreateForm((f) => ({ ...f, send_email: e.target.checked }))} + className="rounded border-border bg-card" + /> + +
+
+
+ + setShowInviteModal(false)} + title="Invite User to Account" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setInviteForm((f) => ({ ...f, email: e.target.value }))} /> +
+
+ + +
+
+
+ + setTempPassword(null)} + title="User Created" + size="sm" + footer={
} + > +
+
+ This password will not be shown again. Copy it now. +
+
+ + {tempPassword} + + +
+
+
+ + setPlanModalOpen(false)} + title="Change Account Plan" + size="sm" + footer={( +
+ + +
+ )} + > + +
+ + setTrialModalOpen(false)} + title="Start or Extend Trial" + size="sm" + footer={( +
+ + +
+ )} + > +
+ + setTrialDays(e.target.value)} /> +
+
+
+ ) +} + +export default AccountDetailPage diff --git a/frontend/src/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx index 4185e71a..09cc1bfb 100644 --- a/frontend/src/pages/admin/UsersPage.tsx +++ b/frontend/src/pages/admin/UsersPage.tsx @@ -10,6 +10,7 @@ import { ExternalLink, Loader2, Mail, + Plus, Search, Shield, Sparkles, @@ -119,6 +120,9 @@ export function UsersPage() { const [trialModalAccount, setTrialModalAccount] = useState(null) const [trialDays, setTrialDays] = useState('14') const [trialSaving, setTrialSaving] = 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) @@ -343,6 +347,25 @@ export function UsersPage() { } } + 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 userActions = (user: UserRecord) => ([ { label: 'View Detail', @@ -413,6 +436,10 @@ export function UsersPage() { description="Manage customer accounts, subscriptions, overrides, and account-level SaaS operations." />
+
{account.subscription ? ( <> @@ -558,6 +589,13 @@ export function UsersPage() {
+ + +
+ )} + > +
+
+ + setCreateAccountForm((form) => ({ ...form, name: e.target.value }))} + placeholder="Acme MSP" + /> +
+
+ + +
+
+ + setRoleModalUser(null)} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index cdfe89ee..fec74d59 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -64,6 +64,7 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout')) const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage')) const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/UsersPage')) +const AdminAccountDetailPage = lazyWithRetry(() => import('@/pages/admin/AccountDetailPage')) const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage')) const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage')) const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage')) @@ -223,6 +224,7 @@ export const router = sentryCreateBrowserRouter([ children: [ { index: true, element: page(AdminDashboardPage) }, { path: 'accounts', element: page(AdminAccountsPage) }, + { path: 'accounts/:accountId', element: page(AdminAccountDetailPage) }, { path: 'users', element: page(AdminAccountsPage) }, { path: 'users/:userId', element: page(AdminUserDetailPage) }, { path: 'invite-codes', element: page(AdminInviteCodesPage) }, diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 1ba28191..0110d0a4 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -98,6 +98,28 @@ export interface AdminAccountListResponse { per_page: number } +export interface AdminAccountInviteSummary { + id: string + email: string + role: string + expires_at: string | null + created_at: string + used_at: string | null +} + +export interface AdminAccountDetailResponse extends AdminAccountListItem { + invites: AdminAccountInviteSummary[] +} + +export interface AdminAccountCreate { + name: string + plan: 'free' | 'pro' | 'team' +} + +export interface AdminAccountUpdate { + name: string +} + export interface AuditLogEntry { id: string user_id: string