Compare commits

..

1 Commits

Author SHA1 Message Date
Nik
534def5e60 feat(admin): add Users v2 page shell with stats bar and SCIM card
New /admin/users2 page with SettingsLayouts, stat cards for active
users / pending invites / requests to join (cloud-only), and a
conditional SCIM sync card. Also adds 40px top padding to the
shared SettingsLayouts.Root for all admin pages.

Sidebar entry is commented out until the page is feature-complete.
2026-03-04 22:18:23 -08:00
13 changed files with 306 additions and 79 deletions

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)}"
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View 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>
);
}

View 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>
);
}

View File

@@ -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,

View File

@@ -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.

View File

@@ -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",

View File

@@ -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),
],
},
]
: []),