Compare commits

..

11 Commits

Author SHA1 Message Date
Jamison Lahman
70d278c555 nit 2026-03-12 00:53:39 -07:00
Jamison Lahman
aae92bf749 nit 2026-03-12 00:34:31 -07:00
Jamison Lahman
9e28a774e5 chore(fe): polish file previews more 2026-03-12 00:25:28 -07:00
Nikolas Garza
c1ce180b72 feat(admin): add role, group, and status filters to Users table - 4/9 (#9179) 2026-03-11 21:56:19 -07:00
Jamison Lahman
b5474dc127 chore(devtools): upgrade ods: 0.6.3->0.7.0 (#9297) 2026-03-11 20:30:55 -07:00
Nikolas Garza
e1df3f533a feat(admin): add Users table with DataTable and server-side pagination - 3/9 (#9178) 2026-03-11 20:26:07 -07:00
Jamison Lahman
df5252db05 chore(devtools): ods backend api (#9295)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 20:07:23 -07:00
Nikolas Garza
f01f210af8 fix(slackbot): resolve channel references and filter search by channel tags (#9256) 2026-03-11 19:37:03 -07:00
Jamison Lahman
781219cf18 chore(models): rm claude-3-5-sonnet-v2 metadata (#9285) 2026-03-12 02:17:09 +00:00
Nikolas Garza
ca39da7de9 feat(admin): add user timestamps and enrich FullUserSnapshot - 2/9 (#9183) 2026-03-11 19:07:45 -07:00
dependabot[bot]
abf76cd747 chore(deps): bump tornado from 6.5.2 to 6.5.5 (#9290)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-12 01:41:01 +00:00
50 changed files with 2043 additions and 237 deletions

View File

@@ -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")

View File

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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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!"

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import cast
from uuid import UUID
import jwt
from email_validator import EmailNotValidError
@@ -18,6 +19,7 @@ from fastapi import Query
from fastapi import Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from onyx.auth.anonymous_user import fetch_anonymous_user_info
@@ -67,11 +69,14 @@ from onyx.db.user_preferences import update_user_role
from onyx.db.user_preferences import update_user_shortcut_enabled
from onyx.db.user_preferences import update_user_temperature_override_enabled
from onyx.db.user_preferences import update_user_theme_preference
from onyx.db.users import batch_get_user_groups
from onyx.db.users import delete_user_from_db
from onyx.db.users import get_all_accepted_users
from onyx.db.users import get_all_users
from onyx.db.users import get_page_of_filtered_users
from onyx.db.users import get_total_filtered_users_count
from onyx.db.users import get_user_by_email
from onyx.db.users import get_user_counts_by_role_and_status
from onyx.db.users import validate_user_role_update
from onyx.key_value_store.factory import get_kv_store
from onyx.redis.redis_pool import get_raw_redis_client
@@ -98,6 +103,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
from onyx.server.models import FullUserSnapshot
from onyx.server.models import InvitedUserSnapshot
from onyx.server.models import MinimalUserSnapshot
from onyx.server.models import UserGroupInfo
from onyx.server.usage_limits import is_tenant_on_trial_fn
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View 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

View File

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

View File

@@ -143,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",

View File

@@ -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
View 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
}

View File

@@ -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
View File

@@ -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]]

View File

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

16
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
);
}

View 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 };
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -68,6 +68,20 @@ export const USER_ROLE_LABELS: Record<UserRole, string> = {
[UserRole.SLACK_USER]: "Slack User",
};
export enum UserStatus {
ACTIVE = "active",
INACTIVE = "inactive",
INVITED = "invited",
REQUESTED = "requested",
}
export const USER_STATUS_LABELS: Record<UserStatus, string> = {
[UserStatus.ACTIVE]: "Active",
[UserStatus.INACTIVE]: "Inactive",
[UserStatus.INVITED]: "Invite Pending",
[UserStatus.REQUESTED]: "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",

View File

@@ -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")}

View File

@@ -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">

View File

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

View File

@@ -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}
/>
</>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View 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;
};

View File

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

View File

@@ -0,0 +1,23 @@
/**
* Derive display initials from a user's name or email.
*
* - If a name is provided, uses the first letter of the first two words.
* - Falls back to the email local part, splitting on `.`, `_`, or `-`.
* - Returns at most 2 uppercase characters.
*/
export function getInitials(name: string | null, email: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
const local = email.split("@")[0];
if (!local) return "?";
const parts = local.split(/[._-]/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return local.slice(0, 2).toUpperCase();
}

View File

@@ -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 */}

View File

@@ -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. */

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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) => (

View File

@@ -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);

View File

@@ -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) => (

View File

@@ -130,6 +130,7 @@ export const docxVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => {
if (lastDocxResult) {
const count = lastDocxResult.wordCount;

View File

@@ -11,6 +11,7 @@ export const imageVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (

View File

@@ -15,10 +15,10 @@ const PREVIEW_VARIANTS: PreviewVariant[] = [
imageVariant,
pdfVariant,
csvVariant,
dataVariant,
textVariant,
markdownVariant,
docxVariant,
textVariant,
dataVariant,
];
export function resolveVariant(

View File

@@ -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,

View File

@@ -7,6 +7,7 @@ export const pdfVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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)",