Compare commits

..

30 Commits

Author SHA1 Message Date
Nik
feba705d8f test(admin): add E2E Playwright tests for Users page
Comprehensive test coverage: layout, search, filters, sorting,
pagination, invite modal, row actions, inline role editing, group
management, and stats bar.

Adds OnyxApiClient methods: deactivateUser, activateUser, deleteUser,
cancelInvite, inviteUsers, setPersonalName.

Fixes OpenButton disabled prop (use Disabled wrapper from @opal/core).
2026-03-11 09:43:23 -07:00
Nik
e1d20a185e fix(admin): download CSV as onyx_users.csv with error handling
Extract downloadUsersCsv into svc.ts, set filename to onyx_users.csv,
and surface errors via toast.
2026-03-11 09:43:23 -07:00
Nik
77224da5fb fix(admin): polish AdminSidebar for users page 2026-03-11 09:43:23 -07:00
Nik
d135212701 fix(admin): handle nullable updated_at in column renderer 2026-03-11 09:43:23 -07:00
Nik
cd2b12cc17 feat(admin): switch /admin/users to new Users page and remove v2 route
Replace the old Users page at /admin/users with the new refresh-pages
implementation. Remove the temporary /admin/users2 route and sidebar
"Permissions" section, moving SCIM into "User Management".
2026-03-11 09:43:23 -07:00
Nik
742bfc0ac7 fix(admin): restyle EditGroupsModal, filters, and groups cell
- Rewrite EditGroupsModal with Section/ContentAction layout primitives
- Add white subsection card with absolute bg pattern from Figma
- Use LineItem for all group entries with tint-01 background
- Add ShadowDiv scroll indicators to filter popovers and group lists
- Fix icon indentation in filter dropdowns (always provide fallback icon)
- Polish GroupsCell and UsersSummary
2026-03-11 09:43:23 -07:00
Nik
b7e9516a4e fix(admin): fix null handling, status-aware actions, data loading, and UI polish
- Add null guards for invited/pending users (id: null, role: null) across
  EditGroupsModal, GroupsCell, UserRowActions
- Add status-aware action menus per user status (Active, Inactive, Invited, Requested)
- Add cancelInvite and approveRequest service functions
- Switch useAdminUsers to /api/manage/users/accepted with toUserRow() mapper
- Add UserStatus enum, USER_STATUS_LABELS, and StatusFilter type
- Fix dark mode avatars with bg-background-neutral-inverted-00 + Text inverted
- Switch page layout to width="full" to prevent table overflow
- Reduce column weights for better space distribution
- Add GroupsCell component and filter-plus icon
2026-03-11 09:43:23 -07:00
Nik
ea56f4e2f0 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-11 09:43:23 -07:00
Nik
1880aab645 fix: use Disabled wrapper for OpenButton in UserRoleCell
OpenButton's InteractiveStatefulProps doesn't include a `disabled` prop.
Wraps the Popover+OpenButton with `<Disabled>` from `@opal/core` instead.
Also fixes nullable type signature in renderLastUpdatedColumn.
2026-03-11 09:43:23 -07:00
Nik
2949026050 fix(admin): add select-tinted variant to OpenButton for role cell tint
Add a new select-tinted Interactive.Stateful variant that renders
bg-background-tint-01 at rest. Expose variant prop on OpenButton
so callers can opt into this style. Use it in UserRoleCell.
2026-03-11 09:43:23 -07:00
Nik
908497af02 fix(admin): add justifyContent prop to OpenButton and polish UserRoleCell
- Add justifyContent="between" to OpenButton for full-width role dropdowns
- Refactor UserRoleCell to use Popover with LineItem pattern
- Add curator role change confirmation modal
2026-03-11 09:43:23 -07:00
Nik
a1bb833cee 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-11 09:43:22 -07:00
Nik
6eb7be8799 fix(admin): improve invite modal chip field with error icons and layout
- Add rightIcon/rightIconClassName props to Chip for warning indicators
- Add error state to ChipItem for invalid email highlighting
- Refactor InputChipField to column layout with wrapped chip row
- Polish InviteUsersModal UI
2026-03-11 09:43:22 -07:00
Nik
d6796dafce 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-11 09:43:22 -07:00
Nik
d2d634c89c fix(admin): remove min-width on row actions popover
Let the popover size to its content instead of forcing min-w-[180px].
2026-03-11 09:43:22 -07:00
Nik
ad73760b14 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-11 09:43:22 -07:00
Nik
5d4e3163b3 feat(admin): rewrite filters with LineItem, counts, multi-status, sorting
- Add UserStatus enum + USER_STATUS_LABELS to types.ts
- Rewrite UserFilters with LineItem-based popovers, count badges, group search
- Add /manage/users/counts endpoint with role + status GROUP BY queries
- Add sort_by/sort_dir params to accepted users endpoint
- Rewrite useAdminUsers to merge accepted/invited/requested users
- Rewrite useUserCounts to use new counts endpoint
- Add filter callbacks to UsersSummary stat cells with hover state
- Multi-status filtering in UsersPage + UsersTable
- Client-side group filtering, debounced search, empty state
2026-03-11 09:43:22 -07:00
Nik
0ae5527e0f 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-11 09:43:22 -07:00
Nik
56e3e28fb9 feat(admin): switch Users table to client-side pagination
Fetch all accepted users via /api/manage/users/accepted/all
and let DataTable handle sorting, filtering, and pagination
entirely on the client side. Removes server-side pagination
wiring, debounced search, and SORT_FIELD_MAP.
2026-03-11 09:43:22 -07:00
Nik
8928968213 fix(admin): address PR review comments on table branch
- Replace bare except with logger.warning for SCIM mapping failures
- Extract getInitials into utils.ts with unit tests
- Add refresh-pages test pattern to jest.config.js
- Rewrite UsersTable: extract column renderers, add debounced search,
  remove unconnected sorting state
2026-03-11 09:43:22 -07:00
Nik
a35f73a98c fix(admin): increase groups column width to prevent edit icon overlap
Bump groups column weight from 20→24 and minWidth from 180→200 so the
pencil edit icon (added downstream) never overlaps group tags.
2026-03-11 09:43:22 -07:00
Nik
43619bd11b fix(admin): prevent group tags from wrapping in table cell 2026-03-11 09:43:21 -07:00
Nik
8a75970934 fix(admin): fix dark mode avatars and reduce column weights
- Fix avatar dark mode: use bg-background-neutral-inverted-00 + Text inverted
  (matches UserAvatarPopover pattern for theme-safe contrast)
- Reduce Name column weight 25→22, Account Type 18→16 to prevent overflow
- Change page size from 10 to 8
2026-03-11 09:43:21 -07:00
Nik
23447b655f feat(admin): expand user search to name, add SCIM sync status, fix table columns
- Backend `q` param now searches both email and personal_name (OR)
- Add `is_scim_synced` field to FullUserSnapshot via batch ScimUserMapping lookup
- Status column shows "SCIM synced" sublabel for SCIM-managed users
- Fix table columns: Name header, larger group pills, role icons, plain text status
- Fix header spacing (Spacer 2.5rem) to prevent content peeking above sticky header
- Simplify UsersSummary to plain static cells (no hover/filter behavior)
2026-03-11 09:43:21 -07:00
Nik
47e5725fb3 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-11 09:43:21 -07:00
Nik
4ceedb588a 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-11 09:43:21 -07:00
Nik
9045926737 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-11 09:43:21 -07:00
Nik
c010804c22 feat(admin): add GET /manage/users/accepted/all endpoint
Returns all accepted users without pagination for client-side
filtering and sorting in the admin Users table.
2026-03-11 09:43:21 -07:00
Nik
aba0dfb7bb test: add unit tests for FullUserSnapshot new fields
Validates personal_name, created_at, updated_at, and groups fields
added to FullUserSnapshot.from_user_model.
2026-03-11 09:43:21 -07:00
Nik
9cca9c231d 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-11 09:43:21 -07:00
48 changed files with 3866 additions and 490 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

