mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-11 18:52:39 +00:00
Compare commits
30 Commits
main
...
nikg/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feba705d8f | ||
|
|
e1d20a185e | ||
|
|
77224da5fb | ||
|
|
d135212701 | ||
|
|
cd2b12cc17 | ||
|
|
742bfc0ac7 | ||
|
|
b7e9516a4e | ||
|
|
ea56f4e2f0 | ||
|
|
1880aab645 | ||
|
|
2949026050 | ||
|
|
908497af02 | ||
|
|
a1bb833cee | ||
|
|
6eb7be8799 | ||
|
|
d6796dafce | ||
|
|
d2d634c89c | ||
|
|
ad73760b14 | ||
|
|
5d4e3163b3 | ||
|
|
0ae5527e0f | ||
|
|
56e3e28fb9 | ||
|
|
8928968213 | ||
|
|
a35f73a98c | ||
|
|
43619bd11b | ||
|
|
8a75970934 | ||
|
|
23447b655f | ||
|
|
47e5725fb3 | ||
|
|
4ceedb588a | ||
|
|
9045926737 | ||
|
|
c010804c22 | ||
|
|
aba0dfb7bb | ||
|
|
9cca9c231d |
@@ -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")
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
54
backend/tests/unit/onyx/server/test_full_user_snapshot.py
Normal file
54
backend/tests/unit/onyx/server/test_full_user_snapshot.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
</>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
=========================================================================== */
|
||||
|
||||
21
web/lib/opal/src/icons/filter-plus.tsx
Normal file
21
web/lib/opal/src/icons/filter-plus.tsx
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@/refresh-pages/admin/UsersPage";
|
||||
@@ -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,
|
||||
|
||||
118
web/src/hooks/useAdminUsers.ts
Normal file
118
web/src/hooks/useAdminUsers.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
314
web/src/refresh-pages/admin/UsersPage/EditGroupsModal.tsx
Normal file
314
web/src/refresh-pages/admin/UsersPage/EditGroupsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
web/src/refresh-pages/admin/UsersPage/GroupsCell.tsx
Normal file
187
web/src/refresh-pages/admin/UsersPage/GroupsCell.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
web/src/refresh-pages/admin/UsersPage/InviteUsersModal.tsx
Normal file
177
web/src/refresh-pages/admin/UsersPage/InviteUsersModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
web/src/refresh-pages/admin/UsersPage/UserFilters.tsx
Normal file
301
web/src/refresh-pages/admin/UsersPage/UserFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
web/src/refresh-pages/admin/UsersPage/UserRoleCell.tsx
Normal file
150
web/src/refresh-pages/admin/UsersPage/UserRoleCell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
324
web/src/refresh-pages/admin/UsersPage/UserRowActions.tsx
Normal file
324
web/src/refresh-pages/admin/UsersPage/UserRowActions.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
257
web/src/refresh-pages/admin/UsersPage/UsersTable.tsx
Normal file
257
web/src/refresh-pages/admin/UsersPage/UsersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
web/src/refresh-pages/admin/UsersPage/interfaces.ts
Normal file
28
web/src/refresh-pages/admin/UsersPage/interfaces.ts
Normal 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[];
|
||||
135
web/src/refresh-pages/admin/UsersPage/svc.ts
Normal file
135
web/src/refresh-pages/admin/UsersPage/svc.ts
Normal 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);
|
||||
}
|
||||
43
web/src/refresh-pages/admin/UsersPage/utils.test.ts
Normal file
43
web/src/refresh-pages/admin/UsersPage/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
23
web/src/refresh-pages/admin/UsersPage/utils.ts
Normal file
23
web/src/refresh-pages/admin/UsersPage/utils.ts
Normal 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();
|
||||
}
|
||||
@@ -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)] : []),
|
||||
],
|
||||
},
|
||||
|
||||
248
web/tests/e2e/admin/users/UsersAdminPage.ts
Normal file
248
web/tests/e2e/admin/users/UsersAdminPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
37
web/tests/e2e/admin/users/fixtures.ts
Normal file
37
web/tests/e2e/admin/users/fixtures.ts
Normal 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 };
|
||||
754
web/tests/e2e/admin/users/users.spec.ts
Normal file
754
web/tests/e2e/admin/users/users.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 ===
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user