Compare commits

..

1 Commits

Author SHA1 Message Date
Jamison Lahman
2e93376148 feat(chat): support attaching more file types to chats 2026-03-11 21:39:25 -07:00
14 changed files with 133 additions and 962 deletions

View File

@@ -4,7 +4,6 @@ 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
@@ -242,41 +241,6 @@ 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)
@@ -353,23 +317,24 @@ def batch_add_ext_perm_user_if_not_exists(
lower_emails = [email.lower() for email in emails]
found_users, missing_lower_emails = _get_users_by_emails(db_session, lower_emails)
# Use savepoints (begin_nested) so that a failed insert only rolls back
# that single user, not the entire transaction. A plain rollback() would
# discard all previously flushed users in the same transaction.
# We also avoid add_all() because SQLAlchemy 2.0's insertmanyvalues
# batch path hits a UUID sentinel mismatch with server_default columns.
new_users: list[User] = []
for email in missing_lower_emails:
user = _generate_ext_permissioned_user(email=email)
savepoint = db_session.begin_nested()
try:
db_session.add(user)
savepoint.commit()
except IntegrityError:
savepoint.rollback()
if not continue_on_error:
raise
new_users.append(_generate_ext_permissioned_user(email=email))
db_session.commit()
try:
db_session.add_all(new_users)
db_session.commit()
except IntegrityError:
db_session.rollback()
if not continue_on_error:
raise
for user in new_users:
try:
db_session.add(user)
db_session.commit()
except IntegrityError:
db_session.rollback()
continue
# Fetch all users again to ensure we have the most up-to-date list
all_users, _ = _get_users_by_emails(db_session, lower_emails)
return all_users

View File

@@ -76,39 +76,11 @@ def get_feedback_reminder_blocks(thread_link: str, include_followup: bool) -> Bl
return SectionBlock(text=text)
def _find_unclosed_fence(text: str) -> tuple[bool, int, str]:
"""Scan *text* line-by-line to determine code-fence state.
Returns (is_open, fence_line_start, lang) where:
- *is_open* is True when the text ends inside an unclosed code fence
- *fence_line_start* is the char offset of the opening fence line
(only meaningful when *is_open* is True)
- *lang* is the language specifier on the opening fence (e.g. "python")
"""
in_fence = False
fence_start = 0
lang = ""
offset = 0
for line in text.splitlines(True): # keep line endings
# Slack only treats ``` as a fence when it starts at column 0.
# Indented backticks (e.g. inside a heredoc) are content, not fences.
if line.startswith("```"):
if not in_fence:
in_fence = True
fence_start = offset
lang = line[3:].strip()
else:
in_fence = False
lang = ""
offset += len(line)
return in_fence, fence_start, lang
def _split_text(text: str, limit: int = 3000) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
chunks = []
while text:
if len(text) <= limit:
chunks.append(text)
@@ -120,37 +92,8 @@ def _split_text(text: str, limit: int = 3000) -> list[str]:
split_at = limit
chunk = text[:split_at]
# Check whether the chunk ends inside an unclosed code fence.
is_open, fence_start, lang = _find_unclosed_fence(chunk)
if is_open:
# Tier 1: try to back up to before the opening fence so the
# entire code block stays in the next chunk.
split_before = text.rfind("\n", 0, fence_start)
if split_before > 0 and text[:split_before].strip():
chunk = text[:split_before]
remainder = text[split_before:]
# Strip only the leading newline to preserve blank lines
# and formatting before the code fence.
if remainder and remainder[0] == "\n":
remainder = remainder[1:]
text = remainder
else:
# Tier 2: the code block itself exceeds the limit — split
# inside it. Close the fence here, reopen in the next.
chunk += "\n```"
remainder = text[split_at:]
# Strip only the single boundary character (space/newline)
# to avoid eating meaningful indentation inside code.
if remainder and remainder[0] in " \n":
remainder = remainder[1:]
text = f"```{lang}\n" + remainder
else:
# No unclosed fence — plain prose split. Leading whitespace
# is cosmetic in Slack mrkdwn, so lstrip() is safe here.
text = text[split_at:].lstrip()
chunks.append(chunk)
text = text[split_at:].lstrip() # Remove leading spaces from the next chunk
return chunks

View File

@@ -157,10 +157,13 @@ def categorize_uploaded_files(
"""
Categorize uploaded files based on text extractability and tokenized length.
- Extracts text using extract_file_text for supported plain/document extensions.
- Images are estimated for token cost via a patch-based heuristic.
- All other files are run through extract_file_text, which handles known
document formats (.pdf, .docx, …) and falls back to a text-detection
heuristic for unknown extensions (.py, .js, .rs, …).
- Uses default tokenizer to compute token length.
- If token length > 100,000, reject file (unless threshold skip is enabled).
- If extension unsupported or text cannot be extracted, reject file.
- If token length > threshold, reject file (unless threshold skip is enabled).
- If text cannot be extracted, reject file.
- Otherwise marked as acceptable.
"""
@@ -235,8 +238,10 @@ def categorize_uploaded_files(
results.acceptable_file_to_token_count[filename] = token_count
continue
# Otherwise, handle as text/document: extract text and count tokens
elif extension in OnyxFileExtensions.ALL_ALLOWED_EXTENSIONS:
# Handle as text/document: attempt text extraction and count tokens.
# This accepts any file that extract_file_text can handle, including
# code files (.py, .js, .rs, etc.) via its is_text_file() fallback.
else:
if is_file_password_protected(
file=upload.file,
file_name=filename,
@@ -259,7 +264,10 @@ def categorize_uploaded_files(
if not text_content:
logger.warning(f"No text content extracted from '{filename}'")
results.rejected.append(
RejectedFile(filename=filename, reason="Could not read file")
RejectedFile(
filename=filename,
reason=f"Unsupported file type: {extension}",
)
)
continue
@@ -282,17 +290,6 @@ def categorize_uploaded_files(
logger.warning(
f"Failed to reset file pointer for '{filename}': {str(e)}"
)
continue
# If not recognized as supported types above, mark unsupported
logger.warning(
f"Unsupported file extension '{extension}' for file '{filename}'"
)
results.rejected.append(
RejectedFile(
filename=filename, reason=f"Unsupported file type: {extension}"
)
)
except Exception as e:
logger.warning(
f"Failed to process uploaded file '{get_safe_filename(upload)}' (error_type={type(e).__name__}, error={str(e)})"

View File

@@ -76,7 +76,6 @@ 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
@@ -286,14 +285,6 @@ def list_all_accepted_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),

View File

@@ -7,8 +7,6 @@ import timeago # type: ignore
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import SavedSearchDoc
from onyx.onyxbot.slack.blocks import _build_documents_blocks
from onyx.onyxbot.slack.blocks import _find_unclosed_fence
from onyx.onyxbot.slack.blocks import _split_text
def _make_saved_doc(updated_at: datetime | None) -> SavedSearchDoc:
@@ -71,154 +69,3 @@ def test_build_documents_blocks_formats_naive_timestamp(
formatted_timestamp: datetime = captured["doc"]
expected_timestamp: datetime = naive_timestamp.replace(tzinfo=pytz.utc)
assert formatted_timestamp == expected_timestamp
# ---------------------------------------------------------------------------
# _split_text tests
# ---------------------------------------------------------------------------
class TestSplitText:
def test_short_text_returns_single_chunk(self) -> None:
result = _split_text("hello world", limit=100)
assert result == ["hello world"]
def test_splits_at_space_boundary(self) -> None:
text = "aaa bbb ccc ddd"
result = _split_text(text, limit=8)
assert len(result) >= 2
def test_code_block_not_split_when_fits(self) -> None:
# Text fits within limit — exercises the early-return path,
# not the fence-aware splitting logic.
text = "before ```code here``` after"
result = _split_text(text, limit=100)
assert result == [text]
def test_code_block_split_backs_up_before_fence(self) -> None:
# Build text where the split point falls inside a code block,
# but the code block itself fits within the limit. The split
# should back up to before the opening ``` so the block stays intact.
before = "some intro text here " * 5 + "\n" # ~105 chars
code_content = "x " * 20 # ~40 chars of code
text = f"{before}```\n{code_content}\n```\nafter"
# limit=120 means the initial split lands inside the code block
# but the code block (~50 chars) fits in the next chunk
result = _split_text(text, limit=120)
assert len(result) >= 2
# Every chunk must have balanced code fences
for chunk in result:
is_open, _, _ = _find_unclosed_fence(chunk)
assert not is_open, f"Unclosed fence in chunk: {chunk[:80]}..."
# The code block should be fully contained in one chunk
code_chunks = [c for c in result if "```" in c]
assert len(code_chunks) == 1, "Code block should not be split across chunks"
def test_no_code_fences_splits_normally(self) -> None:
text = "word " * 100 # 500 chars
result = _split_text(text, limit=100)
assert len(result) >= 5
for chunk in result:
fence_count = chunk.count("```")
assert fence_count == 0
def test_code_block_exceeding_limit_falls_back_to_close_reopen(self) -> None:
# When the code block itself is bigger than the limit, we can't
# avoid splitting inside it — verify fences are still balanced.
code_content = "x " * 100 # ~200 chars
text = f"```\n{code_content}\n```"
result = _split_text(text, limit=80)
assert len(result) >= 2
for chunk in result:
is_open, _, _ = _find_unclosed_fence(chunk)
assert not is_open, f"Unclosed fence in chunk: {chunk[:80]}..."
def test_code_block_exceeding_limit_no_spaces(self) -> None:
# When code has no spaces, split_at is forced to limit.
# Fences should still be balanced.
code_content = "x" * 200
text = f"```\n{code_content}\n```"
result = _split_text(text, limit=80)
assert len(result) >= 2
for chunk in result:
is_open, _, _ = _find_unclosed_fence(chunk)
assert not is_open, f"Unclosed fence in chunk: {chunk[:80]}..."
def test_all_content_preserved_after_split(self) -> None:
text = "intro paragraph and more text here\n```\nprint('hello')\n```\nconclusion here"
result = _split_text(text, limit=50)
# Key content should appear somewhere across the chunks
joined = " ".join(result)
assert "intro" in joined
assert "print('hello')" in joined
assert "conclusion" in joined
def test_language_specifier_preserved_on_reopen(self) -> None:
# When a ```python block exceeds the limit and must be split,
# the continuation chunk should reopen with ```python, not ```.
code_content = "x " * 100 # ~200 chars
text = f"```python\n{code_content}\n```"
result = _split_text(text, limit=80)
assert len(result) >= 2
for chunk in result[1:]:
stripped = chunk.lstrip()
if stripped.startswith("```"):
assert stripped.startswith(
"```python"
), f"Language specifier lost in continuation: {chunk[:40]}"
def test_inline_backticks_inside_code_block_ignored(self) -> None:
# Triple backticks appearing mid-line inside a code block should
# not be mistaken for fence boundaries.
before = "some text here " * 6 + "\n" # ~90 chars
text = f"{before}```bash\necho '```'\necho done\n```\nafter"
result = _split_text(text, limit=110)
assert len(result) >= 2
for chunk in result:
is_open, _, _ = _find_unclosed_fence(chunk)
assert not is_open, f"Chunk has unclosed fence: {chunk[:80]}..."
# ---------------------------------------------------------------------------
# _find_unclosed_fence tests
# ---------------------------------------------------------------------------
class TestFindUnclosedFence:
def test_no_fences(self) -> None:
is_open, _, _ = _find_unclosed_fence("just plain text")
assert not is_open
def test_balanced_fences(self) -> None:
is_open, _, _ = _find_unclosed_fence("```\ncode\n```")
assert not is_open
def test_unclosed_fence(self) -> None:
is_open, start, lang = _find_unclosed_fence("before\n```\ncode here")
assert is_open
assert start == len("before\n")
assert lang == ""
def test_unclosed_fence_with_lang(self) -> None:
is_open, _, lang = _find_unclosed_fence("intro\n```python\ncode")
assert is_open
assert lang == "python"
def test_inline_backticks_not_counted(self) -> None:
# Backticks mid-line should not toggle fence state
text = "```bash\necho '```'\necho done\n```"
is_open, _, _ = _find_unclosed_fence(text)
assert not is_open
def test_indented_backticks_not_counted_as_fence(self) -> None:
# Slack only treats ``` at column 0 as a fence delimiter.
# Indented backticks inside a code block are content, not fences.
text = "```bash\ncat <<'EOF'\n ```\nEOF\n```"
is_open, _, _ = _find_unclosed_fence(text)
assert not is_open

View File

@@ -186,3 +186,42 @@ def test_categorize_uploaded_files_checks_size_before_text_extraction(
assert len(result.acceptable) == 0
assert len(result.rejected) == 1
assert result.rejected[0].reason == "Exceeds 1 MB file size limit"
def test_categorize_uploaded_files_accepts_python_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_common_dependencies(monkeypatch)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_BYTES", 10_000)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_MB", 1)
py_source = b'def hello():\n print("world")\n'
monkeypatch.setattr(
utils, "extract_file_text", lambda **_kwargs: py_source.decode()
)
upload = _make_upload("script.py", size=len(py_source), content=py_source)
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 1
assert result.acceptable[0].filename == "script.py"
assert len(result.rejected) == 0
def test_categorize_uploaded_files_rejects_binary_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_common_dependencies(monkeypatch)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_BYTES", 10_000)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_MB", 1)
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: "")
binary_content = bytes(range(256)) * 4
upload = _make_upload("data.bin", size=len(binary_content), content=binary_content)
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 0
assert len(result.rejected) == 1
assert result.rejected[0].filename == "data.bin"
assert "Unsupported file type" in result.rejected[0].reason

View File

@@ -2,121 +2,18 @@
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
// ---------------------------------------------------------------------------
import type { UserRow } from "@/refresh-pages/admin/UsersPage/interfaces";
export default function useAdminUsers() {
const {
data: acceptedData,
isLoading: acceptedLoading,
error: acceptedError,
mutate: acceptedMutate,
} = useSWR<FullUserSnapshot[]>(
const { data, isLoading, error, mutate } = useSWR<UserRow[]>(
"/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 };
return {
users: data ?? [],
isLoading,
error,
refresh: mutate,
};
}

View File

@@ -4,28 +4,23 @@ 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 UserCountsResponse = {
role_counts: Record<string, number>;
status_counts: Record<string, number>;
type PaginatedCountResponse = {
total_items: 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 {
const { data: countsData, mutate: refreshCounts } =
useSWR<UserCountsResponse>(
"/api/manage/users/counts",
errorHandlingFetcher
);
// 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: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
@@ -37,20 +32,9 @@ export default function useUserCounts(): UserCounts {
errorHandlingFetcher
);
const activeCount = countsData?.status_counts?.active ?? null;
const inactiveCount = countsData?.status_counts?.inactive ?? null;
return {
activeCount,
activeCount: activeData?.total_items ?? null,
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

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

@@ -1,14 +1,11 @@
"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";
@@ -23,18 +20,7 @@ function UsersContent() {
const { data: scimToken } = useScimToken();
const showScim = isEe && !!scimToken;
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]
);
};
const { activeCount, invitedCount, pendingCount } = useUserCounts();
return (
<>
@@ -43,17 +29,9 @@ function UsersContent() {
pendingInvites={invitedCount}
requests={pendingCount}
showScim={showScim}
onFilterActive={() => toggleStatus(UserStatus.ACTIVE)}
onFilterInvites={() => toggleStatus(UserStatus.INVITED)}
onFilterRequests={() => toggleStatus(UserStatus.REQUESTED)}
/>
<UsersTable
selectedStatuses={selectedStatuses}
onStatusesChange={setSelectedStatuses}
roleCounts={roleCounts}
statusCounts={statusCounts}
/>
<UsersTable />
</>
);
}

View File

@@ -1,296 +0,0 @@
"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, SvgFilter, SvgUserSync } from "@opal/icons";
import { SvgArrowUpRight, SvgUserSync } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import { Section } from "@/layouts/general-layouts";
@@ -14,33 +14,19 @@ import { ADMIN_PATHS } from "@/lib/admin-routes";
type StatCellProps = {
value: number | null;
label: string;
onFilter?: () => void;
};
function StatCell({ value, label, onFilter }: StatCellProps) {
function StatCell({ value, label }: StatCellProps) {
const display = value === null ? "\u2014" : value.toLocaleString();
return (
<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}
>
<div className="flex flex-col items-start gap-0.5 w-full p-2">
<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>
);
}
@@ -80,9 +66,6 @@ type UsersSummaryProps = {
pendingInvites: number | null;
requests: number | null;
showScim: boolean;
onFilterActive?: () => void;
onFilterInvites?: () => void;
onFilterRequests?: () => void;
};
export default function UsersSummary({
@@ -90,36 +73,9 @@ 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
@@ -128,7 +84,15 @@ export default function UsersSummary({
alignItems="stretch"
gap={0.5}
>
{statsCard}
<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>
<ScimCard />
</Section>
);
@@ -138,26 +102,14 @@ export default function UsersSummary({
return (
<Section flexDirection="row" gap={0.5}>
<Card padding={0.5}>
<StatCell
value={activeUsers}
label="active users"
onFilter={onFilterActive}
/>
<StatCell value={activeUsers} label="active users" />
</Card>
<Card padding={0.5}>
<StatCell
value={pendingInvites}
label="pending invites"
onFilter={onFilterInvites}
/>
<StatCell value={pendingInvites} label="pending invites" />
</Card>
{showRequests && (
<Card padding={0.5}>
<StatCell
value={requests}
label="requests to join"
onFilter={onFilterRequests}
/>
<StatCell value={requests} label="requests to join" />
</Card>
)}
</Section>

View File

@@ -1,39 +1,26 @@
"use client";
import { useMemo, useState } from "react";
import { 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 { USER_ROLE_LABELS, UserRole } from "@/lib/types";
import { timeAgo } from "@/lib/time";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useAdminUsers from "@/hooks/useAdminUsers";
import useGroups from "@/hooks/useGroups";
import UserFilters from "./UserFilters";
import type {
UserRow,
UserGroupInfo,
GroupOption,
StatusFilter,
StatusCountMap,
} from "./interfaces";
import { ThreeDotsLoader } from "@/components/Loading";
import { SvgUser, SvgUsers, SvgSlack } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { UserRow, UserGroupInfo } from "./interfaces";
import { getInitials } from "./utils";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PAGE_SIZE = 8;
const ROLE_ICONS: Record<UserRole, IconFunctionComponent> = {
[UserRole.BASIC]: SvgUser,
[UserRole.ADMIN]: SvgUser,
@@ -90,14 +77,7 @@ function renderGroupsColumn(groups: UserGroupInfo[]) {
);
}
function renderRoleColumn(role: UserRole | null) {
if (!role) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
function renderRoleColumn(role: UserRole) {
const Icon = ROLE_ICONS[role];
return (
<div className="flex items-center gap-1.5">
@@ -109,11 +89,11 @@ function renderRoleColumn(role: UserRole | null) {
);
}
function renderStatusColumn(value: UserStatus, row: UserRow) {
function renderStatusColumn(isActive: boolean, row: UserRow) {
return (
<div className="flex flex-col">
<Text as="span" mainUiBody text03>
{USER_STATUS_LABELS[value] ?? value}
{isActive ? "Active" : "Inactive"}
</Text>
{row.is_scim_synced && (
<Text as="span" secondaryBody text03>
@@ -124,7 +104,7 @@ function renderStatusColumn(value: UserStatus, row: UserRow) {
);
}
function renderLastUpdatedColumn(value: string | null) {
function renderLastUpdatedColumn(value: string) {
return (
<Text as="span" secondaryBody text03>
{timeAgo(value) ?? "\u2014"}
@@ -154,7 +134,6 @@ const columns = [
header: "Groups",
weight: 24,
minWidth: 200,
enableSorting: false,
cell: renderGroupsColumn,
}),
tc.column("role", {
@@ -163,9 +142,9 @@ const columns = [
minWidth: 180,
cell: renderRoleColumn,
}),
tc.column("status", {
tc.column("is_active", {
header: "Status",
weight: 14,
weight: 15,
minWidth: 100,
cell: renderStatusColumn,
}),
@@ -182,68 +161,12 @@ const columns = [
// 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) {
export default function UsersTable() {
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>
);
return <ThreeDotsLoader />;
}
if (error) {
@@ -262,33 +185,14 @@ export default function UsersTable({
placeholder="Search users..."
leftSearchIcon
/>
<UserFilters
selectedRoles={selectedRoles}
onRolesChange={setSelectedRoles}
selectedGroups={selectedGroups}
onGroupsChange={setSelectedGroups}
groups={groupOptions}
selectedStatuses={selectedStatuses}
onStatusesChange={onStatusesChange}
roleCounts={roleCounts}
statusCounts={statusCounts}
<DataTable
data={users}
columns={columns}
getRowId={(row) => row.id}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{ mode: "summary" }}
/>
{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

@@ -1,4 +1,4 @@
import type { UserRole, UserStatus } from "@/lib/types";
import type { UserRole } from "@/lib/types";
export interface UserGroupInfo {
id: number;
@@ -6,31 +6,15 @@ export interface UserGroupInfo {
}
export interface UserRow {
id: string | null;
id: string;
email: string;
role: UserRole | null;
status: UserStatus;
role: UserRole;
is_active: boolean;
is_scim_synced: boolean;
personal_name: string | null;
created_at: string | null;
updated_at: string | null;
created_at: string;
updated_at: string;
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;
};
export type StatusFilter = "all" | "active" | "inactive";