@@ -339,6 +339,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

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from sqlalchemy.sql import expression
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.elements import KeyedColumnElement
from sqlalchemy.sql.expression import or_
from onyx.auth.invited_users import remove_user_from_invited_users
from onyx.auth.schemas import UserRole
@@ -24,6 +25,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
@@ -162,7 +164,13 @@ def _get_accepted_user_where_clause(
where_clause.append(User.role != UserRole.EXT_PERM_USER)
if email_filter_string is not None:
where_clause.append(email_col.ilike(f"%{email_filter_string}%"))
personal_name_col: KeyedColumnElement[Any] = User.__table__.c.personal_name
where_clause.append(
or_(
email_col.ilike(f"%{email_filter_string}%"),
personal_name_col.ilike(f"%{email_filter_string}%"),
)
)
if roles_filter:
where_clause.append(User.role.in_(roles_filter))
@@ -173,6 +181,30 @@ def _get_accepted_user_where_clause(
return where_clause
def get_all_accepted_users(
db_session: Session,
include_external: bool = False,
) -> Sequence[User]:
"""Returns all accepted users without pagination.
Uses the same filtering as the paginated endpoint but without
search, role, or active filters."""
stmt = select(User)
where_clause = _get_accepted_user_where_clause(
include_external=include_external,
)
stmt = stmt.where(*where_clause)
return db_session.scalars(stmt).unique().all()
_USER_SORTABLE_COLUMNS: dict[str, KeyedColumnElement[Any]] = {
"email": User.email,
"role": User.role,
"is_active": User.is_active,
"created_at": User.created_at,
"updated_at": User.updated_at,
}
def get_page_of_filtered_users(
db_session: Session,
page_size: int,
@@ -181,6 +213,8 @@ def get_page_of_filtered_users(
is_active_filter: bool | None = None,
roles_filter: list[UserRole] = [],
include_external: bool = False,
sort_by: str | None = None,
sort_dir: str | None = None,
) -> Sequence[User]:
users_stmt = select(User)
@@ -190,11 +224,19 @@ def get_page_of_filtered_users(
include_external=include_external,
is_active_filter=is_active_filter,
)
# Apply pagination
users_stmt = users_stmt.offset((page_num) * page_size).limit(page_size)
# Apply filtering
users_stmt = users_stmt.where(*where_clause)
# Apply sorting
col = _USER_SORTABLE_COLUMNS.get(sort_by) if sort_by else None
if col is not None:
users_stmt = users_stmt.order_by(
col.desc() if sort_dir == "desc" else col.asc()
)
# Apply pagination
users_stmt = users_stmt.offset((page_num) * page_size).limit(page_size)
return db_session.scalars(users_stmt).unique().all()
@@ -218,6 +260,36 @@ def get_total_filtered_users_count(
return db_session.scalar(total_count_stmt) or 0
def get_user_counts_by_role_and_status(
db_session: Session,
) -> dict[str, dict[str, int]]:
"""Returns user counts grouped by role and by active/inactive status.
Excludes API key users, anonymous users, and no-auth placeholder users.
"""
base_where = _get_accepted_user_where_clause()
# Counts by role
role_col = User.__table__.c.role
role_stmt = select(role_col, func.count()).where(*base_where).group_by(role_col)
role_counts: dict[str, int] = {}
for role_val, count in db_session.execute(role_stmt).all():
key = role_val.value if hasattr(role_val, "value") else str(role_val)
role_counts[key] = count
# Counts by is_active
is_active_col = User.__table__.c.is_active
status_stmt = (
select(is_active_col, func.count()).where(*base_where).group_by(is_active_col)
)
status_counts: dict[str, int] = {}
for is_active_val, count in db_session.execute(status_stmt).all():
key = "active" if is_active_val else "inactive"
status_counts[key] = count
return {"role_counts": role_counts, "status_counts": status_counts}
def get_user_by_email(email: str, db_session: Session) -> User | None:
user = (
db_session.query(User)
@@ -358,3 +430,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

@@ -5,6 +5,7 @@ from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import cast
from uuid import UUID
import jwt
from email_validator import EmailNotValidError
@@ -18,6 +19,7 @@ from fastapi import Query
from fastapi import Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from onyx.auth.anonymous_user import fetch_anonymous_user_info
@@ -67,11 +69,14 @@ 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_accepted_users
from onyx.db.users import get_all_users
from onyx.db.users import get_page_of_filtered_users
from onyx.db.users import get_total_filtered_users_count
from onyx.db.users import get_user_by_email
from onyx.db.users import get_user_counts_by_role_and_status
from onyx.db.users import validate_user_role_update
from onyx.key_value_store.factory import get_kv_store
from onyx.redis.redis_pool import get_raw_redis_client
@@ -98,6 +103,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
@@ -177,6 +183,8 @@ def list_accepted_users(
page_size: int = Query(10, ge=1, le=1000),
roles: list[UserRole] = Query(default=[]),
is_active: bool | None = Query(default=None),
sort_by: str | None = Query(default=None),
sort_dir: str | None = Query(default=None),
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[FullUserSnapshot]:
@@ -187,6 +195,8 @@ def list_accepted_users(
email_filter_string=q,
is_active_filter=is_active,
roles_filter=roles,
sort_by=sort_by,
sort_dir=sort_dir,
)
total_accepted_users_count = get_total_filtered_users_count(
@@ -203,14 +213,75 @@ 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)
# Batch-fetch SCIM mappings to mark synced users
scim_synced_ids: set[UUID] = set()
try:
from onyx.db.models import ScimUserMapping
scim_mappings = db_session.scalars(
select(ScimUserMapping.user_id).where(ScimUserMapping.user_id.in_(user_ids))
).all()
scim_synced_ids = set(scim_mappings)
except Exception:
logger.warning(
"Failed to fetch SCIM mappings; marking all users as non-synced",
exc_info=True,
)
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, [])
],
is_scim_synced=user.id in scim_synced_ids,
)
for user in filtered_accepted_users
],
total_items=total_accepted_users_count,
)
@router.get("/manage/users/accepted/all", tags=PUBLIC_API_TAGS)
def list_all_accepted_users(
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[FullUserSnapshot]:
"""Returns all accepted users without pagination.
Used by the admin Users page for client-side filtering/sorting."""
users = get_all_accepted_users(db_session=db_session)
if not users:
return []
user_ids = [user.id for user in users]
groups_by_user = batch_get_user_groups(db_session, user_ids)
return [
FullUserSnapshot.from_user_model(
user,
groups=[
UserGroupInfo(id=gid, name=gname)
for gid, gname in groups_by_user.get(user.id, [])
],
)
for user in users
]
@router.get("/manage/users/counts")
def get_user_counts(
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> dict[str, dict[str, int]]:
return get_user_counts_by_role_and_status(db_session)
@router.get("/manage/users/invited", tags=PUBLIC_API_TAGS)
def list_invited_users(
_: User = Depends(current_admin_user),
@@ -269,24 +340,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 +353,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,41 @@ 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]
is_scim_synced: bool
@classmethod
def from_user_model(cls, user: User) -> "FullUserSnapshot":
def from_user_model(
cls,
user: User,
groups: list[UserGroupInfo] | None = None,
is_scim_synced: bool = False,
) -> "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 [],
is_scim_synced=is_scim_synced,
)

View File

@@ -0,0 +1,54 @@
import datetime
from unittest.mock import MagicMock
from uuid import uuid4
from onyx.auth.schemas import UserRole
from onyx.server.models import FullUserSnapshot
from onyx.server.models import UserGroupInfo
def _mock_user(
personal_name: str | None = "Test User",
created_at: datetime.datetime | None = None,
updated_at: datetime.datetime | None = None,
) -> MagicMock:
user = MagicMock()
user.id = uuid4()
user.email = "test@example.com"
user.role = UserRole.BASIC
user.is_active = True
user.password_configured = True
user.personal_name = personal_name
user.created_at = created_at or datetime.datetime(
2025, 1, 1, tzinfo=datetime.timezone.utc
)
user.updated_at = updated_at or datetime.datetime(
2025, 6, 15, tzinfo=datetime.timezone.utc
)
return user
def test_from_user_model_includes_new_fields() -> None:
user = _mock_user(personal_name="Alice")
groups = [UserGroupInfo(id=1, name="Engineering")]
snapshot = FullUserSnapshot.from_user_model(user, groups=groups)
assert snapshot.personal_name == "Alice"
assert snapshot.created_at == user.created_at
assert snapshot.updated_at == user.updated_at
assert snapshot.groups == groups
def test_from_user_model_defaults_groups_to_empty() -> None:
user = _mock_user()
snapshot = FullUserSnapshot.from_user_model(user)
assert snapshot.groups == []
def test_from_user_model_personal_name_none() -> None:
user = _mock_user(personal_name=None)
snapshot = FullUserSnapshot.from_user_model(user)
assert snapshot.personal_name is None

View File

@@ -143,6 +143,7 @@ module.exports = {
"**/src/app/**/utils/*.test.ts",
"**/src/app/**/hooks/*.test.ts", // Pure packet processor tests
"**/src/refresh-components/**/*.test.ts",
"**/src/refresh-pages/**/*.test.ts",
"**/src/sections/**/*.test.ts",
"**/src/components/**/*.test.ts",
// Add more patterns here as you add more unit tests

View File

@@ -55,7 +55,7 @@ type OpenButtonContentProps =
children?: string;
};
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> &
type OpenButtonProps = InteractiveStatefulProps &
OpenButtonContentProps & {
/**
* Size preset — controls gap, text size, and Container height/rounding.
@@ -65,6 +65,13 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> &
/** Width preset. */
width?: WidthVariant;
/**
* Content justify mode. When `"between"`, icon+label group left and
* chevron pushes to the right edge. Default keeps all items in a
* tight `gap-1` row.
*/
justifyContent?: "between";
/** Tooltip text shown on hover. */
tooltip?: string;
@@ -82,9 +89,11 @@ function OpenButton({
size = "lg",
foldable,
width,
justifyContent,
tooltip,
tooltipSide = "top",
interaction,
variant = "select-heavy",
...statefulProps
}: OpenButtonProps) {
const { isDisabled } = useDisabled();
@@ -111,7 +120,7 @@ function OpenButton({
const button = (
<Interactive.Stateful
variant="select-heavy"
variant={variant}
interaction={resolvedInteraction}
{...statefulProps}
>
@@ -125,19 +134,30 @@ function OpenButton({
>
<div
className={cn(
"opal-button interactive-foreground flex flex-row items-center gap-1",
"opal-button interactive-foreground flex flex-row items-center",
justifyContent === "between" ? "w-full justify-between" : "gap-1",
foldable && "interactive-foldable-host"
)}
>
{iconWrapper(Icon, size, !foldable && !!children)}
{foldable ? (
<Interactive.Foldable>
{labelEl}
{justifyContent === "between" ? (
<>
<span className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !foldable && !!children)}
{labelEl}
</span>
{iconWrapper(ChevronIcon, size, !!children)}
</Interactive.Foldable>
</>
) : foldable ? (
<>
{iconWrapper(Icon, size, !foldable && !!children)}
<Interactive.Foldable>
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</Interactive.Foldable>
</>
) : (
<>
{iconWrapper(Icon, size, !foldable && !!children)}
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</>

View File

@@ -9,6 +9,8 @@ import { cn } from "@opal/utils";
type TagColor = "green" | "purple" | "blue" | "gray" | "amber";
type TagSize = "sm" | "md";
interface TagProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
@@ -18,6 +20,9 @@ interface TagProps {
/** Color variant. Default: `"gray"`. */
color?: TagColor;
/** Size variant. Default: `"sm"`. */
size?: TagSize;
}
// ---------------------------------------------------------------------------
@@ -36,11 +41,11 @@ const COLOR_CONFIG: Record<TagColor, { bg: string; text: string }> = {
// Tag
// ---------------------------------------------------------------------------
function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
const config = COLOR_CONFIG[color];
return (
<div className={cn("opal-auxiliary-tag", config.bg)}>
<div className={cn("opal-auxiliary-tag", config.bg)} data-size={size}>
{Icon && (
<div className="opal-auxiliary-tag-icon-container">
<Icon className={cn("opal-auxiliary-tag-icon", config.text)} />
@@ -48,7 +53,8 @@ function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
)}
<span
className={cn(
"opal-auxiliary-tag-title px-[2px] font-figure-small-value",
"opal-auxiliary-tag-title px-[2px]",
size === "md" ? "font-secondary-body" : "font-figure-small-value",
config.text
)}
>
@@ -58,4 +64,4 @@ function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
);
}
export { Tag, type TagProps, type TagColor };
export { Tag, type TagProps, type TagColor, type TagSize };

View File

@@ -13,6 +13,12 @@
gap: 0;
}
.opal-auxiliary-tag[data-size="md"] {
height: 1.375rem;
padding: 0 0.375rem;
border-radius: 0.375rem;
}
.opal-auxiliary-tag-icon-container {
display: flex;
align-items: center;

View File

@@ -10,7 +10,11 @@ import type { WithoutStyles } from "@opal/types";
// Types
// ---------------------------------------------------------------------------
type InteractiveStatefulVariant = "select-light" | "select-heavy" | "sidebar";
type InteractiveStatefulVariant =
| "select-light"
| "select-heavy"
| "select-tinted"
| "sidebar";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";

View File

@@ -211,6 +211,22 @@
--interactive-foreground-icon: var(--action-link-03);
}
/* Select-Tinted — Select-Heavy with tinted rest background */
.interactive[data-interactive-variant="select-tinted"] {
@apply bg-background-tint-01;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-tinted"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
--interactive-foreground-icon: var(--text-04);
}
/* ===========================================================================
Sidebar
=========================================================================== */

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgFilterPlus = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M9.5 12.5L6.83334 11.1667V7.80667L1.5 1.5H14.8333L12.1667 4.65333M12.1667 7V9.5M12.1667 9.5V12M12.1667 9.5H9.66667M12.1667 9.5H14.6667"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgFilterPlus;

View File

@@ -72,6 +72,7 @@ export { default as SvgFileChartPie } from "@opal/icons/file-chart-pie";
export { default as SvgFileSmall } from "@opal/icons/file-small";
export { default as SvgFileText } from "@opal/icons/file-text";
export { default as SvgFilter } from "@opal/icons/filter";
export { default as SvgFilterPlus } from "@opal/icons/filter-plus";
export { default as SvgFold } from "@opal/icons/fold";
export { default as SvgFolder } from "@opal/icons/folder";
export { default as SvgFolderIn } from "@opal/icons/folder-in";

View File

@@ -8,8 +8,65 @@ import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent } from "@opal/types";
import "@opal/components/tooltip.css";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Overflow tooltip helper
// ---------------------------------------------------------------------------
/** Returns a ref + boolean indicating whether the element's text is clipped. */
function useIsOverflowing<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [overflowing, setOverflowing] = useState(false);
const check = useCallback(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, []);
useEffect(() => {
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, [check]);
return { ref, overflowing, check };
}
/**
* Wraps children in a Radix tooltip that only appears when the element is
* overflowing (text truncated). Uses the same opal-tooltip styling as Button.
*/
function OverflowTooltip({
text,
overflowing,
children,
}: {
text: string;
overflowing: boolean;
children: React.ReactNode;
}) {
if (!overflowing) return children;
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side="top"
sideOffset={4}
>
{text}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
// ---------------------------------------------------------------------------
// Types
@@ -142,6 +199,8 @@ function ContentMd({
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const titleOverflow = useIsOverflowing<HTMLSpanElement>();
const descOverflow = useIsOverflowing<HTMLDivElement>();
const config = CONTENT_MD_PRESETS[sizePreset];
@@ -211,18 +270,24 @@ function ContentMd({
/>
</div>
) : (
<span
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<OverflowTooltip
text={title}
overflowing={titleOverflow.overflowing}
>
{title}
</span>
<span
ref={titleOverflow.ref}
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
</OverflowTooltip>
)}
{optional && (
@@ -275,9 +340,17 @@ function ContentMd({
</div>
{description && (
<div className="opal-content-md-description font-secondary-body text-text-03">
{description}
</div>
<OverflowTooltip
text={description}
overflowing={descOverflow.overflowing}
>
<div
ref={descOverflow.ref}
className="opal-content-md-description font-secondary-body text-text-03"
>
{description}
</div>
</OverflowTooltip>
)}
</div>
</div>

View File

@@ -224,7 +224,7 @@
--------------------------------------------------------------------------- */
.opal-content-md {
@apply flex flex-row items-start;
@apply flex flex-row items-start min-w-0;
}
/* ---------------------------------------------------------------------------
@@ -311,7 +311,7 @@
--------------------------------------------------------------------------- */
.opal-content-md-description {
@apply text-left w-full;
@apply text-left w-full truncate;
padding: 0 0.125rem;
}

View File

@@ -1,342 +1 @@
"use client";
import { useState } from "react";
import SimpleTabs from "@/refresh-components/SimpleTabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import InvitedUserTable from "@/components/admin/users/InvitedUserTable";
import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
import Modal from "@/refresh-components/Modal";
import { ThreeDotsLoader } from "@/components/Loading";
import { toast } from "@/hooks/useToast";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useSWR, { mutate } from "swr";
import { ErrorCallout } from "@/components/ErrorCallout";
import BulkAdd, { EmailInviteStatus } from "@/components/admin/users/BulkAdd";
import Text from "@/refresh-components/texts/Text";
import { InvitedUserSnapshot } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import PendingUsersTable from "@/components/admin/users/PendingUsersTable";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { Spinner } from "@/components/Spinner";
import { SvgDownloadCloud, SvgUserPlus } from "@opal/icons";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.USERS]!;
interface CountDisplayProps {
label: string;
value: number | null;
isLoading: boolean;
}
function CountDisplay({ label, value, isLoading }: CountDisplayProps) {
const displayValue = isLoading
? "..."
: value === null
? "-"
: value.toLocaleString();
return (
<div className="flex items-center gap-1 px-1 py-0.5 rounded-06">
<Text as="p" mainUiMuted text03>
{label}
</Text>
<Text as="p" headingH3 text05>
{displayValue}
</Text>
</div>
);
}
function UsersTables({
q,
isDownloadingUsers,
setIsDownloadingUsers,
}: {
q: string;
isDownloadingUsers: boolean;
setIsDownloadingUsers: (loading: boolean) => void;
}) {
const [currentUsersCount, setCurrentUsersCount] = useState<number | null>(
null
);
const [currentUsersLoading, setCurrentUsersLoading] = useState<boolean>(true);
const downloadAllUsers = async () => {
setIsDownloadingUsers(true);
const startTime = Date.now();
const minDurationMsForSpinner = 1000;
try {
const response = await fetch("/api/manage/users/download");
if (!response.ok) {
throw new Error("Failed to download all users");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor_tag = document.createElement("a");
anchor_tag.href = url;
anchor_tag.download = "users.csv";
document.body.appendChild(anchor_tag);
anchor_tag.click();
//Clean up URL after download to avoid memory leaks
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor_tag);
} catch (error) {
toast.error(`Failed to download all users - ${error}`);
} finally {
//Ensure spinner is visible for at least 1 second
//This is to avoid the spinner disappearing too quickly
const endTime = Date.now();
const duration = endTime - startTime;
await new Promise((resolve) =>
setTimeout(resolve, minDurationMsForSpinner - duration)
);
setIsDownloadingUsers(false);
}
};
const {
data: invitedUsers,
error: invitedUsersError,
isLoading: invitedUsersLoading,
mutate: invitedUsersMutate,
} = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
errorHandlingFetcher
);
const { data: validDomains, error: domainsError } = useSWR<string[]>(
"/api/manage/admin/valid-domains",
errorHandlingFetcher
);
const {
data: pendingUsers,
error: pendingUsersError,
isLoading: pendingUsersLoading,
mutate: pendingUsersMutate,
} = useSWR<InvitedUserSnapshot[]>(
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
errorHandlingFetcher
);
const invitedUsersCount =
invitedUsers === undefined ? null : invitedUsers.length;
const pendingUsersCount =
pendingUsers === undefined ? null : pendingUsers.length;
// Show loading animation only during the initial data fetch
if (!validDomains) {
return <ThreeDotsLoader />;
}
if (domainsError) {
return (
<ErrorCallout
errorTitle="Error loading valid domains"
errorMsg={domainsError?.info?.detail}
/>
);
}
const tabs = SimpleTabs.generateTabs({
current: {
name: "Current Users",
content: (
<Card className="w-full">
<CardHeader>
<div className="flex justify-between items-center gap-1">
<CardTitle>Current Users</CardTitle>
<Disabled disabled={isDownloadingUsers}>
<Button
icon={SvgDownloadCloud}
onClick={() => downloadAllUsers()}
>
{isDownloadingUsers ? "Downloading..." : "Download CSV"}
</Button>
</Disabled>
</div>
</CardHeader>
<CardContent>
<SignedUpUserTable
invitedUsers={invitedUsers || []}
q={q}
invitedUsersMutate={invitedUsersMutate}
countDisplay={
<CountDisplay
label="Total users"
value={currentUsersCount}
isLoading={currentUsersLoading}
/>
}
onTotalItemsChange={(count) => setCurrentUsersCount(count)}
onLoadingChange={(loading) => {
setCurrentUsersLoading(loading);
if (loading) {
setCurrentUsersCount(null);
}
}}
/>
</CardContent>
</Card>
),
},
invited: {
name: "Invited Users",
content: (
<Card className="w-full">
<CardHeader>
<div className="flex justify-between items-center gap-1">
<CardTitle>Invited Users</CardTitle>
<CountDisplay
label="Total invited"
value={invitedUsersCount}
isLoading={invitedUsersLoading}
/>
</div>
</CardHeader>
<CardContent>
<InvitedUserTable
users={invitedUsers || []}
mutate={invitedUsersMutate}
error={invitedUsersError}
isLoading={invitedUsersLoading}
q={q}
/>
</CardContent>
</Card>
),
},
...(NEXT_PUBLIC_CLOUD_ENABLED && {
pending: {
name: "Pending Users",
content: (
<Card>
<CardHeader>
<div className="flex justify-between items-center gap-1">
<CardTitle>Pending Users</CardTitle>
<CountDisplay
label="Total pending"
value={pendingUsersCount}
isLoading={pendingUsersLoading}
/>
</div>
</CardHeader>
<CardContent>
<PendingUsersTable
users={pendingUsers || []}
mutate={pendingUsersMutate}
error={pendingUsersError}
isLoading={pendingUsersLoading}
q={q}
/>
</CardContent>
</Card>
),
},
}),
});
return <SimpleTabs tabs={tabs} defaultValue="current" />;
}
function SearchableTables() {
const [query, setQuery] = useState("");
const [isDownloadingUsers, setIsDownloadingUsers] = useState(false);
return (
<div>
{isDownloadingUsers && <Spinner />}
<div className="flex flex-col gap-y-4">
<div className="flex flex-row items-center gap-2">
<InputTypeIn
placeholder="Search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<AddUserButton />
</div>
<UsersTables
q={query}
isDownloadingUsers={isDownloadingUsers}
setIsDownloadingUsers={setIsDownloadingUsers}
/>
</div>
</div>
);
}
function AddUserButton() {
const [bulkAddUsersModal, setBulkAddUsersModal] = useState(false);
const onSuccess = (emailInviteStatus: EmailInviteStatus) => {
mutate(
(key) => typeof key === "string" && key.startsWith("/api/manage/users")
);
setBulkAddUsersModal(false);
if (emailInviteStatus === "NOT_CONFIGURED") {
toast.warning(
"Users added, but no email notification was sent. There is no SMTP server set up for email sending."
);
} else if (emailInviteStatus === "SEND_FAILED") {
toast.warning(
"Users added, but email sending failed. Check your SMTP configuration and try again."
);
} else {
toast.success("Users invited!");
}
};
const onFailure = async (res: Response) => {
const error = (await res.json()).detail;
toast.error(`Failed to invite users - ${error}`);
};
const handleInviteClick = () => {
setBulkAddUsersModal(true);
};
return (
<>
<CreateButton primary onClick={handleInviteClick}>
Invite Users
</CreateButton>
{bulkAddUsersModal && (
<Modal open onOpenChange={() => setBulkAddUsersModal(false)}>
<Modal.Content>
<Modal.Header
icon={SvgUserPlus}
title="Bulk Add Users"
onClose={() => setBulkAddUsersModal(false)}
/>
<Modal.Body>
<div className="flex flex-col gap-2">
<Text as="p">
Add the email addresses to import, separated by whitespaces.
Invited users will be able to login to this domain with their
email address.
</Text>
<BulkAdd onSuccess={onSuccess} onFailure={onFailure} />
</div>
</Modal.Body>
</Modal.Content>
</Modal>
)}
</>
);
}
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header title={route.title} icon={route.icon} separator />
<SettingsLayouts.Body>
<SearchableTables />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}
export { default } from "@/refresh-pages/admin/UsersPage";

View File

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

View File

@@ -31,7 +31,6 @@ 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,118 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { UserStatus } from "@/lib/types";
import type { UserRole, InvitedUserSnapshot } from "@/lib/types";
import type {
UserRow,
UserGroupInfo,
} from "@/refresh-pages/admin/UsersPage/interfaces";
// ---------------------------------------------------------------------------
// Backend response shape (GET /manage/users/accepted/all)
// ---------------------------------------------------------------------------
interface FullUserSnapshot {
id: string;
email: string;
role: UserRole;
is_active: boolean;
password_configured: boolean;
personal_name: string | null;
created_at: string;
updated_at: string;
groups: UserGroupInfo[];
is_scim_synced: boolean;
}
// ---------------------------------------------------------------------------
// Converters
// ---------------------------------------------------------------------------
function toUserRow(snapshot: FullUserSnapshot): UserRow {
return {
id: snapshot.id,
email: snapshot.email,
role: snapshot.role,
status: snapshot.is_active ? UserStatus.ACTIVE : UserStatus.INACTIVE,
is_active: snapshot.is_active,
is_scim_synced: snapshot.is_scim_synced,
personal_name: snapshot.personal_name,
created_at: snapshot.created_at,
updated_at: snapshot.updated_at,
groups: snapshot.groups,
};
}
function emailToUserRow(
email: string,
status: UserStatus.INVITED | UserStatus.REQUESTED
): UserRow {
return {
id: null,
email,
role: null,
status,
is_active: false,
is_scim_synced: false,
personal_name: null,
created_at: null,
updated_at: null,
groups: [],
};
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export default function useAdminUsers() {
const {
data: acceptedData,
isLoading: acceptedLoading,
mutate: acceptedMutate,
} = useSWR<FullUserSnapshot[]>(
"/api/manage/users/accepted/all",
errorHandlingFetcher
);
const {
data: invitedData,
isLoading: invitedLoading,
mutate: invitedMutate,
} = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
errorHandlingFetcher
);
const {
data: requestedData,
isLoading: requestedLoading,
mutate: requestedMutate,
} = useSWR<InvitedUserSnapshot[]>(
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
errorHandlingFetcher
);
const acceptedRows = (acceptedData ?? []).map(toUserRow);
const invitedRows = (invitedData ?? []).map((u) =>
emailToUserRow(u.email, UserStatus.INVITED)
);
const requestedRows = (requestedData ?? []).map((u) =>
emailToUserRow(u.email, UserStatus.REQUESTED)
);
const users = [...invitedRows, ...requestedRows, ...acceptedRows];
const isLoading = acceptedLoading || invitedLoading || requestedLoading;
function refresh() {
acceptedMutate();
invitedMutate();
requestedMutate();
}
return { users, isLoading, refresh };
}

View File

@@ -5,22 +5,26 @@ import { errorHandlingFetcher } from "@/lib/fetcher";
import type { InvitedUserSnapshot } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
type PaginatedCountResponse = {
total_items: number;
type UserCountsResponse = {
role_counts: Record<string, number>;
status_counts: Record<string, number>;
};
type UserCounts = {
activeCount: number | null;
invitedCount: number | null;
pendingCount: number | null;
roleCounts: Record<string, number>;
statusCounts: Record<string, number>;
refreshCounts: () => void;
};
export default function useUserCounts(): UserCounts {
// 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: countsData, mutate: refreshCounts } =
useSWR<UserCountsResponse>(
"/api/manage/users/counts",
errorHandlingFetcher
);
const { data: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
@@ -32,9 +36,20 @@ export default function useUserCounts(): UserCounts {
errorHandlingFetcher
);
const activeCount = countsData?.status_counts?.active ?? null;
const inactiveCount = countsData?.status_counts?.inactive ?? null;
return {
activeCount: activeData?.total_items ?? null,
activeCount,
invitedCount: invitedUsers?.length ?? null,
pendingCount: pendingUsers?.length ?? null,
roleCounts: countsData?.role_counts ?? {},
statusCounts: {
...(activeCount !== null ? { active: activeCount } : {}),
...(inactiveCount !== null ? { inactive: inactiveCount } : {}),
...(invitedUsers ? { invited: invitedUsers.length } : {}),
...(pendingUsers ? { requested: pendingUsers.length } : {}),
} as Record<string, number>,
refreshCounts,
};
}

View File

@@ -58,7 +58,6 @@ 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",
@@ -187,14 +186,9 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
sidebarLabel: "Knowledge Graph",
},
[ADMIN_PATHS.USERS]: {
icon: SvgUser,
title: "Manage Users",
sidebarLabel: "Users",
},
[ADMIN_PATHS.USERS_V2]: {
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users v2",
sidebarLabel: "Users",
},
[ADMIN_PATHS.API_KEYS]: {
icon: SvgKey,

View File

@@ -68,6 +68,20 @@ export const USER_ROLE_LABELS: Record<UserRole, string> = {
[UserRole.SLACK_USER]: "Slack User",
};
export enum UserStatus {
ACTIVE = "active",
INACTIVE = "inactive",
INVITED = "invited",
REQUESTED = "requested",
}
export const USER_STATUS_LABELS: Record<UserStatus, string> = {
[UserStatus.ACTIVE]: "Active",
[UserStatus.INACTIVE]: "Inactive",
[UserStatus.INVITED]: "Invite Pending",
[UserStatus.REQUESTED]: "Request to Join",
};
export const INVALID_ROLE_HOVER_TEXT: Partial<Record<UserRole, string>> = {
[UserRole.BASIC]: "Basic users can't perform any admin actions",
[UserRole.ADMIN]: "Admin users can perform all admin actions",

View File

@@ -6,6 +6,9 @@ import type { IconProps } from "@opal/types";
export interface ChipProps {
children?: string;
icon?: React.FunctionComponent<IconProps>;
/** Icon rendered after the label (e.g. a warning indicator) */
rightIcon?: React.FunctionComponent<IconProps>;
rightIconClassName?: string;
onRemove?: () => void;
smallLabel?: boolean;
}
@@ -24,6 +27,8 @@ export interface ChipProps {
export default function Chip({
children,
icon: Icon,
rightIcon: RightIcon,
rightIconClassName,
onRemove,
smallLabel = true,
}: ChipProps) {
@@ -35,6 +40,9 @@ export default function Chip({
{children}
</Text>
)}
{RightIcon && (
<RightIcon size={14} className={rightIconClassName ?? "text-text-03"} />
)}
{onRemove && (
<Button
onClick={(e) => {

View File

@@ -9,11 +9,14 @@ import {
Variants,
wrapperClasses,
} from "@/refresh-components/inputs/styles";
import { SvgAlertTriangle } from "@opal/icons";
import type { IconProps } from "@opal/types";
export interface ChipItem {
id: string;
label: string;
/** When true the chip shows a warning icon */
error?: boolean;
}
export interface InputChipFieldProps {
@@ -88,36 +91,46 @@ function InputChipField({
return (
<div
className={cn(
"flex flex-row items-center flex-wrap gap-1 p-1.5 rounded-08 cursor-text w-full",
"flex flex-col gap-1 p-1.5 rounded-08 cursor-text w-full",
wrapperClasses[variant],
className
)}
onClick={() => inputRef.current?.focus()}
>
{Icon && <Icon size={16} className="text-text-04 shrink-0" />}
{chips.map((chip) => (
<Chip
key={chip.id}
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
smallLabel={false}
>
{chip.label}
</Chip>
))}
<input
ref={inputRef}
type="text"
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={chips.length === 0 ? placeholder : undefined}
className={cn(
"flex-1 min-w-[80px] h-[1.5rem] bg-transparent p-0.5 focus:outline-none",
innerClasses[variant],
textClasses[variant]
)}
/>
{chips.length > 0 && (
<div className="flex flex-row items-center flex-wrap gap-1">
{chips.map((chip) => (
<Chip
key={chip.id}
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
rightIcon={chip.error ? SvgAlertTriangle : undefined}
rightIconClassName={
chip.error ? "text-status-warning-text" : undefined
}
smallLabel={false}
>
{chip.label}
</Chip>
))}
</div>
)}
<div className="flex flex-row items-center gap-1">
{Icon && <Icon size={16} className="text-text-04 shrink-0" />}
<input
ref={inputRef}
type="text"
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={cn(
"flex-1 min-w-[80px] h-[1.5rem] bg-transparent p-0.5 focus:outline-none",
innerClasses[variant],
textClasses[variant]
)}
/>
</div>
</div>
);
}

View File

@@ -273,6 +273,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
leftExtra={footerConfig.leftExtra}
/>
);
}
@@ -301,7 +302,25 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
: undefined),
}}
>
<Table>
<Table
width={
Object.keys(columnWidths).length > 0
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
: undefined
}
>
<colgroup>
{table.getAllLeafColumns().map((col) => (
<col
key={col.id}
style={
columnWidths[col.id] != null
? { width: columnWidths[col.id] }
: undefined
}
/>
))}
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>

View File

@@ -61,6 +61,8 @@ interface FooterSummaryModeProps {
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** Optional extra element rendered after the summary text (e.g. a download icon). */
leftExtra?: React.ReactNode;
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
size?: TableSize;
className?: string;
@@ -115,12 +117,15 @@ export default function Footer(props: FooterProps) {
isSmall={isSmall}
/>
) : (
<SummaryLeft
rangeStart={props.rangeStart}
rangeEnd={props.rangeEnd}
totalItems={props.totalItems}
isSmall={isSmall}
/>
<>
<SummaryLeft
rangeStart={props.rangeStart}
rangeEnd={props.rangeEnd}
totalItems={props.totalItems}
isSmall={isSmall}
/>
{props.leftExtra}
</>
)}
</div>

View File

@@ -21,13 +21,13 @@ export default function TableCell({
const resolvedSize = size ?? contextSize;
return (
<td
className="tbl-cell"
className="tbl-cell overflow-hidden"
data-size={resolvedSize}
style={width != null ? { width } : undefined}
{...props}
>
<div
className={cn("tbl-cell-inner", "flex items-center")}
className={cn("tbl-cell-inner", "flex items-center overflow-hidden")}
data-size={resolvedSize}
>
{children}

View File

@@ -114,11 +114,16 @@ function TableQualifier({
return (
<div
className={cn(
"flex items-center justify-center rounded-full bg-text-05",
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
resolvedSize === "regular" ? "h-7 w-7" : "h-6 w-6"
)}
>
<Text secondaryAction textLight05 className="select-none uppercase">
<Text
inverted
secondaryAction
text05
className="select-none uppercase"
>
{initials}
</Text>
</div>

View File

@@ -141,6 +141,8 @@ export interface DataTableFooterSelection {
export interface DataTableFooterSummary {
mode: "summary";
/** Optional extra element rendered after the summary text (e.g. a download icon). */
leftExtra?: ReactNode;
}
export type DataTableFooterConfig =

View File

@@ -1,13 +1,18 @@
"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 useUserCounts from "@/hooks/useUserCounts";
import { UserStatus } from "@/lib/types";
import type { StatusFilter } from "./UsersPage/interfaces";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
import InviteUsersModal from "./UsersPage/InviteUsersModal";
// ---------------------------------------------------------------------------
// Users page content
@@ -19,7 +24,18 @@ function UsersContent() {
const { data: scimToken } = useScimToken();
const showScim = isEe && !!scimToken;
const { activeCount, invitedCount, pendingCount } = useUserCounts();
const { activeCount, invitedCount, pendingCount, roleCounts, statusCounts } =
useUserCounts();
const [selectedStatuses, setSelectedStatuses] = useState<StatusFilter>([]);
const toggleStatus = (target: UserStatus) => {
setSelectedStatuses((prev) =>
prev.includes(target)
? prev.filter((s) => s !== target)
: [...prev, target]
);
};
return (
<>
@@ -28,9 +44,17 @@ function UsersContent() {
pendingInvites={invitedCount}
requests={pendingCount}
showScim={showScim}
onFilterActive={() => toggleStatus(UserStatus.ACTIVE)}
onFilterInvites={() => toggleStatus(UserStatus.INVITED)}
onFilterRequests={() => toggleStatus(UserStatus.REQUESTED)}
/>
{/* Table and filters will be added in subsequent PRs */}
<UsersTable
selectedStatuses={selectedStatuses}
onStatusesChange={setSelectedStatuses}
roleCounts={roleCounts}
statusCounts={statusCounts}
/>
</>
);
}
@@ -40,19 +64,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,314 @@
"use client";
import { useState, useMemo, useRef, useCallback } from "react";
import { Button } from "@opal/components";
import { SvgUsers, SvgUser, SvgLogOut, SvgCheck } from "@opal/icons";
import { Disabled } from "@opal/core";
import { ContentAction } from "@opal/layouts";
import Modal from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import LineItem from "@/refresh-components/buttons/LineItem";
import Separator from "@/refresh-components/Separator";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section } from "@/layouts/general-layouts";
import { toast } from "@/hooks/useToast";
import { UserRole } from "@/lib/types";
import useGroups from "@/hooks/useGroups";
import { addUserToGroup, removeUserFromGroup, setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ASSIGNABLE_ROLES: { value: UserRole; label: string }[] = [
{ value: UserRole.ADMIN, label: "Admin" },
{ value: UserRole.GLOBAL_CURATOR, label: "Global Curator" },
{ value: UserRole.BASIC, label: "Basic" },
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface EditGroupsModalProps {
user: UserRow;
onClose: () => void;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function EditGroupsModal({
user,
onClose,
onMutate,
}: EditGroupsModalProps) {
const { data: allGroups, isLoading: groupsLoading } = useGroups();
const [searchTerm, setSearchTerm] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const closeDropdown = useCallback(() => {
// Delay to allow click events on dropdown items to fire before closing
setTimeout(() => {
if (!containerRef.current?.contains(document.activeElement)) {
setDropdownOpen(false);
}
}, 0);
}, []);
const [selectedRole, setSelectedRole] = useState<string>(user.role ?? "");
const initialMemberGroupIds = useMemo(
() => new Set(user.groups.map((g) => g.id)),
[user.groups]
);
const [memberGroupIds, setMemberGroupIds] = useState<Set<number>>(
() => new Set(initialMemberGroupIds)
);
// Dropdown shows all groups filtered by search term
const dropdownGroups = useMemo(() => {
if (!allGroups) return [];
if (searchTerm.length === 0) return allGroups;
const lower = searchTerm.toLowerCase();
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
}, [allGroups, searchTerm]);
// Joined groups shown in the modal body
const joinedGroups = useMemo(() => {
if (!allGroups) return [];
return allGroups.filter((g) => memberGroupIds.has(g.id));
}, [allGroups, memberGroupIds]);
const hasGroupChanges = useMemo(() => {
if (memberGroupIds.size !== initialMemberGroupIds.size) return true;
return Array.from(memberGroupIds).some(
(id) => !initialMemberGroupIds.has(id)
);
}, [memberGroupIds, initialMemberGroupIds]);
const hasRoleChange = user.role !== null && selectedRole !== user.role;
const hasChanges = hasGroupChanges || hasRoleChange;
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 promises: Promise<void>[] = [];
const toAdd = Array.from(memberGroupIds).filter(
(id) => !initialMemberGroupIds.has(id)
);
const toRemove = Array.from(initialMemberGroupIds).filter(
(id) => !memberGroupIds.has(id)
);
if (user.id) {
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)
);
}
}
}
if (hasRoleChange) {
promises.push(setUserRole(user.email, selectedRole));
}
await Promise.all(promises);
onMutate();
toast.success("User updated");
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
};
const displayName = user.personal_name ?? user.email;
return (
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
<Modal.Content width="sm">
<Modal.Header
icon={SvgUsers}
title="Edit User's Groups & Roles"
description={
user.personal_name
? `${user.personal_name} (${user.email})`
: user.email
}
onClose={onClose}
/>
<Modal.Body twoTone>
<Section
gap={1}
height="auto"
alignItems="stretch"
justifyContent="start"
>
{/* Subsection: white card behind search + groups */}
<div className="relative">
<div className="absolute -inset-2 bg-background-neutral-00 rounded-12" />
<Section
gap={0.5}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<div ref={containerRef} className="relative">
<InputTypeIn
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!dropdownOpen) setDropdownOpen(true);
}}
onFocus={() => setDropdownOpen(true)}
onBlur={closeDropdown}
placeholder="Search groups to join..."
leftSearchIcon
/>
{dropdownOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-background-neutral-00 border border-border-02 rounded-12 shadow-md p-1">
{groupsLoading ? (
<Text as="p" text03 secondaryBody className="px-3 py-2">
Loading groups...
</Text>
) : dropdownGroups.length === 0 ? (
<Text as="p" text03 secondaryBody className="px-3 py-2">
No groups found
</Text>
) : (
<ShadowDiv className="max-h-[200px] flex flex-col gap-1">
{dropdownGroups.map((group) => {
const isMember = memberGroupIds.has(group.id);
return (
<LineItem
key={group.id}
icon={isMember ? SvgCheck : SvgUsers}
description={`${group.users.length} ${
group.users.length === 1 ? "user" : "users"
}`}
selected={isMember}
emphasized={isMember}
onMouseDown={(e: React.MouseEvent) =>
e.preventDefault()
}
onClick={() => toggleGroup(group.id)}
>
{group.name}
</LineItem>
);
})}
</ShadowDiv>
)}
</div>
)}
</div>
{joinedGroups.length === 0 ? (
<LineItem
icon={SvgUsers}
description={`${displayName} is not in any groups.`}
muted
>
No groups found
</LineItem>
) : (
<ShadowDiv className="flex flex-col gap-1 max-h-[200px]">
{joinedGroups.map((group) => (
<div
key={group.id}
className="bg-background-tint-01 rounded-08"
>
<LineItem
icon={SvgUsers}
description={`${group.users.length} ${
group.users.length === 1 ? "user" : "users"
}`}
rightChildren={
<SvgLogOut className="w-4 h-4 text-text-03" />
}
onClick={() => toggleGroup(group.id)}
>
{group.name}
</LineItem>
</div>
))}
</ShadowDiv>
)}
</Section>
</div>
{user.role && (
<>
<Separator noPadding />
<ContentAction
title="User Role"
description="This controls their general permissions."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<InputSelect
value={selectedRole}
onValueChange={setSelectedRole}
>
<InputSelect.Trigger />
<InputSelect.Content>
{ASSIGNABLE_ROLES.map(({ value, label }) => (
<InputSelect.Item
key={value}
value={value}
icon={SvgUser}
>
{label}
</InputSelect.Item>
))}
</InputSelect.Content>
</InputSelect>
}
/>
</>
)}
</Section>
</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,187 @@
"use client";
import { useState, useRef, useLayoutEffect, useCallback } from "react";
import { SvgEdit } from "@opal/icons";
import { Tag } from "@opal/components";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow, UserGroupInfo } from "./interfaces";
interface GroupsCellProps {
groups: UserGroupInfo[];
user: UserRow;
onMutate: () => void;
}
/**
* Measures how many Tag pills fit in the container, accounting for a "+N"
* overflow counter when not all tags are visible. Uses a two-phase render:
* first renders all tags (clipped by overflow:hidden) for measurement, then
* re-renders with only the visible subset + "+N".
*
* Hovering the cell shows a tooltip with ALL groups. Clicking opens the
* edit groups modal.
*/
export default function GroupsCell({
groups,
user,
onMutate,
}: GroupsCellProps) {
const [showModal, setShowModal] = useState(false);
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const computeVisibleCount = useCallback(() => {
const container = containerRef.current;
if (!container || groups.length <= 1) {
setVisibleCount(groups.length);
return;
}
const tags = container.querySelectorAll<HTMLElement>("[data-group-tag]");
if (tags.length === 0) return;
const containerWidth = container.clientWidth;
const gap = 4; // gap-1
const counterWidth = 32; // "+N" Tag approximate width
let used = 0;
let count = 0;
for (let i = 0; i < tags.length; i++) {
const tagWidth = tags[i]!.offsetWidth;
const gapBefore = count > 0 ? gap : 0;
const hasMore = i < tags.length - 1;
const reserve = hasMore ? gap + counterWidth : 0;
if (used + gapBefore + tagWidth + reserve <= containerWidth) {
used += gapBefore + tagWidth;
count++;
} else {
break;
}
}
setVisibleCount(Math.max(1, count));
}, [groups]);
// Reset to measurement phase when groups change
useLayoutEffect(() => {
setVisibleCount(null);
}, [groups]);
// Measure after the "show all" render
useLayoutEffect(() => {
if (visibleCount !== null) return;
computeVisibleCount();
}, [visibleCount, computeVisibleCount]);
// Re-measure on container resize
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
setVisibleCount(null);
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const isMeasuring = visibleCount === null;
const effectiveVisible = visibleCount ?? groups.length;
const overflowCount = groups.length - effectiveVisible;
const hasOverflow = !isMeasuring && overflowCount > 0;
const allGroupsTooltip = (
<div className="flex flex-wrap gap-1 max-w-[14rem]">
{groups.map((g) => (
<div key={g.id} className="max-w-[10rem]">
<Tag title={g.name} size="md" />
</div>
))}
</div>
);
const tagsContent = (
<>
{(isMeasuring ? groups : groups.slice(0, effectiveVisible)).map((g) => (
<div key={g.id} data-group-tag className="flex-shrink-0">
<Tag title={g.name} size="md" />
</div>
))}
{hasOverflow && (
<div className="flex-shrink-0">
<Tag title={`+${overflowCount}`} size="md" />
</div>
)}
</>
);
return (
<>
<div
className={`group/groups relative flex items-center w-full min-w-0 ${
user.id ? "cursor-pointer" : ""
}`}
onClick={user.id ? () => setShowModal(true) : undefined}
>
{groups.length === 0 ? (
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
<Text as="span" secondaryBody text03>
</Text>
</div>
) : hasOverflow ? (
<SimpleTooltip
side="bottom"
align="start"
tooltip={allGroupsTooltip}
className="bg-background-neutral-01 border border-border-01 shadow-sm"
delayDuration={200}
>
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
{tagsContent}
</div>
</SimpleTooltip>
) : (
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
{tagsContent}
</div>
)}
{user.id && (
<IconButton
tertiary
icon={SvgEdit}
tooltip="Edit"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-0 opacity-0 group-hover/groups:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
/>
)}
</div>
{showModal && user.id && (
<EditGroupsModal
user={user}
onClose={() => setShowModal(false)}
onMutate={onMutate}
/>
)}
</>
);
}

View File

@@ -0,0 +1,177 @@
"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) {
// Split on commas so pasted lists like "a@b.com, c@d.com" still work
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,
error: !EMAIL_REGEX.test(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);
}
}
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={setInputValue}
placeholder="Add emails to invite, comma separated"
/>
<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,301 @@
"use client";
import { useState } from "react";
import {
SvgCheck,
SvgSlack,
SvgUser,
SvgUserManage,
SvgUsers,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import FilterButton from "@/refresh-components/buttons/FilterButton";
import Popover from "@/refresh-components/Popover";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import LineItem from "@/refresh-components/buttons/LineItem";
import Text from "@/refresh-components/texts/Text";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import {
UserRole,
UserStatus,
USER_ROLE_LABELS,
USER_STATUS_LABELS,
} from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import type { GroupOption, StatusFilter } from "./interfaces";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const VISIBLE_FILTER_ROLES: UserRole[] = [
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
UserRole.BASIC,
UserRole.SLACK_USER,
];
const FILTERABLE_ROLES = VISIBLE_FILTER_ROLES.map(
(role) => [role, USER_ROLE_LABELS[role]] as [UserRole, string]
);
const FILTERABLE_STATUSES = (
Object.entries(USER_STATUS_LABELS) as [UserStatus, string][]
).filter(
([value]) => value !== UserStatus.REQUESTED || NEXT_PUBLIC_CLOUD_ENABLED
);
const ROLE_ICONS: Partial<Record<UserRole, IconFunctionComponent>> = {
[UserRole.ADMIN]: SvgUserManage,
[UserRole.SLACK_USER]: SvgSlack,
};
/** Map UserStatus enum values to the keys returned by the counts endpoint. */
const STATUS_COUNT_KEY: Record<UserStatus, string> = {
[UserStatus.ACTIVE]: "active",
[UserStatus.INACTIVE]: "inactive",
[UserStatus.INVITED]: "invited",
[UserStatus.REQUESTED]: "requested",
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function CountBadge({ count }: { count: number | undefined }) {
return (
<Text as="span" secondaryBody text03>
{count ?? 0}
</Text>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface UserFiltersProps {
selectedRoles: UserRole[];
onRolesChange: (roles: UserRole[]) => void;
selectedGroups: number[];
onGroupsChange: (groupIds: number[]) => void;
groups: GroupOption[];
selectedStatuses: StatusFilter;
onStatusesChange: (statuses: StatusFilter) => void;
roleCounts: Record<string, number>;
statusCounts: Record<string, number>;
}
export default function UserFilters({
selectedRoles,
onRolesChange,
selectedGroups,
onGroupsChange,
groups,
selectedStatuses,
onStatusesChange,
roleCounts,
statusCounts,
}: UserFiltersProps) {
const hasRoleFilter = selectedRoles.length > 0;
const hasGroupFilter = selectedGroups.length > 0;
const hasStatusFilter = selectedStatuses.length > 0;
const [groupSearch, setGroupSearch] = useState("");
const toggleRole = (role: UserRole) => {
if (selectedRoles.includes(role)) {
onRolesChange(selectedRoles.filter((r) => r !== role));
} else {
onRolesChange([...selectedRoles, role]);
}
};
const toggleGroup = (groupId: number) => {
if (selectedGroups.includes(groupId)) {
onGroupsChange(selectedGroups.filter((id) => id !== groupId));
} else {
onGroupsChange([...selectedGroups, groupId]);
}
};
const toggleStatus = (status: UserStatus) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const roleLabel = hasRoleFilter
? FILTERABLE_ROLES.filter(([role]) => selectedRoles.includes(role))
.map(([, label]) => label)
.slice(0, 2)
.join(", ") +
(selectedRoles.length > 2 ? `, +${selectedRoles.length - 2}` : "")
: "All Account Types";
const groupLabel = hasGroupFilter
? groups
.filter((g) => selectedGroups.includes(g.id))
.map((g) => g.name)
.slice(0, 2)
.join(", ") +
(selectedGroups.length > 2 ? `, +${selectedGroups.length - 2}` : "")
: "All Groups";
const statusLabel = hasStatusFilter
? FILTERABLE_STATUSES.filter(([status]) =>
selectedStatuses.includes(status)
)
.map(([, label]) => label)
.slice(0, 2)
.join(", ") +
(selectedStatuses.length > 2 ? `, +${selectedStatuses.length - 2}` : "")
: "All Status";
const filteredGroups = groupSearch
? groups.filter((g) =>
g.name.toLowerCase().includes(groupSearch.toLowerCase())
)
: groups;
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-1 min-w-[200px]">
<LineItem
icon={!hasRoleFilter ? SvgCheck : SvgUsers}
selected={!hasRoleFilter}
emphasized={!hasRoleFilter}
onClick={() => onRolesChange([])}
>
All Account Types
</LineItem>
{FILTERABLE_ROLES.map(([role, label]) => {
const isSelected = selectedRoles.includes(role);
const roleIcon = ROLE_ICONS[role] ?? SvgUser;
return (
<LineItem
key={role}
icon={isSelected ? SvgCheck : roleIcon}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleRole(role)}
rightChildren={<CountBadge count={roleCounts[role]} />}
>
{label}
</LineItem>
);
})}
</div>
</Popover.Content>
</Popover>
{/* Groups filter */}
<Popover>
<Popover.Trigger asChild>
<FilterButton
leftIcon={SvgUsers}
active={hasGroupFilter}
onClear={() => onGroupsChange([])}
>
{groupLabel}
</FilterButton>
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
<InputTypeIn
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
placeholder="Search groups..."
leftSearchIcon
variant="internal"
/>
<LineItem
icon={!hasGroupFilter ? SvgCheck : SvgUsers}
selected={!hasGroupFilter}
emphasized={!hasGroupFilter}
onClick={() => onGroupsChange([])}
>
All Groups
</LineItem>
<ShadowDiv className="flex flex-col gap-1 max-h-[240px]">
{filteredGroups.map((group) => {
const isSelected = selectedGroups.includes(group.id);
return (
<LineItem
key={group.id}
icon={isSelected ? SvgCheck : SvgUsers}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleGroup(group.id)}
rightChildren={<CountBadge count={group.memberCount} />}
>
{group.name}
</LineItem>
);
})}
{filteredGroups.length === 0 && (
<Text as="span" secondaryBody text03 className="px-2 py-1.5">
No groups found
</Text>
)}
</ShadowDiv>
</div>
</Popover.Content>
</Popover>
{/* Status filter */}
<Popover>
<Popover.Trigger asChild>
<FilterButton
leftIcon={SvgUsers}
active={hasStatusFilter}
onClear={() => onStatusesChange([])}
>
{statusLabel}
</FilterButton>
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
<LineItem
icon={!hasStatusFilter ? SvgCheck : SvgUser}
selected={!hasStatusFilter}
emphasized={!hasStatusFilter}
onClick={() => onStatusesChange([])}
>
All Status
</LineItem>
{FILTERABLE_STATUSES.map(([status, label]) => {
const isSelected = selectedStatuses.includes(status);
const countKey = STATUS_COUNT_KEY[status];
return (
<LineItem
key={status}
icon={isSelected ? SvgCheck : SvgUser}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleStatus(status)}
rightChildren={<CountBadge count={statusCounts[countKey]} />}
>
{label}
</LineItem>
);
})}
</div>
</Popover.Content>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { OpenButton } from "@opal/components";
import { Disabled } from "@opal/core";
import GenericConfirmModal from "@/components/modals/GenericConfirmModal";
import {
SvgCheck,
SvgGlobe,
SvgUser,
SvgSlack,
SvgUserManage,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
import Popover from "@/refresh-components/Popover";
import LineItem from "@/refresh-components/buttons/LineItem";
import { setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
const ROLE_ICONS: Record<string, IconFunctionComponent> = {
[UserRole.ADMIN]: SvgUserManage,
[UserRole.GLOBAL_CURATOR]: SvgGlobe,
[UserRole.SLACK_USER]: SvgSlack,
};
const SELECTABLE_ROLES = [
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
UserRole.BASIC,
] as const;
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 [open, setOpen] = useState(false);
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
if (!user.role) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
const applyRole = async (newRole: string) => {
setIsUpdating(true);
try {
await setUserRole(user.email, newRole);
onMutate();
} catch {
onMutate();
} finally {
setIsUpdating(false);
}
};
const handleSelect = (role: UserRole) => {
if (role === user.role) {
setOpen(false);
return;
}
setOpen(false);
if (user.role === UserRole.CURATOR) {
setPendingRole(role);
setShowConfirmModal(true);
} else {
applyRole(role);
}
};
const handleConfirm = () => {
if (pendingRole) {
applyRole(pendingRole);
}
setShowConfirmModal(false);
setPendingRole(null);
};
const currentIcon = ROLE_ICONS[user.role] ?? SvgUser;
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}
/>
)}
<Disabled disabled={isUpdating}>
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<OpenButton
icon={currentIcon}
variant="select-tinted"
width="full"
justifyContent="between"
>
{USER_ROLE_LABELS[user.role]}
</OpenButton>
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[160px]">
{SELECTABLE_ROLES.map((role) => {
if (
role === UserRole.GLOBAL_CURATOR &&
!isPaidEnterpriseFeaturesEnabled
) {
return null;
}
const isSelected = user.role === role;
const icon = ROLE_ICONS[role] ?? SvgUser;
return (
<LineItem
key={role}
icon={isSelected ? SvgCheck : icon}
selected={isSelected}
emphasized={isSelected}
onClick={() => handleSelect(role)}
>
{USER_ROLE_LABELS[role]}
</LineItem>
);
})}
</div>
</Popover.Content>
</Popover>
</Disabled>
</>
);
}

View File

@@ -0,0 +1,324 @@
"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 { UserStatus } from "@/lib/types";
import { toast } from "@/hooks/useToast";
import {
deactivateUser,
activateUser,
deleteUser,
cancelInvite,
approveRequest,
} from "./svc";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ModalType =
| "deactivate"
| "activate"
| "delete"
| "cancelInvite"
| "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);
}
}
const openModal = (type: ModalType) => {
setPopoverOpen(false);
setModal(type);
};
// Status-aware action menus
const actionButtons = (() => {
switch (user.status) {
case UserStatus.INVITED:
return (
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal("cancelInvite")}
>
Cancel Invite
</Button>
);
case UserStatus.REQUESTED:
return (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
handleAction(
() => approveRequest(user.email),
"Request approved"
);
}}
>
Approve
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal("cancelInvite")}
>
Reject
</Button>
</>
);
case UserStatus.ACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => openModal("deactivate")}
>
Deactivate User
</Button>
</>
);
case UserStatus.INACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => openModal("activate")}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => openModal("delete")}
>
Delete User
</Button>
</>
);
}
})();
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">
{actionButtons}
</div>
</Popover.Content>
</Popover>
{modal === "editGroups" && user.id && (
<EditGroupsModal
user={user}
onClose={() => setModal(null)}
onMutate={onMutate}
/>
)}
{modal === "cancelInvite" && (
<ConfirmationModalLayout
icon={SvgXCircle}
title={
user.status === UserStatus.REQUESTED
? "Reject Request"
: "Cancel Invite"
}
onClose={() => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() => {
handleAction(
() => cancelInvite(user.email),
user.status === UserStatus.REQUESTED
? "Request rejected"
: "Invite cancelled"
);
}}
>
{user.status === UserStatus.REQUESTED ? "Reject" : "Cancel"}
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
{user.status === UserStatus.REQUESTED
? "will be removed from the pending requests list."
: "will no longer be able to join Onyx with this invite."}
</Text>
</ConfirmationModalLayout>
)}
{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. Their license seat will be freed. 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. Deletion cannot be undone.
</Text>
</ConfirmationModalLayout>
)}
</>
);
}

View File

@@ -1,32 +1,49 @@
import { SvgArrowUpRight, SvgUserSync } from "@opal/icons";
import { SvgArrowUpRight, SvgFilterPlus, 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 IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import Link from "next/link";
import { ADMIN_PATHS } from "@/lib/admin-routes";
// ---------------------------------------------------------------------------
// Stats cell — number + label
// Stats cell — number + label + hover filter icon
// ---------------------------------------------------------------------------
type StatCellProps = {
value: number | null;
label: string;
onFilter?: () => void;
};
function StatCell({ value, label }: StatCellProps) {
function StatCell({ value, label, onFilter }: StatCellProps) {
const display = value === null ? "\u2014" : value.toLocaleString();
return (
<div className="flex flex-col items-start gap-0.5 w-full p-2">
<div
className="group/stat relative flex flex-col items-start gap-0.5 w-full p-2 rounded-08 transition-colors cursor-pointer hover:bg-background-tint-02"
onClick={onFilter}
>
<Text as="span" mainUiAction text04>
{display}
</Text>
<Text as="span" secondaryBody text03>
{label}
</Text>
<IconButton
tertiary
icon={SvgFilterPlus}
tooltip="Add Filter"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-1 top-1 opacity-0 group-hover/stat:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onFilter?.();
}}
/>
</div>
);
}
@@ -66,6 +83,9 @@ type UsersSummaryProps = {
pendingInvites: number | null;
requests: number | null;
showScim: boolean;
onFilterActive?: () => void;
onFilterInvites?: () => void;
onFilterRequests?: () => void;
};
export default function UsersSummary({
@@ -73,9 +93,36 @@ export default function UsersSummary({
pendingInvites,
requests,
showScim,
onFilterActive,
onFilterInvites,
onFilterRequests,
}: UsersSummaryProps) {
const showRequests = requests !== null && requests > 0;
const statsCard = (
<Card padding={0.5}>
<Section flexDirection="row" gap={0}>
<StatCell
value={activeUsers}
label="active users"
onFilter={onFilterActive}
/>
<StatCell
value={pendingInvites}
label="pending invites"
onFilter={onFilterInvites}
/>
{showRequests && (
<StatCell
value={requests}
label="requests to join"
onFilter={onFilterRequests}
/>
)}
</Section>
</Card>
);
if (showScim) {
return (
<Section
@@ -84,15 +131,7 @@ export default function UsersSummary({
alignItems="stretch"
gap={0.5}
>
<Card padding={0.5}>
<Section flexDirection="row" gap={0}>
<StatCell value={activeUsers} label="active users" />
<StatCell value={pendingInvites} label="pending invites" />
{showRequests && (
<StatCell value={requests} label="requests to join" />
)}
</Section>
</Card>
{statsCard}
<ScimCard />
</Section>
);
@@ -102,14 +141,26 @@ export default function UsersSummary({
return (
<Section flexDirection="row" gap={0.5}>
<Card padding={0.5}>
<StatCell value={activeUsers} label="active users" />
<StatCell
value={activeUsers}
label="active users"
onFilter={onFilterActive}
/>
</Card>
<Card padding={0.5}>
<StatCell value={pendingInvites} label="pending invites" />
<StatCell
value={pendingInvites}
label="pending invites"
onFilter={onFilterInvites}
/>
</Card>
{showRequests && (
<Card padding={0.5}>
<StatCell value={requests} label="requests to join" />
<StatCell
value={requests}
label="requests to join"
onFilter={onFilterRequests}
/>
</Card>
)}
</Section>

View File

@@ -0,0 +1,257 @@
"use client";
import { useMemo, useState } from "react";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Content } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
import { IllustrationContent } from "@opal/layouts";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { UserRole, UserStatus, USER_STATUS_LABELS } 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 useGroups from "@/hooks/useGroups";
import UserFilters from "./UserFilters";
import UserRowActions from "./UserRowActions";
import UserRoleCell from "./UserRoleCell";
import type {
UserRow,
UserGroupInfo,
GroupOption,
StatusFilter,
} from "./interfaces";
import { getInitials } from "./utils";
// ---------------------------------------------------------------------------
// Column renderers
// ---------------------------------------------------------------------------
function renderNameColumn(email: string, row: UserRow) {
return (
<Content
sizePreset="main-ui"
variant="section"
title={row.personal_name ?? email}
description={row.personal_name ? email : undefined}
/>
);
}
function renderGroupsColumn(groups: UserGroupInfo[]) {
if (!groups.length) {
return (
<Text as="span" secondaryBody text03>
{"\u2014"}
</Text>
);
}
const visible = groups.slice(0, 2);
const overflow = groups.length - visible.length;
return (
<div className="flex items-center gap-1 flex-nowrap overflow-hidden min-w-0">
{visible.map((g) => (
<span
key={g.id}
className="inline-flex items-center flex-shrink-0 rounded-md bg-background-tint-02 px-2 py-0.5 whitespace-nowrap"
>
<Text as="span" secondaryBody text03>
{g.name}
</Text>
</span>
))}
{overflow > 0 && (
<Text as="span" secondaryBody text03>
+{overflow}
</Text>
)}
</div>
);
}
function renderStatusColumn(value: UserStatus, row: UserRow) {
return (
<div className="flex flex-col">
<Text as="span" mainUiBody text03>
{USER_STATUS_LABELS[value] ?? value}
</Text>
{row.is_scim_synced && (
<Text as="span" secondaryBody text03>
SCIM synced
</Text>
)}
</div>
);
}
function renderLastUpdatedColumn(value: string | null) {
return (
<Text as="span" secondaryBody text03>
{value ? timeAgo(value) ?? "\u2014" : "\u2014"}
</Text>
);
}
// ---------------------------------------------------------------------------
// 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: "Name",
weight: 22,
minWidth: 140,
cell: renderNameColumn,
}),
tc.column("groups", {
header: "Groups",
weight: 24,
minWidth: 200,
enableSorting: false,
cell: renderGroupsColumn,
}),
tc.column("role", {
header: "Account Type",
weight: 16,
minWidth: 180,
cell: (_value, row) => <UserRoleCell user={row} onMutate={onMutate} />,
}),
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 100,
cell: renderStatusColumn,
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: renderLastUpdatedColumn,
}),
tc.actions({
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
}),
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PAGE_SIZE = 8;
interface UsersTableProps {
selectedStatuses: StatusFilter;
onStatusesChange: (statuses: StatusFilter) => void;
roleCounts: Record<string, number>;
statusCounts: Record<string, number>;
}
export default function UsersTable({
selectedStatuses,
onStatusesChange,
roleCounts,
statusCounts,
}: UsersTableProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedRoles, setSelectedRoles] = useState<UserRole[]>([]);
const [selectedGroups, setSelectedGroups] = useState<number[]>([]);
const { data: allGroups } = useGroups();
const groupOptions: GroupOption[] = useMemo(
() =>
(allGroups ?? []).map((g) => ({
id: g.id,
name: g.name,
memberCount: g.users.length,
})),
[allGroups]
);
const { users, isLoading, refresh } = useAdminUsers();
const columns = useMemo(() => buildColumns(() => refresh()), [refresh]);
// Client-side filtering
const filteredUsers = useMemo(() => {
let result = users;
if (selectedRoles.length > 0) {
result = result.filter(
(u) => u.role !== null && selectedRoles.includes(u.role)
);
}
if (selectedStatuses.length > 0) {
result = result.filter((u) => selectedStatuses.includes(u.status));
}
if (selectedGroups.length > 0) {
result = result.filter((u) =>
u.groups.some((g) => selectedGroups.includes(g.id))
);
}
return result;
}, [users, selectedRoles, selectedStatuses, selectedGroups]);
if (isLoading) {
return (
<div className="flex justify-center py-12">
<SimpleLoader className="h-6 w-6" />
</div>
);
}
return (
<div className="flex flex-col gap-3">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search users..."
leftSearchIcon
/>
<UserFilters
selectedRoles={selectedRoles}
onRolesChange={setSelectedRoles}
selectedGroups={selectedGroups}
onGroupsChange={setSelectedGroups}
groups={groupOptions}
selectedStatuses={selectedStatuses}
onStatusesChange={onStatusesChange}
roleCounts={roleCounts}
statusCounts={statusCounts}
/>
{filteredUsers.length === 0 ? (
<IllustrationContent
illustration={SvgNoResult}
title="No users found"
description={
searchTerm
? "Try a different search term or adjust your filters."
: "No users match the current filters."
}
/>
) : (
<DataTable
data={filteredUsers}
columns={columns}
getRowId={(row) => row.id ?? row.email}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{ mode: "summary" }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { UserRole, UserStatus } from "@/lib/types";
export interface UserGroupInfo {
id: number;
name: string;
}
export interface UserRow {
id: string | null;
email: string;
role: UserRole | null;
status: UserStatus;
is_active: boolean;
is_scim_synced: boolean;
personal_name: string | null;
created_at: string | null;
updated_at: string | null;
groups: UserGroupInfo[];
}
export interface GroupOption {
id: number;
name: string;
memberCount?: number;
}
/** Empty array = no filter (show all). */
export type StatusFilter = UserStatus[];

View File

@@ -0,0 +1,135 @@
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 cancelInvite(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/remove-invited-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 cancel invite");
}
}
export async function approveRequest(email: string): Promise<void> {
const res = await fetch("/api/tenants/users/invite/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to approve request");
}
}
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");
}
}
export async function downloadUsersCsv(): Promise<void> {
const res = await fetch("/api/manage/users/download");
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to download users CSV");
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "onyx_users.csv";
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,43 @@
import { getInitials } from "./utils";
describe("getInitials", () => {
it("returns first letters of first two name parts", () => {
expect(getInitials("Alice Smith", "alice@example.com")).toBe("AS");
});
it("returns first two chars of a single-word name", () => {
expect(getInitials("Alice", "alice@example.com")).toBe("AL");
});
it("handles three-word names (uses first two)", () => {
expect(getInitials("Alice B. Smith", "alice@example.com")).toBe("AB");
});
it("falls back to email local part with dot separator", () => {
expect(getInitials(null, "alice.smith@example.com")).toBe("AS");
});
it("falls back to email local part with underscore separator", () => {
expect(getInitials(null, "alice_smith@example.com")).toBe("AS");
});
it("falls back to email local part with hyphen separator", () => {
expect(getInitials(null, "alice-smith@example.com")).toBe("AS");
});
it("uses first two chars of email local if no separator", () => {
expect(getInitials(null, "alice@example.com")).toBe("AL");
});
it("returns ? for empty email local part", () => {
expect(getInitials(null, "@example.com")).toBe("?");
});
it("uppercases the result", () => {
expect(getInitials("john doe", "jd@test.com")).toBe("JD");
});
it("trims whitespace from name", () => {
expect(getInitials(" Alice Smith ", "a@test.com")).toBe("AS");
});
});

View File

@@ -0,0 +1,23 @@
/**
* Derive display initials from a user's name or email.
*
* - If a name is provided, uses the first letter of the first two words.
* - Falls back to the email local part, splitting on `.`, `_`, or `-`.
* - Returns at most 2 uppercase characters.
*/
export 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();
}

View File

@@ -121,7 +121,6 @@ const collections = (
{
name: "User Management",
items: [
sidebarItem(ADMIN_PATHS.USERS),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.GROUPS)] : []),
sidebarItem(ADMIN_PATHS.API_KEYS),
sidebarItem(ADMIN_PATHS.TOKEN_RATE_LIMITS),
@@ -130,8 +129,7 @@ const collections = (
{
name: "Permissions",
items: [
// TODO (nikolas): Uncommented in switchover PR once Users v2 is ready
// sidebarItem(ADMIN_PATHS.USERS_V2),
sidebarItem(ADMIN_PATHS.USERS),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.SCIM)] : []),
],
},

View File

@@ -0,0 +1,248 @@
/**
* Page Object Model for the Admin Users page (/admin/users).
*
* Encapsulates all locators and interactions so specs remain declarative.
*/
import { type Page, type Locator, expect } from "@playwright/test";
export class UsersAdminPage {
readonly page: Page;
// Top-level elements
readonly inviteButton: Locator;
readonly searchInput: Locator;
// Filter buttons
readonly accountTypesFilter: Locator;
readonly groupsFilter: Locator;
readonly statusFilter: Locator;
// Table
readonly table: Locator;
readonly tableRows: Locator;
// Pagination
readonly paginationSummary: Locator;
constructor(page: Page) {
this.page = page;
this.inviteButton = page.getByRole("button", { name: "Invite Users" });
this.searchInput = page.getByPlaceholder("Search users...");
this.accountTypesFilter = page.getByRole("button", {
name: /Account Types/,
});
this.groupsFilter = page.getByRole("button", { name: /Groups/ });
this.statusFilter = page.getByRole("button", { name: /Status/ });
this.table = page.getByRole("table");
this.tableRows = page.getByRole("table").locator("tbody tr");
this.paginationSummary = page.getByText(/Showing \d/);
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
async goto() {
await this.page.goto("/admin/users");
await expect(this.page.getByText("Users & Requests")).toBeVisible({
timeout: 15000,
});
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
async search(term: string) {
await this.searchInput.fill(term);
await this.page.waitForTimeout(300);
}
async clearSearch() {
await this.searchInput.fill("");
await this.page.waitForTimeout(300);
}
// ---------------------------------------------------------------------------
// Filters
// ---------------------------------------------------------------------------
async openAccountTypesFilter() {
await this.accountTypesFilter.click();
await expect(
this.page
.getByRole("dialog")
.or(this.page.locator("[data-radix-popper-content-wrapper]"))
).toBeVisible();
}
async selectAccountType(label: string) {
const popover = this.page.locator("[data-radix-popper-content-wrapper]");
await popover.getByRole("button", { name: new RegExp(label) }).click();
}
async openStatusFilter() {
await this.statusFilter.click();
await expect(
this.page.locator("[data-radix-popper-content-wrapper]")
).toBeVisible();
}
async selectStatus(label: string) {
const popover = this.page.locator("[data-radix-popper-content-wrapper]");
await popover.getByRole("button", { name: new RegExp(label) }).click();
}
async openGroupsFilter() {
await this.groupsFilter.click();
await expect(
this.page.locator("[data-radix-popper-content-wrapper]")
).toBeVisible();
}
async selectGroup(label: string) {
const popover = this.page.locator("[data-radix-popper-content-wrapper]");
await popover.getByRole("button", { name: new RegExp(label) }).click();
}
async closePopover() {
await this.page.keyboard.press("Escape");
await this.page.waitForTimeout(200);
}
// ---------------------------------------------------------------------------
// Table interactions
// ---------------------------------------------------------------------------
async getVisibleRowCount(): Promise<number> {
return await this.tableRows.count();
}
getRowByEmail(email: string): Locator {
return this.table.getByRole("row").filter({ hasText: email });
}
async sortByColumn(columnName: string) {
const header = this.table
.getByRole("columnheader")
.filter({ hasText: columnName });
await header.getByRole("button").first().click();
await this.page.waitForTimeout(300);
}
// ---------------------------------------------------------------------------
// Row actions
// ---------------------------------------------------------------------------
async openRowActions(email: string) {
const row = this.getRowByEmail(email);
const actionsButton = row.getByRole("button").last();
await actionsButton.click();
await expect(
this.page.locator("[data-radix-popper-content-wrapper]")
).toBeVisible();
}
async clickRowAction(actionName: string) {
const popover = this.page.locator("[data-radix-popper-content-wrapper]");
await popover.getByRole("button", { name: actionName }).click();
}
// ---------------------------------------------------------------------------
// Confirmation modals
// ---------------------------------------------------------------------------
get dialog(): Locator {
return this.page.getByRole("dialog");
}
async confirmModalAction(buttonName: string) {
await this.dialog.getByRole("button", { name: buttonName }).click();
}
async cancelModal() {
await this.dialog.getByRole("button", { name: "Cancel" }).click();
}
async expectToast(message: string | RegExp) {
await expect(this.page.getByText(message)).toBeVisible({ timeout: 10000 });
}
// ---------------------------------------------------------------------------
// Invite modal
// ---------------------------------------------------------------------------
async openInviteModal() {
await this.inviteButton.click();
await expect(this.dialog.getByText("Invite Users")).toBeVisible();
}
async addInviteEmail(email: string) {
const input = this.dialog.getByPlaceholder(
"Add emails to invite, comma separated"
);
await input.fill(email + ",");
await this.page.waitForTimeout(200);
}
async submitInvite() {
await this.dialog.getByRole("button", { name: "Invite" }).click();
}
// ---------------------------------------------------------------------------
// Inline role editing (Popover + OpenButton + LineItem)
// ---------------------------------------------------------------------------
async openRoleDropdown(email: string) {
const row = this.getRowByEmail(email);
// The role cell renders an OpenButton inside a Popover.Trigger
const roleButton = row
.locator("button")
.filter({ hasText: /Basic|Admin|Global Curator|Slack User/ });
await roleButton.click();
await expect(
this.page.locator("[data-radix-popper-content-wrapper]")
).toBeVisible();
}
async selectRole(roleName: string) {
const popover = this.page
.locator("[data-radix-popper-content-wrapper]")
.last();
await popover.getByRole("button", { name: roleName }).click();
await this.page.waitForTimeout(500);
}
// ---------------------------------------------------------------------------
// Edit groups modal
// ---------------------------------------------------------------------------
async openEditGroupsModal(email: string) {
await this.openRowActions(email);
await this.clickRowAction("Groups");
await expect(
this.dialog.getByText("Edit User's Groups & Roles")
).toBeVisible();
}
async searchGroupsInModal(term: string) {
await this.dialog.getByPlaceholder("Search groups to join...").fill(term);
await this.page.waitForTimeout(300);
}
async toggleGroupInModal(groupName: string) {
await this.dialog
.getByRole("button", { name: new RegExp(groupName) })
.first()
.click();
await this.page.waitForTimeout(200);
}
async saveGroupsModal() {
await this.dialog.getByRole("button", { name: "Save Changes" }).click();
}
}

View File

@@ -0,0 +1,37 @@
/**
* Playwright fixtures for Admin Users page tests.
*
* Provides:
* - Authenticated admin page
* - OnyxApiClient for API-level setup/teardown
* - UsersAdminPage page object
*/
import { test as base, expect, type Page } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
import { UsersAdminPage } from "./UsersAdminPage";
export const test = base.extend<{
adminPage: Page;
api: OnyxApiClient;
usersPage: UsersAdminPage;
}>({
adminPage: async ({ page }, use) => {
await page.context().clearCookies();
await loginAs(page, "admin");
await use(page);
},
api: async ({ adminPage }, use) => {
const client = new OnyxApiClient(adminPage.request);
await use(client);
},
usersPage: async ({ adminPage }, use) => {
const usersPage = new UsersAdminPage(adminPage);
await use(usersPage);
},
});
export { expect };

View File

@@ -0,0 +1,754 @@
/**
* E2E Tests: Admin Users Page
*
* Tests the full users management page — search, filters, sorting,
* inline role editing, row actions, invite modal, and group management.
*
* All tests create their own data via API and clean up after themselves.
*
* Tagged @exclusive because tests mutate user state and must run serially.
*/
import { test, expect } from "./fixtures";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function uniqueEmail(prefix: string): string {
return `e2e-${prefix}-${Date.now()}@test.onyx`;
}
const TEST_PASSWORD = "TestPassword123!";
// ---------------------------------------------------------------------------
// Page load & layout
// ---------------------------------------------------------------------------
test.describe("Users page — layout @exclusive", () => {
test("renders page title, invite button, search, and stats bar", async ({
usersPage,
}) => {
await usersPage.goto();
await expect(usersPage.page.getByText("Users & Requests")).toBeVisible();
await expect(usersPage.inviteButton).toBeVisible();
await expect(usersPage.searchInput).toBeVisible();
await expect(usersPage.page.getByText(/active users/i)).toBeVisible();
});
test("table renders with correct column headers", async ({ usersPage }) => {
await usersPage.goto();
for (const header of [
"Name",
"Groups",
"Account Type",
"Status",
"Last Updated",
]) {
await expect(
usersPage.table.getByRole("columnheader", { name: header })
).toBeVisible();
}
});
test("pagination shows summary and controls", async ({ usersPage }) => {
await usersPage.goto();
await expect(usersPage.paginationSummary).toBeVisible();
await expect(usersPage.paginationSummary).toContainText("Showing");
});
test("CSV download button is visible in footer", async ({ usersPage }) => {
await usersPage.goto();
// The download button is an icon-only button with a tooltip
const downloadBtn = usersPage.page.getByRole("button", {
name: /Download CSV/i,
});
await expect(downloadBtn).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
test.describe("Users page — search @exclusive", () => {
let testEmail: string;
const personalName = `Zephyr${Date.now()}`;
test.beforeAll(async ({ browser }) => {
const adminCtx = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const adminApi = new OnyxApiClient(adminCtx.request);
testEmail = uniqueEmail("search");
await adminApi.registerUser(testEmail, TEST_PASSWORD);
// Log in as the new user to set their personal name
const userCtx = await browser.newContext();
try {
await userCtx.request.post(
`${process.env.BASE_URL || "http://localhost:3000"}/api/auth/login`,
{ form: { username: testEmail, password: TEST_PASSWORD } }
);
const userApi = new OnyxApiClient(userCtx.request);
await userApi.setPersonalName(personalName);
} finally {
await userCtx.close();
}
} finally {
await adminCtx.close();
}
});
test("search filters table rows by email", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search(testEmail);
const row = usersPage.getRowByEmail(testEmail);
await expect(row).toBeVisible({ timeout: 10000 });
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThanOrEqual(1);
expect(rowCount).toBeLessThanOrEqual(8);
});
test("search matches by personal name", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search(personalName);
const row = usersPage.getRowByEmail(testEmail);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row).toContainText(personalName);
});
test("search with no results shows empty state", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search("zzz-no-match-exists-xyz@nowhere.invalid");
await expect(usersPage.page.getByText("No users found")).toBeVisible();
});
test("clearing search restores all results", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search("zzz-no-match-exists-xyz@nowhere.invalid");
await expect(usersPage.page.getByText("No users found")).toBeVisible();
await usersPage.clearSearch();
await expect(usersPage.table).toBeVisible();
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThan(0);
});
test.afterAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
await api.deactivateUser(testEmail).catch(() => {});
await api.deleteUser(testEmail).catch(() => {});
} finally {
await context.close();
}
});
});
// ---------------------------------------------------------------------------
// Filters
// ---------------------------------------------------------------------------
test.describe("Users page — filters @exclusive", () => {
let activeEmail: string;
let inactiveEmail: string;
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
activeEmail = uniqueEmail("filt-active");
await api.registerUser(activeEmail, TEST_PASSWORD);
inactiveEmail = uniqueEmail("filt-inactive");
await api.registerUser(inactiveEmail, TEST_PASSWORD);
await api.deactivateUser(inactiveEmail);
} finally {
await context.close();
}
});
test("account types filter shows expected roles", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.openAccountTypesFilter();
const popover = usersPage.page.locator(
"[data-radix-popper-content-wrapper]"
);
await expect(popover.getByText("All Account Types")).toBeVisible();
await expect(popover.getByText("Admin")).toBeVisible();
await expect(popover.getByText("Basic")).toBeVisible();
await usersPage.closePopover();
});
test("filtering by Admin role shows only admin users", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.openAccountTypesFilter();
await usersPage.selectAccountType("Admin");
await usersPage.closePopover();
await expect(usersPage.accountTypesFilter).toContainText("Admin");
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThan(0);
});
test("status filter for Active shows the active user", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.openStatusFilter();
await usersPage.selectStatus("Active");
await usersPage.closePopover();
await expect(usersPage.statusFilter).toContainText("Active");
await usersPage.search(activeEmail);
const row = usersPage.getRowByEmail(activeEmail);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row).toContainText("Active");
});
test("status filter for Inactive shows the inactive user", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.openStatusFilter();
await usersPage.selectStatus("Inactive");
await usersPage.closePopover();
await expect(usersPage.statusFilter).toContainText("Inactive");
await usersPage.search(inactiveEmail);
const row = usersPage.getRowByEmail(inactiveEmail);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row).toContainText("Inactive");
});
test("resetting filter shows all users again", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.openStatusFilter();
await usersPage.selectStatus("Active");
await usersPage.closePopover();
const filteredCount = await usersPage.getVisibleRowCount();
await usersPage.openStatusFilter();
await usersPage.selectStatus("All Status");
await usersPage.closePopover();
const allCount = await usersPage.getVisibleRowCount();
expect(allCount).toBeGreaterThanOrEqual(filteredCount);
});
test.afterAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
await api.deactivateUser(activeEmail).catch(() => {});
await api.deleteUser(activeEmail).catch(() => {});
await api.deleteUser(inactiveEmail).catch(() => {});
} finally {
await context.close();
}
});
});
// ---------------------------------------------------------------------------
// Sorting
// ---------------------------------------------------------------------------
test.describe("Users page — sorting @exclusive", () => {
test("clicking Name sort toggles row order", async ({ usersPage }) => {
await usersPage.goto();
const firstRowBefore = await usersPage.tableRows.first().textContent();
await usersPage.sortByColumn("Name");
const firstRowAfter = await usersPage.tableRows.first().textContent();
expect(firstRowBefore).toBeDefined();
expect(firstRowAfter).toBeDefined();
});
test("clicking Status sort keeps table rendered", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.sortByColumn("Status");
const rowCount = await usersPage.getVisibleRowCount();
expect(rowCount).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
test.describe("Users page — pagination @exclusive", () => {
test("next/previous page buttons navigate between pages", async ({
usersPage,
}) => {
await usersPage.goto();
const summaryBefore = await usersPage.paginationSummary.textContent();
// Click next page if available
const nextButton = usersPage.page.getByRole("button", { name: /next/i });
if (await nextButton.isEnabled()) {
await nextButton.click();
await usersPage.page.waitForTimeout(300);
const summaryAfter = await usersPage.paginationSummary.textContent();
expect(summaryAfter).not.toBe(summaryBefore);
// Go back
const prevButton = usersPage.page.getByRole("button", {
name: /previous/i,
});
await prevButton.click();
await usersPage.page.waitForTimeout(300);
}
});
});
// ---------------------------------------------------------------------------
// Invite users
// ---------------------------------------------------------------------------
test.describe("Users page — invite users @exclusive", () => {
test("invite modal opens with correct structure", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.openInviteModal();
await expect(usersPage.dialog.getByText("Invite Users")).toBeVisible();
await expect(
usersPage.dialog.getByPlaceholder("Add emails to invite, comma separated")
).toBeVisible();
await expect(usersPage.dialog.getByText("User Role")).toBeVisible();
await usersPage.cancelModal();
await expect(usersPage.dialog).not.toBeVisible();
});
test("invite a user and verify Invite Pending status", async ({
usersPage,
api,
}) => {
const email = uniqueEmail("invite");
await usersPage.goto();
await usersPage.openInviteModal();
await usersPage.addInviteEmail(email);
await usersPage.submitInvite();
await usersPage.expectToast(/Invited 1 user/);
// Reload and search
await usersPage.goto();
await usersPage.search(email);
const row = usersPage.getRowByEmail(email);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row).toContainText("Invite Pending");
// Cleanup
await api.cancelInvite(email);
});
test("invite multiple users at once", async ({ usersPage, api }) => {
const email1 = uniqueEmail("multi1");
const email2 = uniqueEmail("multi2");
await usersPage.goto();
await usersPage.openInviteModal();
const input = usersPage.dialog.getByPlaceholder(
"Add emails to invite, comma separated"
);
await input.fill(`${email1}, ${email2},`);
await usersPage.page.waitForTimeout(200);
await usersPage.submitInvite();
await usersPage.expectToast(/Invited 2 users/);
// Cleanup
await api.cancelInvite(email1);
await api.cancelInvite(email2);
});
test("invite modal shows error icon for invalid emails", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.openInviteModal();
const input = usersPage.dialog.getByPlaceholder(
"Add emails to invite, comma separated"
);
await input.fill("not-an-email,");
await usersPage.page.waitForTimeout(200);
// The chip should be rendered with an error state
await expect(usersPage.dialog.getByText("not-an-email")).toBeVisible();
await usersPage.cancelModal();
});
});
// ---------------------------------------------------------------------------
// Row actions — deactivate / activate
// ---------------------------------------------------------------------------
test.describe("Users page — deactivate & activate @exclusive", () => {
let testUserEmail: string;
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
testUserEmail = uniqueEmail("deact");
await api.registerUser(testUserEmail, TEST_PASSWORD);
} finally {
await context.close();
}
});
test("deactivate and then reactivate a user", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row).toContainText("Active");
// Deactivate
await usersPage.openRowActions(testUserEmail);
await usersPage.clickRowAction("Deactivate User");
await expect(usersPage.dialog.getByText("Deactivate User")).toBeVisible();
await expect(usersPage.dialog.getByText(testUserEmail)).toBeVisible();
await expect(
usersPage.dialog.getByText("will immediately lose access")
).toBeVisible();
await usersPage.confirmModalAction("Deactivate");
await usersPage.expectToast("User deactivated");
// Verify Inactive
await usersPage.page.waitForTimeout(500);
await usersPage.search(testUserEmail);
const inactiveRow = usersPage.getRowByEmail(testUserEmail);
await expect(inactiveRow).toContainText("Inactive");
// Reactivate
await usersPage.openRowActions(testUserEmail);
await usersPage.clickRowAction("Activate User");
await expect(usersPage.dialog.getByText("Activate User")).toBeVisible();
await usersPage.confirmModalAction("Activate");
await usersPage.expectToast("User activated");
// Verify Active again
await usersPage.page.waitForTimeout(500);
await usersPage.search(testUserEmail);
const reactivatedRow = usersPage.getRowByEmail(testUserEmail);
await expect(reactivatedRow).toContainText("Active");
});
test.afterAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
await api.deactivateUser(testUserEmail).catch(() => {});
await api.deleteUser(testUserEmail).catch(() => {});
} finally {
await context.close();
}
});
});
// ---------------------------------------------------------------------------
// Row actions — delete user
// ---------------------------------------------------------------------------
test.describe("Users page — delete user @exclusive", () => {
test("delete an inactive user", async ({ usersPage, api }) => {
const email = uniqueEmail("delete");
await api.registerUser(email, TEST_PASSWORD);
await api.deactivateUser(email);
await usersPage.goto();
await usersPage.search(email);
const row = usersPage.getRowByEmail(email);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row).toContainText("Inactive");
await usersPage.openRowActions(email);
await usersPage.clickRowAction("Delete User");
await expect(usersPage.dialog.getByText("Delete User")).toBeVisible();
await expect(
usersPage.dialog.getByText("will be permanently removed")
).toBeVisible();
await usersPage.confirmModalAction("Delete");
await usersPage.expectToast("User deleted");
// User gone
await usersPage.page.waitForTimeout(500);
await usersPage.search(email);
await expect(usersPage.page.getByText("No users found")).toBeVisible({
timeout: 10000,
});
});
});
// ---------------------------------------------------------------------------
// Row actions — cancel invite
// ---------------------------------------------------------------------------
test.describe("Users page — cancel invite @exclusive", () => {
test("cancel a pending invite", async ({ usersPage, api }) => {
const email = uniqueEmail("cancel-inv");
await api.inviteUsers([email]);
await usersPage.goto();
await usersPage.search(email);
const row = usersPage.getRowByEmail(email);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row).toContainText("Invite Pending");
await usersPage.openRowActions(email);
await usersPage.clickRowAction("Cancel Invite");
await expect(usersPage.dialog.getByText("Cancel Invite")).toBeVisible();
await usersPage.confirmModalAction("Cancel");
await usersPage.expectToast("Invite cancelled");
// User gone
await usersPage.page.waitForTimeout(500);
await usersPage.search(email);
await expect(usersPage.page.getByText("No users found")).toBeVisible({
timeout: 10000,
});
});
});
// ---------------------------------------------------------------------------
// Inline role editing
// ---------------------------------------------------------------------------
test.describe("Users page — inline role editing @exclusive", () => {
let testUserEmail: string;
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
testUserEmail = uniqueEmail("role");
await api.registerUser(testUserEmail, TEST_PASSWORD);
} finally {
await context.close();
}
});
test("change user role from Basic to Admin and back", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible({ timeout: 10000 });
// Initially Basic — the OpenButton shows the role label
await expect(row.getByText("Basic")).toBeVisible();
// Change to Admin
await usersPage.openRoleDropdown(testUserEmail);
await usersPage.selectRole("Admin");
await usersPage.page.waitForTimeout(500);
await expect(row.getByText("Admin")).toBeVisible();
// Change back to Basic
await usersPage.openRoleDropdown(testUserEmail);
await usersPage.selectRole("Basic");
await usersPage.page.waitForTimeout(500);
await expect(row.getByText("Basic")).toBeVisible();
});
test.afterAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
await api.deactivateUser(testUserEmail).catch(() => {});
await api.deleteUser(testUserEmail).catch(() => {});
} finally {
await context.close();
}
});
});
// ---------------------------------------------------------------------------
// Group management
// ---------------------------------------------------------------------------
test.describe("Users page — group management @exclusive", () => {
let testUserEmail: string;
let testGroupId: number;
const groupName = `E2E-UsersTest-${Date.now()}`;
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
testUserEmail = uniqueEmail("grp");
await api.registerUser(testUserEmail, TEST_PASSWORD);
testGroupId = await api.createUserGroup(groupName);
} finally {
await context.close();
}
});
test("add user to group via edit groups modal", async ({ usersPage }) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible({ timeout: 10000 });
await usersPage.openEditGroupsModal(testUserEmail);
await usersPage.searchGroupsInModal(groupName);
await usersPage.toggleGroupInModal(groupName);
await usersPage.saveGroupsModal();
await usersPage.expectToast("User updated");
// Verify group shows in the row
await usersPage.page.waitForTimeout(500);
await usersPage.search(testUserEmail);
const rowWithGroup = usersPage.getRowByEmail(testUserEmail);
await expect(rowWithGroup).toContainText(groupName);
});
test("remove user from group via edit groups modal", async ({
usersPage,
}) => {
await usersPage.goto();
await usersPage.search(testUserEmail);
const row = usersPage.getRowByEmail(testUserEmail);
await expect(row).toBeVisible({ timeout: 10000 });
await usersPage.openEditGroupsModal(testUserEmail);
// Group shows as joined — click to remove
await usersPage.toggleGroupInModal(groupName);
await usersPage.saveGroupsModal();
await usersPage.expectToast("User updated");
// Verify group removed
await usersPage.page.waitForTimeout(500);
await usersPage.search(testUserEmail);
await expect(usersPage.getRowByEmail(testUserEmail)).not.toContainText(
groupName
);
});
test.afterAll(async ({ browser }) => {
const context = await browser.newContext({
storageState: "admin_auth.json",
});
try {
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
const api = new OnyxApiClient(context.request);
await api.deleteUserGroup(testGroupId).catch(() => {});
await api.deactivateUser(testUserEmail).catch(() => {});
await api.deleteUser(testUserEmail).catch(() => {});
} finally {
await context.close();
}
});
});
// ---------------------------------------------------------------------------
// Stats bar
// ---------------------------------------------------------------------------
test.describe("Users page — stats bar @exclusive", () => {
test("stats bar shows active users count", async ({ usersPage }) => {
await usersPage.goto();
await expect(usersPage.page.getByText(/\d+ active users/i)).toBeVisible();
});
test("stats bar updates after inviting a user", async ({
usersPage,
api,
}) => {
const email = uniqueEmail("stats");
// Get initial pending count text
await usersPage.goto();
await usersPage.openInviteModal();
await usersPage.addInviteEmail(email);
await usersPage.submitInvite();
await usersPage.expectToast(/Invited 1 user/);
// Stats bar should reflect the new invite
await usersPage.goto();
await expect(usersPage.page.getByText(/pending invites/i)).toBeVisible();
// Cleanup
await api.cancelInvite(email);
});
});

