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
6 changed files with 240 additions and 2 deletions

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