mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-12 11:12:40 +00:00
Compare commits
11 Commits
jamison/in
...
jamison/Co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d278c555 | ||
|
|
aae92bf749 | ||
|
|
9e28a774e5 | ||
|
|
c1ce180b72 | ||
|
|
b5474dc127 | ||
|
|
e1df3f533a | ||
|
|
df5252db05 | ||
|
|
f01f210af8 | ||
|
|
781219cf18 | ||
|
|
ca39da7de9 | ||
|
|
abf76cd747 |
@@ -0,0 +1,43 @@
|
||||
"""add timestamps to user table
|
||||
|
||||
Revision ID: 27fb147a843f
|
||||
Revises: b5c4d7e8f9a1
|
||||
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 = "b5c4d7e8f9a1"
|
||||
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`
|
||||
|
||||
@@ -4,6 +4,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi_users.password import PasswordHelper
|
||||
from sqlalchemy import case
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -11,6 +12,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 +26,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 +165,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 +182,21 @@ 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).order_by(User.email)
|
||||
return db_session.scalars(stmt).unique().all()
|
||||
|
||||
|
||||
def get_page_of_filtered_users(
|
||||
db_session: Session,
|
||||
page_size: int,
|
||||
@@ -218,6 +242,41 @@ 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.
|
||||
Uses a single query with conditional aggregation.
|
||||
"""
|
||||
base_where = _get_accepted_user_where_clause()
|
||||
role_col = User.__table__.c.role
|
||||
is_active_col = User.__table__.c.is_active
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
role_col,
|
||||
func.count().label("total"),
|
||||
func.sum(case((is_active_col.is_(True), 1), else_=0)).label("active"),
|
||||
func.sum(case((is_active_col.is_(False), 1), else_=0)).label("inactive"),
|
||||
)
|
||||
.where(*base_where)
|
||||
.group_by(role_col)
|
||||
)
|
||||
|
||||
role_counts: dict[str, int] = {}
|
||||
status_counts: dict[str, int] = {"active": 0, "inactive": 0}
|
||||
|
||||
for role_val, total, active, inactive in db_session.execute(stmt).all():
|
||||
key = role_val.value if hasattr(role_val, "value") else str(role_val)
|
||||
role_counts[key] = total
|
||||
status_counts["active"] += active or 0
|
||||
status_counts["inactive"] += inactive or 0
|
||||
|
||||
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 +417,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
|
||||
|
||||
@@ -3782,16 +3782,6 @@
|
||||
"display_name": "Claude Sonnet 3.5",
|
||||
"model_vendor": "anthropic"
|
||||
},
|
||||
"vertex_ai/claude-3-5-sonnet-v2": {
|
||||
"display_name": "Claude Sonnet 3.5",
|
||||
"model_vendor": "anthropic",
|
||||
"model_version": "v2"
|
||||
},
|
||||
"vertex_ai/claude-3-5-sonnet-v2@20241022": {
|
||||
"display_name": "Claude Sonnet 3.5 v2",
|
||||
"model_vendor": "anthropic",
|
||||
"model_version": "20241022"
|
||||
},
|
||||
"vertex_ai/claude-3-5-sonnet@20240620": {
|
||||
"display_name": "Claude Sonnet 3.5",
|
||||
"model_vendor": "anthropic",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import re
|
||||
from enum import Enum
|
||||
|
||||
# Matches Slack channel references like <#C097NBWMY8Y> or <#C097NBWMY8Y|channel-name>
|
||||
SLACK_CHANNEL_REF_PATTERN = re.compile(r"<#([A-Z0-9]+)(?:\|([^>]+))?>")
|
||||
|
||||
LIKE_BLOCK_ACTION_ID = "feedback-like"
|
||||
DISLIKE_BLOCK_ACTION_ID = "feedback-dislike"
|
||||
SHOW_EVERYONE_ACTION_ID = "show-everyone"
|
||||
|
||||
@@ -18,15 +18,18 @@ from onyx.configs.onyxbot_configs import ONYX_BOT_DISPLAY_ERROR_MSGS
|
||||
from onyx.configs.onyxbot_configs import ONYX_BOT_NUM_RETRIES
|
||||
from onyx.configs.onyxbot_configs import ONYX_BOT_REACT_EMOJI
|
||||
from onyx.context.search.models import BaseFilters
|
||||
from onyx.context.search.models import Tag
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.models import SlackChannelConfig
|
||||
from onyx.db.models import User
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.onyxbot.slack.blocks import build_slack_response_blocks
|
||||
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
|
||||
from onyx.onyxbot.slack.handlers.utils import send_team_member_message
|
||||
from onyx.onyxbot.slack.models import SlackMessageInfo
|
||||
from onyx.onyxbot.slack.models import ThreadMessage
|
||||
from onyx.onyxbot.slack.utils import get_channel_from_id
|
||||
from onyx.onyxbot.slack.utils import get_channel_name_from_id
|
||||
from onyx.onyxbot.slack.utils import respond_in_thread_or_channel
|
||||
from onyx.onyxbot.slack.utils import SlackRateLimiter
|
||||
@@ -41,6 +44,51 @@ srl = SlackRateLimiter()
|
||||
RT = TypeVar("RT") # return type
|
||||
|
||||
|
||||
def resolve_channel_references(
|
||||
message: str,
|
||||
client: WebClient,
|
||||
logger: OnyxLoggingAdapter,
|
||||
) -> tuple[str, list[Tag]]:
|
||||
"""Parse Slack channel references from a message, resolve IDs to names,
|
||||
replace the raw markup with readable #channel-name, and return channel tags
|
||||
for search filtering."""
|
||||
tags: list[Tag] = []
|
||||
channel_matches = SLACK_CHANNEL_REF_PATTERN.findall(message)
|
||||
seen_channel_ids: set[str] = set()
|
||||
|
||||
for channel_id, channel_name_from_markup in channel_matches:
|
||||
if channel_id in seen_channel_ids:
|
||||
continue
|
||||
seen_channel_ids.add(channel_id)
|
||||
|
||||
channel_name = channel_name_from_markup or None
|
||||
|
||||
if not channel_name:
|
||||
try:
|
||||
channel_info = get_channel_from_id(client=client, channel_id=channel_id)
|
||||
channel_name = channel_info.get("name") or None
|
||||
except Exception:
|
||||
logger.warning(f"Failed to resolve channel name for ID: {channel_id}")
|
||||
|
||||
if not channel_name:
|
||||
continue
|
||||
|
||||
# Replace raw Slack markup with readable channel name
|
||||
if channel_name_from_markup:
|
||||
message = message.replace(
|
||||
f"<#{channel_id}|{channel_name_from_markup}>",
|
||||
f"#{channel_name}",
|
||||
)
|
||||
else:
|
||||
message = message.replace(
|
||||
f"<#{channel_id}>",
|
||||
f"#{channel_name}",
|
||||
)
|
||||
tags.append(Tag(tag_key="Channel", tag_value=channel_name))
|
||||
|
||||
return message, tags
|
||||
|
||||
|
||||
def rate_limits(
|
||||
client: WebClient, channel: str, thread_ts: Optional[str]
|
||||
) -> Callable[[Callable[..., RT]], Callable[..., RT]]:
|
||||
@@ -157,6 +205,20 @@ def handle_regular_answer(
|
||||
user_message = messages[-1]
|
||||
history_messages = messages[:-1]
|
||||
|
||||
# Resolve any <#CHANNEL_ID> references in the user message to readable
|
||||
# channel names and extract channel tags for search filtering
|
||||
resolved_message, channel_tags = resolve_channel_references(
|
||||
message=user_message.message,
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
user_message = ThreadMessage(
|
||||
message=resolved_message,
|
||||
sender=user_message.sender,
|
||||
role=user_message.role,
|
||||
)
|
||||
|
||||
channel_name, _ = get_channel_name_from_id(
|
||||
client=client,
|
||||
channel_id=channel,
|
||||
@@ -207,6 +269,7 @@ def handle_regular_answer(
|
||||
source_type=None,
|
||||
document_set=document_set_names,
|
||||
time_cutoff=None,
|
||||
tags=channel_tags if channel_tags else None,
|
||||
)
|
||||
|
||||
new_message_request = SendMessageRequest(
|
||||
@@ -231,6 +294,16 @@ def handle_regular_answer(
|
||||
slack_context_str=slack_context_str,
|
||||
)
|
||||
|
||||
# If a channel filter was applied but no results were found, override
|
||||
# the LLM response to avoid hallucinated answers about unindexed channels
|
||||
if channel_tags and not answer.citation_info and not answer.top_documents:
|
||||
channel_names = ", ".join(f"#{tag.tag_value}" for tag in channel_tags)
|
||||
answer.answer = (
|
||||
f"No indexed data found for {channel_names}. "
|
||||
"This channel may not be indexed, or there may be no messages "
|
||||
"matching your query within it."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Unable to process message - did not successfully answer "
|
||||
@@ -285,6 +358,7 @@ def handle_regular_answer(
|
||||
only_respond_if_citations
|
||||
and not answer.citation_info
|
||||
and not message_info.bypass_filters
|
||||
and not channel_tags
|
||||
):
|
||||
logger.error(
|
||||
f"Unable to find citations to answer: '{answer.answer}' - not answering!"
|
||||
|
||||
@@ -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
|
||||
@@ -203,14 +209,91 @@ 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)
|
||||
|
||||
# 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 [
|
||||
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 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 +352,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 +365,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1020,7 +1020,7 @@ toolz==1.1.0
|
||||
# dask
|
||||
# distributed
|
||||
# partd
|
||||
tornado==6.5.2
|
||||
tornado==6.5.5
|
||||
# via distributed
|
||||
tqdm==4.67.1
|
||||
# via
|
||||
|
||||
@@ -263,7 +263,7 @@ oauthlib==3.2.2
|
||||
# via
|
||||
# kubernetes
|
||||
# requests-oauthlib
|
||||
onyx-devtools==0.6.3
|
||||
onyx-devtools==0.7.0
|
||||
# via onyx
|
||||
openai==2.14.0
|
||||
# via
|
||||
@@ -466,7 +466,7 @@ tokenizers==0.21.4
|
||||
# via
|
||||
# cohere
|
||||
# litellm
|
||||
tornado==6.5.2
|
||||
tornado==6.5.5
|
||||
# via
|
||||
# ipykernel
|
||||
# jupyter-client
|
||||
|
||||
204
backend/tests/unit/onyx/onyxbot/test_handle_regular_answer.py
Normal file
204
backend/tests/unit/onyx/onyxbot/test_handle_regular_answer.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Tests for Slack channel reference resolution and tag filtering
|
||||
in handle_regular_answer.py."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
from onyx.context.search.models import Tag
|
||||
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
|
||||
from onyx.onyxbot.slack.handlers.handle_regular_answer import resolve_channel_references
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_client_with_channels(
|
||||
channel_map: dict[str, str],
|
||||
) -> MagicMock:
|
||||
"""Return a mock WebClient where conversations_info resolves IDs to names."""
|
||||
client = MagicMock()
|
||||
|
||||
def _conversations_info(channel: str) -> MagicMock:
|
||||
if channel in channel_map:
|
||||
resp = MagicMock()
|
||||
resp.validate = MagicMock()
|
||||
resp.__getitem__ = lambda _self, key: {
|
||||
"channel": {
|
||||
"name": channel_map[channel],
|
||||
"is_im": False,
|
||||
"is_mpim": False,
|
||||
}
|
||||
}[key]
|
||||
return resp
|
||||
raise SlackApiError("channel_not_found", response=MagicMock())
|
||||
|
||||
client.conversations_info = _conversations_info
|
||||
return client
|
||||
|
||||
|
||||
def _mock_logger() -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SLACK_CHANNEL_REF_PATTERN regex tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlackChannelRefPattern:
|
||||
def test_matches_bare_channel_id(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("<#C097NBWMY8Y>")
|
||||
assert matches == [("C097NBWMY8Y", "")]
|
||||
|
||||
def test_matches_channel_id_with_name(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("<#C097NBWMY8Y|eng-infra>")
|
||||
assert matches == [("C097NBWMY8Y", "eng-infra")]
|
||||
|
||||
def test_matches_multiple_channels(self) -> None:
|
||||
msg = "compare <#C111AAA> and <#C222BBB|general>"
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall(msg)
|
||||
assert len(matches) == 2
|
||||
assert ("C111AAA", "") in matches
|
||||
assert ("C222BBB", "general") in matches
|
||||
|
||||
def test_no_match_on_plain_text(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("no channels here")
|
||||
assert matches == []
|
||||
|
||||
def test_no_match_on_user_mention(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("<@U12345>")
|
||||
assert matches == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_channel_references tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveChannelReferences:
|
||||
def test_resolves_bare_channel_id_via_api(self) -> None:
|
||||
client = _mock_client_with_channels({"C097NBWMY8Y": "eng-infra"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="summary of <#C097NBWMY8Y> this week",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "summary of #eng-infra this week"
|
||||
assert len(tags) == 1
|
||||
assert tags[0] == Tag(tag_key="Channel", tag_value="eng-infra")
|
||||
|
||||
def test_uses_name_from_pipe_format_without_api_call(self) -> None:
|
||||
client = MagicMock()
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="check <#C097NBWMY8Y|eng-infra> for updates",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "check #eng-infra for updates"
|
||||
assert tags == [Tag(tag_key="Channel", tag_value="eng-infra")]
|
||||
# Should NOT have called the API since name was in the markup
|
||||
client.conversations_info.assert_not_called()
|
||||
|
||||
def test_multiple_channels(self) -> None:
|
||||
client = _mock_client_with_channels(
|
||||
{
|
||||
"C111AAA": "eng-infra",
|
||||
"C222BBB": "eng-general",
|
||||
}
|
||||
)
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="compare <#C111AAA> and <#C222BBB>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert "#eng-infra" in message
|
||||
assert "#eng-general" in message
|
||||
assert "<#" not in message
|
||||
assert len(tags) == 2
|
||||
tag_values = {t.tag_value for t in tags}
|
||||
assert tag_values == {"eng-infra", "eng-general"}
|
||||
|
||||
def test_no_channel_references_returns_unchanged(self) -> None:
|
||||
client = MagicMock()
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="just a normal message with no channels",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "just a normal message with no channels"
|
||||
assert tags == []
|
||||
|
||||
def test_api_failure_skips_channel_gracefully(self) -> None:
|
||||
# Client that fails for all channel lookups
|
||||
client = _mock_client_with_channels({})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="check <#CBADID123>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
# Message should remain unchanged for the failed channel
|
||||
assert "<#CBADID123>" in message
|
||||
assert tags == []
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
def test_partial_failure_resolves_what_it_can(self) -> None:
|
||||
# Only one of two channels resolves
|
||||
client = _mock_client_with_channels({"C111AAA": "eng-infra"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="compare <#C111AAA> and <#CBADID123>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert "#eng-infra" in message
|
||||
assert "<#CBADID123>" in message # failed one stays raw
|
||||
assert len(tags) == 1
|
||||
assert tags[0].tag_value == "eng-infra"
|
||||
|
||||
def test_duplicate_channel_produces_single_tag(self) -> None:
|
||||
client = _mock_client_with_channels({"C111AAA": "eng-infra"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="summarize <#C111AAA> and compare with <#C111AAA>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "summarize #eng-infra and compare with #eng-infra"
|
||||
assert len(tags) == 1
|
||||
assert tags[0].tag_value == "eng-infra"
|
||||
|
||||
def test_mixed_pipe_and_bare_formats(self) -> None:
|
||||
client = _mock_client_with_channels({"C222BBB": "random"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="see <#C111AAA|eng-infra> and <#C222BBB>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert "#eng-infra" in message
|
||||
assert "#random" in message
|
||||
assert len(tags) == 2
|
||||
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,7 +143,7 @@ dev = [
|
||||
"matplotlib==3.10.8",
|
||||
"mypy-extensions==1.0.0",
|
||||
"mypy==1.13.0",
|
||||
"onyx-devtools==0.6.3",
|
||||
"onyx-devtools==0.7.0",
|
||||
"openapi-generator-cli==7.17.0",
|
||||
"pandas-stubs~=2.3.3",
|
||||
"pre-commit==3.2.2",
|
||||
|
||||
@@ -25,6 +25,9 @@ Some commands require external tools to be installed and configured:
|
||||
- **Docker** - Required for `compose`, `logs`, and `pull` commands
|
||||
- Install from [docker.com](https://docs.docker.com/get-docker/)
|
||||
|
||||
- **uv** - Required for `backend` commands
|
||||
- Install from [docs.astral.sh/uv](https://docs.astral.sh/uv/)
|
||||
|
||||
- **GitHub CLI** (`gh`) - Required for `run-ci` and `cherry-pick` commands
|
||||
- Install from [cli.github.com](https://cli.github.com/)
|
||||
- Authenticate with `gh auth login`
|
||||
@@ -170,6 +173,53 @@ ods pull
|
||||
ods pull --tag edge
|
||||
```
|
||||
|
||||
### `backend` - Run Backend Services
|
||||
|
||||
Run backend services (API server, model server) with environment loaded from
|
||||
`.vscode/.env`. On first run, copies `.vscode/env_template.txt` to `.vscode/.env`
|
||||
if the `.env` file does not already exist.
|
||||
|
||||
Enterprise Edition features are enabled by default with license enforcement
|
||||
disabled, matching the `compose` command behavior.
|
||||
|
||||
```shell
|
||||
ods backend <subcommand>
|
||||
```
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `api` - Start the FastAPI backend server (`uvicorn onyx.main:app --reload`)
|
||||
- `model_server` - Start the model server (`uvicorn model_server.main:app --reload`)
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--no-ee` | `false` | Disable Enterprise Edition features (enabled by default) |
|
||||
| `--port` | `8080` (api) / `9000` (model_server) | Port to listen on |
|
||||
|
||||
Shell environment takes precedence over `.env` file values, so inline overrides
|
||||
work as expected (e.g. `S3_ENDPOINT_URL=foo ods backend api`).
|
||||
|
||||
**Examples:**
|
||||
|
||||
```shell
|
||||
# Start the API server
|
||||
ods backend api
|
||||
|
||||
# Start the API server on a custom port
|
||||
ods backend api --port 9090
|
||||
|
||||
# Start without Enterprise Edition
|
||||
ods backend api --no-ee
|
||||
|
||||
# Start the model server
|
||||
ods backend model_server
|
||||
|
||||
# Start the model server on a custom port
|
||||
ods backend model_server --port 9001
|
||||
```
|
||||
|
||||
### `web` - Run Frontend Scripts
|
||||
|
||||
Run npm scripts from `web/package.json` without manually changing directories.
|
||||
|
||||
242
tools/ods/cmd/backend.go
Normal file
242
tools/ods/cmd/backend.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
|
||||
)
|
||||
|
||||
// NewBackendCommand creates the parent "backend" command with subcommands for
|
||||
// running backend services.
|
||||
// BackendOptions holds options shared across backend subcommands.
|
||||
type BackendOptions struct {
|
||||
NoEE bool
|
||||
}
|
||||
|
||||
func NewBackendCommand() *cobra.Command {
|
||||
opts := &BackendOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "backend",
|
||||
Short: "Run backend services (api, model_server)",
|
||||
Long: `Run backend services with environment from .vscode/.env.
|
||||
|
||||
On first run, copies .vscode/env_template.txt to .vscode/.env if the
|
||||
.env file does not already exist.
|
||||
|
||||
Enterprise Edition features are enabled by default for development,
|
||||
with license enforcement disabled.
|
||||
|
||||
Available subcommands:
|
||||
api Start the FastAPI backend server
|
||||
model_server Start the model server`,
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().BoolVar(&opts.NoEE, "no-ee", false, "Disable Enterprise Edition features (enabled by default)")
|
||||
|
||||
cmd.AddCommand(newBackendAPICommand(opts))
|
||||
cmd.AddCommand(newBackendModelServerCommand(opts))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newBackendAPICommand(opts *BackendOptions) *cobra.Command {
|
||||
var port string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "Start the backend API server (uvicorn with hot-reload)",
|
||||
Long: `Start the backend API server using uvicorn with hot-reload.
|
||||
|
||||
Examples:
|
||||
ods backend api
|
||||
ods backend api --port 9090
|
||||
ods backend api --no-ee`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runBackendService("api", "onyx.main:app", port, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&port, "port", "8080", "Port to listen on")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newBackendModelServerCommand(opts *BackendOptions) *cobra.Command {
|
||||
var port string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "model_server",
|
||||
Short: "Start the model server (uvicorn with hot-reload)",
|
||||
Long: `Start the model server using uvicorn with hot-reload.
|
||||
|
||||
Examples:
|
||||
ods backend model_server
|
||||
ods backend model_server --port 9001`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runBackendService("model_server", "model_server.main:app", port, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&port, "port", "9000", "Port to listen on")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runBackendService(name, module, port string, opts *BackendOptions) {
|
||||
root, err := paths.GitRoot()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to find git root: %v", err)
|
||||
}
|
||||
|
||||
envFile := ensureBackendEnvFile(root)
|
||||
fileVars := loadBackendEnvFile(envFile)
|
||||
|
||||
eeDefaults := eeEnvDefaults(opts.NoEE)
|
||||
fileVars = append(fileVars, eeDefaults...)
|
||||
|
||||
backendDir := filepath.Join(root, "backend")
|
||||
|
||||
uvicornArgs := []string{
|
||||
"run", "uvicorn", module,
|
||||
"--reload",
|
||||
"--port", port,
|
||||
}
|
||||
log.Infof("Starting %s on port %s...", name, port)
|
||||
if !opts.NoEE {
|
||||
log.Info("Enterprise Edition enabled (use --no-ee to disable)")
|
||||
}
|
||||
log.Debugf("Running in %s: uv %v", backendDir, uvicornArgs)
|
||||
|
||||
mergedEnv := mergeEnv(os.Environ(), fileVars)
|
||||
log.Debugf("Applied %d env vars from %s (shell takes precedence)", len(fileVars), envFile)
|
||||
|
||||
svcCmd := exec.Command("uv", uvicornArgs...)
|
||||
svcCmd.Dir = backendDir
|
||||
svcCmd.Stdout = os.Stdout
|
||||
svcCmd.Stderr = os.Stderr
|
||||
svcCmd.Stdin = os.Stdin
|
||||
svcCmd.Env = mergedEnv
|
||||
|
||||
if err := svcCmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if code := exitErr.ExitCode(); code != -1 {
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
log.Fatalf("Failed to run %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// eeEnvDefaults returns env entries for EE and license enforcement settings.
|
||||
// These are appended to the file vars so they act as defaults — shell env
|
||||
// and .env file values still take precedence via mergeEnv.
|
||||
func eeEnvDefaults(noEE bool) []string {
|
||||
if noEE {
|
||||
return []string{
|
||||
"ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=false",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true",
|
||||
"LICENSE_ENFORCEMENT_ENABLED=false",
|
||||
}
|
||||
}
|
||||
|
||||
// ensureBackendEnvFile copies env_template.txt to .env if .env doesn't exist.
|
||||
func ensureBackendEnvFile(root string) string {
|
||||
vscodeDir := filepath.Join(root, ".vscode")
|
||||
envFile := filepath.Join(vscodeDir, ".env")
|
||||
templateFile := filepath.Join(vscodeDir, "env_template.txt")
|
||||
|
||||
if _, err := os.Stat(envFile); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Fatalf("Failed to stat env file %s: %v", envFile, err)
|
||||
}
|
||||
} else {
|
||||
log.Debugf("Using existing env file: %s", envFile)
|
||||
return envFile
|
||||
}
|
||||
|
||||
templateData, err := os.ReadFile(templateFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read env template %s: %v", templateFile, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(vscodeDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create .vscode directory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(envFile, templateData, 0644); err != nil {
|
||||
log.Fatalf("Failed to write env file %s: %v", envFile, err)
|
||||
}
|
||||
|
||||
log.Infof("Created %s from template (review and fill in <REPLACE THIS> values)", envFile)
|
||||
return envFile
|
||||
}
|
||||
|
||||
// mergeEnv combines shell environment with file-based defaults. Shell values
|
||||
// take precedence — file entries are only added for keys not already present.
|
||||
func mergeEnv(shellEnv, fileVars []string) []string {
|
||||
existing := make(map[string]bool, len(shellEnv))
|
||||
for _, entry := range shellEnv {
|
||||
if idx := strings.Index(entry, "="); idx > 0 {
|
||||
existing[entry[:idx]] = true
|
||||
}
|
||||
}
|
||||
|
||||
merged := make([]string, len(shellEnv))
|
||||
copy(merged, shellEnv)
|
||||
for _, entry := range fileVars {
|
||||
if idx := strings.Index(entry, "="); idx > 0 {
|
||||
key := entry[:idx]
|
||||
if !existing[key] {
|
||||
merged = append(merged, entry)
|
||||
} else {
|
||||
log.Debugf("Env var %s already set in shell, skipping .env value", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// loadBackendEnvFile parses a .env file into KEY=VALUE entries suitable for
|
||||
// appending to os.Environ(). Blank lines and comments are skipped.
|
||||
func loadBackendEnvFile(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open env file %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var envVars []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(line, "="); idx > 0 {
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
value = strings.Trim(value, `"'`)
|
||||
envVars = append(envVars, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatalf("Failed to read env file %s: %v", path, err)
|
||||
}
|
||||
|
||||
return envVars
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.PersistentFlags().BoolVar(&opts.Debug, "debug", false, "run in debug mode")
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(NewBackendCommand())
|
||||
cmd.AddCommand(NewCheckLazyImportsCommand())
|
||||
cmd.AddCommand(NewCherryPickCommand())
|
||||
cmd.AddCommand(NewDBCommand())
|
||||
|
||||
41
uv.lock
generated
41
uv.lock
generated
@@ -4443,7 +4443,7 @@ requires-dist = [
|
||||
{ name = "numpy", marker = "extra == 'model-server'", specifier = "==2.4.1" },
|
||||
{ name = "oauthlib", marker = "extra == 'backend'", specifier = "==3.2.2" },
|
||||
{ name = "office365-rest-python-client", marker = "extra == 'backend'", specifier = "==2.6.2" },
|
||||
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.3" },
|
||||
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.7.0" },
|
||||
{ name = "openai", specifier = "==2.14.0" },
|
||||
{ name = "openapi-generator-cli", marker = "extra == 'dev'", specifier = "==7.17.0" },
|
||||
{ name = "openinference-instrumentation", marker = "extra == 'backend'", specifier = "==0.1.42" },
|
||||
@@ -4548,20 +4548,19 @@ requires-dist = [{ name = "onyx", extras = ["backend", "dev", "ee"], editable =
|
||||
|
||||
[[package]]
|
||||
name = "onyx-devtools"
|
||||
version = "0.6.3"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "openapi-generator-cli" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/e2/e7619722c3ccd18eb38100f776fb3dd6b4ae0fbbee09fca5af7c69a279b5/onyx_devtools-0.6.3-py3-none-any.whl", hash = "sha256:d3a5422945d9da12cafc185f64b39f6e727ee4cc92b37427deb7a38f9aad4966", size = 3945381, upload-time = "2026-03-05T20:39:25.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/09/513d2dabedc1e54ad4376830fc9b34a3d9c164bdbcdedfcdbb8b8154dc5a/onyx_devtools-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:efe300e9f3a2e7ae75f88a4f9e0a5c4c471478296cb1615b6a1f03d247582e13", size = 3978761, upload-time = "2026-03-05T20:39:28.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/41/e757602a0de032d74ed01c7ee57f30e57728fb9cd4f922f50d2affda3889/onyx_devtools-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:594066eed3f917cfab5a8c7eac3d4a210df30259f2049f664787749709345e19", size = 3665378, upload-time = "2026-03-05T20:44:22.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/1c/c93b65d0b32e202596a2647922a75c7011cb982f899ddfcfd171f792c58f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:384ef66030b55c0fd68b3898782b5b4b868ff3de119569dfc8544e2ce534b98a", size = 3540890, upload-time = "2026-03-05T20:39:28.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/33/760eb656013f7f0cdff24570480d3dc4e52bbd8e6147ea1e8cf6fad7554f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e218f3a49f64910c2c4c34d5dc12d1ea1520a27e0b0f6e4c0949ff9abaf0e1", size = 3945396, upload-time = "2026-03-05T20:39:34.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/eb/f54b3675c464df8a51194ff75afc97c2417659e3a209dc46948b47c28860/onyx_devtools-0.6.3-py3-none-win_amd64.whl", hash = "sha256:8af614ae7229290ef2417cb85270184a1e826ed9a3a34658da93851edb36df57", size = 4045936, upload-time = "2026-03-05T20:39:28.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b8/5bee38e748f3d4b8ec935766224db1bbc1214c91092e5822c080fccd9130/onyx_devtools-0.6.3-py3-none-win_arm64.whl", hash = "sha256:717589db4b42528d33ae96f8006ee6aad3555034dcfee724705b6576be6a6ec4", size = 3608268, upload-time = "2026-03-05T20:39:28.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/9e/6957b11555da57d9e97092f4cd8ac09a86666264b0c9491838f4b27db5dc/onyx_devtools-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ad962a168d46ea11dcde9fa3b37e4f12ec520b4a4cb4d49d8732de110d46c4b6", size = 3998057, upload-time = "2026-03-12T03:09:11.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/90/c72f3d06ba677012d77c77de36195b6a32a15c755c79ba0282be74e3c366/onyx_devtools-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e46d252e2b048ff053b03519c3a875998780738d7c334eaa1c9a32ff445e3e1a", size = 3687753, upload-time = "2026-03-12T03:09:11.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/42/4e9fe36eccf9f76d67ba8f4ff6539196a09cd60351fb63f5865e1544cbfa/onyx_devtools-0.7.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f280bc9320e1cc310e7d753a371009bfaab02cc0e0cfd78559663b15655b5a50", size = 3560144, upload-time = "2026-03-12T03:12:24.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/40/36dc12d99760b358c7f39b27361cb18fa9681ffe194107f982d0e1a74016/onyx_devtools-0.7.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:e31df751c7540ae7e70a7fe8e1153c79c31c2254af6aa4c72c0dd54fa381d2ab", size = 3964387, upload-time = "2026-03-12T03:09:11.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/18/74744230c3820a5a7687335507ca5f1dbebab2c5325805041c1cd5703e6a/onyx_devtools-0.7.0-py3-none-win_amd64.whl", hash = "sha256:541bfd347c2d5b11e7f63ab5001d2594df91d215ad9d07b1562f5e715700f7e6", size = 4068030, upload-time = "2026-03-12T03:09:12.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/78/1320436607d3ffcb321ba7b064556c020ea15843a7e7d903fbb7529a71f5/onyx_devtools-0.7.0-py3-none-win_arm64.whl", hash = "sha256:83016330a9d39712431916cc25b2fb2cfcaa0112a55cc4f919d545da3a8974f9", size = 3626409, upload-time = "2026-03-12T03:09:10.222Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7233,21 +7232,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.2"
|
||||
version = "6.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
|
||||
|
||||
16
web/package-lock.json
generated
16
web/package-lock.json
generated
@@ -59,6 +59,7 @@
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"mime": "^4.1.0",
|
||||
"motion": "^12.29.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.4",
|
||||
@@ -13883,6 +13884,21 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
|
||||
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"mime": "^4.1.0",
|
||||
"motion": "^12.29.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.4",
|
||||
|
||||
@@ -11,14 +11,13 @@ import rehypeHighlight from "rehype-highlight";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { transformLinkUri } from "@/lib/utils";
|
||||
import { cn, transformLinkUri } from "@/lib/utils";
|
||||
|
||||
type MinimalMarkdownComponentOverrides = Partial<Components>;
|
||||
|
||||
interface MinimalMarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
showHeader?: boolean;
|
||||
/**
|
||||
* Override specific markdown renderers.
|
||||
@@ -30,7 +29,6 @@ interface MinimalMarkdownProps {
|
||||
export default function MinimalMarkdown({
|
||||
content,
|
||||
className = "",
|
||||
style,
|
||||
showHeader = true,
|
||||
components,
|
||||
}: MinimalMarkdownProps) {
|
||||
@@ -63,19 +61,17 @@ export default function MinimalMarkdown({
|
||||
}, [content, components, showHeader]);
|
||||
|
||||
return (
|
||||
<div style={style || {}} className={`${className}`}>
|
||||
<ReactMarkdown
|
||||
className="prose dark:prose-invert max-w-full text-sm break-words"
|
||||
components={markdownComponents}
|
||||
rehypePlugins={[rehypeHighlight, rehypeKatex]}
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
urlTransform={transformLinkUri}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<ReactMarkdown
|
||||
className={cn(
|
||||
"prose dark:prose-invert max-w-full text-sm break-words",
|
||||
className
|
||||
)}
|
||||
components={markdownComponents}
|
||||
rehypePlugins={[rehypeHighlight, rehypeKatex]}
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
urlTransform={transformLinkUri}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
122
web/src/hooks/useAdminUsers.ts
Normal file
122
web/src/hooks/useAdminUsers.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
"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,
|
||||
error: acceptedError,
|
||||
mutate: acceptedMutate,
|
||||
} = useSWR<FullUserSnapshot[]>(
|
||||
"/api/manage/users/accepted/all",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const {
|
||||
data: invitedData,
|
||||
isLoading: invitedLoading,
|
||||
error: invitedError,
|
||||
mutate: invitedMutate,
|
||||
} = useSWR<InvitedUserSnapshot[]>(
|
||||
"/api/manage/users/invited",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const {
|
||||
data: requestedData,
|
||||
isLoading: requestedLoading,
|
||||
error: requestedError,
|
||||
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;
|
||||
const error = acceptedError ?? invitedError ?? requestedError;
|
||||
|
||||
function refresh() {
|
||||
acceptedMutate();
|
||||
invitedMutate();
|
||||
requestedMutate();
|
||||
}
|
||||
|
||||
return { users, isLoading, error, refresh };
|
||||
}
|
||||
@@ -4,23 +4,28 @@ import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import type { InvitedUserSnapshot } from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import type { StatusCountMap } from "@/refresh-pages/admin/UsersPage/interfaces";
|
||||
|
||||
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: StatusCountMap;
|
||||
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 +37,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 } : {}),
|
||||
} satisfies StatusCountMap,
|
||||
refreshCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface LinguistLanguage {
|
||||
type: string;
|
||||
extensions?: string[];
|
||||
filenames?: string[];
|
||||
codemirrorMimeType?: string;
|
||||
}
|
||||
|
||||
interface LanguageMaps {
|
||||
@@ -14,7 +15,13 @@ interface LanguageMaps {
|
||||
filenames: Map<string, string>;
|
||||
}
|
||||
|
||||
const allLanguages = Object.values(languages) as LinguistLanguage[];
|
||||
// Sort so that languages with more extensions (i.e. more general-purpose) win
|
||||
// when multiple languages claim the same extension (e.g. Ecmarkup vs HTML both
|
||||
// claim .html — HTML should win because it's the canonical language for that
|
||||
// extension).
|
||||
const allLanguages = (Object.values(languages) as LinguistLanguage[]).sort(
|
||||
(a, b) => (b.extensions?.length ?? 0) - (a.extensions?.length ?? 0)
|
||||
);
|
||||
|
||||
// Collect extensions that linguist-languages assigns to "Markdown" so we can
|
||||
// exclude them from the code-language map
|
||||
@@ -25,14 +32,15 @@ const markdownExtensions = new Set(
|
||||
);
|
||||
|
||||
function buildLanguageMaps(
|
||||
type: string,
|
||||
types: string[],
|
||||
excludedExtensions?: Set<string>
|
||||
): LanguageMaps {
|
||||
const typeSet = new Set(types);
|
||||
const extensions = new Map<string, string>();
|
||||
const filenames = new Map<string, string>();
|
||||
|
||||
for (const lang of allLanguages) {
|
||||
if (lang.type !== type) continue;
|
||||
if (!typeSet.has(lang.type)) continue;
|
||||
|
||||
const name = lang.name.toLowerCase();
|
||||
for (const ext of lang.extensions ?? []) {
|
||||
@@ -57,13 +65,17 @@ function lookupLanguage(name: string, maps: LanguageMaps): string | null {
|
||||
return (ext && maps.extensions.get(ext)) ?? maps.filenames.get(lower) ?? null;
|
||||
}
|
||||
|
||||
const codeMaps = buildLanguageMaps("programming", markdownExtensions);
|
||||
const dataMaps = buildLanguageMaps("data");
|
||||
const codeMaps = buildLanguageMaps(
|
||||
["programming", "markup"],
|
||||
markdownExtensions
|
||||
);
|
||||
const dataMaps = buildLanguageMaps(["data"]);
|
||||
|
||||
/**
|
||||
* Returns the language name for a given file name, or null if it's not a
|
||||
* recognised code file. Looks up by extension first, then by exact filename
|
||||
* (e.g. "Dockerfile", "Makefile"). Runs in O(1).
|
||||
* recognised code or markup file (programming + markup types from
|
||||
* linguist-languages, e.g. Python, HTML, CSS, Vue). Looks up by extension
|
||||
* first, then by exact filename (e.g. "Dockerfile", "Makefile"). Runs in O(1).
|
||||
*/
|
||||
export function getCodeLanguage(name: string): string | null {
|
||||
return lookupLanguage(name, codeMaps);
|
||||
@@ -86,3 +98,20 @@ export function isMarkdownFile(name: string): boolean {
|
||||
const ext = name.toLowerCase().match(LANGUAGE_EXT_PATTERN)?.[0];
|
||||
return !!ext && markdownExtensions.has(ext);
|
||||
}
|
||||
|
||||
const mimeToLanguage = new Map<string, string>();
|
||||
for (const lang of allLanguages) {
|
||||
if (lang.codemirrorMimeType && !mimeToLanguage.has(lang.codemirrorMimeType)) {
|
||||
mimeToLanguage.set(lang.codemirrorMimeType, lang.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language name for a given MIME type using the codemirrorMimeType
|
||||
* field from linguist-languages (~297 entries). Returns null if unrecognised.
|
||||
*/
|
||||
export function getLanguageByMime(mime: string): string | null {
|
||||
const base = mime.split(";")[0];
|
||||
if (!base) return null;
|
||||
return mimeToLanguage.get(base.trim().toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
@@ -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]: "Requested",
|
||||
};
|
||||
|
||||
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,10 +6,42 @@ import { cn } from "@/lib/utils";
|
||||
// Throttle interval for scroll events (~60fps)
|
||||
const SCROLL_THROTTLE_MS = 16;
|
||||
|
||||
/**
|
||||
* A scrollable container that shows gradient or shadow indicators when
|
||||
* content overflows above or below the visible area.
|
||||
*
|
||||
* HEIGHT CONSTRAINT REQUIREMENT
|
||||
*
|
||||
* This component relies on its inner scroll container having a smaller
|
||||
* clientHeight than its scrollHeight. For that to happen, the entire
|
||||
* ancestor chain must constrain height via flex sizing (flex-1 min-h-0),
|
||||
* NOT via percentage heights (h-full).
|
||||
*
|
||||
* height: 100% resolves to "auto" when the containing block's height is
|
||||
* determined by flex layout (flex-auto, flex-1) rather than an explicit
|
||||
* height property — this is per the CSS spec. When that happens, the
|
||||
* container grows to fit its content and scrollHeight === clientHeight,
|
||||
* making scroll indicators invisible.
|
||||
*
|
||||
* Correct pattern: every ancestor up to the nearest fixed-height boundary
|
||||
* must form an unbroken flex column chain using "flex-1 min-h-0":
|
||||
*
|
||||
* fixed-height-ancestor (e.g. h-[500px])
|
||||
* flex flex-col flex-1 min-h-0 <-- use flex-1, NOT h-full
|
||||
* ScrollIndicatorDiv
|
||||
* ...tall content...
|
||||
*
|
||||
* Common mistakes:
|
||||
* - Using h-full instead of flex-1 min-h-0 anywhere in the chain.
|
||||
* - Placing this inside a parent with overflow-y: auto (e.g. Modal.Body),
|
||||
* which becomes the scroll container instead of this component's inner div.
|
||||
*/
|
||||
export interface ScrollIndicatorDivProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
// Mask/Shadow options
|
||||
disableIndicators?: boolean;
|
||||
disableTopIndicator?: boolean;
|
||||
disableBottomIndicator?: boolean;
|
||||
backgroundColor?: string;
|
||||
indicatorHeight?: string;
|
||||
|
||||
@@ -22,6 +54,8 @@ export interface ScrollIndicatorDivProps
|
||||
|
||||
export default function ScrollIndicatorDiv({
|
||||
disableIndicators = false,
|
||||
disableTopIndicator = false,
|
||||
disableBottomIndicator = false,
|
||||
backgroundColor = "var(--background-tint-02)",
|
||||
indicatorHeight = "3rem",
|
||||
variant = "gradient",
|
||||
@@ -77,13 +111,24 @@ export default function ScrollIndicatorDiv({
|
||||
// Update on scroll (throttled)
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
// Update on resize (in case content changes)
|
||||
// Update when the container itself resizes
|
||||
const resizeObserver = new ResizeObserver(updateScrollIndicators);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Update when descendants change (e.g. syntax highlighting mutates the
|
||||
// DOM after initial render, which changes scrollHeight without firing
|
||||
// resize or scroll events on the container).
|
||||
const mutationObserver = new MutationObserver(updateScrollIndicators);
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
if (throttleTimeoutRef.current) {
|
||||
clearTimeout(throttleTimeoutRef.current);
|
||||
}
|
||||
@@ -120,7 +165,7 @@ export default function ScrollIndicatorDiv({
|
||||
return (
|
||||
<div className="relative flex-1 min-h-0 overflow-y-hidden flex flex-col w-full">
|
||||
{/* Top indicator */}
|
||||
{!disableIndicators && showTopIndicator && (
|
||||
{!disableIndicators && !disableTopIndicator && showTopIndicator && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
|
||||
style={getIndicatorStyle("top")}
|
||||
@@ -141,7 +186,7 @@ export default function ScrollIndicatorDiv({
|
||||
</div>
|
||||
|
||||
{/* Bottom indicator */}
|
||||
{!disableIndicators && showBottomIndicator && (
|
||||
{!disableIndicators && !disableBottomIndicator && showBottomIndicator && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
|
||||
style={getIndicatorStyle("bottom")}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import LineItem, { LineItemProps } from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import type { IconProps } from "@opal/types";
|
||||
@@ -298,10 +298,7 @@ function InputSelectContent({
|
||||
)}
|
||||
sideOffset={4}
|
||||
position="popper"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={noProp()}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="flex flex-col gap-1">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users page content
|
||||
@@ -19,7 +23,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 +43,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
296
web/src/refresh-pages/admin/UsersPage/UserFilters.tsx
Normal file
296
web/src/refresh-pages/admin/UsersPage/UserFilters.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SvgCheck, SvgSlack, SvgUser, 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 Separator from "@/refresh-components/Separator";
|
||||
import {
|
||||
UserRole,
|
||||
UserStatus,
|
||||
USER_ROLE_LABELS,
|
||||
USER_STATUS_LABELS,
|
||||
} from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import type { GroupOption, StatusFilter, StatusCountMap } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: StatusCountMap;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILTERABLE_ROLES = Object.entries(USER_ROLE_LABELS).filter(
|
||||
([role]) => role !== UserRole.EXT_PERM_USER
|
||||
) 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.SLACK_USER]: SvgSlack,
|
||||
};
|
||||
|
||||
/** Map UserStatus enum values to the keys returned by the counts endpoint. */
|
||||
const STATUS_COUNT_KEY: Record<UserStatus, keyof StatusCountMap> = {
|
||||
[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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 [groupPopoverOpen, setGroupPopoverOpen] = useState(false);
|
||||
|
||||
const toggleRole = (role: UserRole) => {
|
||||
if (selectedRoles.includes(role)) {
|
||||
onRolesChange(selectedRoles.filter((r) => r !== role));
|
||||
} else {
|
||||
onRolesChange([...selectedRoles, role]);
|
||||
}
|
||||
};
|
||||
|
||||
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 toggleGroup = (groupId: number) => {
|
||||
if (selectedGroups.includes(groupId)) {
|
||||
onGroupsChange(selectedGroups.filter((id) => id !== groupId));
|
||||
} else {
|
||||
onGroupsChange([...selectedGroups, groupId]);
|
||||
}
|
||||
};
|
||||
|
||||
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 toggleStatus = (status: UserStatus) => {
|
||||
if (selectedStatuses.includes(status)) {
|
||||
onStatusesChange(selectedStatuses.filter((s) => s !== status));
|
||||
} else {
|
||||
onStatusesChange([...selectedStatuses, status]);
|
||||
}
|
||||
};
|
||||
|
||||
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={SvgUsers}
|
||||
selected={!hasRoleFilter}
|
||||
onClick={() => onRolesChange([])}
|
||||
>
|
||||
All Account Types
|
||||
</LineItem>
|
||||
<Separator noPadding />
|
||||
{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}
|
||||
onClick={() => toggleRole(role)}
|
||||
rightChildren={<CountBadge count={roleCounts[role]} />}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{/* Groups filter */}
|
||||
<Popover
|
||||
open={groupPopoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
setGroupPopoverOpen(open);
|
||||
if (!open) setGroupSearch("");
|
||||
}}
|
||||
>
|
||||
<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]">
|
||||
<div className="px-1 pt-1">
|
||||
<InputTypeIn
|
||||
value={groupSearch}
|
||||
onChange={(e) => setGroupSearch(e.target.value)}
|
||||
placeholder="Search groups..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
</div>
|
||||
<LineItem
|
||||
icon={SvgUsers}
|
||||
selected={!hasGroupFilter}
|
||||
onClick={() => onGroupsChange([])}
|
||||
>
|
||||
All Groups
|
||||
</LineItem>
|
||||
<Separator noPadding />
|
||||
<div className="flex flex-col gap-1 max-h-[240px] overflow-y-auto">
|
||||
{filteredGroups.map((group) => {
|
||||
const isSelected = selectedGroups.includes(group.id);
|
||||
return (
|
||||
<LineItem
|
||||
key={group.id}
|
||||
icon={isSelected ? SvgCheck : undefined}
|
||||
selected={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>
|
||||
)}
|
||||
</div>
|
||||
</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 : undefined}
|
||||
selected={!hasStatusFilter}
|
||||
onClick={() => onStatusesChange([])}
|
||||
>
|
||||
All Status
|
||||
</LineItem>
|
||||
<Separator noPadding />
|
||||
{FILTERABLE_STATUSES.map(([status, label]) => {
|
||||
const isSelected = selectedStatuses.includes(status);
|
||||
const countKey = STATUS_COUNT_KEY[status];
|
||||
return (
|
||||
<LineItem
|
||||
key={status}
|
||||
icon={isSelected ? SvgCheck : undefined}
|
||||
selected={isSelected}
|
||||
onClick={() => toggleStatus(status)}
|
||||
rightChildren={<CountBadge count={statusCounts[countKey]} />}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SvgArrowUpRight, SvgUserSync } from "@opal/icons";
|
||||
import { SvgArrowUpRight, SvgFilter, SvgUserSync } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
@@ -14,19 +14,33 @@ import { ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
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 ${
|
||||
onFilter ? "cursor-pointer hover:bg-background-tint-02" : ""
|
||||
}`}
|
||||
onClick={onFilter}
|
||||
>
|
||||
<Text as="span" mainUiAction text04>
|
||||
{display}
|
||||
</Text>
|
||||
<Text as="span" secondaryBody text03>
|
||||
{label}
|
||||
</Text>
|
||||
{onFilter && (
|
||||
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 group-hover/stat:opacity-100 transition-opacity">
|
||||
<Text as="span" secondaryBody text03>
|
||||
Filter
|
||||
</Text>
|
||||
<SvgFilter size={16} className="text-text-03" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +80,9 @@ type UsersSummaryProps = {
|
||||
pendingInvites: number | null;
|
||||
requests: number | null;
|
||||
showScim: boolean;
|
||||
onFilterActive?: () => void;
|
||||
onFilterInvites?: () => void;
|
||||
onFilterRequests?: () => void;
|
||||
};
|
||||
|
||||
export default function UsersSummary({
|
||||
@@ -73,9 +90,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 +128,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 +138,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>
|
||||
|
||||
294
web/src/refresh-pages/admin/UsersPage/UsersTable.tsx
Normal file
294
web/src/refresh-pages/admin/UsersPage/UsersTable.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"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 { SvgUser, SvgUsers, SvgSlack } from "@opal/icons";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import {
|
||||
UserRole,
|
||||
UserStatus,
|
||||
USER_ROLE_LABELS,
|
||||
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 type {
|
||||
UserRow,
|
||||
UserGroupInfo,
|
||||
GroupOption,
|
||||
StatusFilter,
|
||||
StatusCountMap,
|
||||
} from "./interfaces";
|
||||
import { getInitials } from "./utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROLE_ICONS: Record<UserRole, IconFunctionComponent> = {
|
||||
[UserRole.BASIC]: SvgUser,
|
||||
[UserRole.ADMIN]: SvgUser,
|
||||
[UserRole.GLOBAL_CURATOR]: SvgUsers,
|
||||
[UserRole.CURATOR]: SvgUsers,
|
||||
[UserRole.LIMITED]: SvgUser,
|
||||
[UserRole.EXT_PERM_USER]: SvgUser,
|
||||
[UserRole.SLACK_USER]: SvgSlack,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 renderRoleColumn(role: UserRole | null) {
|
||||
if (!role) {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const Icon = ROLE_ICONS[role];
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Icon && <Icon size={14} className="text-text-03 shrink-0" />}
|
||||
<Text as="span" mainUiBody text03>
|
||||
{USER_ROLE_LABELS[role] ?? role}
|
||||
</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>
|
||||
{timeAgo(value) ?? "\u2014"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns (stable reference — defined at module scope)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<UserRow>();
|
||||
|
||||
const columns = [
|
||||
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: renderRoleColumn,
|
||||
}),
|
||||
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(),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
interface UsersTableProps {
|
||||
selectedStatuses: StatusFilter;
|
||||
onStatusesChange: (statuses: StatusFilter) => void;
|
||||
roleCounts: Record<string, number>;
|
||||
statusCounts: StatusCountMap;
|
||||
}
|
||||
|
||||
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, error } = useAdminUsers();
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Text as="p" secondaryBody text03>
|
||||
Failed to load users. Please try refreshing the page.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
36
web/src/refresh-pages/admin/UsersPage/interfaces.ts
Normal file
36
web/src/refresh-pages/admin/UsersPage/interfaces.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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[];
|
||||
|
||||
/** Keys match the UserStatus-derived labels used in filter badges. */
|
||||
export type StatusCountMap = {
|
||||
active?: number;
|
||||
inactive?: number;
|
||||
invited?: number;
|
||||
requested?: number;
|
||||
};
|
||||
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();
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { getCodeLanguage, getDataLanguage } from "@/lib/languages";
|
||||
import mime from "mime";
|
||||
import {
|
||||
getCodeLanguage,
|
||||
getDataLanguage,
|
||||
getLanguageByMime,
|
||||
} from "@/lib/languages";
|
||||
import { fetchChatFile } from "@/lib/chat/svc";
|
||||
import { PreviewContext } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import {
|
||||
getMimeLanguage,
|
||||
resolveMimeType,
|
||||
} from "@/sections/modals/PreviewModal/mimeUtils";
|
||||
import { resolveVariant } from "@/sections/modals/PreviewModal/variants";
|
||||
|
||||
interface PreviewModalProps {
|
||||
@@ -41,7 +42,7 @@ export default function PreviewModal({
|
||||
const language = useMemo(
|
||||
() =>
|
||||
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
|
||||
getMimeLanguage(mimeType) ||
|
||||
getLanguageByMime(mimeType) ||
|
||||
getDataLanguage(presentingDocument.semantic_identifier || "") ||
|
||||
"plaintext",
|
||||
[mimeType, presentingDocument.semantic_identifier]
|
||||
@@ -86,7 +87,10 @@ export default function PreviewModal({
|
||||
|
||||
const rawContentType =
|
||||
response.headers.get("Content-Type") || "application/octet-stream";
|
||||
const resolvedMime = resolveMimeType(rawContentType, originalFileName);
|
||||
const resolvedMime =
|
||||
rawContentType === "application/octet-stream"
|
||||
? mime.getType(originalFileName) ?? rawContentType
|
||||
: rawContentType;
|
||||
setMimeType(resolvedMime);
|
||||
|
||||
const resolved = resolveVariant(
|
||||
@@ -166,24 +170,24 @@ export default function PreviewModal({
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Body + floating footer wrapper */}
|
||||
<Modal.Body padding={0} gap={0}>
|
||||
<Section padding={0} gap={0}>
|
||||
{isLoading ? (
|
||||
<Section>
|
||||
<SimpleLoader className="h-8 w-8" />
|
||||
</Section>
|
||||
) : loadError ? (
|
||||
<Section padding={1}>
|
||||
<Text text03 mainUiBody>
|
||||
{loadError}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
variant.renderContent(ctx)
|
||||
)}
|
||||
</Section>
|
||||
</Modal.Body>
|
||||
{/* Body — uses flex-1/min-h-0/overflow-hidden (not Modal.Body)
|
||||
so that child ScrollIndicatorDivs become the actual scroll
|
||||
container instead of the body stealing it via overflow-y-auto. */}
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden w-full bg-background-tint-01">
|
||||
{isLoading ? (
|
||||
<Section>
|
||||
<SimpleLoader className="h-8 w-8" />
|
||||
</Section>
|
||||
) : loadError ? (
|
||||
<Section padding={1}>
|
||||
<Text text03 mainUiBody>
|
||||
{loadError}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
variant.renderContent(ctx)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating footer */}
|
||||
{!isLoading && !loadError && (
|
||||
@@ -194,8 +198,9 @@ export default function PreviewModal({
|
||||
"p-4 pointer-events-none w-full"
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, var(--background-code-01) 40%, transparent)",
|
||||
background: `linear-gradient(to top, var(--background-${
|
||||
variant.codeBackground ? "code-01" : "tint-01"
|
||||
}) 40%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{/* Left slot */}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface PreviewVariant
|
||||
matches: (semanticIdentifier: string | null, mimeType: string) => boolean;
|
||||
/** Whether the fetcher should read the blob as text. */
|
||||
needsTextContent: boolean;
|
||||
/** Whether the variant renders on a code-style background (bg-background-code-01). */
|
||||
codeBackground: boolean;
|
||||
/** String shown below the title in the modal header. */
|
||||
headerDescription: (ctx: PreviewContext) => string;
|
||||
/** Body content. */
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
const MIME_LANGUAGE_PREFIXES: Array<[prefix: string, language: string]> = [
|
||||
["application/json", "json"],
|
||||
["application/xml", "xml"],
|
||||
["text/xml", "xml"],
|
||||
["application/x-yaml", "yaml"],
|
||||
["application/yaml", "yaml"],
|
||||
["text/yaml", "yaml"],
|
||||
["text/x-yaml", "yaml"],
|
||||
];
|
||||
|
||||
const OCTET_STREAM_EXTENSION_TO_MIME: Record<string, string> = {
|
||||
".md": "text/markdown",
|
||||
".markdown": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
".log": "text/plain",
|
||||
".conf": "text/plain",
|
||||
".sql": "text/plain",
|
||||
".csv": "text/csv",
|
||||
".tsv": "text/tab-separated-values",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".yml": "application/x-yaml",
|
||||
".yaml": "application/x-yaml",
|
||||
};
|
||||
|
||||
export function getMimeLanguage(mimeType: string): string | null {
|
||||
return (
|
||||
MIME_LANGUAGE_PREFIXES.find(([prefix]) =>
|
||||
mimeType.startsWith(prefix)
|
||||
)?.[1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMimeType(mimeType: string, fileName: string): string {
|
||||
if (mimeType !== "application/octet-stream") {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
const lowerFileName = fileName.toLowerCase();
|
||||
|
||||
for (const [extension, resolvedMime] of Object.entries(
|
||||
OCTET_STREAM_EXTENSION_TO_MIME
|
||||
)) {
|
||||
if (lowerFileName.endsWith(extension)) {
|
||||
return resolvedMime;
|
||||
}
|
||||
}
|
||||
|
||||
return mimeType;
|
||||
}
|
||||
@@ -1,22 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import { cn } from "@/lib/utils";
|
||||
import "@/app/app/message/custom-code-styles.css";
|
||||
|
||||
interface CodePreviewProps {
|
||||
content: string;
|
||||
language?: string | null;
|
||||
normalize?: boolean;
|
||||
}
|
||||
|
||||
export function CodePreview({ content, language }: CodePreviewProps) {
|
||||
const normalizedContent = content.replace(/~~~/g, "\\~\\~\\~");
|
||||
const fenceHeader = language ? `~~~${language}` : "~~~";
|
||||
export function CodePreview({
|
||||
content,
|
||||
language,
|
||||
normalize,
|
||||
}: CodePreviewProps) {
|
||||
const markdownContent = normalize
|
||||
? `~~~${language || ""}\n${content.replace(/~~~/g, "\\~\\~\\~")}\n~~~`
|
||||
: content;
|
||||
|
||||
return (
|
||||
<MinimalMarkdown
|
||||
content={`${fenceHeader}\n${normalizedContent}\n\n~~~`}
|
||||
className="w-full h-full"
|
||||
showHeader={false}
|
||||
/>
|
||||
<ScrollIndicatorDiv
|
||||
className={cn("p-4", normalize && "bg-background-code-01")}
|
||||
backgroundColor={normalize ? "var(--background-code-01)" : undefined}
|
||||
variant="shadow"
|
||||
bottomSpacing="2rem"
|
||||
disableBottomIndicator
|
||||
>
|
||||
<MinimalMarkdown content={markdownContent} showHeader={false} />
|
||||
</ScrollIndicatorDiv>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const codeVariant: PreviewVariant = {
|
||||
width: "md",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
headerDescription: (ctx) =>
|
||||
ctx.fileContent
|
||||
@@ -22,7 +23,7 @@ export const codeVariant: PreviewVariant = {
|
||||
: "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
@@ -34,6 +34,7 @@ export const csvVariant: PreviewVariant = {
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
codeBackground: false,
|
||||
headerDescription: (ctx) => {
|
||||
if (!ctx.fileContent) return "";
|
||||
const { rows } = parseCsv(ctx.fileContent);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { getDataLanguage } from "@/lib/languages";
|
||||
import { getDataLanguage, getLanguageByMime } from "@/lib/languages";
|
||||
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import { getMimeLanguage } from "@/sections/modals/PreviewModal/mimeUtils";
|
||||
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
|
||||
import {
|
||||
CopyButton,
|
||||
@@ -22,10 +21,11 @@ function formatContent(language: string, content: string): string {
|
||||
|
||||
export const dataVariant: PreviewVariant = {
|
||||
matches: (name, mime) =>
|
||||
!!getDataLanguage(name || "") || !!getMimeLanguage(mime),
|
||||
!!getDataLanguage(name || "") || !!getLanguageByMime(mime),
|
||||
width: "md",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
headerDescription: (ctx) =>
|
||||
ctx.fileContent
|
||||
@@ -36,7 +36,9 @@ export const dataVariant: PreviewVariant = {
|
||||
|
||||
renderContent: (ctx) => {
|
||||
const formatted = formatContent(ctx.language, ctx.fileContent);
|
||||
return <CodePreview content={formatted} language={ctx.language} />;
|
||||
return (
|
||||
<CodePreview normalize content={formatted} language={ctx.language} />
|
||||
);
|
||||
},
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
@@ -130,6 +130,7 @@ export const docxVariant: PreviewVariant = {
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
headerDescription: () => {
|
||||
if (lastDocxResult) {
|
||||
const count = lastDocxResult.wordCount;
|
||||
|
||||
@@ -11,6 +11,7 @@ export const imageVariant: PreviewVariant = {
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
|
||||
@@ -15,10 +15,10 @@ const PREVIEW_VARIANTS: PreviewVariant[] = [
|
||||
imageVariant,
|
||||
pdfVariant,
|
||||
csvVariant,
|
||||
dataVariant,
|
||||
textVariant,
|
||||
markdownVariant,
|
||||
docxVariant,
|
||||
textVariant,
|
||||
dataVariant,
|
||||
];
|
||||
|
||||
export function resolveVariant(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { isMarkdownFile } from "@/lib/languages";
|
||||
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
|
||||
import {
|
||||
CopyButton,
|
||||
DownloadButton,
|
||||
@@ -23,15 +22,11 @@ export const markdownVariant: PreviewVariant = {
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
codeBackground: false,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<ScrollIndicatorDiv className="flex-1 min-h-0 p-4" variant="shadow">
|
||||
<MinimalMarkdown
|
||||
content={ctx.fileContent}
|
||||
className="w-full pb-4 text-lg break-words"
|
||||
/>
|
||||
</ScrollIndicatorDiv>
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
),
|
||||
|
||||
renderFooterLeft: () => null,
|
||||
|
||||
@@ -7,6 +7,7 @@ export const pdfVariant: PreviewVariant = {
|
||||
width: "lg",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
|
||||
@@ -28,6 +28,7 @@ export const textVariant: PreviewVariant = {
|
||||
width: "md",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
headerDescription: (ctx) =>
|
||||
ctx.fileContent
|
||||
? `${ctx.lineCount} ${ctx.lineCount === 1 ? "line" : "lines"} · ${
|
||||
@@ -36,7 +37,7 @@ export const textVariant: PreviewVariant = {
|
||||
: "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
@@ -5,13 +5,14 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
|
||||
|
||||
export const unsupportedVariant: PreviewVariant = {
|
||||
matches: () => true,
|
||||
width: "lg",
|
||||
width: "md",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<div className="flex flex-col items-center justify-center flex-1 min-h-0 gap-4 p-6">
|
||||
<div className="flex flex-col items-center justify-center flex-1 w-full min-h-0 gap-4 p-6">
|
||||
<Text as="p" text03 mainUiBody>
|
||||
This file format is not supported for preview.
|
||||
</Text>
|
||||
|
||||
@@ -260,6 +260,7 @@ module.exports = {
|
||||
"code-string": "var(--code-string)",
|
||||
"code-number": "var(--code-number)",
|
||||
"code-definition": "var(--code-definition)",
|
||||
"background-code-01": "var(--background-code-01)",
|
||||
|
||||
// Shimmer colors for loading animations
|
||||
"shimmer-base": "var(--shimmer-base)",
|
||||
|
||||
Reference in New Issue
Block a user