mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-06 16:15:46 +00:00
Compare commits
1 Commits
nikg/std-e
...
nikg/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534def5e60 |
@@ -2,13 +2,12 @@ from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Request
|
||||
|
||||
from onyx.configs.app_configs import DATA_PLANE_SECRET
|
||||
from onyx.configs.app_configs import EXPECTED_API_KEY
|
||||
from onyx.configs.app_configs import JWT_ALGORITHM
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -33,24 +32,22 @@ async def control_plane_dep(request: Request) -> None:
|
||||
api_key = request.headers.get("X-API-KEY")
|
||||
if api_key != EXPECTED_API_KEY:
|
||||
logger.warning("Invalid API key")
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, "Invalid API key")
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
logger.warning("Invalid authorization header")
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, "Invalid authorization header")
|
||||
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
try:
|
||||
payload = jwt.decode(token, DATA_PLANE_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
if payload.get("scope") != "tenant:create":
|
||||
logger.warning("Insufficient permissions")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS, "Insufficient permissions"
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token has expired")
|
||||
raise OnyxError(OnyxErrorCode.TOKEN_EXPIRED, "Token has expired")
|
||||
raise HTTPException(status_code=401, detail="Token has expired")
|
||||
except jwt.InvalidTokenError:
|
||||
logger.warning("Invalid token")
|
||||
raise OnyxError(OnyxErrorCode.INVALID_TOKEN, "Invalid token")
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Response
|
||||
from fastapi_users import exceptions
|
||||
|
||||
@@ -11,8 +12,6 @@ from onyx.auth.users import get_redis_strategy
|
||||
from onyx.auth.users import User
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -31,7 +30,7 @@ async def impersonate_user(
|
||||
except exceptions.UserNotExists:
|
||||
detail = f"User has no tenant mapping: {impersonate_request.email=}"
|
||||
logger.warning(detail)
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, detail)
|
||||
raise HTTPException(status_code=422, detail=detail)
|
||||
|
||||
with get_session_with_tenant(tenant_id=tenant_id) as tenant_session:
|
||||
user_to_impersonate = get_user_by_email(
|
||||
@@ -42,7 +41,7 @@ async def impersonate_user(
|
||||
f"User not found in tenant: {impersonate_request.email=} {tenant_id=}"
|
||||
)
|
||||
logger.warning(detail)
|
||||
raise OnyxError(OnyxErrorCode.USER_NOT_FOUND, detail)
|
||||
raise HTTPException(status_code=422, detail=detail)
|
||||
|
||||
token = await get_redis_strategy().write_token(user_to_impersonate)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Response
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
@@ -17,8 +18,6 @@ from onyx.auth.users import User
|
||||
from onyx.configs.constants import ANONYMOUS_USER_COOKIE_NAME
|
||||
from onyx.configs.constants import FASTAPI_USERS_AUTH_COOKIE_NAME
|
||||
from onyx.db.engine.sql_engine import get_session_with_shared_schema
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
@@ -34,7 +33,7 @@ async def get_anonymous_user_path_api(
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
if tenant_id is None:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Tenant not found")
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
with get_session_with_shared_schema() as db_session:
|
||||
current_path = get_anonymous_user_path(tenant_id, db_session)
|
||||
@@ -51,21 +50,21 @@ async def set_anonymous_user_path_api(
|
||||
try:
|
||||
validate_anonymous_user_path(anonymous_user_path)
|
||||
except ValueError as e:
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
with get_session_with_shared_schema() as db_session:
|
||||
try:
|
||||
modify_anonymous_user_path(tenant_id, anonymous_user_path, db_session)
|
||||
except IntegrityError:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONFLICT,
|
||||
"The anonymous user path is already in use. Please choose a different path.",
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="The anonymous user path is already in use. Please choose a different path.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to modify anonymous user path: {str(e)}")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
"An unexpected error occurred while modifying the anonymous user path",
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="An unexpected error occurred while modifying the anonymous user path",
|
||||
)
|
||||
|
||||
|
||||
@@ -78,10 +77,10 @@ async def login_as_anonymous_user(
|
||||
anonymous_user_path, db_session
|
||||
)
|
||||
if not tenant_id:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Tenant not found")
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
if not anonymous_user_enabled(tenant_id=tenant_id):
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHORIZED, "Anonymous user is not enabled")
|
||||
raise HTTPException(status_code=403, detail="Anonymous user is not enabled")
|
||||
|
||||
token = generate_anonymous_user_jwt_token(tenant_id)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
import aiohttp # Async HTTP client
|
||||
import httpx
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -40,8 +41,6 @@ from onyx.db.models import AvailableTenant
|
||||
from onyx.db.models import IndexModelStatus
|
||||
from onyx.db.models import SearchSettings
|
||||
from onyx.db.models import UserTenantMapping
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.llm.well_known_providers.auto_update_models import LLMRecommendations
|
||||
from onyx.llm.well_known_providers.constants import ANTHROPIC_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import OPENAI_PROVIDER_NAME
|
||||
@@ -117,9 +116,9 @@ async def get_or_provision_tenant(
|
||||
# If we've encountered an error, log and raise an exception
|
||||
error_msg = "Failed to provision tenant"
|
||||
logger.error(error_msg, exc_info=e)
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
"Failed to provision tenant. Please try again later.",
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to provision tenant. Please try again later.",
|
||||
)
|
||||
|
||||
|
||||
@@ -145,18 +144,18 @@ async def create_tenant(
|
||||
await rollback_tenant_provisioning(tenant_id)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to rollback tenant provisioning for {tenant_id}")
|
||||
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Failed to provision tenant.")
|
||||
raise HTTPException(status_code=500, detail="Failed to provision tenant.")
|
||||
|
||||
return tenant_id
|
||||
|
||||
|
||||
async def provision_tenant(tenant_id: str, email: str) -> None:
|
||||
if not MULTI_TENANT:
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHORIZED, "Multi-tenancy is not enabled")
|
||||
raise HTTPException(status_code=403, detail="Multi-tenancy is not enabled")
|
||||
|
||||
if user_owns_a_tenant(email):
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONFLICT, "User already belongs to an organization"
|
||||
raise HTTPException(
|
||||
status_code=409, detail="User already belongs to an organization"
|
||||
)
|
||||
|
||||
logger.debug(f"Provisioning tenant {tenant_id} for user {email}")
|
||||
@@ -176,8 +175,8 @@ async def provision_tenant(tenant_id: str, email: str) -> None:
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to create tenant {tenant_id}")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR, f"Failed to create tenant: {str(e)}"
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to create tenant: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import Header
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
|
||||
@@ -35,8 +36,6 @@ from ee.onyx.server.tenants.access import generate_data_plane_token
|
||||
from ee.onyx.utils.license import is_license_valid
|
||||
from ee.onyx.utils.license import verify_license_signature
|
||||
from onyx.configs.app_configs import CONTROL_PLANE_API_BASE_URL
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -47,9 +46,9 @@ router = APIRouter(prefix="/proxy")
|
||||
def _check_license_enforcement_enabled() -> None:
|
||||
"""Ensure LICENSE_ENFORCEMENT_ENABLED is true (proxy endpoints only work on cloud DP)."""
|
||||
if not LICENSE_ENFORCEMENT_ENABLED:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_IMPLEMENTED,
|
||||
"Proxy endpoints are only available on cloud data plane",
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Proxy endpoints are only available on cloud data plane",
|
||||
)
|
||||
|
||||
|
||||
@@ -82,9 +81,8 @@ def _extract_license_from_header(
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
if required:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.UNAUTHENTICATED,
|
||||
"Missing or invalid authorization header",
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Missing or invalid authorization header"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -112,10 +110,10 @@ def verify_license_auth(
|
||||
try:
|
||||
payload = verify_license_signature(license_data)
|
||||
except ValueError as e:
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, f"Invalid license: {e}")
|
||||
raise HTTPException(status_code=401, detail=f"Invalid license: {e}")
|
||||
|
||||
if not allow_expired and not is_license_valid(payload):
|
||||
raise OnyxError(OnyxErrorCode.TOKEN_EXPIRED, "License has expired")
|
||||
raise HTTPException(status_code=401, detail="License has expired")
|
||||
|
||||
return payload
|
||||
|
||||
@@ -199,12 +197,12 @@ async def forward_to_control_plane(
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"Control plane returned {status_code}: {detail}")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=status_code
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
except httpx.RequestError:
|
||||
logger.exception("Failed to connect to control plane")
|
||||
raise OnyxError(OnyxErrorCode.BAD_GATEWAY, "Failed to connect to control plane")
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Failed to connect to control plane"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -296,9 +294,9 @@ async def proxy_claim_license(
|
||||
|
||||
if not tenant_id or not license_data:
|
||||
logger.error(f"Control plane returned incomplete claim response: {result}")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY,
|
||||
"Control plane returned incomplete license data",
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Control plane returned incomplete license data",
|
||||
)
|
||||
|
||||
return ClaimLicenseResponse(
|
||||
@@ -328,7 +326,7 @@ async def proxy_create_customer_portal_session(
|
||||
# tenant_id is a required field in LicensePayload (Pydantic validates this),
|
||||
# but we check explicitly for defense in depth
|
||||
if not license_payload.tenant_id:
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, "License missing tenant_id")
|
||||
raise HTTPException(status_code=401, detail="License missing tenant_id")
|
||||
|
||||
tenant_id = license_payload.tenant_id
|
||||
|
||||
@@ -369,7 +367,7 @@ async def proxy_billing_information(
|
||||
# tenant_id is a required field in LicensePayload (Pydantic validates this),
|
||||
# but we check explicitly for defense in depth
|
||||
if not license_payload.tenant_id:
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, "License missing tenant_id")
|
||||
raise HTTPException(status_code=401, detail="License missing tenant_id")
|
||||
|
||||
tenant_id = license_payload.tenant_id
|
||||
|
||||
@@ -400,12 +398,12 @@ async def proxy_license_fetch(
|
||||
# tenant_id is a required field in LicensePayload (Pydantic validates this),
|
||||
# but we check explicitly for defense in depth
|
||||
if not license_payload.tenant_id:
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, "License missing tenant_id")
|
||||
raise HTTPException(status_code=401, detail="License missing tenant_id")
|
||||
|
||||
if tenant_id != license_payload.tenant_id:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.UNAUTHORIZED,
|
||||
"Cannot fetch license for a different tenant",
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cannot fetch license for a different tenant",
|
||||
)
|
||||
|
||||
result = await forward_to_control_plane("GET", f"/license/{tenant_id}")
|
||||
@@ -413,9 +411,9 @@ async def proxy_license_fetch(
|
||||
license_data = result.get("license")
|
||||
if not license_data:
|
||||
logger.error(f"Control plane returned incomplete license response: {result}")
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY,
|
||||
"Control plane returned incomplete license data",
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Control plane returned incomplete license data",
|
||||
)
|
||||
|
||||
# Return license to caller - self-hosted instance stores it via /api/license/claim
|
||||
@@ -434,7 +432,7 @@ async def proxy_seat_update(
|
||||
Returns the regenerated license in the response for the caller to store.
|
||||
"""
|
||||
if not license_payload.tenant_id:
|
||||
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, "License missing tenant_id")
|
||||
raise HTTPException(status_code=401, detail="License missing tenant_id")
|
||||
|
||||
tenant_id = license_payload.tenant_id
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ee.onyx.server.tenants.provisioning import delete_user_from_control_plane
|
||||
@@ -11,8 +12,6 @@ from onyx.db.auth import get_user_count
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.users import delete_user_from_db
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.manage.models import UserByEmail
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
@@ -31,14 +30,13 @@ async def leave_organization(
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
if current_user.email != user_email.user_email:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
"You can only leave the organization as yourself",
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You can only leave the organization as yourself"
|
||||
)
|
||||
|
||||
user_to_delete = get_user_by_email(user_email.user_email, db_session)
|
||||
if user_to_delete is None:
|
||||
raise OnyxError(OnyxErrorCode.USER_NOT_FOUND, "User not found")
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
num_admin_users = await get_user_count(only_admin_users=True)
|
||||
|
||||
@@ -55,9 +53,9 @@ async def leave_organization(
|
||||
logger.exception(
|
||||
f"Failed to delete user from control plane for tenant {tenant_id}: {e}"
|
||||
)
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
f"Failed to remove user from control plane: {str(e)}",
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to remove user from control plane: {str(e)}",
|
||||
)
|
||||
|
||||
db_session.expunge(user_to_delete)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ee.onyx.server.tenants.models import ApproveUserRequest
|
||||
from ee.onyx.server.tenants.models import PendingUserSnapshot
|
||||
@@ -12,8 +13,6 @@ from onyx.auth.invited_users import get_pending_users
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.auth.users import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
@@ -33,7 +32,7 @@ async def request_invite(
|
||||
logger.exception(
|
||||
f"Failed to invite self to tenant {invite_request.tenant_id}: {e}"
|
||||
)
|
||||
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/users/pending")
|
||||
@@ -65,7 +64,7 @@ async def accept_invite(
|
||||
accept_user_invite(user.email, invite_request.tenant_id)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to accept invite: {str(e)}")
|
||||
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Failed to accept invitation")
|
||||
raise HTTPException(status_code=500, detail="Failed to accept invitation")
|
||||
|
||||
|
||||
@router.post("/users/invite/deny")
|
||||
@@ -80,4 +79,4 @@ async def deny_invite(
|
||||
deny_user_invite(user.email, invite_request.tenant_id)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to deny invite: {str(e)}")
|
||||
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Failed to deny invitation")
|
||||
raise HTTPException(status_code=500, detail="Failed to deny invitation")
|
||||
|
||||
133
web/src/app/admin/users2/StatsBar.tsx
Normal file
133
web/src/app/admin/users2/StatsBar.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { SvgArrowUpRight, SvgUserSync } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Link from "next/link";
|
||||
import { ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats cell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StatCellProps {
|
||||
value: number | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function StatCell({ value, label }: StatCellProps) {
|
||||
const display = value === null ? "—" : value.toLocaleString();
|
||||
|
||||
return (
|
||||
<Section alignItems="start" gap={0.25} width="fit" padding={0.5}>
|
||||
<Text as="p" headingH3 text05>
|
||||
{display}
|
||||
</Text>
|
||||
<Text as="p" mainUiMuted text03>
|
||||
{label}
|
||||
</Text>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCIM card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ScimCard() {
|
||||
return (
|
||||
<Card gap={0.5} padding={0.75}>
|
||||
<ContentAction
|
||||
icon={SvgUserSync}
|
||||
title="SCIM Sync"
|
||||
description="Users are synced from your identity provider."
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Link href={ADMIN_PATHS.SCIM}>
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowUpRight} size="sm">
|
||||
Manage
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats bar — layout varies by SCIM status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StatsBarProps {
|
||||
activeUsers: number | null;
|
||||
pendingInvites: number | null;
|
||||
requests: number | null;
|
||||
showScim: boolean;
|
||||
}
|
||||
|
||||
export default function StatsBar({
|
||||
activeUsers,
|
||||
pendingInvites,
|
||||
requests,
|
||||
showScim,
|
||||
}: StatsBarProps) {
|
||||
if (showScim) {
|
||||
// With SCIM: one card containing all 3 stats (dividers) + separate SCIM card
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="stretch"
|
||||
gap={0.5}
|
||||
>
|
||||
<Card padding={0}>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="stretch"
|
||||
gap={0}
|
||||
width="fit"
|
||||
height="auto"
|
||||
>
|
||||
<StatCell value={activeUsers} label="active users" />
|
||||
<Separator orientation="vertical" noPadding />
|
||||
<StatCell value={pendingInvites} label="pending invites" />
|
||||
{requests !== null && (
|
||||
<>
|
||||
<Separator orientation="vertical" noPadding />
|
||||
<StatCell value={requests} label="requests to join" />
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</Card>
|
||||
|
||||
<ScimCard />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// Without SCIM: 3 separate cards
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="stretch"
|
||||
gap={0.5}
|
||||
>
|
||||
<Card padding={0.5}>
|
||||
<StatCell value={activeUsers} label="active users" />
|
||||
</Card>
|
||||
<Card padding={0.5}>
|
||||
<StatCell value={pendingInvites} label="pending invites" />
|
||||
</Card>
|
||||
{requests !== null && (
|
||||
<Card padding={0.5}>
|
||||
<StatCell value={requests} label="requests to join" />
|
||||
</Card>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
94
web/src/app/admin/users2/page.tsx
Normal file
94
web/src/app/admin/users2/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { SvgUser, SvgUserPlus } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { useScimToken } from "@/hooks/useScimToken";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import type { InvitedUserSnapshot } from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
|
||||
import StatsBar from "./StatsBar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PaginatedResponse {
|
||||
items: unknown[];
|
||||
total_items: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users page content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UsersContent() {
|
||||
const isEe = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const { data: scimToken } = useScimToken();
|
||||
const showScim = isEe && !!scimToken;
|
||||
|
||||
// Active user count — lightweight fetch (page_size=1 to minimize payload)
|
||||
const { data: activeData } = useSWR<PaginatedResponse>(
|
||||
"/api/manage/users/accepted?page_num=0&page_size=1",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const { data: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
|
||||
"/api/manage/users/invited",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const { data: pendingUsers } = useSWR<InvitedUserSnapshot[]>(
|
||||
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const activeCount = activeData?.total_items ?? null;
|
||||
const invitedCount = invitedUsers?.length ?? null;
|
||||
const pendingCount = pendingUsers?.length ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatsBar
|
||||
activeUsers={activeCount}
|
||||
pendingInvites={invitedCount}
|
||||
requests={pendingCount}
|
||||
showScim={showScim}
|
||||
/>
|
||||
|
||||
{/* Table and filters will be added in subsequent PRs */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Page() {
|
||||
// TODO (ENG-3806): Wire up invite modal in a future PR
|
||||
const [_showInviteModal, setShowInviteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
title="Users & Requests"
|
||||
icon={SvgUser}
|
||||
rightChildren={
|
||||
<Button icon={SvgUserPlus} onClick={() => setShowInviteModal(true)}>
|
||||
Invite Users
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<UsersContent />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const SETTINGS_LAYOUT_PREFIXES = [
|
||||
ADMIN_PATHS.LLM_MODELS,
|
||||
ADMIN_PATHS.AGENTS,
|
||||
ADMIN_PATHS.USERS,
|
||||
ADMIN_PATHS.USERS_V2,
|
||||
ADMIN_PATHS.TOKEN_RATE_LIMITS,
|
||||
ADMIN_PATHS.SEARCH_SETTINGS,
|
||||
ADMIN_PATHS.DOCUMENT_PROCESSING,
|
||||
|
||||
@@ -86,7 +86,7 @@ function SettingsRoot({ width = "md", ...props }: SettingsRootProps) {
|
||||
return (
|
||||
<div
|
||||
id="page-wrapper-scroll-container"
|
||||
className="w-full h-full flex flex-col items-center overflow-y-auto"
|
||||
className="w-full h-full flex flex-col items-center overflow-y-auto pt-10"
|
||||
>
|
||||
{/* WARNING: The id="page-wrapper-scroll-container" above is used by SettingsHeader
|
||||
to detect scroll position and show/hide the scroll shadow.
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ADMIN_PATHS = {
|
||||
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
|
||||
KNOWLEDGE_GRAPH: "/admin/kg",
|
||||
USERS: "/admin/users",
|
||||
USERS_V2: "/admin/users2",
|
||||
API_KEYS: "/admin/api-key",
|
||||
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
|
||||
USAGE: "/admin/performance/usage",
|
||||
@@ -190,6 +191,11 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
|
||||
title: "Manage Users",
|
||||
sidebarLabel: "Users",
|
||||
},
|
||||
[ADMIN_PATHS.USERS_V2]: {
|
||||
icon: SvgUser,
|
||||
title: "Users & Requests",
|
||||
sidebarLabel: "Users v2",
|
||||
},
|
||||
[ADMIN_PATHS.API_KEYS]: {
|
||||
icon: SvgKey,
|
||||
title: "API Keys",
|
||||
|
||||
@@ -131,7 +131,11 @@ const collections = (
|
||||
? [
|
||||
{
|
||||
name: "Permissions",
|
||||
items: [sidebarItem(ADMIN_PATHS.SCIM)],
|
||||
items: [
|
||||
// TODO: Uncomment once Users v2 page is complete
|
||||
// sidebarItem(ADMIN_PATHS.USERS_V2),
|
||||
sidebarItem(ADMIN_PATHS.SCIM),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
Reference in New Issue
Block a user