6 Commits

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 04:54:26 +00:00
chihlasm
9452e5d408 fix: remove unused admin account icon import 2026-04-12 04:54:26 +00:00
chihlasm
e002fe4969 feat: add admin account detail management 2026-04-12 04:54:26 +00:00
chihlasm
7cbc9fe224 feat: expand admin customer account controls 2026-04-12 04:54:26 +00:00
chihlasm
70242ad037 feat: reorganize admin panel around accounts 2026-04-12 04:54:26 +00:00
62 changed files with 3144 additions and 6039 deletions

View File

@@ -1,132 +0,0 @@
"""Add account-scoped device_types table with platform seed data.
Revision ID: 073
Revises: b3c7e9f2a1d8
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
import uuid
revision = "073"
down_revision = "b3c7e9f2a1d8"
branch_labels = None
depends_on = None
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
SYSTEM_DEVICE_TYPES = [
("router", "Router", "network", 0),
("switch", "Switch", "network", 1),
("firewall", "Firewall", "network", 2),
("access-point", "Access Point", "network", 3),
("load-balancer", "Load Balancer", "network", 4),
("server", "Server", "compute", 0),
("workstation", "Workstation", "compute", 1),
("vm", "Virtual Machine", "compute", 2),
("container", "Container", "compute", 3),
("nas", "NAS", "storage", 0),
("san", "SAN", "storage", 1),
("cloud-storage", "Cloud Storage", "storage", 2),
("cloud", "Cloud", "cloud", 0),
("aws", "AWS", "cloud", 1),
("azure", "Azure", "cloud", 2),
("gcp", "Google Cloud", "cloud", 3),
("printer", "Printer", "endpoint", 0),
("phone", "Phone", "endpoint", 1),
("iot", "IoT Device", "endpoint", 2),
("camera", "Camera", "endpoint", 3),
("tablet", "Tablet", "endpoint", 4),
("laptop", "Laptop", "endpoint", 5),
("ups", "UPS", "infrastructure", 0),
("pdu", "PDU", "infrastructure", 1),
("rack", "Rack", "infrastructure", 2),
("patch-panel", "Patch Panel", "infrastructure", 3),
("nvr", "NVR", "security", 0),
("badge-reader", "Badge Reader", "security", 1),
]
def upgrade() -> None:
op.create_table(
"device_types",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("slug", sa.String(50), nullable=False),
sa.Column("label", sa.String(100), nullable=False),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_unique_constraint("uq_device_types_slug_account", "device_types", ["slug", "account_id"])
op.create_index("ix_device_types_account_id", "device_types", ["account_id"])
device_types_table = sa.table(
"device_types",
sa.column("id", UUID(as_uuid=True)),
sa.column("slug", sa.String),
sa.column("label", sa.String),
sa.column("category", sa.String),
sa.column("is_system", sa.Boolean),
sa.column("account_id", UUID(as_uuid=True)),
sa.column("sort_order", sa.Integer),
)
op.bulk_insert(device_types_table, [
{
"id": uuid.uuid4(),
"slug": slug,
"label": label,
"category": category,
"is_system": True,
"account_id": uuid.UUID(_PLATFORM_UUID),
"sort_order": sort_order,
}
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
])
op.execute("ALTER TABLE device_types ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE device_types FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY device_types_select ON device_types
FOR SELECT
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
op.execute(f"""
CREATE POLICY device_types_insert ON device_types
FOR INSERT
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_update ON device_types
FOR UPDATE
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_delete ON device_types
FOR DELETE
USING (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS device_types_delete ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_update ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_insert ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_select ON device_types")
op.execute("ALTER TABLE device_types DISABLE ROW LEVEL SECURITY")
op.drop_table("device_types")

View File

@@ -1,57 +0,0 @@
"""Add network_diagrams table.
Revision ID: 074
Revises: 073
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "074"
down_revision = "073"
branch_labels = None
depends_on = None
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
def upgrade() -> None:
op.create_table(
"network_diagrams",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("client_name", sa.String(255), nullable=True),
sa.Column("asset_name", sa.String(255), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("thumbnail_url", sa.Text(), nullable=True),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_network_diagrams_account_id", "network_diagrams", ["account_id"])
op.create_index("idx_network_diagrams_account_client", "network_diagrams", ["account_id", "client_name"])
op.execute("ALTER TABLE network_diagrams ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE network_diagrams FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON network_diagrams
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON network_diagrams")
op.execute("ALTER TABLE network_diagrams DISABLE ROW LEVEL SECURITY")
op.drop_table("network_diagrams")

View File

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

View File

@@ -1,120 +0,0 @@
"""Device types API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.schemas.device_type import (
DeviceTypeCreate,
DeviceTypeUpdate,
DeviceTypeResponse,
)
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/device-types", tags=["device-types"])
@router.get("/", response_model=list[DeviceTypeResponse])
async def list_device_types(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[DeviceTypeResponse]:
stmt = (
select(DeviceType)
.where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == current_user.account_id,
)
)
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
)
result = await db.execute(stmt)
rows = result.scalars().all()
return [DeviceTypeResponse.model_validate(r) for r in rows]
@router.post("/", response_model=DeviceTypeResponse, status_code=201)
async def create_device_type(
data: DeviceTypeCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == current_user.account_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
system_existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
)
)
if system_existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type")
device_type = DeviceType(
slug=data.slug,
label=data.label,
category=data.category,
is_system=False,
account_id=current_user.account_id,
sort_order=data.sort_order,
)
db.add(device_type)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
async def update_device_type(
device_type_id: UUID,
data: DeviceTypeUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot modify system device types")
if device_type.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Device type not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(device_type, field, value)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.delete("/{device_type_id}", status_code=204)
async def delete_device_type(
device_type_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot delete system device types")
if device_type.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Device type not found")
await db.delete(device_type)
await db.commit()

View File

@@ -1,331 +0,0 @@
"""Network diagrams API endpoints."""
import logging
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.models.network_diagram import NetworkDiagram
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.schemas.network_diagram import (
NetworkDiagramCreate,
NetworkDiagramUpdate,
NetworkDiagramResponse,
NetworkDiagramListItem,
AIGenerateRequest,
AIGenerateResponse,
DiagramImportRequest,
DiagramImportResponse,
DiagramExportResponse,
DiagramNode,
DiagramEdge,
)
from app.services import network_diagram_ai_service
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
_SLUG_CATEGORY: dict[str, str] = {
"router": "network", "switch": "network", "access-point": "network", "load-balancer": "network",
"firewall": "security", "badge-reader": "security",
"server": "compute", "vm": "compute", "container": "compute",
"nas": "storage", "san": "storage", "cloud-storage": "storage",
"cloud": "cloud", "aws": "cloud", "azure": "cloud", "gcp": "cloud", "isp": "cloud",
"workstation": "endpoint", "laptop": "endpoint", "tablet": "endpoint",
"phone": "endpoint", "printer": "endpoint",
"ups": "infrastructure", "pdu": "infrastructure", "rack": "infrastructure",
"patch-panel": "infrastructure", "camera": "infrastructure",
"nvr": "infrastructure", "iot": "infrastructure",
}
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
async def _get_diagram_or_404(
diagram_id: UUID,
account_id: UUID,
db: AsyncSession,
) -> NetworkDiagram:
diagram = await db.get(NetworkDiagram, diagram_id)
if not diagram or diagram.account_id != account_id or diagram.is_archived:
raise HTTPException(status_code=404, detail="Diagram not found")
return diagram
def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
return NetworkDiagramResponse.model_validate(diagram)
def _diagram_to_list_item(
diagram: NetworkDiagram,
custom_slug_category: dict[str, str] | None = None,
) -> NetworkDiagramListItem:
nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
slug_to_cat = {**_SLUG_CATEGORY, **(custom_slug_category or {})}
category_counts: dict[str, int] = {}
for node in nodes:
slug = node.get("type", "") if isinstance(node, dict) else ""
cat = slug_to_cat.get(slug, "other")
category_counts[cat] = category_counts.get(cat, 0) + 1
return NetworkDiagramListItem(
id=diagram.id,
name=diagram.name,
client_name=diagram.client_name,
description=diagram.description,
node_count=len(nodes),
category_counts=category_counts,
created_by=diagram.created_by,
created_at=diagram.created_at,
updated_at=diagram.updated_at,
)
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
stmt = select(DeviceType.slug).where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == account_id,
)
)
result = await db.execute(stmt)
return {row[0] for row in result.all()}
@router.get("/clients", response_model=list[str])
async def list_client_names(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
stmt = (
select(NetworkDiagram.client_name)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.is_archived.is_(False),
NetworkDiagram.client_name.isnot(None),
NetworkDiagram.client_name != "",
)
.distinct()
.order_by(NetworkDiagram.client_name)
)
result = await db.execute(stmt)
return [row[0] for row in result.all()]
@router.get("/", response_model=list[NetworkDiagramListItem])
async def list_diagrams(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
client_name: str | None = Query(default=None),
search: str | None = Query(default=None),
) -> list[NetworkDiagramListItem]:
stmt = (
select(NetworkDiagram)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.is_archived.is_(False),
)
.order_by(NetworkDiagram.updated_at.desc())
)
if client_name:
stmt = stmt.where(NetworkDiagram.client_name == client_name)
if search:
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
search_filter = f"%{escaped}%"
stmt = stmt.where(
or_(
NetworkDiagram.name.ilike(search_filter),
NetworkDiagram.client_name.ilike(search_filter),
)
)
# Single query for custom device types so category_counts is accurate
dt_stmt = select(DeviceType.slug, DeviceType.category).where(
DeviceType.is_system.is_(False),
DeviceType.account_id == current_user.account_id,
)
dt_result = await db.execute(dt_stmt)
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
result = await db.execute(stmt)
rows = result.scalars().all()
return [_diagram_to_list_item(r, custom_slug_category) for r in rows]
@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
async def create_diagram(
data: NetworkDiagramCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = NetworkDiagram(
account_id=current_user.account_id,
name=data.name,
client_name=data.client_name,
asset_name=data.asset_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.get("/{diagram_id}", response_model=NetworkDiagramResponse)
async def get_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
return _diagram_to_response(diagram)
@router.put("/{diagram_id}", response_model=NetworkDiagramResponse)
async def update_diagram(
diagram_id: UUID,
data: NetworkDiagramUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
update_data = data.model_dump(exclude_unset=True)
if "nodes" in update_data and update_data["nodes"] is not None:
update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]]
if "edges" in update_data and update_data["edges"] is not None:
update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]]
for field, value in update_data.items():
setattr(diagram, field, value)
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.delete("/{diagram_id}", status_code=204)
async def archive_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
diagram.is_archived = True
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
@router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201)
async def duplicate_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
source = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
copy = NetworkDiagram(
account_id=current_user.account_id,
name=f"Copy of {source.name}",
client_name=source.client_name,
asset_name=source.asset_name,
description=source.description,
nodes=source.nodes,
edges=source.edges,
created_by=current_user.id,
)
db.add(copy)
await db.commit()
await db.refresh(copy)
return _diagram_to_response(copy)
@router.get("/{diagram_id}/export", response_model=DiagramExportResponse)
async def export_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramExportResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
edges = [DiagramEdge(**e) for e in (diagram.edges or [])]
return DiagramExportResponse(
schemaVersion=1,
name=diagram.name,
client_name=diagram.client_name,
description=diagram.description,
nodes=nodes,
edges=edges,
exportedAt=datetime.now(timezone.utc).isoformat(),
)
@router.post("/import", response_model=DiagramImportResponse, status_code=201)
async def import_diagram(
data: DiagramImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramImportResponse:
available_slugs = await _get_available_slugs(current_user.account_id, db)
warnings: list[str] = []
for node in data.nodes:
if node.type not in available_slugs:
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
diagram = NetworkDiagram(
account_id=current_user.account_id,
name=data.name,
client_name=data.client_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return DiagramImportResponse(
diagram=_diagram_to_response(diagram),
warnings=warnings,
)
@router.post("/ai-generate", response_model=AIGenerateResponse)
async def ai_generate_diagram(
data: AIGenerateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> AIGenerateResponse:
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
available_slugs = list(available_slugs_set)
existing_node_ids: list[str] | None = None
if data.mode == "merge" and data.existingBounds:
existing_node_ids = []
try:
return await network_diagram_ai_service.generate_diagram(
request=data,
available_slugs=available_slugs,
existing_node_ids=existing_node_ids,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception:
logger.exception("AI diagram generation failed")
raise HTTPException(status_code=500, detail="Diagram generation failed")

View File

@@ -24,7 +24,6 @@ from app.api.endpoints import (
branding,
categories,
copilot,
device_types,
feedback,
flow_proposals,
flowpilot_analytics,
@@ -33,7 +32,6 @@ from app.api.endpoints import (
invite,
kb_accelerator,
maintenance_schedules,
network_diagrams,
notifications,
onboarding,
public_templates,
@@ -95,6 +93,7 @@ api_router.include_router(admin_settings.router)
api_router.include_router(admin_categories.router)
api_router.include_router(admin_survey.router)
api_router.include_router(admin_gallery.router)
# ---------------------------------------------------------------------------
# User-facing endpoints — tenant context required
# ---------------------------------------------------------------------------
@@ -131,7 +130,6 @@ api_router.include_router(integrations.router, dependencies=_tenant_deps)
api_router.include_router(onboarding.router, dependencies=_tenant_deps)
api_router.include_router(branding.router, dependencies=_tenant_deps)
api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
# session_handoffs queue router must come before ai_sessions to avoid conflict
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
@@ -144,4 +142,3 @@ api_router.include_router(script_builder.router, dependencies=_tenant_deps)
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
api_router.include_router(session_branches.router, dependencies=_tenant_deps)
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
api_router.include_router(device_types.router, dependencies=_tenant_deps)

View File

@@ -128,7 +128,6 @@ class Settings(BaseSettings):
"variable_inference": "fast",
"kb_convert": "standard",
"script_build": "standard",
"network_diagram_generate": "standard",
}
def get_model_for_action(self, action_type: str) -> str:

View File

@@ -56,8 +56,6 @@ from .session_handoff import SessionHandoff
from .session_resolution_output import SessionResolutionOutput
from .template_tree import TemplateTree
from .platform_step import PlatformStep
from .device_type import DeviceType
from .network_diagram import NetworkDiagram
__all__ = [
"User",
@@ -128,6 +126,4 @@ __all__ = [
"SessionResolutionOutput",
"TemplateTree",
"PlatformStep",
"DeviceType",
"NetworkDiagram",
]

View File

@@ -1,47 +0,0 @@
"""Device type model for network diagrams."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class DeviceType(Base):
"""A device type for network diagram nodes (platform or account-custom)."""
__tablename__ = "device_types"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
slug: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="Unique identifier used in diagram node data",
)
label: Mapped[str] = mapped_column(
String(100), nullable=False,
comment="Display name",
)
category: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="network, compute, storage, cloud, endpoint, infrastructure, security",
)
is_system: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
comment="True for built-in types that cannot be deleted",
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
comment="Platform account for system types, tenant account for custom types",
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Display order within category",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -1,53 +0,0 @@
"""Network diagram model."""
import uuid
from datetime import datetime, timezone
from typing import Any, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class NetworkDiagram(Base):
"""A network topology diagram scoped to one account."""
__tablename__ = "network_diagrams"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
is_archived: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
creator: Mapped["User | None"] = relationship("User", foreign_keys=[created_by])

View File

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

View File

@@ -1,37 +0,0 @@
"""Pydantic schemas for device types."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class DeviceTypeCreate(BaseModel):
slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$")
label: str = Field(min_length=1, max_length=100)
category: str = Field(
min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int = Field(default=0, ge=0)
class DeviceTypeUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=100)
category: str | None = Field(
default=None, min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int | None = Field(default=None, ge=0)
class DeviceTypeResponse(BaseModel):
id: UUID
slug: str
label: str
category: str
is_system: bool
account_id: UUID
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -1,136 +0,0 @@
"""Pydantic schemas for network diagrams."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class Position(BaseModel):
x: float
y: float
class DeviceProperties(BaseModel):
hostname: str | None = None
ip: str | None = None
subnet: str | None = None
vendor: str | None = None
model: str | None = None
role: str | None = None
vlan: str | None = None
notes: str | None = None
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
class DiagramNode(BaseModel):
id: str
type: str
label: str
position: Position
properties: DeviceProperties = Field(default_factory=DeviceProperties)
class DiagramEdge(BaseModel):
id: str
source: str
target: str
label: str | None = None
connectionType: str = "ethernet"
speed: str | None = None
notes: str | None = None
routing: str | None = None
class NetworkDiagramCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class NetworkDiagramUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] | None = None
edges: list[DiagramEdge] | None = None
class NetworkDiagramResponse(BaseModel):
id: UUID
account_id: UUID
name: str
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
thumbnail_url: str | None = None
is_archived: bool = False
created_by: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class NetworkDiagramListItem(BaseModel):
id: UUID
name: str
client_name: str | None = None
description: str | None = None
node_count: int = 0
category_counts: dict[str, int] = Field(default_factory=dict)
created_by: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ExistingBounds(BaseModel):
minX: float
maxX: float
minY: float
maxY: float
class AIGenerateRequest(BaseModel):
description: str = Field(min_length=1, max_length=5000)
client_name: str | None = None
mode: str = Field(default="replace", pattern=r"^(replace|merge)$")
existingBounds: ExistingBounds | None = None
class AIGenerateResponse(BaseModel):
nodes: list[DiagramNode]
edges: list[DiagramEdge]
suggestedName: str | None = None
notes: str | None = None
class DiagramImportRequest(BaseModel):
schemaVersion: int = Field(ge=1, le=1)
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class DiagramImportResponse(BaseModel):
diagram: NetworkDiagramResponse
warnings: list[str] = Field(default_factory=list)
class DiagramExportResponse(BaseModel):
schemaVersion: int = 1
name: str
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode]
edges: list[DiagramEdge]
exportedAt: str

View File

@@ -1,151 +0,0 @@
"""AI service for generating network diagrams from natural language."""
import json
import logging
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.schemas.network_diagram import (
AIGenerateRequest,
AIGenerateResponse,
DiagramNode,
DiagramEdge,
DeviceProperties,
Position,
)
logger = logging.getLogger(__name__)
SYSTEM_PROMPT_TEMPLATE = """You are a network diagram generator for MSP engineers.
Given a plain English description of a network, you must return ONLY valid JSON with no markdown, no explanation, no preamble.
Return this exact structure:
{{
"nodes": [
{{
"id": "unique-string",
"type": "device-type-slug",
"label": "device label",
"position": {{ "x": number, "y": number }},
"properties": {{
"hostname": "string or null",
"ip": "string or null",
"subnet": "string or null",
"vendor": "string or null",
"model": "string or null",
"role": "string or null",
"vlan": "string or null",
"notes": "string or null",
"status": "unknown"
}}
}}
],
"edges": [
{{
"id": "unique-string",
"source": "node-id",
"target": "node-id",
"label": "connection label or null",
"connectionType": "ethernet|fiber|wifi|vpn|vlan|wan",
"speed": "string or null",
"notes": "string or null"
}}
],
"suggestedName": "short descriptive diagram name",
"notes": "any important assumptions or missing info, or null"
}}
Available device type slugs: {available_slugs}
Position nodes thoughtfully in a logical network topology layout.
Use x/y coordinates between 0 and 1200 for x, 0 and 800 for y.
Place WAN/internet at top, core network in middle, endpoints at bottom.
{merge_instructions}"""
MERGE_INSTRUCTIONS = """
IMPORTANT: You are ADDING devices to an existing diagram. Do NOT replace existing devices.
The existing diagram occupies this bounding box: minX={minX}, maxX={maxX}, minY={minY}, maxY={maxY}.
Place all new nodes OUTSIDE this bounding box — below (y > {maxY} + 100) or to the right (x > {maxX} + 100).
You may create edges that connect new nodes to existing nodes if the description implies a connection.
Use these existing node IDs for connections: {existing_node_ids}"""
async def generate_diagram(
request: AIGenerateRequest,
available_slugs: list[str],
existing_node_ids: list[str] | None = None,
) -> AIGenerateResponse:
merge_instructions = ""
if request.mode == "merge" and request.existingBounds:
b = request.existingBounds
merge_instructions = MERGE_INSTRUCTIONS.format(
minX=b.minX, maxX=b.maxX, minY=b.minY, maxY=b.maxY,
existing_node_ids=", ".join(existing_node_ids or []),
)
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
available_slugs=", ".join(available_slugs),
merge_instructions=merge_instructions,
)
model = settings.get_model_for_action("network_diagram_generate")
provider = get_ai_provider(model)
messages = [{"role": "user", "content": request.description}]
response_text, input_tokens, output_tokens = await provider.generate_json(
system_prompt=system_prompt,
messages=messages,
max_tokens=4096,
)
logger.info(
"Network diagram AI generation: input_tokens=%d, output_tokens=%d",
input_tokens, output_tokens,
)
try:
data = json.loads(response_text)
except json.JSONDecodeError as e:
logger.error("Failed to parse AI response as JSON: %s", e)
raise ValueError("AI generated an invalid response, please try again")
try:
nodes = []
for raw_node in data.get("nodes", []):
node_type = raw_node.get("type", "server")
if node_type not in available_slugs:
logger.warning("Unknown device type '%s', falling back to 'server'", node_type)
node_type = "server"
nodes.append(DiagramNode(
id=raw_node["id"],
type=node_type,
label=raw_node.get("label", node_type),
position=Position(**raw_node.get("position", {"x": 0, "y": 0})),
properties=DeviceProperties(**{
k: v for k, v in raw_node.get("properties", {}).items()
if k in DeviceProperties.model_fields
}),
))
edges = []
for raw_edge in data.get("edges", []):
edges.append(DiagramEdge(
id=raw_edge["id"],
source=raw_edge["source"],
target=raw_edge["target"],
label=raw_edge.get("label"),
connectionType=raw_edge.get("connectionType", "ethernet"),
speed=raw_edge.get("speed"),
notes=raw_edge.get("notes"),
))
except KeyError as e:
logger.warning("AI response missing required field: %s", e)
raise ValueError(f"AI generated incomplete data (missing {e}), please try again")
return AIGenerateResponse(
nodes=nodes,
edges=edges,
suggestedName=data.get("suggestedName"),
notes=data.get("notes"),
)

View File

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

View File

@@ -1,96 +0,0 @@
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from app.models.device_type import DeviceType
from app.models.user import User
from app.core.service_account import PLATFORM_ACCOUNT_ID
async def _login_headers(client, email: str, password: str) -> dict[str, str]:
response = await client.post(
"/api/v1/auth/login/json",
json={"email": email, "password": password},
)
assert response.status_code == 200
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_device_types_include_platform_and_account_custom(client, test_db, auth_headers, test_user):
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
user = result.scalar_one()
test_db.add(
DeviceType(
id=uuid.uuid4(),
slug="platform-router",
label="Platform Router",
category="network",
is_system=True,
account_id=PLATFORM_ACCOUNT_ID,
sort_order=0,
)
)
await test_db.commit()
create_response = await client.post(
"/api/v1/device-types/",
json={
"slug": "tenant-appliance",
"label": "Tenant Appliance",
"category": "network",
"sort_order": 3,
},
headers=auth_headers,
)
assert create_response.status_code == 201
assert create_response.json()["account_id"] == str(user.account_id)
list_response = await client.get("/api/v1/device-types/", headers=auth_headers)
assert list_response.status_code == 200
payload = list_response.json()
slugs = {item["slug"] for item in payload}
assert "platform-router" in slugs
assert "tenant-appliance" in slugs
@pytest.mark.asyncio
async def test_network_diagrams_are_account_scoped(client, test_db, auth_headers, test_user):
other_user = {
"email": "other-network@example.com",
"password": "TestPassword123!",
"name": "Other Network User",
}
register_response = await client.post("/api/v1/auth/register", json=other_user)
assert register_response.status_code in (200, 201)
other_headers = await _login_headers(client, other_user["email"], other_user["password"])
owner_result = await test_db.execute(select(User).where(User.email == test_user["email"]))
owner = owner_result.scalar_one()
create_response = await client.post(
"/api/v1/network-diagrams/",
json={
"name": "HQ Core",
"client_name": "Acme",
"description": "Primary topology",
"nodes": [],
"edges": [],
},
headers=auth_headers,
)
assert create_response.status_code == 201
diagram = create_response.json()
assert diagram["account_id"] == str(owner.account_id)
own_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=auth_headers)
assert own_get.status_code == 200
other_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=other_headers)
assert other_get.status_code == 404

View File

@@ -23,7 +23,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"immer": "^11.1.3",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
@@ -5332,12 +5331,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",

View File

@@ -36,7 +36,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"immer": "^11.1.3",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

View File

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

View File

@@ -1,23 +0,0 @@
import apiClient from './client'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
export const deviceTypesApi = {
async list(): Promise<DeviceTypeResponse[]> {
const response = await apiClient.get<DeviceTypeResponse[]>('/device-types/')
return response.data
},
async create(data: DeviceTypeCreate): Promise<DeviceTypeResponse> {
const response = await apiClient.post<DeviceTypeResponse>('/device-types/', data)
return response.data
},
async update(id: string, data: Partial<DeviceTypeCreate>): Promise<DeviceTypeResponse> {
const response = await apiClient.put<DeviceTypeResponse>(`/device-types/${id}`, data)
return response.data
},
async remove(id: string): Promise<void> {
await apiClient.delete(`/device-types/${id}`)
},
}

View File

@@ -35,5 +35,3 @@ export { betaFeedbackApi } from './betaFeedback'
export { branchesApi } from './branches'
export { handoffsApi } from './handoffs'
export { resolutionsApi } from './resolutions'
export { deviceTypesApi } from './deviceTypes'
export { networkDiagramsApi } from './networkDiagrams'

View File

@@ -1,63 +0,0 @@
import apiClient from './client'
import type {
NetworkDiagramResponse,
NetworkDiagramListItem,
NetworkDiagramCreate,
NetworkDiagramUpdate,
AIGenerateRequest,
AIGenerateResponse,
DiagramImportData,
DiagramImportResponse,
DiagramExportResponse,
} from '@/types'
export const networkDiagramsApi = {
async list(params?: { client_name?: string; search?: string }): Promise<NetworkDiagramListItem[]> {
const response = await apiClient.get<NetworkDiagramListItem[]>('/network-diagrams/', { params })
return response.data
},
async get(id: string): Promise<NetworkDiagramResponse> {
const response = await apiClient.get<NetworkDiagramResponse>(`/network-diagrams/${id}`)
return response.data
},
async create(data: NetworkDiagramCreate): Promise<NetworkDiagramResponse> {
const response = await apiClient.post<NetworkDiagramResponse>('/network-diagrams/', data)
return response.data
},
async update(id: string, data: NetworkDiagramUpdate): Promise<NetworkDiagramResponse> {
const response = await apiClient.put<NetworkDiagramResponse>(`/network-diagrams/${id}`, data)
return response.data
},
async archive(id: string): Promise<void> {
await apiClient.delete(`/network-diagrams/${id}`)
},
async duplicate(id: string): Promise<NetworkDiagramResponse> {
const response = await apiClient.post<NetworkDiagramResponse>(`/network-diagrams/${id}/duplicate`)
return response.data
},
async exportJson(id: string): Promise<DiagramExportResponse> {
const response = await apiClient.get<DiagramExportResponse>(`/network-diagrams/${id}/export`)
return response.data
},
async importJson(data: DiagramImportData): Promise<DiagramImportResponse> {
const response = await apiClient.post<DiagramImportResponse>('/network-diagrams/import', data)
return response.data
},
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
return response.data
},
async listClients(): Promise<string[]> {
const response = await apiClient.get<string[]>('/network-diagrams/clients')
return response.data
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import {
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
ListChecks, Download, BarChart3,
Settings, Pin, PinOff,
History, FileText, Network,
History, FileText,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -86,11 +86,10 @@ export function Sidebar() {
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue', '/network-diagrams'],
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue'],
children: [
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/network-diagrams', label: 'Network Maps' },
{ href: '/step-library', label: 'Solutions Library' },
{ href: '/review-queue', label: 'Review Queue' },
],
@@ -135,7 +134,6 @@ export function Sidebar() {
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
],
},
{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },

View File

@@ -1,232 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { Sparkles, ArrowRight, PencilRuler, Wand2, X } from 'lucide-react'
import { networkDiagramsApi } from '@/api'
import type { AIGenerateResponse } from '@/types'
const EXAMPLE_PROMPTS = [
'Small office with firewall and core switch',
'Azure hybrid cloud with VPN gateway',
'Branch office connected to HQ via MPLS',
'Data center with redundant core switches',
'Remote workforce with Meraki and cloud apps',
]
interface CanvasEmptyPromptProps {
onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
}
export function CanvasEmptyPrompt({ onGenerate }: CanvasEmptyPromptProps) {
const [mode, setMode] = useState<'choice' | 'ai' | 'manual'>('choice')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const switchToManual = useCallback(() => {
if (loading) return
setMode('manual')
setError(null)
}, [loading])
const handleGenerate = useCallback(async (text?: string) => {
const desc = (text ?? description).trim()
if (!desc) return
setLoading(true)
setError(null)
try {
const result = await networkDiagramsApi.aiGenerate({
description: desc,
mode: 'replace',
existingBounds: null,
})
onGenerate(result, 'replace')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Generation failed. Please try again.')
} finally {
setLoading(false)
}
}, [description, onGenerate])
useEffect(() => {
if (mode === 'manual') return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
switchToManual()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [mode, switchToManual])
if (mode === 'manual') {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-10 flex justify-center px-6">
<div className="pointer-events-auto flex max-w-xl items-center gap-3 rounded-lg border border-default bg-card px-4 py-3 shadow-xl">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
<PencilRuler size={14} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-heading">Manual mode is on</p>
<p className="text-xs text-muted-foreground">
Drag devices from the left panel onto the canvas, or reopen AI whenever you want.
</p>
</div>
<button
onClick={() => setMode('ai')}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-default px-3 py-1 text-xs font-medium text-primary hover:border-accent hover:text-accent"
>
<Sparkles size={12} />
Open AI Generator
</button>
</div>
</div>
)
}
return (
<div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[rgba(10,14,20,0.42)] px-6"
onClick={event => {
if (event.target === event.currentTarget) {
switchToManual()
}
}}
>
<div className="pointer-events-auto relative w-full max-w-lg rounded-lg border border-default bg-card p-8 shadow-2xl">
<button
onClick={switchToManual}
disabled={loading}
aria-label="Close AI prompt and build manually"
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full border border-default text-muted-foreground hover:border-hover hover:text-primary disabled:opacity-40"
>
<X size={14} />
</button>
{mode === 'choice' ? (
<>
<div className="mb-6 text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<Wand2 size={16} className="text-accent" />
<h2 className="font-heading text-base font-semibold text-heading">
Start a network map
</h2>
</div>
<p className="text-xs text-muted-foreground">
Generate a topology with AI or start with a blank canvas and build it manually.
</p>
<p className="mt-2 text-[11px] text-muted-foreground/80">
Press <span className="font-medium text-primary">Esc</span> or click outside to skip AI and start dragging devices.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
onClick={() => setMode('ai')}
className="rounded-lg border border-accent/40 bg-accent/10 p-4 text-left transition-colors hover:border-accent hover:bg-accent/15"
>
<div className="mb-3 inline-flex rounded-lg bg-accent/15 p-2 text-accent">
<Sparkles size={16} />
</div>
<div className="mb-1 text-sm font-semibold text-heading">Generate with AI</div>
<p className="text-xs text-muted-foreground">
Describe the environment and let AI lay out the first version for you.
</p>
</button>
<button
onClick={switchToManual}
className="rounded-lg border border-default bg-elevated/40 p-4 text-left transition-colors hover:border-accent hover:bg-elevated/60"
>
<div className="mb-3 inline-flex rounded-lg bg-primary/10 p-2 text-primary">
<PencilRuler size={16} />
</div>
<div className="mb-1 text-sm font-semibold text-heading">Build manually</div>
<p className="text-xs text-muted-foreground">
Close this prompt and use click-and-drag from the left toolbar to place devices on the canvas.
</p>
</button>
</div>
</>
) : (
<>
<div className="mb-5 text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<Sparkles size={16} className="text-accent" />
<h2 className="font-heading text-base font-semibold text-heading">
Describe your network
</h2>
</div>
<p className="text-xs text-muted-foreground">
AI will generate the topology in seconds, or you can go back and switch to manual creation.
</p>
<p className="mt-2 text-[11px] text-muted-foreground/80">
Press <span className="font-medium text-primary">Esc</span>, click outside, or use the close button to build manually instead.
</p>
</div>
<div className="relative mb-3">
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate()
}}
placeholder="e.g. Small office with a firewall, core switch, 3 access points, a file server, and 20 workstations"
rows={3}
disabled={loading}
autoFocus
className="w-full resize-none rounded-lg border border-default bg-input px-4 py-3 pb-7 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
/>
<span className="pointer-events-none absolute bottom-2 right-3 text-[10px] text-muted-foreground">
to generate
</span>
</div>
<div className="mb-4 flex flex-wrap gap-1.5">
{EXAMPLE_PROMPTS.map(p => (
<button
key={p}
onClick={() => handleGenerate(p)}
disabled={loading}
className="rounded-full border border-default px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-accent hover:text-accent disabled:opacity-40"
>
{p}
</button>
))}
</div>
{error && <p className="mb-3 text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<button
onClick={switchToManual}
disabled={loading}
className="flex-1 rounded-lg border border-default px-4 py-2.5 text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40"
>
Build Manually
</button>
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 py-2.5">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent border-t-transparent" />
<span className="text-sm text-muted-foreground">Mapping your network</span>
</div>
) : (
<button
onClick={() => handleGenerate()}
disabled={!description.trim()}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:bg-accent/90 disabled:opacity-40"
>
<Sparkles size={14} />
Generate Diagram
<ArrowRight size={14} />
</button>
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,119 +0,0 @@
import { useEffect, useRef } from 'react'
import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2, BringToFront, SendToBack } from 'lucide-react'
import { cn } from '@/lib/utils'
interface MenuAction {
label: string
icon: React.ElementType
shortcut: string
onClick: () => void
disabled?: boolean
dividerBefore?: boolean
}
interface ContextMenuProps {
position: { x: number; y: number }
actions: MenuAction[]
onClose: () => void
}
export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const clampedPosition = { ...position }
if (typeof window !== 'undefined') {
const itemCount = actions.length
const dividerCount = actions.filter(a => a.dividerBefore).length
const menuWidth = 192
const menuHeight = itemCount * 36 + dividerCount * 9 + 8
if (clampedPosition.x + menuWidth > window.innerWidth) {
clampedPosition.x = window.innerWidth - menuWidth - 8
}
if (clampedPosition.y + menuHeight > window.innerHeight) {
clampedPosition.y = window.innerHeight - menuHeight - 8
}
}
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as HTMLElement)) {
onClose()
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const handleScroll = () => onClose()
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
document.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
document.removeEventListener('scroll', handleScroll, true)
}
}, [onClose])
return (
<div
ref={menuRef}
className="fixed z-50 w-48 rounded-lg border border-default bg-card py-1 shadow-lg"
style={{ left: clampedPosition.x, top: clampedPosition.y }}
>
{actions.map((action) => (
<div key={action.label}>
{action.dividerBefore && (
<div className="my-1 border-t border-default" />
)}
<button
onClick={() => {
action.onClick()
onClose()
}}
disabled={action.disabled}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated',
action.disabled && 'opacity-40 pointer-events-none',
)}
>
<action.icon size={14} />
<span>{action.label}</span>
<span className="ml-auto text-[10px] text-muted-foreground">{action.shortcut}</span>
</button>
</div>
))}
</div>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function getNodeMenuActions(handlers: {
onCopy: () => void
onDuplicate: () => void
onBringToFront: () => void
onSendToBack: () => void
onDelete: () => void
}): MenuAction[] {
return [
{ label: 'Copy', icon: Copy, shortcut: 'Ctrl+C', onClick: handlers.onCopy },
{ label: 'Duplicate', icon: CopyPlus, shortcut: 'Ctrl+D', onClick: handlers.onDuplicate },
{ label: 'Bring to Front', icon: BringToFront, shortcut: ']', onClick: handlers.onBringToFront, dividerBefore: true },
{ label: 'Send to Back', icon: SendToBack, shortcut: '[', onClick: handlers.onSendToBack },
{ label: 'Delete', icon: Trash2, shortcut: 'Del', onClick: handlers.onDelete, dividerBefore: true },
]
}
// eslint-disable-next-line react-refresh/only-export-components
export function getCanvasMenuActions(handlers: {
onPaste: () => void
onSelectAll: () => void
onFitView: () => void
hasClipboard: boolean
}): MenuAction[] {
return [
{ label: 'Paste', icon: ClipboardPaste, shortcut: 'Ctrl+V', onClick: handlers.onPaste, disabled: !handlers.hasClipboard },
{ label: 'Select All', icon: BoxSelect, shortcut: 'Ctrl+A', onClick: handlers.onSelectAll },
{ label: 'Fit View', icon: Maximize2, shortcut: '⌘⇧F', onClick: handlers.onFitView },
]
}

View File

@@ -1,167 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
interface DiagramHeaderProps {
name: string
clientName: string | null
isDirty: boolean
isSaving: boolean
lastSavedAt: Date | null
diagramId: string | null
onNameChange: (name: string) => void
onSave: () => void
onExportPng: () => void
onExportPdf: () => void
onExportJson: () => void
}
export function DiagramHeader({
name,
clientName,
isDirty,
isSaving,
lastSavedAt,
diagramId,
onNameChange,
onSave,
onExportPng,
onExportPdf,
onExportJson,
}: DiagramHeaderProps) {
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(name)
const [showExportMenu, setShowExportMenu] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const exportMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editing])
useEffect(() => {
setEditValue(name)
}, [name])
useEffect(() => {
if (!showExportMenu) return
const handleClick = (e: MouseEvent) => {
if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as HTMLElement)) {
setShowExportMenu(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showExportMenu])
const handleConfirmName = useCallback(() => {
setEditing(false)
if (editValue.trim() && editValue !== name) {
onNameChange(editValue.trim())
} else {
setEditValue(name)
}
}, [editValue, name, onNameChange])
const formatLastSaved = () => {
if (!lastSavedAt) return null
// eslint-disable-next-line react-hooks/purity
const diff = Date.now() - lastSavedAt.getTime()
if (diff < 60_000) return 'Saved just now'
const mins = Math.floor(diff / 60_000)
return `Saved ${mins}m ago`
}
return (
<div className="flex h-14 items-center gap-3 border-b border-default bg-card px-4">
<button
onClick={() => navigate('/network-diagrams')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
>
<ChevronLeft size={16} />
Network Maps
</button>
<div className="mx-2 h-5 w-px bg-border-default" />
{editing ? (
<input
ref={inputRef}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={handleConfirmName}
onKeyDown={e => { if (e.key === 'Enter') handleConfirmName(); if (e.key === 'Escape') { setEditing(false); setEditValue(name) } }}
className="rounded border border-accent bg-input px-2 py-1 text-sm font-heading font-semibold text-heading focus:outline-none"
/>
) : (
<button
onClick={() => setEditing(true)}
className="text-sm font-heading font-semibold text-heading hover:text-accent"
>
{name || 'Untitled Diagram'}
</button>
)}
{clientName && (
<span className="rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
{clientName}
</span>
)}
<div className="flex-1" />
{isDirty && !isSaving ? (
<span className="text-[10px] text-amber-400">Unsaved changes</span>
) : lastSavedAt ? (
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
) : null}
<button
onClick={onSave}
disabled={isSaving}
className="flex items-center gap-1.5 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
<div className="relative" ref={exportMenuRef}>
<button
onClick={() => setShowExportMenu(prev => !prev)}
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
>
<Download size={14} />
Export
</button>
{showExportMenu && (
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
<button
onClick={() => { onExportPng(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<Image size={12} /> Export PNG
</button>
<button
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileText size={12} /> Export PDF
</button>
{diagramId && (
<button
onClick={() => { onExportJson(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileJson size={12} /> Export JSON
</button>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,119 +0,0 @@
import { useCallback } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
BackgroundVariant,
type OnConnect,
type OnNodesChange,
type OnEdgesChange,
type Node,
type Edge,
} from '@xyflow/react'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
import type { DeviceNodeData } from './nodes/DeviceNode'
interface NetworkCanvasProps {
nodes: Node[]
edges: Edge[]
onNodesChange: OnNodesChange
onEdgesChange: OnEdgesChange
onConnect: OnConnect
onNodeSelect: (nodeId: string | null) => void
onEdgeSelect: (edgeId: string | null) => void
onDrop: (event: React.DragEvent) => void
onDragOver: (event: React.DragEvent) => void
onDragLeave?: (event: React.DragEvent) => void
isDragOver?: boolean
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
onPaneClick?: () => void
}
export function NetworkCanvas({
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
onNodeSelect,
onEdgeSelect,
onDrop,
onDragOver,
onDragLeave,
isDragOver,
onNodeContextMenu,
onPaneContextMenu,
onPaneClick: onPaneClickProp,
}: NetworkCanvasProps) {
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
if (selectedNodes.length === 1) {
onNodeSelect(selectedNodes[0].id)
onEdgeSelect(null)
} else if (selectedEdges.length === 1) {
onEdgeSelect(selectedEdges[0].id)
onNodeSelect(null)
} else {
onNodeSelect(null)
onEdgeSelect(null)
}
}, [onNodeSelect, onEdgeSelect])
const handlePaneClick = useCallback(() => {
onNodeSelect(null)
onEdgeSelect(null)
onPaneClickProp?.()
}, [onNodeSelect, onEdgeSelect, onPaneClickProp])
const getNodeColor = useCallback((node: Node) => {
if (node.type === 'group') return 'var(--color-bg-elevated)'
const data = node.data as unknown as DeviceNodeData
return getDeviceRenderConfig(data?.deviceType || '', data?.category).color
}, [])
return (
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onSelectionChange={handleSelectionChange}
onPaneClick={handlePaneClick}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeContextMenu={onNodeContextMenu}
onPaneContextMenu={onPaneContextMenu}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{ type: 'connection' }}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode="Shift"
snapToGrid={true}
snapGrid={[20, 20]}
fitView
className="bg-page"
>
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
<MiniMap
nodeColor={getNodeColor}
maskColor="rgba(0,0,0,0.5)"
className="!border-default !bg-card"
position="bottom-right"
/>
</ReactFlow>
{isDragOver && (
<div className="pointer-events-none absolute inset-2 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/30">
<span className="rounded-md bg-card/80 px-3 py-1.5 text-sm text-muted-foreground">
Drop to add
</span>
</div>
)}
</div>
)
}

View File

@@ -1,71 +0,0 @@
import { memo } from 'react'
import { BaseEdge, EdgeLabelRenderer, getStraightPath, getBezierPath, getSmoothStepPath, type EdgeProps } from '@xyflow/react'
interface ConnectionEdgeData {
connectionType?: string
routing?: string | null
speed?: string | null
notes?: string | null
[key: string]: unknown
}
const CONNECTION_STYLES: Record<string, { stroke: string; strokeDasharray?: string; strokeWidth: number }> = {
ethernet: { stroke: '#60a5fa', strokeWidth: 2 },
fiber: { stroke: '#34d399', strokeWidth: 3 },
wifi: { stroke: '#a78bfa', strokeDasharray: '3,3', strokeWidth: 2 },
vpn: { stroke: '#eab308', strokeDasharray: '8,4', strokeWidth: 2 },
vlan: { stroke: '#848b9b', strokeWidth: 2 },
wan: { stroke: '#f87171', strokeDasharray: '12,4', strokeWidth: 2 },
}
const DEFAULT_STYLE = { stroke: '#848b9b', strokeWidth: 2 }
function getEdgePath(routing: string | null | undefined, props: EdgeProps) {
const base = {
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}
if (routing === 'curved') return getBezierPath(base)
if (routing === 'step') return getSmoothStepPath(base)
return getStraightPath(base)
}
function ConnectionEdgeComponent(props: EdgeProps) {
const edgeData = props.data as ConnectionEdgeData | undefined
const connectionType = edgeData?.connectionType || 'ethernet'
const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE
const [edgePath, labelX, labelY] = getEdgePath(edgeData?.routing, props)
return (
<>
<BaseEdge
path={edgePath}
style={{
...style,
...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
}}
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
export const ConnectionEdge = memo(ConnectionEdgeComponent)

View File

@@ -1,7 +0,0 @@
import { ConnectionEdge } from './ConnectionEdge'
import { AnimatedSvgEdge } from '../ui/animated-svg-edge'
export const edgeTypes = {
connection: ConnectionEdge,
animated: AnimatedSvgEdge,
}

View File

@@ -1,252 +0,0 @@
import { useCallback, useEffect, useRef } from 'react'
import { useReactFlow, type Node, type Edge } from '@xyflow/react'
interface ClipboardData {
nodes: Array<{
type: string
data: Record<string, unknown>
style?: React.CSSProperties
relativePosition: { x: number; y: number }
}>
edges: Array<{
sourceIndex: number
targetIndex: number
type?: string
data?: Record<string, unknown>
label?: string
}>
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
function isInputFocused(): boolean {
const tag = document.activeElement?.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
}
export function useCanvasShortcuts({
nodes: _nodes, // eslint-disable-line @typescript-eslint/no-unused-vars
edges,
setNodes,
setEdges,
setIsDirty,
canvasRef,
}: {
nodes: Node[]
edges: Edge[]
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
setIsDirty: (dirty: boolean) => void
canvasRef: React.RefObject<HTMLDivElement | null>
}) {
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
const clipboardRef = useRef<ClipboardData | null>(null)
const getSelectedNodes = useCallback((): Node[] => {
return getNodes().filter(n => n.selected)
}, [getNodes])
const copyNodes = useCallback(() => {
const selected = getSelectedNodes()
if (selected.length === 0) return
const centroid = {
x: selected.reduce((sum, n) => sum + n.position.x, 0) / selected.length,
y: selected.reduce((sum, n) => sum + n.position.y, 0) / selected.length,
}
const selectedIds = new Set(selected.map(n => n.id))
const clipNodes = selected.map(n => ({
type: n.type || 'device',
data: structuredClone(n.data),
style: n.style ? { ...n.style } : undefined,
relativePosition: {
x: n.position.x - centroid.x,
y: n.position.y - centroid.y,
},
}))
const selectedList = selected.map(n => n.id)
const clipEdges = edges
.filter(e => selectedIds.has(e.source) && selectedIds.has(e.target))
.map(e => ({
sourceIndex: selectedList.indexOf(e.source),
targetIndex: selectedList.indexOf(e.target),
type: e.type,
data: e.data ? structuredClone(e.data) as Record<string, unknown> : undefined,
label: typeof e.label === 'string' ? e.label : undefined,
}))
clipboardRef.current = { nodes: clipNodes, edges: clipEdges }
}, [getSelectedNodes, edges])
const pasteNodes = useCallback(() => {
const clipboard = clipboardRef.current
if (!clipboard || clipboard.nodes.length === 0) return
const canvasEl = canvasRef.current
if (!canvasEl) return
const rect = canvasEl.getBoundingClientRect()
const center = screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
const newNodeIds: string[] = []
const newNodes: Node[] = clipboard.nodes.map(cn => {
const prefix = cn.type === 'group' ? 'group' : 'device'
const id = generateId(prefix)
newNodeIds.push(id)
return {
id,
type: cn.type,
position: {
x: center.x + cn.relativePosition.x,
y: center.y + cn.relativePosition.y,
},
data: structuredClone(cn.data) as Record<string, unknown>,
style: cn.style ? { ...cn.style } : undefined,
selected: true,
}
})
const newEdges: Edge[] = clipboard.edges.map(ce => ({
id: generateId('edge'),
source: newNodeIds[ce.sourceIndex],
target: newNodeIds[ce.targetIndex],
type: ce.type,
data: ce.data ? structuredClone(ce.data) as Record<string, unknown> : undefined,
label: ce.label,
}))
setNodes(nds => [
...nds.map(n => ({ ...n, selected: false })),
...newNodes,
])
setEdges(eds => [...eds, ...newEdges])
setIsDirty(true)
}, [canvasRef, screenToFlowPosition, setNodes, setEdges, setIsDirty])
const duplicateNodes = useCallback(() => {
const selected = getSelectedNodes()
if (selected.length === 0) return
const selectedIds = new Set(selected.map(n => n.id))
const idMap = new Map<string, string>()
const newNodes: Node[] = selected.map(n => {
const prefix = n.type === 'group' ? 'group' : 'device'
const newId = generateId(prefix)
idMap.set(n.id, newId)
return {
id: newId,
type: n.type,
position: { x: n.position.x + 30, y: n.position.y + 30 },
data: structuredClone(n.data) as Record<string, unknown>,
style: n.style ? { ...n.style } : undefined,
selected: true,
}
})
const newEdges: Edge[] = edges
.filter(e => selectedIds.has(e.source) && selectedIds.has(e.target))
.map(e => ({
id: generateId('edge'),
source: idMap.get(e.source)!,
target: idMap.get(e.target)!,
type: e.type,
data: e.data ? structuredClone(e.data) as Record<string, unknown> : undefined,
label: e.label,
}))
setNodes(nds => [
...nds.map(n => ({ ...n, selected: false })),
...newNodes,
])
setEdges(eds => [...eds, ...newEdges])
setIsDirty(true)
}, [getSelectedNodes, edges, setNodes, setEdges, setIsDirty])
const selectAll = useCallback(() => {
rfSetNodes(nds => nds.map(n => ({ ...n, selected: true })))
}, [rfSetNodes])
const deleteSelected = useCallback(() => {
const selected = getSelectedNodes()
if (selected.length === 0) return
const selectedIds = new Set(selected.map(n => n.id))
setNodes(nds => nds.filter(n => !selectedIds.has(n.id)))
setEdges(eds => eds.filter(e => !selectedIds.has(e.source) && !selectedIds.has(e.target)))
setIsDirty(true)
}, [getSelectedNodes, setNodes, setEdges, setIsDirty])
const bringSelectedToFront = useCallback(() => {
const selected = getSelectedNodes()
if (!selected.length) return
const selectedIds = new Set(selected.map(n => n.id))
setNodes(nds => {
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: maxZ + 1 } : n)
})
setIsDirty(true)
}, [getSelectedNodes, setNodes, setIsDirty])
const sendSelectedToBack = useCallback(() => {
const selected = getSelectedNodes()
if (!selected.length) return
const selectedIds = new Set(selected.map(n => n.id))
setNodes(nds => {
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: minZ - 1 } : n)
})
setIsDirty(true)
}, [getSelectedNodes, setNodes, setIsDirty])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isInputFocused()) return
const ctrl = e.ctrlKey || e.metaKey
if (ctrl && e.key === 'c') {
e.preventDefault()
copyNodes()
} else if (ctrl && e.key === 'v') {
e.preventDefault()
pasteNodes()
} else if (ctrl && e.key === 'd') {
e.preventDefault()
duplicateNodes()
} else if (ctrl && e.key === 'a') {
e.preventDefault()
selectAll()
} else if (ctrl && e.shiftKey && (e.key === 'f' || e.key === 'F')) {
e.preventDefault()
fitView({ padding: 0.2 })
} else if (e.key === ']' && !ctrl) {
e.preventDefault()
bringSelectedToFront()
} else if (e.key === '[' && !ctrl) {
e.preventDefault()
sendSelectedToBack()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack])
return {
copyNodes,
pasteNodes,
duplicateNodes,
selectAll,
deleteSelected,
bringSelectedToFront,
sendSelectedToBack,
hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0,
}
}

View File

@@ -1,106 +0,0 @@
import { memo } from 'react'
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
import { BaseHandle } from '../ui/base-handle'
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
import { getDeviceRenderConfig } from './deviceRegistry'
import type { DeviceProperties } from '@/types'
export interface DeviceNodeData {
label: string
deviceType: string
category?: string
properties: DeviceProperties
[key: string]: unknown
}
function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null
return (
<div className="flex gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-mono text-primary">{value}</span>
</div>
)
}
const NODE_DEFAULT = 120 // default square side in px
const NODE_MIN = 80 // minimum square side in px
const NODE_MAX = 280 // maximum square side in px
function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
const ip = nodeData.properties?.ip
const props = nodeData.properties || {}
// Use the shorter dimension so content never overflows a non-square node
const size = Math.min(width ?? NODE_DEFAULT, height ?? NODE_DEFAULT)
const scale = size / NODE_DEFAULT
// Icon: 28px at default, clamped to [14, 72]
const iconPx = Math.round(Math.max(14, Math.min(72, scale * 28)))
// Label font: 11px at default, clamped to [9, 20]
const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11)))
// IP font: 9px at default, clamped to [8, 16]
const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9)))
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
return (
<>
<NodeResizer
isVisible={selected}
minWidth={NODE_MIN}
minHeight={NODE_MIN}
maxWidth={NODE_MAX}
maxHeight={NODE_MAX}
keepAspectRatio
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
/>
<NodeStatusIndicator status={status}>
<NodeTooltip>
<NodeTooltipTrigger>
<BaseNode className="w-full h-full group flex flex-col items-center justify-center">
<BaseNodeHeader className="flex-col gap-1 items-center py-2 px-2">
<Icon size={iconPx} style={{ color }} />
<BaseNodeHeaderTitle className="text-center leading-tight" style={{ fontSize: labelPx }}>
{nodeData.label}
</BaseNodeHeaderTitle>
</BaseNodeHeader>
{ip && (
<BaseNodeContent className="items-center pt-0 pb-1">
<span className="font-mono text-muted-foreground" style={{ fontSize: ipPx }}>{ip}</span>
</BaseNodeContent>
)}
<BaseHandle type="target" position={Position.Top} />
<BaseHandle type="source" position={Position.Bottom} />
<BaseHandle type="target" position={Position.Left} id="left" />
<BaseHandle type="source" position={Position.Right} id="right" />
</BaseNode>
</NodeTooltipTrigger>
{hasTooltipContent && (
<NodeTooltipContent position={Position.Top}>
<div className="flex flex-col gap-1 min-w-[140px]">
<TooltipRow label="Host" value={props.hostname} />
<TooltipRow label="IP" value={props.ip} />
{(props.vendor || props.model) && (
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
)}
<TooltipRow label="Role" value={props.role} />
{props.notes && (
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
)}
</div>
</NodeTooltipContent>
)}
</NodeTooltip>
</NodeStatusIndicator>
</>
)
}
export const DeviceNode = memo(DeviceNodeComponent)

View File

@@ -1,113 +0,0 @@
import type { LucideIcon } from 'lucide-react'
import {
Router, Network, BrickWallFire, Wifi, Server, Monitor, Boxes, Package, Cloud,
Printer, Smartphone, HardDrive, Gauge, Database, CloudCog,
Cpu, Tablet, Laptop, BatteryCharging, RectangleVertical,
Cable, Camera, KeyRound, Globe, Video, PlugZap, Radio,
} from 'lucide-react'
export interface DeviceRenderConfig {
icon: LucideIcon
color: string
}
// Category-semantic color palette — each color carries meaning:
// Network (blue) — backbone connectivity layer
// Security (orange) — critical/protective elements
// Compute (emerald)— running workloads and VMs
// Endpoint (amber) — user-facing devices
// Storage (violet) — data at rest
// Cloud (cyan) — external/internet-connected
// Infra (steel) — physical/passive hardware
export const NETWORK_COLOR = '#60a5fa'
export const SECURITY_COLOR = '#f87171'
export const COMPUTE_COLOR = '#34d399'
export const ENDPOINT_COLOR = '#fbbf24'
export const STORAGE_COLOR = '#a78bfa'
export const CLOUD_COLOR = '#67e8f9'
export const INFRA_COLOR = '#94a3b8'
const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
// Network layer
'router': { icon: Router, color: NETWORK_COLOR },
'switch': { icon: Network, color: NETWORK_COLOR },
'access-point': { icon: Wifi, color: NETWORK_COLOR },
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
// Security
'firewall': { icon: BrickWallFire, color: SECURITY_COLOR },
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
// Compute
'server': { icon: Server, color: COMPUTE_COLOR },
'vm': { icon: Boxes, color: COMPUTE_COLOR },
'container': { icon: Package, color: COMPUTE_COLOR },
// Storage
'nas': { icon: Database, color: STORAGE_COLOR },
'san': { icon: HardDrive, color: STORAGE_COLOR },
'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR },
// Cloud / Internet
'cloud': { icon: Cloud, color: CLOUD_COLOR },
'aws': { icon: Cloud, color: CLOUD_COLOR },
'azure': { icon: Cloud, color: CLOUD_COLOR },
'gcp': { icon: Cloud, color: CLOUD_COLOR },
'isp': { icon: Globe, color: CLOUD_COLOR },
// Endpoints
'workstation': { icon: Monitor, color: ENDPOINT_COLOR },
'laptop': { icon: Laptop, color: ENDPOINT_COLOR },
'tablet': { icon: Tablet, color: ENDPOINT_COLOR },
'phone': { icon: Smartphone, color: ENDPOINT_COLOR },
'printer': { icon: Printer, color: ENDPOINT_COLOR },
// Infrastructure / physical
'ups': { icon: BatteryCharging, color: INFRA_COLOR },
'pdu': { icon: PlugZap, color: INFRA_COLOR },
'rack': { icon: RectangleVertical, color: INFRA_COLOR },
'patch-panel': { icon: Cable, color: INFRA_COLOR },
'camera': { icon: Camera, color: INFRA_COLOR },
'nvr': { icon: Video, color: INFRA_COLOR },
'iot': { icon: Radio, color: INFRA_COLOR },
}
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
'network': { icon: Router, color: NETWORK_COLOR },
'compute': { icon: Server, color: COMPUTE_COLOR },
'storage': { icon: Database, color: STORAGE_COLOR },
'cloud': { icon: Cloud, color: CLOUD_COLOR },
'endpoint': { icon: Monitor, color: ENDPOINT_COLOR },
'infrastructure': { icon: PlugZap, color: INFRA_COLOR },
'security': { icon: BrickWallFire, color: SECURITY_COLOR },
}
const FALLBACK: DeviceRenderConfig = { icon: Cpu, color: INFRA_COLOR }
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
if (category && CATEGORY_DEFAULTS[category]) return CATEGORY_DEFAULTS[category]
return FALLBACK
}
export const CATEGORY_LABELS: Record<string, string> = {
'network': 'Network',
'compute': 'Compute',
'storage': 'Storage',
'cloud': 'Cloud',
'endpoint': 'Endpoints',
'infrastructure': 'Infrastructure',
'security': 'Security',
}
export const CATEGORY_COLORS: Record<string, string> = {
'network': NETWORK_COLOR,
'compute': COMPUTE_COLOR,
'storage': STORAGE_COLOR,
'cloud': CLOUD_COLOR,
'endpoint': ENDPOINT_COLOR,
'infrastructure': INFRA_COLOR,
'security': SECURITY_COLOR,
}
export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security']

View File

@@ -1,7 +0,0 @@
import { DeviceNode } from './DeviceNode'
import { GroupNode } from '../ui/labeled-group-node'
export const nodeTypes = {
device: DeviceNode,
group: GroupNode,
}

View File

@@ -1,168 +0,0 @@
import { useState, useCallback } from 'react'
import { Sparkles, ChevronUp, ChevronDown, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import type { AIGenerateResponse } from '@/types'
interface AIAssistPanelProps {
onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
getExistingBounds: () => { minX: number; maxX: number; minY: number; maxY: number } | null
hasNodes: boolean
}
export function AIAssistPanel({ onGenerate, getExistingBounds, hasNodes }: AIAssistPanelProps) {
const [expanded, setExpanded] = useState(false)
const [description, setDescription] = useState('')
const [mode, setMode] = useState<'replace' | 'merge'>('replace')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [replaceConfirm, setReplaceConfirm] = useState(false)
const handleGenerate = useCallback(async () => {
if (!description.trim()) return
setLoading(true)
setError(null)
setReplaceConfirm(false)
try {
const result = await networkDiagramsApi.aiGenerate({
description: description.trim(),
mode,
existingBounds: mode === 'merge' ? getExistingBounds() : null,
})
onGenerate(result, mode)
setDescription('')
setExpanded(false)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Generation failed. Please try again.'
setError(msg)
} finally {
setLoading(false)
}
}, [description, mode, onGenerate, getExistingBounds])
// Reset confirm state when mode changes or panel collapses
const handleModeChange = (newMode: 'replace' | 'merge') => {
setMode(newMode)
setReplaceConfirm(false)
}
const needsReplaceConfirm = mode === 'replace' && hasNodes
if (!expanded) {
return (
<div className="border-t border-default bg-card">
<button
onClick={() => setExpanded(true)}
className="flex w-full items-center justify-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-primary"
>
<Sparkles size={14} />
AI Generate
<ChevronUp size={14} />
</button>
</div>
)
}
return (
<div className="border-t border-default bg-card">
<div className="flex items-center justify-between border-b border-default px-4 py-2">
<div className="flex items-center gap-2 text-xs font-medium text-heading">
<Sparkles size={14} />
AI Generate
</div>
<button
onClick={() => { setExpanded(false); setReplaceConfirm(false) }}
className="text-muted-foreground hover:text-primary"
>
<ChevronDown size={14} />
</button>
</div>
<div className="flex flex-col gap-3 p-4">
<div className="flex gap-2">
<button
onClick={() => handleModeChange('replace')}
className={cn(
'rounded px-3 py-1 text-xs font-medium transition-colors',
mode === 'replace'
? 'bg-accent text-white'
: 'border border-default text-muted-foreground hover:text-primary',
)}
>
Generate New
</button>
<button
onClick={() => handleModeChange('merge')}
className={cn(
'rounded px-3 py-1 text-xs font-medium transition-colors',
mode === 'merge'
? 'bg-accent text-white'
: 'border border-default text-muted-foreground hover:text-primary',
)}
>
Add to Diagram
</button>
</div>
{needsReplaceConfirm && (
<div className="flex items-start gap-2 rounded border border-yellow-500/30 bg-yellow-500/5 px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-yellow-400" />
<p className="text-[11px] text-yellow-400">
This will replace your current diagram. Save first if needed.
</p>
</div>
)}
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe the network you want to create... e.g. 'Small office with a firewall, core switch, 3 access points, and a file server'"
rows={3}
disabled={loading}
className="w-full resize-none rounded border border-default bg-input px-3 py-2 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
/>
{error && <p className="text-[11px] text-red-400">{error}</p>}
{loading ? (
<div className="flex items-center justify-center gap-2 py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent border-t-transparent" />
<span className="text-xs text-muted-foreground">Generating your network diagram</span>
</div>
) : needsReplaceConfirm && !replaceConfirm ? (
<button
onClick={() => setReplaceConfirm(true)}
disabled={!description.trim()}
className="rounded border border-yellow-500/40 bg-yellow-500/10 px-4 py-2 text-xs font-medium text-yellow-400 hover:bg-yellow-500/20 disabled:opacity-50"
>
Replace Diagram
</button>
) : needsReplaceConfirm && replaceConfirm ? (
<div className="flex gap-2">
<button
onClick={() => setReplaceConfirm(false)}
className="flex-1 rounded border border-default px-3 py-2 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={handleGenerate}
disabled={!description.trim()}
className="flex-1 rounded bg-red-500/20 px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/30 disabled:opacity-50"
>
Yes, Replace
</button>
</div>
) : (
<button
onClick={handleGenerate}
disabled={!description.trim()}
className="rounded bg-accent px-4 py-2 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
Generate
</button>
)}
</div>
</div>
)
}

View File

@@ -1,227 +0,0 @@
import { useState, useMemo, useCallback } from 'react'
import { Search, Plus, ChevronDown, ChevronRight, X, LayoutGrid, GripVertical, Globe } from 'lucide-react'
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
import { deviceTypesApi } from '@/api'
interface DeviceToolbarProps {
deviceTypes: DeviceTypeResponse[]
onDeviceTypesChange: () => void
}
export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolbarProps) {
const [search, setSearch] = useState('')
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
const [showAddForm, setShowAddForm] = useState(false)
const [newType, setNewType] = useState<DeviceTypeCreate>({ slug: '', label: '', category: 'network' })
const [addError, setAddError] = useState<string | null>(null)
const [addLoading, setAddLoading] = useState(false)
const filteredByCategory = useMemo(() => {
const lower = search.toLowerCase()
const filtered = search
? deviceTypes.filter(dt => dt.label.toLowerCase().includes(lower) || dt.slug.toLowerCase().includes(lower))
: deviceTypes
const grouped: Record<string, DeviceTypeResponse[]> = {}
for (const dt of filtered) {
if (!grouped[dt.category]) grouped[dt.category] = []
grouped[dt.category].push(dt)
}
return grouped
}, [deviceTypes, search])
const toggleCategory = useCallback((cat: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
return next
})
}, [])
const handleDragStart = useCallback((e: React.DragEvent, deviceType: DeviceTypeResponse) => {
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
slug: deviceType.slug,
label: deviceType.label,
category: deviceType.category,
}))
e.dataTransfer.effectAllowed = 'move'
}, [])
const handleAddType = useCallback(async () => {
if (!newType.slug || !newType.label) {
setAddError('Slug and label are required')
return
}
setAddLoading(true)
setAddError(null)
try {
await deviceTypesApi.create(newType)
setNewType({ slug: '', label: '', category: 'network' })
setShowAddForm(false)
onDeviceTypesChange()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to create device type'
setAddError(msg)
} finally {
setAddLoading(false)
}
}, [newType, onDeviceTypesChange])
return (
<div className="flex h-full w-[200px] flex-col border-r border-default bg-sidebar">
<div className="relative p-2">
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search devices..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded-md border border-default bg-input pl-8 pr-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{CATEGORY_ORDER.map(cat => {
const items = filteredByCategory[cat] || []
const isCloud = cat === 'cloud'
const ispMatchesSearch = !search || 'isp'.includes(search.toLowerCase()) || 'internet service provider'.includes(search.toLowerCase())
const showIsp = isCloud && ispMatchesSearch
if (!items.length && !showIsp) return null
const collapsed = collapsedCategories.has(cat)
const totalCount = items.length + (showIsp ? 1 : 0)
return (
<div key={cat} className="mb-1">
<button
onClick={() => toggleCategory(cat)}
className="flex w-full items-center gap-1 rounded px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-primary"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
{CATEGORY_LABELS[cat] || cat}
<span className="ml-auto text-[10px] font-normal">{totalCount}</span>
</button>
{!collapsed && (
<div className="flex flex-col gap-0.5">
{items.map(dt => {
const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category)
return (
<div
key={dt.id}
draggable
onDragStart={e => handleDragStart(e, dt)}
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
>
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
<Icon size={14} style={{ color }} />
<span>{dt.label}</span>
</div>
)
})}
{showIsp && (
<div
draggable
onDragStart={e => {
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
slug: 'isp',
label: 'ISP',
category: 'cloud',
}))
e.dataTransfer.effectAllowed = 'move'
}}
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
>
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
<span>ISP</span>
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Grouping section */}
<div className="mb-1 mt-2 border-t border-default pt-2">
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Grouping
</div>
<div className="flex flex-col gap-0.5">
{[
{ slug: 'subnet', label: 'Subnet' },
{ slug: 'vlan', label: 'VLAN' },
{ slug: 'site', label: 'Site' },
{ slug: 'dmz', label: 'DMZ' },
].map(item => (
<div
key={item.slug}
draggable
onDragStart={e => {
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
e.dataTransfer.effectAllowed = 'move'
}}
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
>
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
<LayoutGrid size={14} className="text-muted-foreground" />
<span>{item.label}</span>
</div>
))}
</div>
</div>
<div className="border-t border-default p-2">
{!showAddForm ? (
<button
onClick={() => setShowAddForm(true)}
className="flex w-full items-center justify-center gap-1 rounded border border-default px-2 py-1.5 text-xs text-muted-foreground hover:border-hover hover:text-primary"
>
<Plus size={12} />
Custom Type
</button>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">New Type</span>
<button onClick={() => { setShowAddForm(false); setAddError(null) }} className="text-muted-foreground hover:text-primary">
<X size={12} />
</button>
</div>
<input
placeholder="slug (e.g. pacs-server)"
value={newType.slug}
onChange={e => setNewType(prev => ({ ...prev, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<input
placeholder="Label (e.g. PACS Server)"
value={newType.label}
onChange={e => setNewType(prev => ({ ...prev, label: e.target.value }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<select
value={newType.category}
onChange={e => setNewType(prev => ({ ...prev, category: e.target.value }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
>
{CATEGORY_ORDER.map(c => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
))}
</select>
{addError && <p className="text-[10px] text-red-400">{addError}</p>}
<button
onClick={handleAddType}
disabled={addLoading}
className="rounded bg-accent px-2 py-1 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
{addLoading ? 'Adding...' : 'Add Type'}
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,412 +0,0 @@
import { useCallback, useState, useEffect } from 'react'
import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { DeviceProperties, DiagramEdge } from '@/types'
import type { Node, Edge } from '@xyflow/react'
import type { DeviceNodeData } from '../nodes/DeviceNode'
interface PropertiesPanelProps {
selectedNode: Node | null
selectedEdge: Edge | null
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
onEdgeTypeChange: (edgeId: string, edgeType: string) => void
onBringToFront: (nodeId: string) => void
onSendToBack: (nodeId: string) => void
onDeleteNode: (nodeId: string) => void
onDeleteEdge: (edgeId: string) => void
}
type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
const STATUS_CONFIG: Record<NodeStatus, { color: string; label: string }> = {
online: { color: '#34d399', label: 'Online' },
offline: { color: '#f87171', label: 'Offline' },
degraded: { color: '#fbbf24', label: 'Degraded' },
unknown: { color: '#94a3b8', label: 'Unknown' },
}
const STATUS_OPTIONS = Object.keys(STATUS_CONFIG) as NodeStatus[]
const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</label>
)
}
function FieldInput({ value, onChange, placeholder, mono }: {
value: string
onChange: (val: string) => void
placeholder?: string
mono?: boolean
}) {
return (
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
'w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none',
mono && 'font-mono',
)}
/>
)
}
function SectionDivider({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 pt-1">
<span className="whitespace-nowrap text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex-1 border-t border-default" />
</div>
)
}
export function PropertiesPanel({
selectedNode,
selectedEdge,
onNodeUpdate,
onEdgeUpdate,
onEdgeTypeChange,
onBringToFront,
onSendToBack,
onDeleteNode,
onDeleteEdge,
}: PropertiesPanelProps) {
const [deleteConfirm, setDeleteConfirm] = useState(false)
// Reset confirm state whenever the selection changes
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setDeleteConfirm(false) }, [selectedNode?.id, selectedEdge?.id])
const handlePropertyChange = useCallback((field: keyof DeviceProperties, value: string) => {
if (!selectedNode) return
const nodeData = selectedNode.data as unknown as DeviceNodeData
onNodeUpdate(selectedNode.id, {
properties: { ...nodeData.properties, [field]: value },
} as Partial<DeviceNodeData>)
}, [selectedNode, onNodeUpdate])
const handleLabelChange = useCallback((value: string) => {
if (!selectedNode) return
onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
}, [selectedNode, onNodeUpdate])
if (!selectedNode && !selectedEdge) {
return (
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
<p className="text-center text-xs text-muted-foreground">
Select a device or connection to edit its properties
</p>
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
Hover a device to preview its info
</p>
</div>
)
}
if (selectedEdge) {
const edgeData = (selectedEdge.data || {}) as Record<string, unknown>
const connectionType = (edgeData.connectionType as string) || 'ethernet'
const isCustomType = !CONNECTION_TYPE_OPTIONS.includes(connectionType as typeof CONNECTION_TYPE_OPTIONS[number])
return (
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
<div className="border-b border-default px-3 py-2">
<h3 className="text-xs font-semibold text-heading">Connection</h3>
</div>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
<div className="flex flex-col gap-1">
<FieldLabel>Label</FieldLabel>
<FieldInput
value={(selectedEdge.label as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { label: val || null })}
placeholder="Connection label"
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Type</FieldLabel>
<select
value={isCustomType ? '__custom__' : connectionType}
onChange={e => {
const val = e.target.value
if (val !== '__custom__') {
onEdgeUpdate(selectedEdge.id, { connectionType: val })
}
}}
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
>
{CONNECTION_TYPE_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
<option value="__custom__">Custom</option>
</select>
{isCustomType && (
<FieldInput
value={connectionType}
onChange={val => onEdgeUpdate(selectedEdge.id, { connectionType: val })}
placeholder="Custom type name"
/>
)}
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Speed</FieldLabel>
<FieldInput
value={(edgeData.speed as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { speed: val || null })}
placeholder="e.g. 1 Gbps"
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Notes</FieldLabel>
<FieldInput
value={(edgeData.notes as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
placeholder="Port info, cable type…"
mono
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Line Style</FieldLabel>
<div className="flex gap-1">
{([
{ value: null, icon: Minus, label: 'Straight' },
{ value: 'curved', icon: Spline, label: 'Curved' },
{ value: 'step', icon: GitBranch, label: 'Step' },
] as const).map(({ value, icon: Icon, label }) => {
const routing = (edgeData.routing as string | null | undefined) ?? null
const active = routing === value
return (
<button
key={label}
title={label}
onClick={() => onEdgeUpdate(selectedEdge.id, { routing: value })}
className={cn(
'flex flex-1 items-center justify-center gap-1 rounded border py-1.5 text-[10px] transition-colors',
active
? 'border-accent bg-accent/10 text-accent'
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
)}
>
<Icon size={12} />
{label}
</button>
)
})}
</div>
</div>
<div className="flex items-center justify-between">
<FieldLabel>Show Traffic</FieldLabel>
<button
onClick={() => {
const newType = selectedEdge.type === 'animated' ? 'connection' : 'animated'
onEdgeTypeChange(selectedEdge.id, newType)
}}
className={cn(
'relative h-5 w-9 rounded-full transition-colors',
selectedEdge.type === 'animated' ? 'bg-accent' : 'bg-elevated',
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform',
selectedEdge.type === 'animated' && 'translate-x-4',
)}
/>
</button>
</div>
</div>
<div className="border-t border-default p-3">
{deleteConfirm ? (
<div className="flex flex-col gap-1.5">
<p className="text-center text-[10px] text-muted-foreground">Delete this connection?</p>
<div className="flex gap-1.5">
<button
onClick={() => setDeleteConfirm(false)}
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={() => onDeleteEdge(selectedEdge.id)}
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
>
Delete
</button>
</div>
</div>
) : (
<button
onClick={() => setDeleteConfirm(true)}
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>
<Trash2 size={12} />
Delete Connection
</button>
)}
</div>
</div>
)
}
const nodeData = selectedNode!.data as unknown as DeviceNodeData
const props = nodeData.properties || {} as DeviceProperties
const currentStatus = (props.status || 'unknown') as NodeStatus
return (
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
<div className="border-b border-default px-3 py-2">
<h3 className="text-xs font-semibold text-heading">Device Properties</h3>
</div>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
{/* Identity */}
<div className="flex flex-col gap-1">
<FieldLabel>Name</FieldLabel>
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
</div>
{/* Layering */}
<div className="flex flex-col gap-1">
<FieldLabel>Layer</FieldLabel>
<div className="flex gap-1.5">
<button
onClick={() => onBringToFront(selectedNode!.id)}
title="Bring to Front ]"
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
>
<BringToFront size={12} />
Bring Front
</button>
<button
onClick={() => onSendToBack(selectedNode!.id)}
title="Send to Back ["
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
>
<SendToBack size={12} />
Send Back
</button>
</div>
</div>
{/* Status badge grid */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Status</FieldLabel>
<div className="grid grid-cols-2 gap-1">
{STATUS_OPTIONS.map(opt => {
const { color, label } = STATUS_CONFIG[opt]
const active = currentStatus === opt
return (
<button
key={opt}
onClick={() => handlePropertyChange('status', opt)}
className={cn(
'flex items-center justify-center gap-1.5 rounded border py-1.5 text-[10px] font-medium transition-colors',
active
? 'border-transparent text-white'
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
)}
style={active ? { backgroundColor: color } : undefined}
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: active ? 'rgba(255,255,255,0.8)' : color }}
/>
{label}
</button>
)
})}
</div>
</div>
{/* Network section */}
<SectionDivider label="Network" />
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<FieldLabel>IP Address</FieldLabel>
<FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Subnet</FieldLabel>
<FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>VLAN</FieldLabel>
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" mono />
</div>
</div>
{/* Hardware section */}
<SectionDivider label="Hardware" />
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<FieldLabel>Hostname</FieldLabel>
<FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Vendor</FieldLabel>
<FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Model</FieldLabel>
<FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Role</FieldLabel>
<FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
</div>
</div>
{/* Notes */}
<SectionDivider label="Notes" />
<div className="flex flex-col gap-1">
<textarea
value={props.notes || ''}
onChange={e => handlePropertyChange('notes', e.target.value)}
placeholder="Additional notes…"
rows={3}
className="w-full resize-none rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
</div>
<div className="border-t border-default p-3">
{deleteConfirm ? (
<div className="flex flex-col gap-1.5">
<p className="text-center text-[10px] text-muted-foreground">Delete this device?</p>
<div className="flex gap-1.5">
<button
onClick={() => setDeleteConfirm(false)}
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={() => onDeleteNode(selectedNode!.id)}
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
>
Delete
</button>
</div>
</div>
) : (
<button
onClick={() => setDeleteConfirm(true)}
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>
<Trash2 size={12} />
Delete Device
</button>
)}
</div>
</div>
)
}

View File

@@ -1,131 +0,0 @@
import { memo } from 'react'
import {
BaseEdge,
getSmoothStepPath,
getStraightPath,
getBezierPath,
type EdgeProps,
} from '@xyflow/react'
interface AnimatedEdgeData {
connectionType?: string
duration?: number
direction?: 'forward' | 'reverse' | 'alternate' | 'alternate-reverse'
path?: 'bezier' | 'smoothstep' | 'step' | 'straight'
repeat?: number | 'indefinite'
shape?: 'circle' | 'package'
speed?: string | null
notes?: string | null
[key: string]: unknown
}
const CONNECTION_COLORS: Record<string, string> = {
ethernet: '#60a5fa',
fiber: '#34d399',
wifi: '#a78bfa',
vpn: '#eab308',
vlan: '#848b9b',
wan: '#f87171',
}
const DEFAULT_COLOR = '#848b9b'
function getPath(
props: EdgeProps,
pathType: string,
): [string, number, number] {
const params = {
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}
switch (pathType) {
case 'bezier': {
const [path, labelX, labelY] = getBezierPath(params)
return [path, labelX, labelY]
}
case 'straight': {
const [path, labelX, labelY] = getStraightPath(params)
return [path, labelX, labelY]
}
default: {
const [path, labelX, labelY] = getSmoothStepPath(params)
return [path, labelX, labelY]
}
}
}
function getAnimateMotionProps(data: AnimatedEdgeData) {
const duration = data.duration ?? 2
const direction = data.direction ?? 'forward'
const repeat = data.repeat ?? 'indefinite'
const keyPoints: Record<string, string> = {
forward: '0;1',
reverse: '1;0',
alternate: '0;1',
'alternate-reverse': '1;0',
}
return {
dur: `${duration}s`,
repeatCount: String(repeat),
keyPoints: keyPoints[direction] || '0;1',
keyTimes: '0;1',
}
}
function AnimatedSvgEdgeComponent(props: EdgeProps) {
const data = (props.data || {}) as AnimatedEdgeData
const connectionType = data.connectionType || 'ethernet'
const color = CONNECTION_COLORS[connectionType] || DEFAULT_COLOR
const pathType = data.path ?? 'smoothstep'
const shape = data.shape ?? 'circle'
const [edgePath] = getPath(props, pathType)
const motionProps = getAnimateMotionProps(data)
return (
<>
<BaseEdge
path={edgePath}
style={{
stroke: color,
strokeWidth: props.selected ? 3 : 2,
...(connectionType === 'wifi' || connectionType === 'wan' || connectionType === 'vpn'
? { strokeDasharray: connectionType === 'wifi' ? '3,3' : '8,4' }
: {}),
}}
/>
<circle r={0} fill={color}>
<animateMotion
path={edgePath}
calcMode="linear"
{...motionProps}
/>
<animate
attributeName="r"
values="0;3;3;3;0"
keyTimes="0;0.05;0.5;0.95;1"
dur={motionProps.dur}
repeatCount={motionProps.repeatCount}
/>
</circle>
{shape === 'package' && (
<rect x={-4} y={-4} width={8} height={8} rx={2} fill={color} opacity={0.8}>
<animateMotion
path={edgePath}
calcMode="linear"
{...motionProps}
/>
</rect>
)}
</>
)
}
export const AnimatedSvgEdge = memo(AnimatedSvgEdgeComponent)

View File

@@ -1,20 +0,0 @@
import type { ComponentProps } from 'react'
import { Handle, type HandleProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
export type BaseHandleProps = HandleProps
export function BaseHandle({ className, children, ...props }: ComponentProps<typeof Handle>) {
return (
<Handle
{...props}
className={cn(
'h-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
'opacity-0 group-hover:opacity-100',
className,
)}
>
{children}
</Handle>
)
}

View File

@@ -1,56 +0,0 @@
import type { ComponentProps } from 'react'
import { cn } from '@/lib/utils'
export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
return (
<div
className={cn(
'bg-card text-heading relative rounded-lg border border-default',
'transition-colors hover:border-hover',
'in-[.selected]:border-accent',
className,
)}
tabIndex={0}
{...props}
/>
)
}
export function BaseNodeHeader({ className, ...props }: ComponentProps<'header'>) {
return (
<header
{...props}
className={cn('flex flex-row items-center gap-2 px-3 py-2', className)}
/>
)
}
export function BaseNodeHeaderTitle({ className, ...props }: ComponentProps<'h3'>) {
return (
<h3
data-slot="base-node-title"
className={cn('select-none flex-1 text-xs font-semibold text-heading', className)}
{...props}
/>
)
}
export function BaseNodeContent({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="base-node-content"
className={cn('flex flex-col gap-y-1 px-3 pb-2', className)}
{...props}
/>
)
}
export function BaseNodeFooter({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="base-node-footer"
className={cn('flex flex-col items-center gap-y-1 border-t border-default px-3 pt-1.5 pb-2', className)}
{...props}
/>
)
}

View File

@@ -1,68 +0,0 @@
import type { ReactNode, ComponentProps } from 'react'
import { Panel, NodeResizer, type NodeProps, type PanelPosition } from '@xyflow/react'
import { BaseNode } from './base-node'
import { cn } from '@/lib/utils'
export type GroupNodeLabelProps = ComponentProps<'div'>
export function GroupNodeLabel({ children, className, ...props }: GroupNodeLabelProps) {
return (
<div className="h-full w-full" {...props}>
<div className={cn('bg-card text-muted-foreground w-fit p-2 text-[10px] font-semibold uppercase tracking-wider', className)}>
{children}
</div>
</div>
)
}
export interface GroupNodeData {
label?: string
groupType?: string
[key: string]: unknown
}
export type GroupNodeProps = Partial<NodeProps> & {
label?: ReactNode
position?: PanelPosition
}
function getLabelClassName(position?: PanelPosition): string {
switch (position) {
case 'top-left': return 'rounded-br-sm'
case 'top-center': return 'rounded-b-sm'
case 'top-right': return 'rounded-bl-sm'
case 'bottom-left': return 'rounded-tr-sm'
case 'bottom-right': return 'rounded-tl-sm'
case 'bottom-center': return 'rounded-t-sm'
default: return 'rounded-br-sm'
}
}
export function GroupNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GroupNodeData
const label = nodeData.label || 'Group'
return (
<>
<NodeResizer
isVisible={selected}
minWidth={150}
minHeight={100}
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
/>
<BaseNode
className={cn(
'h-full w-full min-h-[100px] min-w-[150px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
selected && 'border-accent',
)}
>
<Panel className="m-0 p-0" position="top-left">
<GroupNodeLabel className={getLabelClassName('top-left')}>
{label}
</GroupNodeLabel>
</Panel>
</BaseNode>
</>
)
}

View File

@@ -1,39 +0,0 @@
import type { ComponentProps } from 'react'
import { type HandleProps, Position } from '@xyflow/react'
import { cn } from '@/lib/utils'
import { BaseHandle } from './base-handle'
const flexDirections: Record<string, string> = {
[Position.Top]: 'flex-col',
[Position.Right]: 'flex-row-reverse justify-end',
[Position.Bottom]: 'flex-col-reverse justify-end',
[Position.Left]: 'flex-row',
}
export function LabeledHandle({
className,
labelClassName,
handleClassName,
title,
position,
...props
}: HandleProps &
ComponentProps<'div'> & {
title: string
handleClassName?: string
labelClassName?: string
}) {
const { ref, ...handleProps } = props
return (
<div
title={title}
className={cn('relative flex items-center', flexDirections[position], className)}
ref={ref}
>
<BaseHandle position={position} className={handleClassName} {...handleProps} />
<label className={cn('text-muted-foreground text-[10px] font-mono px-1.5', labelClassName)}>
{title}
</label>
</div>
)
}

View File

@@ -1,43 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
online: 'border-emerald-400',
offline: 'border-red-400',
degraded: 'border-yellow-400',
unknown: '',
}
const STATUS_GLOW: Record<NodeStatus, string> = {
online: 'shadow-[0_0_8px_rgba(52,211,153,0.3)]',
offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]',
degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]',
unknown: '',
}
interface NodeStatusIndicatorProps {
status?: NodeStatus
children: ReactNode
className?: string
}
export function NodeStatusIndicator({ status = 'unknown', children, className }: NodeStatusIndicatorProps) {
if (status === 'unknown') {
return <>{children}</>
}
return (
<div
className={cn(
'rounded-lg border-2 transition-colors',
STATUS_BORDER_COLORS[status],
STATUS_GLOW[status],
className,
)}
>
{children}
</div>
)
}

View File

@@ -1,77 +0,0 @@
import { createContext, useContext, useState, useCallback, type ReactNode, type ComponentProps } from 'react'
import { NodeToolbar, type NodeToolbarProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
interface NodeTooltipContextValue {
visible: boolean
show: () => void
hide: () => void
}
const NodeTooltipContext = createContext<NodeTooltipContextValue>({
visible: false,
show: () => {},
hide: () => {},
})
export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) {
const [visible, setVisible] = useState(false)
const show = useCallback(() => setVisible(true), [])
const hide = useCallback(() => setVisible(false), [])
return (
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
<div {...props}>{children}</div>
</NodeTooltipContext.Provider>
)
}
export function NodeTooltipTrigger({
children,
onMouseEnter,
onMouseLeave,
...props
}: ComponentProps<'div'>) {
const { show, hide } = useContext(NodeTooltipContext)
return (
<div
onMouseEnter={(e) => {
show()
onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
hide()
onMouseLeave?.(e)
}}
{...props}
>
{children}
</div>
)
}
export function NodeTooltipContent({
className,
position,
children,
...props
}: Omit<NodeToolbarProps, 'children'> & { children: ReactNode }) {
const { visible } = useContext(NodeTooltipContext)
if (!visible) return null
return (
<NodeToolbar
position={position}
className={cn(
'rounded-lg border border-default bg-elevated px-3 py-2',
'pointer-events-none',
className,
)}
{...props}
>
{children}
</NodeToolbar>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,660 +0,0 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ReactFlowProvider,
useNodesState,
useEdgesState,
addEdge,
useReactFlow,
getNodesBounds,
getViewportForBounds,
type Node,
type Edge,
type Connection,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
import { DiagramHeader } from '@/components/network/DiagramHeader'
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
import { networkDiagramsApi, deviceTypesApi } from '@/api'
import { toast } from '@/lib/toast'
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
type ContextMenuState = {
type: 'node' | 'canvas'
position: { x: number; y: number }
nodeId?: string
} | null
function DiagramEditorInner() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { getNodes, fitView, screenToFlowPosition } = useReactFlow()
const [diagramId, setDiagramId] = useState<string | null>(id || null)
const [name, setName] = useState('Untitled Diagram')
const [clientName, setClientName] = useState<string | null>(null)
const [assetName, setAssetName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) ?? null : null
const selectedEdge = selectedEdgeId ? edges.find(e => e.id === selectedEdgeId) ?? null : null
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const isDirtyRef = useRef(false)
const diagramIdRef = useRef<string | null>(id || null)
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
const [loading, setLoading] = useState(!!id)
const [isDragOver, setIsDragOver] = useState(false)
const canvasRef = useRef<HTMLDivElement | null>(null)
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
const {
copyNodes,
pasteNodes,
duplicateNodes,
selectAll,
deleteSelected,
hasClipboard,
} = useCanvasShortcuts({
nodes,
edges,
setNodes,
setEdges,
setIsDirty: (v: boolean) => setIsDirty(v),
canvasRef,
})
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
onNodesChange(changes)
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onNodesChange])
const handleEdgesChange: typeof onEdgesChange = useCallback((changes) => {
onEdgesChange(changes)
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onEdgesChange])
const loadDeviceTypes = useCallback(async () => {
try {
const types = await deviceTypesApi.list()
setDeviceTypes(types)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadDeviceTypes() }, [loadDeviceTypes])
useEffect(() => {
if (!id) return
let cancelled = false
;(async () => {
try {
const diagram = await networkDiagramsApi.get(id)
if (cancelled) return
setName(diagram.name)
setClientName(diagram.client_name)
setAssetName(diagram.asset_name)
setDescription(diagram.description)
setNodes(
diagram.nodes.map(n => {
if (n.nodeType === 'group') {
return {
id: n.id,
type: 'group',
position: n.position,
style: n.style || { width: 300, height: 200 },
data: {
label: n.label,
groupType: n.type,
},
}
}
return {
id: n.id,
type: 'device',
position: n.position,
style: n.style || { width: 120, height: 120 },
data: {
label: n.label,
deviceType: n.type,
properties: n.properties,
} satisfies DeviceNodeData,
}
})
)
setEdges(
diagram.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: {
connectionType: e.connectionType,
speed: e.speed,
notes: e.notes,
routing: e.routing ?? null,
},
}))
)
setLastSavedAt(new Date(diagram.updated_at))
} catch {
toast.error('Failed to load diagram')
navigate('/network-diagrams')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [id, navigate, setNodes, setEdges])
const serializeNodes = useCallback((): DiagramNode[] => {
return getNodes().map(n => {
if (n.type === 'group') {
const data = n.data as Record<string, unknown>
const width = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 300)
const height = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 200)
return {
id: n.id,
type: (data.groupType as string) || 'subnet',
label: (data.label as string) || 'Group',
position: n.position,
properties: {} as DeviceProperties,
nodeType: 'group',
style: { width, height },
}
}
const data = n.data as unknown as DeviceNodeData
const dw = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 120)
const dh = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 120)
return {
id: n.id,
type: data.deviceType,
label: data.label,
position: n.position,
properties: data.properties,
style: { width: dw, height: dh },
}
})
}, [getNodes])
const serializeEdges = useCallback((): DiagramEdge[] => {
return edges.map(e => {
const d = (e.data as Record<string, unknown>) || {}
return {
id: e.id,
source: e.source,
target: e.target,
label: (e.label as string) || null,
connectionType: d.connectionType as string || 'ethernet',
speed: d.speed as string || null,
notes: d.notes as string || null,
routing: d.routing as string || null,
}
})
}, [edges])
const handleSave = useCallback(async () => {
setIsSaving(true)
try {
const payload = {
name,
client_name: clientName,
asset_name: assetName,
description,
nodes: serializeNodes(),
edges: serializeEdges(),
}
if (diagramIdRef.current) {
await networkDiagramsApi.update(diagramIdRef.current, payload)
} else {
const created = await networkDiagramsApi.create(payload)
setDiagramId(created.id)
navigate(`/network-diagrams/${created.id}`, { replace: true })
}
setIsDirty(false)
setLastSavedAt(new Date())
} catch {
toast.error('Failed to save diagram')
} finally {
setIsSaving(false)
}
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
useEffect(() => {
const interval = setInterval(() => {
if (isDirtyRef.current && diagramIdRef.current) {
handleSave()
}
}, 30_000)
return () => clearInterval(interval)
}, [handleSave])
const onConnect = useCallback((connection: Connection) => {
setEdges(eds => addEdge({
...connection,
type: 'connection',
data: { connectionType: 'ethernet' },
}, eds))
setIsDirty(true)
}, [setEdges])
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setIsDragOver(true)
}, [])
const onDragLeave = useCallback((event: React.DragEvent) => {
const relatedTarget = event.relatedTarget as HTMLElement | null
if (relatedTarget && (event.currentTarget as HTMLElement).contains(relatedTarget)) return
setIsDragOver(false)
}, [])
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
event.preventDefault()
const currentNodes = getNodes()
const isInSelection = currentNodes.find(n => n.id === node.id)?.selected
if (!isInSelection) {
setNodes(nds => nds.map(n => ({ ...n, selected: n.id === node.id })))
setSelectedNodeId(node.id)
setSelectedEdgeId(null)
}
setContextMenu({
type: 'node',
position: { x: event.clientX, y: event.clientY },
nodeId: node.id,
})
}, [getNodes, setNodes, setSelectedNodeId, setSelectedEdgeId])
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
event.preventDefault()
setContextMenu({
type: 'canvas',
position: { x: event.clientX, y: event.clientY },
})
}, [])
const closeContextMenu = useCallback(() => {
setContextMenu(null)
}, [])
const onDrop = useCallback((event: React.DragEvent) => {
event.preventDefault()
setIsDragOver(false)
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
// Handle device drops
const deviceRaw = event.dataTransfer.getData('application/reactflow-device')
if (deviceRaw) {
const { slug, label, category } = JSON.parse(deviceRaw) as { slug: string; label: string; category: string }
const newNode: Node = {
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: 'device',
position,
style: { width: 120, height: 120 },
data: {
label,
deviceType: slug,
category,
properties: {
hostname: null,
ip: null,
subnet: null,
vendor: null,
model: null,
role: null,
vlan: null,
notes: null,
status: 'unknown',
} satisfies DeviceProperties,
} satisfies DeviceNodeData,
}
setNodes(nds => [...nds, newNode])
setIsDirty(true)
return
}
// Handle group drops
const groupRaw = event.dataTransfer.getData('application/reactflow-group')
if (groupRaw) {
const { slug, label } = JSON.parse(groupRaw) as { slug: string; label: string }
const newNode: Node = {
id: `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: 'group',
position,
style: { width: 300, height: 200 },
data: {
label,
groupType: slug,
},
}
setNodes(nds => [...nds, newNode])
setIsDirty(true)
}
}, [setNodes, screenToFlowPosition])
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
setNodes(nds => nds.map(n => {
if (n.id !== nodeId) return n
return { ...n, data: { ...n.data, ...updates } }
}))
setIsDirty(true)
}, [setNodes])
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
setEdges(eds => eds.map(e => {
if (e.id !== edgeId) return e
return {
...e,
label: updates.label !== undefined ? (updates.label || undefined) : e.label,
data: {
...(e.data || {}),
...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}),
...(updates.speed !== undefined ? { speed: updates.speed } : {}),
...(updates.notes !== undefined ? { notes: updates.notes } : {}),
...(updates.routing !== undefined ? { routing: updates.routing } : {}),
},
}
}))
setIsDirty(true)
}, [setEdges])
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
setEdges(eds => eds.map(e => {
if (e.id !== edgeId) return e
return { ...e, type: edgeType }
}))
setIsDirty(true)
}, [setEdges])
const handleDeleteNode = useCallback((nodeId: string) => {
setNodes(nds => nds.filter(n => n.id !== nodeId))
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
setSelectedNodeId(null)
setIsDirty(true)
}, [setNodes, setEdges])
const handleDeleteEdge = useCallback((edgeId: string) => {
setEdges(eds => eds.filter(e => e.id !== edgeId))
setSelectedEdgeId(null)
setIsDirty(true)
}, [setEdges])
const handleBringToFront = useCallback((nodeId: string) => {
setNodes(nds => {
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
})
setIsDirty(true)
}, [setNodes])
const handleSendToBack = useCallback((nodeId: string) => {
setNodes(nds => {
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n)
})
setIsDirty(true)
}, [setNodes])
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
const newNodes: Node[] = result.nodes.map(n => ({
id: n.id,
type: 'device',
position: n.position,
data: {
label: n.label,
deviceType: n.type,
properties: n.properties,
} satisfies DeviceNodeData,
}))
const newEdges: Edge[] = result.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
}))
if (mode === 'replace') {
setNodes(newNodes)
setEdges(newEdges)
} else {
setNodes(nds => [...nds, ...newNodes])
setEdges(eds => [...eds, ...newEdges])
}
if (result.suggestedName && !diagramId) {
setName(result.suggestedName)
toast.success(`Generated: ${result.suggestedName}`)
} else {
toast.success('Diagram generated')
}
if (result.notes) {
toast.info(result.notes)
}
setIsDirty(true)
setTimeout(() => fitView({ padding: 0.2 }), 100)
}, [setNodes, setEdges, diagramId, fitView])
const getExistingBounds = useCallback(() => {
const currentNodes = getNodes()
if (currentNodes.length === 0) return null
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
for (const n of currentNodes) {
minX = Math.min(minX, n.position.x)
maxX = Math.max(maxX, n.position.x + 120)
minY = Math.min(minY, n.position.y)
maxY = Math.max(maxY, n.position.y + 80)
}
return { minX, maxX, minY, maxY }
}, [getNodes])
const handleExportPng = useCallback(async () => {
if (nodes.length === 0) {
toast.warning('Add some devices to the diagram before exporting')
return
}
try {
const { toPng } = await import('html-to-image')
const IMAGE_WIDTH = 1920
const IMAGE_HEIGHT = 1080
const bounds = getNodesBounds(nodes)
const viewport = getViewportForBounds(bounds, IMAGE_WIDTH, IMAGE_HEIGHT, 0.5, 2, 0.15)
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
if (!flowEl) {
toast.error('Could not find canvas to export')
return
}
const dataUrl = await toPng(flowEl, {
backgroundColor: '#16181f',
width: IMAGE_WIDTH,
height: IMAGE_HEIGHT,
style: {
width: `${IMAGE_WIDTH}px`,
height: `${IMAGE_HEIGHT}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: 'top left',
},
})
const a = document.createElement('a')
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.png`
a.href = dataUrl
a.click()
} catch {
toast.error('PNG export failed — try Print > Save as PDF instead')
}
}, [nodes, name])
const handleExportPdf = useCallback(() => {
window.print()
}, [])
const handleExportJson = useCallback(async () => {
if (!diagramId) return
try {
const data = await networkDiagramsApi.exportJson(diagramId)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('Failed to export diagram')
}
}, [diagramId, name])
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
)
}
return (
<div className="flex h-full flex-col">
<DiagramHeader
name={name}
clientName={clientName}
isDirty={isDirty}
isSaving={isSaving}
lastSavedAt={lastSavedAt}
diagramId={diagramId}
onNameChange={n => { setName(n); setIsDirty(true) }}
onSave={handleSave}
onExportPng={handleExportPng}
onExportPdf={handleExportPdf}
onExportJson={handleExportJson}
/>
<div className="flex flex-1 min-h-0">
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
<div className="flex flex-1 flex-col min-h-0">
<div className="relative flex-1 min-h-0" ref={canvasRef}>
<NetworkCanvas
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeSelect={setSelectedNodeId}
onEdgeSelect={setSelectedEdgeId}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
isDragOver={isDragOver}
onNodeContextMenu={handleNodeContextMenu}
onPaneContextMenu={handlePaneContextMenu}
onPaneClick={closeContextMenu}
/>
{nodes.length === 0 && !loading && (
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
)}
</div>
{nodes.length > 0 && (
<AIAssistPanel
onGenerate={handleAIGenerate}
getExistingBounds={getExistingBounds}
hasNodes={nodes.length > 0}
/>
)}
</div>
<PropertiesPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
onNodeUpdate={handleNodeUpdate}
onEdgeUpdate={handleEdgeUpdate}
onEdgeTypeChange={handleEdgeTypeChange}
onBringToFront={handleBringToFront}
onSendToBack={handleSendToBack}
onDeleteNode={handleDeleteNode}
onDeleteEdge={handleDeleteEdge}
/>
</div>
{contextMenu && (
<ContextMenu
position={contextMenu.position}
actions={
contextMenu.type === 'node'
? getNodeMenuActions({
onCopy: copyNodes,
onDuplicate: duplicateNodes,
onBringToFront: () => { if (contextMenu.nodeId) handleBringToFront(contextMenu.nodeId) },
onSendToBack: () => { if (contextMenu.nodeId) handleSendToBack(contextMenu.nodeId) },
onDelete: () => {
const nodeId = contextMenu.nodeId
setContextMenu(null)
if (nodeId) setPendingDeleteNodeId(nodeId)
else deleteSelected()
},
})
: getCanvasMenuActions({
onPaste: pasteNodes,
onSelectAll: selectAll,
onFitView: () => fitView({ padding: 0.2 }),
hasClipboard: hasClipboard(),
})
}
onClose={closeContextMenu}
/>
)}
{pendingDeleteNodeId && (
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center">
<div className="pointer-events-auto flex items-center gap-3 rounded-lg border border-default bg-card px-4 py-2.5 shadow-lg">
<span className="text-xs text-muted-foreground">Delete this device?</span>
<button
onClick={() => setPendingDeleteNodeId(null)}
className="rounded border border-default px-3 py-1 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={() => { handleDeleteNode(pendingDeleteNodeId); setPendingDeleteNodeId(null) }}
className="rounded bg-red-500/20 px-3 py-1 text-xs font-medium text-red-400 hover:bg-red-500/30"
>
Delete
</button>
</div>
</div>
)}
</div>
)
}
export default function DiagramEditor() {
return (
<ReactFlowProvider>
<DiagramEditorInner />
</ReactFlowProvider>
)
}

View File

@@ -1,323 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import { toast } from '@/lib/toast'
import { CATEGORY_COLORS } from '@/components/network/nodes/deviceRegistry'
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
const OTHER_COLOR = '#4f5666'
function TopologyBar({ categoryCounts, nodeCount }: { categoryCounts: Record<string, number>; nodeCount: number }) {
if (nodeCount === 0) return null
const sorted = Object.entries(categoryCounts).sort(([, a], [, b]) => b - a)
const tooltipLabel = sorted.map(([cat, count]) => `${count} ${cat}`).join(' · ')
return (
<div className="group/bar relative flex h-2 w-full overflow-hidden rounded-full" title={tooltipLabel}>
{sorted.map(([cat, count]) => (
<div
key={cat}
style={{
width: `${(count / nodeCount) * 100}%`,
backgroundColor: CATEGORY_COLORS[cat] ?? OTHER_COLOR,
}}
/>
))}
</div>
)
}
export default function NetworkDiagramsPage() {
const navigate = useNavigate()
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [clientFilter, setClientFilter] = useState<string | null>(null)
const [clientOptions, setClientOptions] = useState<string[]>([])
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
const [clientSearch, setClientSearch] = useState('')
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
const clientDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!clientDropdownOpen) return
const handleClick = (e: MouseEvent) => {
if (clientDropdownRef.current && !clientDropdownRef.current.contains(e.target as Node)) {
setClientDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [clientDropdownOpen])
const loadDiagrams = useCallback(async () => {
try {
const params: Record<string, string> = {}
if (clientFilter) params.client_name = clientFilter
if (search) params.search = search
const data = await networkDiagramsApi.list(params)
setDiagrams(data)
} catch {
toast.error('Failed to load diagrams')
} finally {
setLoading(false)
}
}, [clientFilter, search])
const loadClients = useCallback(async () => {
try {
const clients = await networkDiagramsApi.listClients()
setClientOptions(clients)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadDiagrams() }, [loadDiagrams])
useEffect(() => { loadClients() }, [loadClients])
const filteredClients = useMemo(() => {
if (!clientSearch) return clientOptions
const lower = clientSearch.toLowerCase()
return clientOptions.filter(c => c.toLowerCase().includes(lower))
}, [clientOptions, clientSearch])
const handleDuplicate = useCallback(async (id: string) => {
try {
const dup = await networkDiagramsApi.duplicate(id)
toast.success(`Created: ${dup.name}`)
loadDiagrams()
} catch {
toast.error('Failed to duplicate')
}
setMenuOpenId(null)
}, [loadDiagrams])
const handleArchive = useCallback(async (id: string) => {
try {
await networkDiagramsApi.archive(id)
toast.success('Diagram archived')
loadDiagrams()
} catch {
toast.error('Failed to archive')
}
setMenuOpenId(null)
setConfirmArchiveId(null)
}, [loadDiagrams])
const handleImport = useCallback(async () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text) as DiagramImportData
const result = await networkDiagramsApi.importJson(data)
if (result.warnings.length > 0) {
toast.warning(`Imported with warnings: ${result.warnings.join(', ')}`)
} else {
toast.success('Diagram imported')
}
navigate(`/network-diagrams/${result.diagram.id}`)
} catch {
toast.error('Failed to import — check that the file is a valid diagram JSON')
}
}
input.click()
}, [navigate])
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
return (
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="font-heading text-2xl font-bold text-heading">Network Maps</h1>
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
</div>
<div className="flex gap-2">
<button
onClick={handleImport}
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
>
<Upload size={14} />
Import
</button>
<button
onClick={() => navigate('/network-diagrams/new')}
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
<Plus size={14} />
New Diagram
</button>
</div>
</div>
<div className="mb-6 flex gap-3">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search diagrams..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded border border-default bg-input pl-9 pr-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
<div className="relative w-48" ref={clientDropdownRef}>
<button
onClick={() => setClientDropdownOpen(prev => !prev)}
className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
>
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
{clientFilter || 'All clients'}
</span>
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
</button>
{clientDropdownOpen && (
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
<div className="p-2">
<input
type="text"
placeholder="Search clients..."
value={clientSearch}
onChange={e => setClientSearch(e.target.value)}
className="w-full rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto">
<button
onClick={() => { setClientFilter(null); setClientDropdownOpen(false); setClientSearch('') }}
className={cn('w-full px-3 py-1.5 text-left text-xs hover:bg-elevated', !clientFilter && 'text-accent')}
>
All clients
</button>
{filteredClients.map(c => (
<button
key={c}
onClick={() => { setClientFilter(c); setClientDropdownOpen(false); setClientSearch('') }}
className={cn('w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated', clientFilter === c && 'text-accent')}
>
{c}
</button>
))}
</div>
</div>
)}
</div>
</div>
{loading && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-40 animate-pulse rounded-lg border border-default bg-card" />
))}
</div>
)}
{!loading && diagrams.length === 0 && (
<div className="flex flex-col items-center justify-center py-20">
<Network size={48} className="mb-4 text-muted-foreground" />
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
<button
onClick={() => navigate('/network-diagrams/new')}
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
Create First Diagram
</button>
</div>
)}
{!loading && diagrams.length > 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{diagrams.map(d => (
<div
key={d.id}
onClick={() => navigate(`/network-diagrams/${d.id}`)}
className="group relative cursor-pointer rounded-lg border border-default bg-card p-4 hover:border-hover"
>
<div className="mb-2 flex items-start justify-between">
<h3 className="font-heading text-sm font-semibold text-heading">{d.name}</h3>
<button
onClick={e => { e.stopPropagation(); setMenuOpenId(menuOpenId === d.id ? null : d.id) }}
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-elevated group-hover:opacity-100"
>
<MoreHorizontal size={14} />
</button>
</div>
{d.client_name && (
<span className="mb-2 inline-block rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
{d.client_name}
</span>
)}
{d.description && (
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
)}
{d.node_count > 0 && (
<div className="mb-2">
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
</div>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
<span>{formatDate(d.created_at)}</span>
</div>
{menuOpenId === d.id && (
<div className="absolute right-2 top-10 z-50 w-44 rounded border border-default bg-card py-1 shadow-lg">
{confirmArchiveId === d.id ? (
<>
<p className="px-3 py-1.5 text-[10px] text-muted-foreground">Archive this diagram?</p>
<div className="flex gap-1 px-2 pb-1.5">
<button
onClick={e => { e.stopPropagation(); setConfirmArchiveId(null) }}
className="flex-1 rounded border border-default px-2 py-1 text-[10px] text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
className="flex-1 rounded bg-red-500/20 px-2 py-1 text-[10px] font-medium text-red-400 hover:bg-red-500/30"
>
Archive
</button>
</div>
</>
) : (
<>
<button
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
Open
</button>
<button
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
Duplicate
</button>
<button
onClick={e => { e.stopPropagation(); setConfirmArchiveId(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
>
Archive
</button>
</>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,12 +60,11 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage'))
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
// Admin pages
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
const AdminUsersPage = lazyWithRetry(() => import('@/pages/admin/UsersPage'))
const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/AccountsPage'))
const AdminAccountDetailPage = lazyWithRetry(() => import('@/pages/admin/AccountDetailPage'))
const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage'))
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
@@ -197,9 +196,6 @@ export const router = sentryCreateBrowserRouter([
{ path: 'scripts', element: page(ScriptLibraryPage) },
{ path: 'scripts/manage', element: page(ScriptManagePage) },
{ path: 'script-builder', element: page(ScriptBuilderPage) },
{ path: 'network-diagrams', element: page(NetworkDiagramsPage) },
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
{ path: 'assistant', element: page(AssistantChatPage) },
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
@@ -227,7 +223,9 @@ export const router = sentryCreateBrowserRouter([
),
children: [
{ index: true, element: page(AdminDashboardPage) },
{ path: 'users', element: page(AdminUsersPage) },
{ path: 'accounts', element: page(AdminAccountsPage) },
{ path: 'accounts/:accountId', element: page(AdminAccountDetailPage) },
{ path: 'users', element: page(AdminAccountsPage) },
{ path: 'users/:userId', element: page(AdminUserDetailPage) },
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },

View File

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

View File

@@ -98,4 +98,3 @@ export * from './script-builder'
export * from './integrations'
export * from './notification'
export type * from './public-templates'
export * from './network-diagram'

View File

@@ -1,134 +0,0 @@
export interface DeviceProperties {
hostname: string | null
ip: string | null
subnet: string | null
vendor: string | null
model: string | null
role: string | null
vlan: string | null
notes: string | null
status: 'unknown' | 'online' | 'offline' | 'degraded'
}
export interface DiagramNode {
id: string
type: string
label: string
position: { x: number; y: number }
properties: DeviceProperties
nodeType?: string
style?: { width?: number; height?: number } | null
}
export interface DiagramEdge {
id: string
source: string
target: string
label: string | null
connectionType: string
speed: string | null
notes: string | null
routing?: string | null
}
export interface DeviceTypeResponse {
id: string
slug: string
label: string
category: string
is_system: boolean
account_id: string
sort_order: number
created_at: string
}
export interface DeviceTypeCreate {
slug: string
label: string
category: string
sort_order?: number
}
export interface NetworkDiagramResponse {
id: string
account_id: string
name: string
client_name: string | null
asset_name: string | null
description: string | null
nodes: DiagramNode[]
edges: DiagramEdge[]
thumbnail_url: string | null
is_archived: boolean
created_by: string | null
created_at: string
updated_at: string
}
export interface NetworkDiagramListItem {
id: string
name: string
client_name: string | null
description: string | null
node_count: number
category_counts: Record<string, number>
created_by: string | null
created_at: string
updated_at: string
}
export interface NetworkDiagramCreate {
name: string
client_name?: string | null
asset_name?: string | null
description?: string | null
nodes?: DiagramNode[]
edges?: DiagramEdge[]
}
export interface NetworkDiagramUpdate {
name?: string
client_name?: string | null
asset_name?: string | null
description?: string | null
nodes?: DiagramNode[]
edges?: DiagramEdge[]
}
export interface AIGenerateRequest {
description: string
client_name?: string | null
mode: 'replace' | 'merge'
existingBounds?: { minX: number; maxX: number; minY: number; maxY: number } | null
}
export interface AIGenerateResponse {
nodes: DiagramNode[]
edges: DiagramEdge[]
suggestedName: string | null
notes: string | null
}
export interface DiagramImportData {
schemaVersion: number
name: string
client_name?: string | null
description?: string | null
nodes: DiagramNode[]
edges: DiagramEdge[]
}
export interface DiagramImportResponse {
diagram: NetworkDiagramResponse
warnings: string[]
}
export interface DiagramExportResponse {
schemaVersion: number
name: string
client_name: string | null
description: string | null
nodes: DiagramNode[]
edges: DiagramEdge[]
exportedAt: string
}