Compare commits

..

9 Commits

Author SHA1 Message Date
Nik
c0774169fa feat(admin): add edit group membership modal (ENG-3807)
Add modal to view and toggle a user's group memberships from the row
actions menu. Lists all groups with search, shows "Joined" badges for
current memberships, and saves changes via add/remove user APIs.
2026-03-08 18:15:24 -07:00
Nik
584d818555 feat(admin): add inline role editing in Users table (ENG-3808)
Replace the static Account Type column with an interactive InputSelect
dropdown that lets admins change user roles inline. Includes curator
demotion confirmation modal and role visibility filtering matching the
existing UserRoleDropdown behavior.
2026-03-08 18:09:07 -07:00
Nik
491b8e9cda feat(admin): add invite users modal (ENG-3806)
Email tag input with comma-separated parsing, role selector defaulting
to Basic, and bulk invite via PUT /api/manage/admin/users.
2026-03-08 17:46:04 -07:00
Nik
13f4633752 feat(admin): add row actions with confirmation modals
- Add UserRowActions with deactivate/activate/delete actions via Popover
- Add ConfirmationModalLayout for each destructive action
- Extract mutation fetches to svc.ts (deactivateUser, activateUser, deleteUser)
- Convert columns to buildColumns(onMutate) for refresh-on-mutate
- Use opal Disabled wrapper for submit button disabled state
2026-03-08 17:45:59 -07:00
Nik
24289c86ba feat(admin): add role and status filters to Users table
- Create UserFilters component with FilterButton + Popover + Checkbox
- Role filter: multi-select checkboxes for all roles (excludes EXT_PERM_USER)
- Status filter: single-select for All/Active/Inactive
- Filter state feeds into server-side API query params (roles[], is_active)
- Filters reset pagination to page 0 on change
2026-03-08 17:44:05 -07:00
Nik
d1873054b0 refactor(admin): extract types to interfaces.ts and data fetching to useAdminUsers hook
Move UserRow, PaginatedUsersResponse, and StatusFilter types into a
shared interfaces module. Extract paginated user fetching into a
dedicated useAdminUsers hook. Use Content variant="section" for the
User column to get title + description layout from the component
library instead of hand-rolling with div + Text.
2026-03-08 17:42:19 -07:00
Nik
649edcb244 feat(admin): wire up enriched user fields in table columns
Add Name (personal_name with email subtitle), Groups (tag pills with
+N overflow), and Last Updated (timeAgo) columns to the users table.
2026-03-08 17:32:45 -07:00
Nik
4e479896cb feat(admin): add Users table with DataTable and server-side pagination
- Create UsersTable component using DataTable with server-side mode
- Columns: avatar qualifier (initials from email), email, role, status tag
- Search input with server-side filtering via q param
- Pagination with "Showing X~Y of Z" summary footer
- Wire into UsersPage below the stats bar
2026-03-08 17:26:12 -07:00
Nik
faaf7dc08e 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-08 17:24:27 -07:00
15 changed files with 1301 additions and 41 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,46 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { UserRole } from "@/lib/types";
import type { PaginatedUsersResponse } from "@/refresh-pages/admin/UsersPage/interfaces";
interface UseAdminUsersParams {
pageIndex: number;
pageSize: number;
searchTerm?: string;
roles?: UserRole[];
isActive?: boolean | undefined;
}
export default function useAdminUsers({
pageIndex,
pageSize,
searchTerm,
roles,
isActive,
}: UseAdminUsersParams) {
const queryParams = new URLSearchParams({
page_num: String(pageIndex),
page_size: String(pageSize),
...(searchTerm && { q: searchTerm }),
...(isActive === true && { is_active: "true" }),
...(isActive === false && { is_active: "false" }),
});
for (const role of roles ?? []) {
queryParams.append("roles", role);
}
const { data, isLoading, error, mutate } = useSWR<PaginatedUsersResponse>(
`/api/manage/users/accepted?${queryParams.toString()}`,
errorHandlingFetcher
);
return {
users: data?.items ?? [],
totalItems: data?.total_items ?? 0,
isLoading,
error,
refresh: mutate,
};
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { SvgUser, SvgUserPlus } from "@opal/icons";
import { Button } from "@opal/components";
import * as SettingsLayouts from "@/layouts/settings-layouts";
@@ -8,6 +9,8 @@ import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidE
import useUserCounts from "@/hooks/useUserCounts";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
import InviteUsersModal from "./UsersPage/InviteUsersModal";
// ---------------------------------------------------------------------------
// Users page content
@@ -30,7 +33,7 @@ function UsersContent() {
showScim={showScim}
/>
{/* Table and filters will be added in subsequent PRs */}
<UsersTable />
</>
);
}
@@ -40,19 +43,24 @@ function UsersContent() {
// ---------------------------------------------------------------------------
export default function UsersPage() {
const [inviteOpen, setInviteOpen] = useState(false);
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>
<Button icon={SvgUserPlus} onClick={() => setInviteOpen(true)}>
Invite Users
</Button>
}
/>
<SettingsLayouts.Body>
<UsersContent />
</SettingsLayouts.Body>
<InviteUsersModal open={inviteOpen} onOpenChange={setInviteOpen} />
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { useState, useMemo } from "react";
import { Button, Tag } from "@opal/components";
import { SvgUsers } from "@opal/icons";
import { Disabled } from "@opal/core";
import Modal from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { toast } from "@/hooks/useToast";
import useGroups from "@/hooks/useGroups";
import { addUserToGroup, removeUserFromGroup } from "./svc";
import type { UserRow } from "./interfaces";
interface EditGroupsModalProps {
user: UserRow;
onClose: () => void;
onMutate: () => void;
}
export default function EditGroupsModal({
user,
onClose,
onMutate,
}: EditGroupsModalProps) {
const { data: allGroups, isLoading: groupsLoading } = useGroups();
const [searchTerm, setSearchTerm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// Track which groups the user is currently a member of (local state for optimistic toggling)
const initialMemberGroupIds = useMemo(
() => new Set(user.groups.map((g) => g.id)),
[user.groups]
);
const [memberGroupIds, setMemberGroupIds] = useState<Set<number>>(
() => new Set(initialMemberGroupIds)
);
const filteredGroups = useMemo(() => {
if (!allGroups) return [];
if (!searchTerm) return allGroups;
const lower = searchTerm.toLowerCase();
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
}, [allGroups, searchTerm]);
const hasChanges = useMemo(() => {
if (memberGroupIds.size !== initialMemberGroupIds.size) return true;
return Array.from(memberGroupIds).some(
(id) => !initialMemberGroupIds.has(id)
);
}, [memberGroupIds, initialMemberGroupIds]);
const toggleGroup = (groupId: number) => {
setMemberGroupIds((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const handleSave = async () => {
setIsSubmitting(true);
try {
const toAdd = Array.from(memberGroupIds).filter(
(id) => !initialMemberGroupIds.has(id)
);
const toRemove = Array.from(initialMemberGroupIds).filter(
(id) => !memberGroupIds.has(id)
);
const promises: Promise<void>[] = [];
for (const groupId of toAdd) {
promises.push(addUserToGroup(groupId, user.id));
}
for (const groupId of toRemove) {
const group = allGroups?.find((g) => g.id === groupId);
if (group) {
const currentUserIds = group.users.map((u) => u.id);
promises.push(removeUserFromGroup(groupId, currentUserIds, user.id));
}
}
await Promise.all(promises);
onMutate();
toast.success("Group memberships updated");
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
};
return (
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
<Modal.Content width="sm">
<Modal.Header
icon={SvgUsers}
title="Edit User's Groups"
description={`${user.personal_name ?? user.email} (${user.email})`}
onClose={onClose}
/>
<Modal.Body twoTone>
<div className="flex flex-col gap-3">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search groups..."
leftSearchIcon
/>
{groupsLoading ? (
<Text as="p" text03 secondaryBody>
Loading groups...
</Text>
) : filteredGroups.length === 0 ? (
<Text as="p" text03 secondaryBody>
{searchTerm ? "No groups match your search" : "No groups found"}
</Text>
) : (
<div className="flex flex-col gap-1 max-h-[320px] overflow-y-auto">
{filteredGroups.map((group) => {
const isMember = memberGroupIds.has(group.id);
return (
<button
key={group.id}
type="button"
onClick={() => toggleGroup(group.id)}
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-background-neutral-02 transition-colors text-left"
>
<div className="flex flex-col gap-0.5">
<Text as="span" mainUiAction text05>
{group.name}
</Text>
<Text as="span" secondaryBody text03>
{group.users.length}{" "}
{group.users.length === 1 ? "member" : "members"}
</Text>
</div>
{isMember && <Tag title="Joined" color="green" />}
</button>
);
})}
</div>
)}
</div>
</Modal.Body>
<Modal.Footer>
<Button prominence="secondary" onClick={onClose}>
Cancel
</Button>
<Disabled disabled={isSubmitting || !hasChanges}>
<Button onClick={handleSave}>Save Changes</Button>
</Disabled>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useState } from "react";
import { Button } from "@opal/components";
import { SvgUsers, SvgUser } from "@opal/icons";
import { Disabled } from "@opal/core";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import InputChipField from "@/refresh-components/inputs/InputChipField";
import type { ChipItem } from "@/refresh-components/inputs/InputChipField";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import Text from "@/refresh-components/texts/Text";
import { toast } from "@/hooks/useToast";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
import { inviteUsers } from "./svc";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Roles available for invite — excludes curator-specific and system roles */
const INVITE_ROLES = [
UserRole.BASIC,
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
] as const;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InviteUsersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function InviteUsersModal({
open,
onOpenChange,
}: InviteUsersModalProps) {
const [chips, setChips] = useState<ChipItem[]>([]);
const [inputValue, setInputValue] = useState("");
const [role, setRole] = useState<string>(UserRole.BASIC);
const [isSubmitting, setIsSubmitting] = useState(false);
function addEmail(value: string) {
// Support comma-separated input
const entries = value
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
const newChips: ChipItem[] = [];
for (const email of entries) {
const alreadyAdded = chips.some((c) => c.label === email);
if (!alreadyAdded) {
newChips.push({ id: email, label: email });
}
}
if (newChips.length > 0) {
setChips((prev) => [...prev, ...newChips]);
}
setInputValue("");
}
function removeChip(id: string) {
setChips((prev) => prev.filter((c) => c.id !== id));
}
function handleClose() {
onOpenChange(false);
// Reset state after close animation
setTimeout(() => {
setChips([]);
setInputValue("");
setRole(UserRole.BASIC);
}, 200);
}
async function handleInvite() {
const validEmails = chips
.map((c) => c.label)
.filter((e) => EMAIL_REGEX.test(e));
if (validEmails.length === 0) {
toast.error("Please add at least one valid email address");
return;
}
setIsSubmitting(true);
try {
await inviteUsers(validEmails);
toast.success(
`Invited ${validEmails.length} user${validEmails.length > 1 ? "s" : ""}`
);
handleClose();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to invite users"
);
} finally {
setIsSubmitting(false);
}
}
const hasInvalidEmails = chips.some((c) => !EMAIL_REGEX.test(c.label));
return (
<Modal open={open} onOpenChange={onOpenChange}>
<Modal.Content width="sm" height="fit">
<Modal.Header
icon={SvgUsers}
title="Invite Users"
onClose={handleClose}
/>
<Modal.Body>
<InputChipField
chips={chips}
onRemoveChip={removeChip}
onAdd={addEmail}
value={inputValue}
onChange={(val) => {
// Auto-add on comma
if (val.includes(",")) {
addEmail(val);
} else {
setInputValue(val);
}
}}
placeholder="Add emails to invite, comma separated"
/>
{hasInvalidEmails && (
<Text as="p" secondaryBody className="text-status-error-text">
Some entries are not valid email addresses and will be skipped.
</Text>
)}
<div className="flex items-start justify-between w-full gap-4">
<div className="flex flex-col gap-0.5">
<Text as="p" mainUiAction text04>
User Role
</Text>
<Text as="p" secondaryBody text03>
Invite new users as
</Text>
</div>
<div className="w-[200px]">
<InputSelect value={role} onValueChange={setRole}>
<InputSelect.Trigger />
<InputSelect.Content>
{INVITE_ROLES.map((r) => (
<InputSelect.Item key={r} value={r} icon={SvgUser}>
{USER_ROLE_LABELS[r]}
</InputSelect.Item>
))}
</InputSelect.Content>
</InputSelect>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Button prominence="tertiary" onClick={handleClose}>
Cancel
</Button>
}
submit={
<Disabled disabled={isSubmitting || chips.length === 0}>
<Button onClick={handleInvite}>Invite</Button>
</Disabled>
}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { SvgFilter, SvgUsers } from "@opal/icons";
import FilterButton from "@/refresh-components/buttons/FilterButton";
import Popover from "@/refresh-components/Popover";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import Text from "@/refresh-components/texts/Text";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface UserFiltersProps {
selectedRoles: UserRole[];
onRolesChange: (roles: UserRole[]) => void;
selectedStatus: "all" | "active" | "inactive";
onStatusChange: (status: "all" | "active" | "inactive") => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FILTERABLE_ROLES = Object.entries(USER_ROLE_LABELS).filter(
([role]) => role !== UserRole.EXT_PERM_USER
) as [UserRole, string][];
const STATUS_OPTIONS = [
{ value: "all" as const, label: "All Status" },
{ value: "active" as const, label: "Active" },
{ value: "inactive" as const, label: "Inactive" },
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function UserFilters({
selectedRoles,
onRolesChange,
selectedStatus,
onStatusChange,
}: UserFiltersProps) {
const hasRoleFilter = selectedRoles.length > 0;
const hasStatusFilter = selectedStatus !== "all";
const toggleRole = (role: UserRole) => {
if (selectedRoles.includes(role)) {
onRolesChange(selectedRoles.filter((r) => r !== role));
} else {
onRolesChange([...selectedRoles, role]);
}
};
const roleLabel = hasRoleFilter
? `${selectedRoles.length} role${
selectedRoles.length > 1 ? "s" : ""
} selected`
: "All Roles";
const statusLabel = hasStatusFilter
? STATUS_OPTIONS.find((o) => o.value === selectedStatus)?.label ?? "Status"
: "All Status";
return (
<div className="flex gap-2">
{/* Role filter */}
<Popover>
<Popover.Trigger asChild>
<FilterButton
leftIcon={SvgUsers}
active={hasRoleFilter}
onClear={() => onRolesChange([])}
>
{roleLabel}
</FilterButton>
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-2 min-w-[200px]">
{FILTERABLE_ROLES.map(([role, label]) => (
<label
key={role}
className="flex items-center gap-2 px-2 py-1.5 rounded-8 cursor-pointer hover:bg-background-tint-01"
>
<Checkbox
checked={selectedRoles.includes(role)}
onCheckedChange={() => toggleRole(role)}
/>
<Text as="span" mainUiAction>
{label}
</Text>
</label>
))}
</div>
</Popover.Content>
</Popover>
{/* Status filter */}
<Popover>
<Popover.Trigger asChild>
<FilterButton
leftIcon={SvgFilter}
active={hasStatusFilter}
onClear={() => onStatusChange("all")}
>
{statusLabel}
</FilterButton>
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-2 min-w-[160px]">
{STATUS_OPTIONS.map((option) => (
<label
key={option.value}
className="flex items-center gap-2 px-2 py-1.5 rounded-8 cursor-pointer hover:bg-background-tint-01"
>
<Checkbox
checked={selectedStatus === option.value}
onCheckedChange={() => onStatusChange(option.value)}
/>
<Text as="span" mainUiAction>
{option.label}
</Text>
</label>
))}
</div>
</Popover.Content>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import GenericConfirmModal from "@/components/modals/GenericConfirmModal";
import { setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
interface UserRoleCellProps {
user: UserRow;
onMutate: () => void;
}
export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
const [isUpdating, setIsUpdating] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [pendingRole, setPendingRole] = useState<string | null>(null);
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const applyRole = async (newRole: string) => {
setIsUpdating(true);
try {
await setUserRole(user.email, newRole);
onMutate();
} catch {
// error is surfaced by svc layer; refresh to show current state
onMutate();
} finally {
setIsUpdating(false);
}
};
const handleChange = (value: string) => {
if (value === user.role) return;
if (user.role === UserRole.CURATOR) {
setPendingRole(value);
setShowConfirmModal(true);
} else {
applyRole(value);
}
};
const handleConfirm = () => {
if (pendingRole) {
applyRole(pendingRole);
}
setShowConfirmModal(false);
setPendingRole(null);
};
return (
<>
{showConfirmModal && (
<GenericConfirmModal
title="Change Curator Role"
message={`Warning: Switching roles from Curator to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
} will remove their status as individual curators from all groups.`}
confirmText={`Switch Role to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
}`}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirm}
/>
)}
<InputSelect
value={user.role}
onValueChange={handleChange}
disabled={isUpdating}
>
<InputSelect.Trigger />
<InputSelect.Content>
{(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map(
([role, label]) => {
if (role === UserRole.EXT_PERM_USER) return null;
const isNotVisibleRole =
(!isPaidEnterpriseFeaturesEnabled &&
role === UserRole.GLOBAL_CURATOR) ||
role === UserRole.CURATOR ||
role === UserRole.LIMITED ||
role === UserRole.SLACK_USER;
const isCurrentRole = user.role === role;
return isNotVisibleRole && !isCurrentRole ? null : (
<InputSelect.Item key={role} value={role}>
{label}
</InputSelect.Item>
);
}
)}
</InputSelect.Content>
</InputSelect>
</>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useState } from "react";
import { Button } from "@opal/components";
import {
SvgMoreHorizontal,
SvgUsers,
SvgXCircle,
SvgTrash,
SvgCheck,
} from "@opal/icons";
import { Disabled } from "@opal/core";
import Popover from "@/refresh-components/Popover";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Text from "@/refresh-components/texts/Text";
import { toast } from "@/hooks/useToast";
import { deactivateUser, activateUser, deleteUser } from "./svc";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ModalType = "deactivate" | "activate" | "delete" | "editGroups" | null;
interface UserRowActionsProps {
user: UserRow;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function UserRowActions({
user,
onMutate,
}: UserRowActionsProps) {
const [modal, setModal] = useState<ModalType>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleAction(
action: () => Promise<void>,
successMessage: string
) {
setIsSubmitting(true);
try {
await action();
onMutate();
toast.success(successMessage);
setModal(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}
return (
<>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
</Popover.Trigger>
<Popover.Content align="end">
<div className="flex flex-col gap-0.5 p-1 min-w-[180px]">
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => {
setPopoverOpen(false);
setModal("editGroups");
}}
>
Groups
</Button>
{user.is_active ? (
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => {
setPopoverOpen(false);
setModal("deactivate");
}}
>
Deactivate User
</Button>
) : (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
setModal("activate");
}}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => {
setPopoverOpen(false);
setModal("delete");
}}
>
Delete User
</Button>
</>
)}
</div>
</Popover.Content>
</Popover>
{modal === "editGroups" && (
<EditGroupsModal
user={user}
onClose={() => setModal(null)}
onMutate={onMutate}
/>
)}
{modal === "deactivate" && (
<ConfirmationModalLayout
icon={SvgXCircle}
title="Deactivate User"
onClose={() => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() => {
handleAction(
() => deactivateUser(user.email),
"User deactivated"
);
}}
>
Deactivate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will
be preserved. You can reactivate this account later.
</Text>
</ConfirmationModalLayout>
)}
{modal === "activate" && (
<ConfirmationModalLayout
icon={SvgCheck}
title="Activate User"
onClose={() => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
onClick={() => {
handleAction(
() => activateUser(user.email),
"User activated"
);
}}
>
Activate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will regain access to Onyx.
</Text>
</ConfirmationModalLayout>
)}
{modal === "delete" && (
<ConfirmationModalLayout
icon={SvgTrash}
title="Delete User"
onClose={() => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() => {
handleAction(() => deleteUser(user.email), "User deleted");
}}
>
Delete
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history
will be deleted. This cannot be undone.
</Text>
</ConfirmationModalLayout>
)}
</>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useMemo, useState } from "react";
import type { SortingState } from "@tanstack/react-table";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Content } from "@opal/layouts";
import { Tag } from "@opal/components";
import { UserRole } from "@/lib/types";
import { timeAgo } from "@/lib/time";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useAdminUsers from "@/hooks/useAdminUsers";
import UserFilters from "./UserFilters";
import UserRowActions from "./UserRowActions";
import UserRoleCell from "./UserRoleCell";
import type { UserRow, StatusFilter } from "./interfaces";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getInitials(name: string | null, email: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
const local = email.split("@")[0];
if (!local) return "?";
const parts = local.split(/[._-]/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return local.slice(0, 2).toUpperCase();
}
function statusLabel(isActive: boolean): string {
return isActive ? "Active" : "Inactive";
}
function statusColor(isActive: boolean): "green" | "gray" {
return isActive ? "green" : "gray";
}
// ---------------------------------------------------------------------------
// Columns
// ---------------------------------------------------------------------------
const tc = createTableColumns<UserRow>();
function buildColumns(onMutate: () => void) {
return [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: false,
}),
tc.column("email", {
header: "User",
weight: 25,
minWidth: 180,
cell: (value, row) => (
<Content
sizePreset="main-ui"
variant="section"
title={row.personal_name ?? value}
description={row.personal_name ? value : undefined}
/>
),
}),
tc.column("groups", {
header: "Groups",
weight: 20,
minWidth: 120,
cell: (value) => {
if (!value.length) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
const visible = value.slice(0, 2);
const overflow = value.length - visible.length;
return (
<div className="flex items-center gap-1 flex-wrap">
{visible.map((g) => (
<Tag key={g.id} title={g.name} />
))}
{overflow > 0 && (
<Text as="span" secondaryBody text03>
+{overflow}
</Text>
)}
</div>
);
},
}),
tc.column("role", {
header: "Account Type",
weight: 18,
minWidth: 120,
cell: (_value, row) => <UserRoleCell user={row} onMutate={onMutate} />,
}),
tc.column("is_active", {
header: "Status",
weight: 15,
minWidth: 100,
cell: (value) => (
<Tag title={statusLabel(value)} color={statusColor(value)} />
),
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: (value) => (
<Text as="span" secondaryBody text03>
{timeAgo(value) ?? "—"}
</Text>
),
}),
tc.actions({
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
}),
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PAGE_SIZE = 10;
export default function UsersTable() {
const [searchTerm, setSearchTerm] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pageIndex, setPageIndex] = useState(0);
const [selectedRoles, setSelectedRoles] = useState<UserRole[]>([]);
const [selectedStatus, setSelectedStatus] = useState<StatusFilter>("all");
const isActive =
selectedStatus === "active"
? true
: selectedStatus === "inactive"
? false
: undefined;
const { users, totalItems, isLoading, refresh } = useAdminUsers({
pageIndex,
pageSize: PAGE_SIZE,
searchTerm: searchTerm || undefined,
roles: selectedRoles.length > 0 ? selectedRoles : undefined,
isActive,
});
const columns = useMemo(() => buildColumns(() => refresh()), [refresh]);
return (
<div className="flex flex-col gap-3">
<InputTypeIn
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPageIndex(0);
}}
placeholder="Search users by email..."
leftSearchIcon
/>
<UserFilters
selectedRoles={selectedRoles}
onRolesChange={(roles) => {
setSelectedRoles(roles);
setPageIndex(0);
}}
selectedStatus={selectedStatus}
onStatusChange={(status) => {
setSelectedStatus(status);
setPageIndex(0);
}}
/>
<DataTable
data={users}
columns={columns}
getRowId={(row) => row.id}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{ mode: "summary" }}
serverSide={{
totalItems,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx) => {
setPageIndex(idx);
},
onSearchTermChange: () => {
// search state managed via searchTerm prop above
},
}}
/>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { UserRole } from "@/lib/types";
export interface UserGroupInfo {
id: number;
name: string;
}
export interface UserRow {
id: string;
email: string;
role: UserRole;
is_active: boolean;
personal_name: string | null;
created_at: string;
updated_at: string;
groups: UserGroupInfo[];
}
export interface PaginatedUsersResponse {
items: UserRow[];
total_items: number;
}
export type StatusFilter = "all" | "active" | "inactive";

View File

@@ -0,0 +1,96 @@
export async function deactivateUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/deactivate-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to deactivate user");
}
}
export async function activateUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/activate-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to activate user");
}
}
export async function deleteUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/delete-user", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to delete user");
}
}
export async function setUserRole(
email: string,
newRole: string
): Promise<void> {
const res = await fetch("/api/manage/set-user-role", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email, new_role: newRole }),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to update user role");
}
}
export async function addUserToGroup(
groupId: number,
userId: string
): Promise<void> {
const res = await fetch(`/api/manage/admin/user-group/${groupId}/add-users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_ids: [userId] }),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to add user to group");
}
}
export async function removeUserFromGroup(
groupId: number,
currentUserIds: string[],
userIdToRemove: string
): Promise<void> {
const res = await fetch(`/api/manage/admin/user-group/${groupId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_ids: currentUserIds.filter((id) => id !== userIdToRemove),
cc_pair_ids: [],
}),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to remove user from group");
}
}
export async function inviteUsers(emails: string[]): Promise<void> {
const res = await fetch("/api/manage/admin/users", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emails }),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to invite users");
}
}