Compare commits

...

6 Commits

Author SHA1 Message Date
Nik
f3c3e29fb7 fix(admin): use lg width for users page layout 2026-03-09 19:44:26 -07:00
Nik
eaa1e94bed fix(admin): use full-width layout to prevent table column overflow
Switch SettingsLayouts.Root from width="lg" (992px cap) to width="full"
so the users table has enough horizontal space for all columns.
2026-03-09 12:18:23 -07:00
Nik
a5bb2d8130 fix(admin): move Users v2 to Permissions sidebar section and fix stats bar layout
- Move Users v2 sidebar entry from User Management to Permissions section
  (alongside SCIM) to match Figma mocks
- Stats bar: use single card for all stats (not separate cards per stat)
  when SCIM is disabled
- Hide "requests to join" cell when count is 0 (not just null)
- Add onStatClick callback prop for future filter integration
2026-03-08 18:55:25 -07:00
Nik
dbbca1c4e5 refactor(admin): extract useUserCounts hook and rename StatsBar to UsersSummary
Move SWR data fetching out of UsersPage component into a dedicated
useUserCounts hook. Rename StatsBar → UsersSummary for clarity.
2026-03-08 16:05:33 -07:00
Nik
ff92928b31 fix(admin): refine stats bar layout and move to refresh-pages
- Replace LineItem with custom StatCell using Text to fix label truncation
- Use width="full" on StatCell for even distribution when fewer cells render
- Move UsersPage to refresh-pages pattern, route file re-exports
- Delete dead users2/StatsBar.tsx (replaced by UsersPage/StatsBar.tsx)
- Move sidebar entry from Permissions to User Management section
2026-03-08 14:31:09 -07:00
Nik
f452a777cc 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-06 12:34:34 -08:00
8 changed files with 241 additions and 9 deletions

View File

@@ -0,0 +1 @@
export { default } from "@/refresh-pages/admin/UsersPage";

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

@@ -0,0 +1,34 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { InvitedUserSnapshot } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
interface PaginatedCountResponse {
total_items: number;
}
export default function useUserCounts() {
// Active user count — lightweight fetch (page_size=1 to minimize payload)
const { data: activeData } = useSWR<PaginatedCountResponse>(
"/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
);
return {
activeCount: activeData?.total_items ?? null,
invitedCount: invitedUsers?.length ?? null,
pendingCount: pendingUsers?.length ?? null,
};
}

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

@@ -0,0 +1,58 @@
"use client";
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 useUserCounts from "@/hooks/useUserCounts";
import UsersSummary from "./UsersPage/UsersSummary";
// ---------------------------------------------------------------------------
// Users page content
// ---------------------------------------------------------------------------
function UsersContent() {
const isEe = usePaidEnterpriseFeaturesEnabled();
const { data: scimToken } = useScimToken();
const showScim = isEe && !!scimToken;
const { activeCount, invitedCount, pendingCount } = useUserCounts();
return (
<>
<UsersSummary
activeUsers={activeCount}
pendingInvites={invitedCount}
requests={pendingCount}
showScim={showScim}
/>
{/* Table and filters will be added in subsequent PRs */}
</>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function UsersPage() {
return (
<SettingsLayouts.Root width="lg">
<SettingsLayouts.Header
title="Users & Requests"
icon={SvgUser}
rightChildren={
// TODO (ENG-3806): Wire up invite modal
<Button icon={SvgUserPlus}>Invite Users</Button>
}
/>
<SettingsLayouts.Body>
<UsersContent />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1,133 @@
import { SvgArrowUpRight, SvgFilter, 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 Text from "@/refresh-components/texts/Text";
import Link from "next/link";
import { ADMIN_PATHS } from "@/lib/admin-routes";
// ---------------------------------------------------------------------------
// Stats cell — number + label, filter icon on hover
// ---------------------------------------------------------------------------
interface StatCellProps {
value: number | null;
label: string;
onClick?: () => void;
}
function StatCell({ value, label, onClick }: StatCellProps) {
const display = value === null ? "—" : value.toLocaleString();
return (
<button
type="button"
onClick={onClick}
className="group relative flex flex-col items-start gap-0.5 w-full p-2 text-left rounded-md hover:bg-background-neutral-02 transition-colors cursor-pointer"
>
<Text as="span" mainUiAction text04>
{display}
</Text>
<Text as="span" secondaryBody text03>
{label}
</Text>
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Text as="span" secondaryBody text03>
Filter
</Text>
<SvgFilter size={16} className="text-text-03" />
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
export type StatFilter = "active" | "invited" | "requests";
interface UsersSummaryProps {
activeUsers: number | null;
pendingInvites: number | null;
requests: number | null;
showScim: boolean;
onStatClick?: (filter: StatFilter) => void;
}
export default function UsersSummary({
activeUsers,
pendingInvites,
requests,
showScim,
onStatClick,
}: UsersSummaryProps) {
const showRequests = requests !== null && requests > 0;
const statsCard = (
<Card padding={0.5}>
<Section flexDirection="row" gap={0}>
<StatCell
value={activeUsers}
label="active users"
onClick={() => onStatClick?.("active")}
/>
<StatCell
value={pendingInvites}
label="pending invites"
onClick={() => onStatClick?.("invited")}
/>
{showRequests && (
<StatCell
value={requests}
label="requests to join"
onClick={() => onStatClick?.("requests")}
/>
)}
</Section>
</Card>
);
if (showScim) {
return (
<Section
flexDirection="row"
justifyContent="start"
alignItems="stretch"
gap={0.5}
>
{statsCard}
<ScimCard />
</Section>
);
}
return statsCard;
}

View File

@@ -127,14 +127,13 @@ const collections = (
sidebarItem(ADMIN_PATHS.TOKEN_RATE_LIMITS),
],
},
...(enableEnterprise
? [
{
name: "Permissions",
items: [sidebarItem(ADMIN_PATHS.SCIM)],
},
]
: []),
{
name: "Permissions",
items: [
sidebarItem(ADMIN_PATHS.USERS_V2),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.SCIM)] : []),
],
},
...(enableEnterprise
? [
{