Compare commits

...

7 Commits

Author SHA1 Message Date
Nik
b9dce93a6f feat(admin): add user timestamps and enrich FullUserSnapshot
- Alembic migration adding created_at/updated_at to user table
- Add personal_name, created_at, updated_at, and groups to FullUserSnapshot
- Batch query for user group memberships to avoid N+1 in list endpoint
- Replace direct FullUserSnapshot construction with from_user_model()
2026-03-09 22:24:47 -07:00
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
13 changed files with 358 additions and 47 deletions

View File

@@ -0,0 +1,43 @@
"""add timestamps to user table
Revision ID: 27fb147a843f
Revises: a3b8d9e2f1c4
Create Date: 2026-03-08 17:18:40.828644
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "27fb147a843f"
down_revision = "a3b8d9e2f1c4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.add_column(
"user",
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("user", "updated_at")
op.drop_column("user", "created_at")

View File

@@ -280,6 +280,16 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
TIMESTAMPAware(timezone=True), nullable=True
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
default_model: Mapped[str] = mapped_column(Text, nullable=True)
# organized in typical structured fashion
# formatted as `displayName__provider__modelName`

View File

@@ -24,6 +24,7 @@ from onyx.db.models import Persona__User
from onyx.db.models import SamlAccount
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -358,3 +359,28 @@ def delete_user_from_db(
# NOTE: edge case may exist with race conditions
# with this `invited user` scheme generally.
remove_user_from_invited_users(user_to_delete.email)
def batch_get_user_groups(
db_session: Session,
user_ids: list[UUID],
) -> dict[UUID, list[tuple[int, str]]]:
"""Fetch group memberships for a batch of users in a single query.
Returns a mapping of user_id -> list of (group_id, group_name) tuples."""
if not user_ids:
return {}
rows = db_session.execute(
select(
User__UserGroup.user_id,
UserGroup.id,
UserGroup.name,
)
.join(UserGroup, UserGroup.id == User__UserGroup.user_group_id)
.where(User__UserGroup.user_id.in_(user_ids))
).all()
result: dict[UUID, list[tuple[int, str]]] = {uid: [] for uid in user_ids}
for user_id, group_id, group_name in rows:
result[user_id].append((group_id, group_name))
return result

View File

@@ -67,6 +67,7 @@ from onyx.db.user_preferences import update_user_role
from onyx.db.user_preferences import update_user_shortcut_enabled
from onyx.db.user_preferences import update_user_temperature_override_enabled
from onyx.db.user_preferences import update_user_theme_preference
from onyx.db.users import batch_get_user_groups
from onyx.db.users import delete_user_from_db
from onyx.db.users import get_all_users
from onyx.db.users import get_page_of_filtered_users
@@ -98,6 +99,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
from onyx.server.models import FullUserSnapshot
from onyx.server.models import InvitedUserSnapshot
from onyx.server.models import MinimalUserSnapshot
from onyx.server.models import UserGroupInfo
from onyx.server.usage_limits import is_tenant_on_trial_fn
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
@@ -203,9 +205,19 @@ def list_accepted_users(
total_items=0,
)
user_ids = [user.id for user in filtered_accepted_users]
groups_by_user = batch_get_user_groups(db_session, user_ids)
return PaginatedReturn(
items=[
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
FullUserSnapshot.from_user_model(
user,
groups=[
UserGroupInfo(id=gid, name=gname)
for gid, gname in groups_by_user.get(user.id, [])
],
)
for user in filtered_accepted_users
],
total_items=total_accepted_users_count,
)
@@ -269,24 +281,10 @@ def list_all_users(
if accepted_page is None or invited_page is None or slack_users_page is None:
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
FullUserSnapshot.from_user_model(user) for user in accepted_users
],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
FullUserSnapshot.from_user_model(user) for user in slack_users
],
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
accepted_pages=1,
@@ -296,26 +294,10 @@ def list_all_users(
# Otherwise, return paginated results
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
][
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
],
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
slack_users_page
* USERS_PAGE_SIZE : (slack_users_page + 1)
* USERS_PAGE_SIZE

View File

@@ -1,3 +1,4 @@
import datetime
from typing import Generic
from typing import Optional
from typing import TypeVar
@@ -31,21 +32,38 @@ class MinimalUserSnapshot(BaseModel):
email: str
class UserGroupInfo(BaseModel):
id: int
name: str
class FullUserSnapshot(BaseModel):
id: UUID
email: str
role: UserRole
is_active: bool
password_configured: bool
personal_name: str | None
created_at: datetime.datetime
updated_at: datetime.datetime
groups: list[UserGroupInfo]
@classmethod
def from_user_model(cls, user: User) -> "FullUserSnapshot":
def from_user_model(
cls,
user: User,
groups: list[UserGroupInfo] | None = None,
) -> "FullUserSnapshot":
return cls(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
personal_name=user.personal_name,
created_at=user.created_at,
updated_at=user.updated_at,
groups=groups or [],
)

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
? [
{