View File

@@ -1073,6 +1073,62 @@ export class OnyxApiClient {
);
}
// === User Management Methods ===
async deactivateUser(email: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/manage/admin/deactivate-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to deactivate user ${email}`);
this.log(`Deactivated user: ${email}`);
}
async activateUser(email: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/manage/admin/activate-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to activate user ${email}`);
this.log(`Activated user: ${email}`);
}
async deleteUser(email: string): Promise<void> {
const response = await this.request.delete(
`${this.baseUrl}/manage/admin/delete-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to delete user ${email}`);
this.log(`Deleted user: ${email}`);
}
async cancelInvite(email: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/manage/admin/remove-invited-user`,
{ data: { user_email: email } }
);
await this.handleResponse(response, `Failed to cancel invite for ${email}`);
this.log(`Cancelled invite for: ${email}`);
}
async inviteUsers(emails: string[]): Promise<void> {
const response = await this.put("/manage/admin/users", { emails });
await this.handleResponse(response, `Failed to invite users`);
this.log(`Invited users: ${emails.join(", ")}`);
}
async setPersonalName(name: string): Promise<void> {
const response = await this.request.patch(
`${this.baseUrl}/user/personalization`,
{ data: { name } }
);
await this.handleResponse(
response,
`Failed to set personal name to ${name}`
);
this.log(`Set personal name: ${name}`);
}
// === Chat Session Methods ===
/**