Compare commits

..

17 Commits

Author SHA1 Message Date
Nik
46bdd978a0 fix(admin): download CSV as onyx_users.csv with error handling
Extract downloadUsersCsv into svc.ts, set filename to onyx_users.csv,
and surface errors via toast.
2026-03-12 19:38:09 -07:00
Nik
d7b376b7c2 fix(admin): polish AdminSidebar for users page 2026-03-12 19:38:09 -07:00
Nik
d773ca063b fix(admin): handle nullable updated_at in column renderer 2026-03-12 19:38:09 -07:00
Nik
a25f3dd742 feat(admin): switch /admin/users to new Users page and remove v2 route
Replace the old Users page at /admin/users with the new refresh-pages
implementation. Remove the temporary /admin/users2 route and sidebar
"Permissions" section, moving SCIM into "User Management".
2026-03-12 19:38:09 -07:00
Nik
76fd97a98f fix: restore GroupsCell + CSV download lost during rebase
The rebase of f494089b1 silently dropped UsersTable.tsx changes from
the original commit ffbfe4db1, leaving GroupsCell as dead code and
the old inline renderGroupsColumn in place.

- Wire GroupsCell into the groups column (Tag pills, +N overflow,
  edit pencil on hover)
- Add CSV download button in table footer
- Revert ContentMd styles.css to match main (remove min-w-0 and
  truncate that were never needed — GroupsCell doesn't use ContentMd)
2026-03-12 19:35:54 -07:00
Nik
f268514b4e reapply Tag size variant + fix description truncation in HorizontalInputLayout
Tag: re-add size prop (sm/md) and apply size="md" in GroupsCell.

input-layouts: add min-w-0 to Content wrapper and set widthVariant="full"
so descriptions truncate properly instead of overflowing past the switch.
This fixes the "Always Start with an Agent" row on chat-preferences where
the long description pushed the toggle out of the viewport.
2026-03-12 18:55:25 -07:00
Nik
51dcd2b43b revert Tag size variant — not needed, use default sm everywhere 2026-03-12 18:43:41 -07:00
Nik
2354011173 revert: remove OverflowTooltip from ContentMd
The tooltip wrapper broke flex layout constraints, preventing
description text from truncating. This pushed content off-screen
and broke the disable_default_agent Playwright tests.
2026-03-12 18:37:26 -07:00
Nik
e44fe4a305 fix: address remaining Greptile concerns
- Fix OverflowTooltip title detection: check scrollHeight for line-clamped
  elements in addition to scrollWidth
- Show current role in InputSelect even if not manually assignable (prevents
  blank trigger for CURATOR/LIMITED/SLACK_USER users)
- Remove Global Curator from assignable roles (only Admin and Basic)
- Add exhaustive default case to actionButtons switch in UserRowActions
2026-03-12 17:22:33 -07:00
Nik
ab4d9daaff fix: preserve cc_pair_ids in removeUserFromGroup, fix ResizeObserver stale ref, sequential saves
- Pass existing cc_pair_ids to PATCH /user-group to avoid wiping associations
- Fix GroupsCell ResizeObserver that went stale when ref moved between DOM
  elements across overflow/no-overflow render branches
- Replace Promise.all with sequential awaits so partial failures don't leave
  stale modal state and already-applied mutations are visible via onMutate
2026-03-12 16:47:15 -07:00
Nik
1800583fb5 style: fix prettier formatting in UserRowActions 2026-03-12 16:33:17 -07:00
Nik
13260fbfba fix: address Greptile review comments on edit groups modal
- Use parseErrorDetail consistently across all svc.ts functions
- Remove cc_pair_ids from removeUserFromGroup to avoid wiping associations
- Type selectedRole as UserRole | "" instead of string
- Fix type narrowing for EditGroupsModal user prop in GroupsCell
- Guard role change check properly in handleSave
2026-03-12 15:39:06 -07:00
Nik
9f1010d495 fix(admin): restyle EditGroupsModal, filters, and groups cell
- Rewrite EditGroupsModal with Section/ContentAction layout primitives
- Add white subsection card with absolute bg pattern from Figma
- Use LineItem for all group entries with tint-01 background
- Add ShadowDiv scroll indicators to filter popovers and group lists
- Fix icon indentation in filter dropdowns (always provide fallback icon)
- Polish GroupsCell and UsersSummary
2026-03-12 15:39:06 -07:00
Nik
f494089b10 fix(admin): fix null handling, status-aware actions, data loading, and UI polish
- Add null guards for invited/pending users (id: null, role: null) across
  EditGroupsModal, GroupsCell, UserRowActions
- Add status-aware action menus per user status (Active, Inactive, Invited, Requested)
- Add cancelInvite and approveRequest service functions
- Switch useAdminUsers to /api/manage/users/accepted with toUserRow() mapper
- Add UserStatus enum, USER_STATUS_LABELS, and StatusFilter type
- Fix dark mode avatars with bg-background-neutral-inverted-00 + Text inverted
- Switch page layout to width="full" to prevent table overflow
- Reduce column weights for better space distribution
- Add GroupsCell component and filter-plus icon
2026-03-12 15:39:06 -07:00
Nik
18f7613c2e feat(admin): add edit group membership modal (ENG-3807)
Add modal to view and toggle a user's group memberships from the row
actions menu. Lists all groups with search, shows "Joined" badges for
current memberships, and saves changes via add/remove user APIs.
2026-03-12 15:39:06 -07:00
Nik
abce7a853b feat(admin): add inline role editing in Users table (ENG-3808)
Replace the static Account Type column with an interactive InputSelect
dropdown that lets admins change user roles inline. Includes curator
demotion confirmation modal and role visibility filtering matching the
existing UserRoleDropdown behavior.
2026-03-12 15:39:06 -07:00
Nik
a12d5fbcb0 chore: trigger re-review 2026-03-12 15:39:06 -07:00
49 changed files with 1101 additions and 896 deletions

View File

@@ -1042,8 +1042,6 @@ POD_NAMESPACE = os.environ.get("POD_NAMESPACE")
DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
HOOK_ENABLED = os.environ.get("HOOK_ENABLED", "").lower() == "true"
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
#####

View File

@@ -1,26 +0,0 @@
from onyx.configs.app_configs import HOOK_ENABLED
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from shared_configs.configs import MULTI_TENANT
def require_hook_enabled() -> None:
"""FastAPI dependency that gates all hook management endpoints.
Hooks are only available in single-tenant / self-hosted deployments with
HOOK_ENABLED=true explicitly set. Two layers of protection:
1. MULTI_TENANT check — rejects even if HOOK_ENABLED is accidentally set true
2. HOOK_ENABLED flag — explicit opt-in by the operator
Use as: Depends(require_hook_enabled)
"""
if MULTI_TENANT:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Custom code hooks are not available in multi-tenant deployments",
)
if not HOOK_ENABLED:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Custom code hooks are not enabled. Set HOOK_ENABLED=true to enable.",
)

View File

@@ -1,40 +0,0 @@
"""Unit tests for the hooks feature gate."""
from unittest.mock import patch
import pytest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.api_dependencies import require_hook_enabled
class TestRequireHookEnabled:
def test_raises_when_multi_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", True),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
assert exc_info.value.status_code == 404
assert "multi-tenant" in exc_info.value.detail
def test_raises_when_flag_disabled(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", False),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
assert exc_info.value.status_code == 404
assert "HOOK_ENABLED" in exc_info.value.detail
def test_passes_when_enabled_single_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
require_hook_enabled() # must not raise

View File

@@ -53,8 +53,6 @@ const sharedConfig = {
// Testing & Mocking
"msw",
"until-async",
// Language Detection
"linguist-languages",
// Markdown & Syntax Highlighting
"react-markdown",
"remark-.*", // All remark packages

View File

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

View File

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

View File

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

View File

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

16
web/package-lock.json generated
View File

@@ -59,7 +59,6 @@
"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",
@@ -13884,21 +13883,6 @@
"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,7 +77,6 @@
"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

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

View File

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

View File

@@ -31,7 +31,6 @@ const SETTINGS_LAYOUT_PREFIXES = [
ADMIN_PATHS.LLM_MODELS,
ADMIN_PATHS.AGENTS,
ADMIN_PATHS.USERS,
ADMIN_PATHS.USERS_V2,
ADMIN_PATHS.TOKEN_RATE_LIMITS,
ADMIN_PATHS.SEARCH_SETTINGS,
ADMIN_PATHS.DOCUMENT_PROCESSING,

View File

@@ -11,13 +11,14 @@ import rehypeHighlight from "rehype-highlight";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import { cn, transformLinkUri } from "@/lib/utils";
import { transformLinkUri } from "@/lib/utils";
type MinimalMarkdownComponentOverrides = Partial<Components>;
interface MinimalMarkdownProps {
content: string;
className?: string;
style?: CSSProperties;
showHeader?: boolean;
/**
* Override specific markdown renderers.
@@ -29,6 +30,7 @@ interface MinimalMarkdownProps {
export default function MinimalMarkdown({
content,
className = "",
style,
showHeader = true,
components,
}: MinimalMarkdownProps) {
@@ -61,17 +63,19 @@ export default function MinimalMarkdown({
}, [content, components, showHeader]);
return (
<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>
<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>
);
}

View File

@@ -136,13 +136,14 @@ function HorizontalInputLayout({
justifyContent="between"
alignItems={center ? "center" : "start"}
>
<div className="flex flex-col flex-1 self-stretch">
<div className="flex flex-col flex-1 min-w-0 self-stretch">
<Content
title={title}
description={description}
optional={optional}
sizePreset={sizePreset}
variant="section"
widthVariant="full"
/>
</div>
<div className="flex flex-col items-end">{children}</div>

View File

@@ -58,7 +58,6 @@ export const ADMIN_PATHS = {
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
KNOWLEDGE_GRAPH: "/admin/kg",
USERS: "/admin/users",
USERS_V2: "/admin/users2",
API_KEYS: "/admin/api-key",
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
USAGE: "/admin/performance/usage",
@@ -187,14 +186,9 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
sidebarLabel: "Knowledge Graph",
},
[ADMIN_PATHS.USERS]: {
icon: SvgUser,
title: "Manage Users",
sidebarLabel: "Users",
},
[ADMIN_PATHS.USERS_V2]: {
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users v2",
sidebarLabel: "Users",
},
[ADMIN_PATHS.API_KEYS]: {
icon: SvgKey,

View File

@@ -1,102 +0,0 @@
import {
getCodeLanguage,
getDataLanguage,
getLanguageByMime,
isMarkdownFile,
} from "./languages";
describe("getCodeLanguage", () => {
it.each([
["app.py", "python"],
["index.ts", "typescript"],
["main.go", "go"],
["style.css", "css"],
["page.html", "html"],
["App.vue", "vue"],
["lib.rs", "rust"],
["main.cpp", "c++"],
["util.c", "c"],
["script.js", "javascript"],
])("%s → %s", (filename, expected) => {
expect(getCodeLanguage(filename)).toBe(expected);
});
it.each([
[".h", "c"],
[".inc", "php"],
[".m", "objective-c"],
[".re", "reason"],
])("override: %s → %s", (ext, expected) => {
expect(getCodeLanguage(`file${ext}`)).toBe(expected);
});
it("resolves by exact filename when there is no extension", () => {
expect(getCodeLanguage("Dockerfile")).toBe("dockerfile");
expect(getCodeLanguage("Makefile")).toBe("makefile");
});
it("is case-insensitive for filenames", () => {
expect(getCodeLanguage("INDEX.JS")).toBe("javascript");
expect(getCodeLanguage("dockerfile")).toBe("dockerfile");
});
it("returns null for unknown extensions", () => {
expect(getCodeLanguage("file.xyz123")).toBeNull();
});
it("excludes markdown extensions", () => {
expect(getCodeLanguage("README.md")).toBeNull();
expect(getCodeLanguage("notes.markdown")).toBeNull();
});
});
describe("getDataLanguage", () => {
it.each([
["config.json", "json"],
["config.yaml", "yaml"],
["config.yml", "yaml"],
["config.toml", "toml"],
["data.xml", "xml"],
["data.csv", "csv"],
])("%s → %s", (filename, expected) => {
expect(getDataLanguage(filename)).toBe(expected);
});
it("returns null for code files", () => {
expect(getDataLanguage("app.py")).toBeNull();
expect(getDataLanguage("header.h")).toBeNull();
expect(getDataLanguage("view.m")).toBeNull();
expect(getDataLanguage("component.re")).toBeNull();
});
});
describe("isMarkdownFile", () => {
it("recognises markdown extensions", () => {
expect(isMarkdownFile("README.md")).toBe(true);
expect(isMarkdownFile("doc.markdown")).toBe(true);
});
it("is case-insensitive", () => {
expect(isMarkdownFile("NOTES.MD")).toBe(true);
});
it("rejects non-markdown files", () => {
expect(isMarkdownFile("app.py")).toBe(false);
expect(isMarkdownFile("data.json")).toBe(false);
});
});
describe("getLanguageByMime", () => {
it("resolves known MIME types", () => {
expect(getLanguageByMime("text/x-python")).toBe("python");
expect(getLanguageByMime("text/javascript")).toBe("javascript");
});
it("strips parameters before matching", () => {
expect(getLanguageByMime("text/x-python; charset=utf-8")).toBe("python");
});
it("returns null for unknown MIME types", () => {
expect(getLanguageByMime("application/x-unknown-thing")).toBeNull();
});
});

View File

@@ -7,7 +7,6 @@ interface LinguistLanguage {
type: string;
extensions?: string[];
filenames?: string[];
codemirrorMimeType?: string;
}
interface LanguageMaps {
@@ -15,23 +14,7 @@ interface LanguageMaps {
filenames: Map<string, string>;
}
// Explicit winners for extensions claimed by multiple linguist-languages entries
// where the "most extensions" heuristic below picks the wrong language.
const EXTENSION_OVERRIDES: Record<string, string> = {
".h": "c",
".inc": "php",
".m": "objective-c",
".re": "reason",
".rs": "rust",
};
// 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). Known mis-rankings are patched by EXTENSION_OVERRIDES above.
const allLanguages = (Object.values(languages) as LinguistLanguage[]).sort(
(a, b) => (b.extensions?.length ?? 0) - (a.extensions?.length ?? 0)
);
const allLanguages = Object.values(languages) as LinguistLanguage[];
// Collect extensions that linguist-languages assigns to "Markdown" so we can
// exclude them from the code-language map
@@ -42,22 +25,14 @@ const markdownExtensions = new Set(
);
function buildLanguageMaps(
types: string[],
type: string,
excludedExtensions?: Set<string>
): LanguageMaps {
const typeSet = new Set(types);
const extensions = new Map<string, string>();
const filenames = new Map<string, string>();
if (typeSet.has("programming") || typeSet.has("markup")) {
for (const [ext, lang] of Object.entries(EXTENSION_OVERRIDES)) {
if (excludedExtensions?.has(ext.toLowerCase())) continue;
extensions.set(ext, lang);
}
}
for (const lang of allLanguages) {
if (!typeSet.has(lang.type)) continue;
if (lang.type !== type) continue;
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
@@ -82,17 +57,13 @@ function lookupLanguage(name: string, maps: LanguageMaps): string | null {
return (ext && maps.extensions.get(ext)) ?? maps.filenames.get(lower) ?? null;
}
const codeMaps = buildLanguageMaps(
["programming", "markup"],
markdownExtensions
);
const dataMaps = buildLanguageMaps(["data"]);
const codeMaps = buildLanguageMaps("programming", markdownExtensions);
const dataMaps = buildLanguageMaps("data");
/**
* Returns the language name for a given file name, or null if it's not a
* 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).
* recognised code file. 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);
@@ -115,20 +86,3 @@ 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

@@ -79,7 +79,7 @@ export const USER_STATUS_LABELS: Record<UserStatus, string> = {
[UserStatus.ACTIVE]: "Active",
[UserStatus.INACTIVE]: "Inactive",
[UserStatus.INVITED]: "Invite Pending",
[UserStatus.REQUESTED]: "Requested",
[UserStatus.REQUESTED]: "Request to Join",
};
export const INVALID_ROLE_HOVER_TEXT: Partial<Record<UserRole, string>> = {

View File

@@ -6,42 +6,10 @@ 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;
@@ -54,8 +22,6 @@ export interface ScrollIndicatorDivProps
export default function ScrollIndicatorDiv({
disableIndicators = false,
disableTopIndicator = false,
disableBottomIndicator = false,
backgroundColor = "var(--background-tint-02)",
indicatorHeight = "3rem",
variant = "gradient",
@@ -111,19 +77,13 @@ export default function ScrollIndicatorDiv({
// Update on scroll (throttled)
container.addEventListener("scroll", handleScroll, { passive: true });
// Update when the container itself resizes
// Update on resize (in case content changes)
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(handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
resizeObserver.disconnect();
mutationObserver.disconnect();
if (throttleTimeoutRef.current) {
clearTimeout(throttleTimeoutRef.current);
}
@@ -160,7 +120,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 && !disableTopIndicator && showTopIndicator && (
{!disableIndicators && showTopIndicator && (
<div
className="absolute top-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
style={getIndicatorStyle("top")}
@@ -181,7 +141,7 @@ export default function ScrollIndicatorDiv({
</div>
{/* Bottom indicator */}
{!disableIndicators && !disableBottomIndicator && showBottomIndicator && (
{!disableIndicators && showBottomIndicator && (
<div
className="absolute bottom-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
style={getIndicatorStyle("bottom")}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,328 @@
"use client";
import { useState, useMemo, useRef, useCallback } from "react";
import { Button } from "@opal/components";
import { SvgUsers, SvgUser, SvgLogOut, SvgCheck } from "@opal/icons";
import { Disabled } from "@opal/core";
import { ContentAction } from "@opal/layouts";
import Modal from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import LineItem from "@/refresh-components/buttons/LineItem";
import Separator from "@/refresh-components/Separator";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section } from "@/layouts/general-layouts";
import { toast } from "@/hooks/useToast";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
import useGroups from "@/hooks/useGroups";
import { addUserToGroup, removeUserFromGroup, setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ASSIGNABLE_ROLES: UserRole[] = [UserRole.ADMIN, UserRole.BASIC];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface EditGroupsModalProps {
user: UserRow & { id: string };
onClose: () => void;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function EditGroupsModal({
user,
onClose,
onMutate,
}: EditGroupsModalProps) {
const { data: allGroups, isLoading: groupsLoading } = useGroups();
const [searchTerm, setSearchTerm] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const closeDropdown = useCallback(() => {
// Delay to allow click events on dropdown items to fire before closing
setTimeout(() => {
if (!containerRef.current?.contains(document.activeElement)) {
setDropdownOpen(false);
}
}, 0);
}, []);
const [selectedRole, setSelectedRole] = useState<UserRole | "">(
user.role ?? ""
);
const initialMemberGroupIds = useMemo(
() => new Set(user.groups.map((g) => g.id)),
[user.groups]
);
const [memberGroupIds, setMemberGroupIds] = useState<Set<number>>(
() => new Set(initialMemberGroupIds)
);
// Dropdown shows all groups filtered by search term
const dropdownGroups = useMemo(() => {
if (!allGroups) return [];
if (searchTerm.length === 0) return allGroups;
const lower = searchTerm.toLowerCase();
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
}, [allGroups, searchTerm]);
// Joined groups shown in the modal body
const joinedGroups = useMemo(() => {
if (!allGroups) return [];
return allGroups.filter((g) => memberGroupIds.has(g.id));
}, [allGroups, memberGroupIds]);
const hasGroupChanges = useMemo(() => {
if (memberGroupIds.size !== initialMemberGroupIds.size) return true;
return Array.from(memberGroupIds).some(
(id) => !initialMemberGroupIds.has(id)
);
}, [memberGroupIds, initialMemberGroupIds]);
const hasRoleChange =
user.role !== null && selectedRole !== "" && selectedRole !== user.role;
const hasChanges = hasGroupChanges || hasRoleChange;
const toggleGroup = (groupId: number) => {
setMemberGroupIds((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const handleSave = async () => {
setIsSubmitting(true);
try {
const toAdd = Array.from(memberGroupIds).filter(
(id) => !initialMemberGroupIds.has(id)
);
const toRemove = Array.from(initialMemberGroupIds).filter(
(id) => !memberGroupIds.has(id)
);
if (user.id) {
for (const groupId of toAdd) {
await addUserToGroup(groupId, user.id);
}
for (const groupId of toRemove) {
const group = allGroups?.find((g) => g.id === groupId);
if (group) {
const currentUserIds = group.users.map((u) => u.id);
const ccPairIds = group.cc_pairs.map((cc) => cc.id);
await removeUserFromGroup(
groupId,
currentUserIds,
user.id,
ccPairIds
);
}
}
}
if (
user.role !== null &&
selectedRole !== "" &&
selectedRole !== user.role
) {
await setUserRole(user.email, selectedRole);
}
onMutate();
toast.success("User updated");
onClose();
} catch (err) {
onMutate(); // refresh to show partially-applied state
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
};
const displayName = user.personal_name ?? user.email;
return (
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
<Modal.Content width="sm">
<Modal.Header
icon={SvgUsers}
title="Edit User's Groups & Roles"
description={
user.personal_name
? `${user.personal_name} (${user.email})`
: user.email
}
onClose={onClose}
/>
<Modal.Body twoTone>
<Section
gap={1}
height="auto"
alignItems="stretch"
justifyContent="start"
>
{/* Subsection: white card behind search + groups */}
<div className="relative">
<div className="absolute -inset-2 bg-background-neutral-00 rounded-12" />
<Section
gap={0.5}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<div ref={containerRef} className="relative">
<InputTypeIn
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!dropdownOpen) setDropdownOpen(true);
}}
onFocus={() => setDropdownOpen(true)}
onBlur={closeDropdown}
placeholder="Search groups to join..."
leftSearchIcon
/>
{dropdownOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-background-neutral-00 border border-border-02 rounded-12 shadow-md p-1">
{groupsLoading ? (
<Text as="p" text03 secondaryBody className="px-3 py-2">
Loading groups...
</Text>
) : dropdownGroups.length === 0 ? (
<Text as="p" text03 secondaryBody className="px-3 py-2">
No groups found
</Text>
) : (
<ShadowDiv className="max-h-[200px] flex flex-col gap-1">
{dropdownGroups.map((group) => {
const isMember = memberGroupIds.has(group.id);
return (
<LineItem
key={group.id}
icon={isMember ? SvgCheck : SvgUsers}
description={`${group.users.length} ${
group.users.length === 1 ? "user" : "users"
}`}
selected={isMember}
emphasized={isMember}
onMouseDown={(e: React.MouseEvent) =>
e.preventDefault()
}
onClick={() => toggleGroup(group.id)}
>
{group.name}
</LineItem>
);
})}
</ShadowDiv>
)}
</div>
)}
</div>
{joinedGroups.length === 0 ? (
<LineItem
icon={SvgUsers}
description={`${displayName} is not in any groups.`}
muted
>
No groups joined
</LineItem>
) : (
<ShadowDiv className="flex flex-col gap-1 max-h-[200px]">
{joinedGroups.map((group) => (
<div
key={group.id}
className="bg-background-tint-01 rounded-08"
>
<LineItem
icon={SvgUsers}
description={`${group.users.length} ${
group.users.length === 1 ? "user" : "users"
}`}
rightChildren={
<SvgLogOut className="w-4 h-4 text-text-03" />
}
onClick={() => toggleGroup(group.id)}
>
{group.name}
</LineItem>
</div>
))}
</ShadowDiv>
)}
</Section>
</div>
{user.role && (
<>
<Separator noPadding />
<ContentAction
title="User Role"
description="This controls their general permissions."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<InputSelect
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as UserRole)}
>
<InputSelect.Trigger />
<InputSelect.Content>
{user.role && !ASSIGNABLE_ROLES.includes(user.role) && (
<InputSelect.Item
key={user.role}
value={user.role}
icon={SvgUser}
>
{USER_ROLE_LABELS[user.role]}
</InputSelect.Item>
)}
{ASSIGNABLE_ROLES.map((role) => (
<InputSelect.Item
key={role}
value={role}
icon={SvgUser}
>
{USER_ROLE_LABELS[role]}
</InputSelect.Item>
))}
</InputSelect.Content>
</InputSelect>
}
/>
</>
)}
</Section>
</Modal.Body>
<Modal.Footer>
<Button prominence="secondary" onClick={onClose}>
Cancel
</Button>
<Disabled disabled={isSubmitting || !hasChanges}>
<Button onClick={handleSave}>Save Changes</Button>
</Disabled>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import {
useState,
useRef,
useLayoutEffect,
useCallback,
useEffect,
} from "react";
import { SvgEdit } from "@opal/icons";
import { Tag } from "@opal/components";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow, UserGroupInfo } from "./interfaces";
interface GroupsCellProps {
groups: UserGroupInfo[];
user: UserRow;
onMutate: () => void;
}
/**
* Measures how many Tag pills fit in the container, accounting for a "+N"
* overflow counter when not all tags are visible. Uses a two-phase render:
* first renders all tags (clipped by overflow:hidden) for measurement, then
* re-renders with only the visible subset + "+N".
*
* Hovering the cell shows a tooltip with ALL groups. Clicking opens the
* edit groups modal.
*/
export default function GroupsCell({
groups,
user,
onMutate,
}: GroupsCellProps) {
const [showModal, setShowModal] = useState(false);
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const computeVisibleCount = useCallback(() => {
const container = containerRef.current;
if (!container || groups.length <= 1) {
setVisibleCount(groups.length);
return;
}
const tags = container.querySelectorAll<HTMLElement>("[data-group-tag]");
if (tags.length === 0) return;
const containerWidth = container.clientWidth;
const gap = 4; // gap-1
const counterWidth = 32; // "+N" Tag approximate width
let used = 0;
let count = 0;
for (let i = 0; i < tags.length; i++) {
const tagWidth = tags[i]!.offsetWidth;
const gapBefore = count > 0 ? gap : 0;
const hasMore = i < tags.length - 1;
const reserve = hasMore ? gap + counterWidth : 0;
if (used + gapBefore + tagWidth + reserve <= containerWidth) {
used += gapBefore + tagWidth;
count++;
} else {
break;
}
}
setVisibleCount(Math.max(1, count));
}, [groups]);
// Reset to measurement phase when groups change
useLayoutEffect(() => {
setVisibleCount(null);
}, [groups]);
// Measure after the "show all" render
useLayoutEffect(() => {
if (visibleCount !== null) return;
computeVisibleCount();
}, [visibleCount, computeVisibleCount]);
// Re-measure on container resize.
// The ref moves between DOM elements depending on overflow state, so we
// track the observed node and re-attach when it changes.
const observedNodeRef = useRef<HTMLElement | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!observerRef.current) {
observerRef.current = new ResizeObserver(() => {
setVisibleCount(null);
});
}
const observer = observerRef.current;
const node = containerRef.current;
if (node !== observedNodeRef.current) {
if (observedNodeRef.current) {
observer.unobserve(observedNodeRef.current);
}
if (node) {
observer.observe(node);
}
observedNodeRef.current = node;
}
return () => {
observer.disconnect();
observedNodeRef.current = null;
};
});
const isMeasuring = visibleCount === null;
const effectiveVisible = visibleCount ?? groups.length;
const overflowCount = groups.length - effectiveVisible;
const hasOverflow = !isMeasuring && overflowCount > 0;
const allGroupsTooltip = (
<div className="flex flex-wrap gap-1 max-w-[14rem]">
{groups.map((g) => (
<div key={g.id} className="max-w-[10rem]">
<Tag title={g.name} size="md" />
</div>
))}
</div>
);
const tagsContent = (
<>
{(isMeasuring ? groups : groups.slice(0, effectiveVisible)).map((g) => (
<div key={g.id} data-group-tag className="flex-shrink-0">
<Tag title={g.name} size="md" />
</div>
))}
{hasOverflow && (
<div className="flex-shrink-0">
<Tag title={`+${overflowCount}`} size="md" />
</div>
)}
</>
);
return (
<>
<div
className={`group/groups relative flex items-center w-full min-w-0 ${
user.id ? "cursor-pointer" : ""
}`}
onClick={user.id ? () => setShowModal(true) : undefined}
>
{groups.length === 0 ? (
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
<Text as="span" secondaryBody text03>
</Text>
</div>
) : hasOverflow ? (
<SimpleTooltip
side="bottom"
align="start"
tooltip={allGroupsTooltip}
className="bg-background-neutral-01 border border-border-01 shadow-sm"
delayDuration={200}
>
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
{tagsContent}
</div>
</SimpleTooltip>
) : (
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
{tagsContent}
</div>
)}
{user.id && (
<IconButton
tertiary
icon={SvgEdit}
tooltip="Edit"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-0 opacity-0 group-hover/groups:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
/>
)}
</div>
{showModal && user.id != null && (
<EditGroupsModal
user={{ ...user, id: user.id }}
onClose={() => setShowModal(false)}
onMutate={onMutate}
/>
)}
</>
);
}

View File

@@ -1,14 +1,20 @@
"use client";
import { useState } from "react";
import { SvgCheck, SvgSlack, SvgUser, SvgUsers } from "@opal/icons";
import {
SvgCheck,
SvgSlack,
SvgUser,
SvgUserManage,
SvgUsers,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import FilterButton from "@/refresh-components/buttons/FilterButton";
import Popover from "@/refresh-components/Popover";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import LineItem from "@/refresh-components/buttons/LineItem";
import Text from "@/refresh-components/texts/Text";
import Separator from "@/refresh-components/Separator";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import {
UserRole,
UserStatus,
@@ -18,29 +24,20 @@ import {
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 VISIBLE_FILTER_ROLES: UserRole[] = [
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
UserRole.BASIC,
UserRole.SLACK_USER,
];
const FILTERABLE_ROLES = VISIBLE_FILTER_ROLES.map(
(role) => [role, USER_ROLE_LABELS[role]] as [UserRole, string]
);
const FILTERABLE_STATUSES = (
Object.entries(USER_STATUS_LABELS) as [UserStatus, string][]
@@ -49,6 +46,7 @@ const FILTERABLE_STATUSES = (
);
const ROLE_ICONS: Partial<Record<UserRole, IconFunctionComponent>> = {
[UserRole.ADMIN]: SvgUserManage,
[UserRole.SLACK_USER]: SvgSlack,
};
@@ -76,6 +74,18 @@ function CountBadge({ count }: { count: number | undefined }) {
// Component
// ---------------------------------------------------------------------------
interface UserFiltersProps {
selectedRoles: UserRole[];
onRolesChange: (roles: UserRole[]) => void;
selectedGroups: number[];
onGroupsChange: (groupIds: number[]) => void;
groups: GroupOption[];
selectedStatuses: StatusFilter;
onStatusesChange: (statuses: StatusFilter) => void;
roleCounts: Record<string, number>;
statusCounts: StatusCountMap;
}
export default function UserFilters({
selectedRoles,
onRolesChange,
@@ -101,14 +111,6 @@ export default function UserFilters({
}
};
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));
@@ -117,6 +119,22 @@ export default function UserFilters({
}
};
const toggleStatus = (status: UserStatus) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const roleLabel = hasRoleFilter
? FILTERABLE_ROLES.filter(([role]) => selectedRoles.includes(role))
.map(([, label]) => label)
.slice(0, 2)
.join(", ") +
(selectedRoles.length > 2 ? `, +${selectedRoles.length - 2}` : "")
: "All Account Types";
const groupLabel = hasGroupFilter
? groups
.filter((g) => selectedGroups.includes(g.id))
@@ -126,14 +144,6 @@ export default function UserFilters({
(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)
@@ -166,13 +176,13 @@ export default function UserFilters({
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
<LineItem
icon={SvgUsers}
icon={!hasRoleFilter ? SvgCheck : SvgUsers}
selected={!hasRoleFilter}
emphasized={!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;
@@ -181,6 +191,7 @@ export default function UserFilters({
key={role}
icon={isSelected ? SvgCheck : roleIcon}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleRole(role)}
rightChildren={<CountBadge count={roleCounts[role]} />}
>
@@ -211,30 +222,30 @@ export default function UserFilters({
</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>
<InputTypeIn
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
placeholder="Search groups..."
leftSearchIcon
variant="internal"
/>
<LineItem
icon={SvgUsers}
icon={!hasGroupFilter ? SvgCheck : SvgUsers}
selected={!hasGroupFilter}
emphasized={!hasGroupFilter}
onClick={() => onGroupsChange([])}
>
All Groups
</LineItem>
<Separator noPadding />
<div className="flex flex-col gap-1 max-h-[240px] overflow-y-auto">
<ShadowDiv className="flex flex-col gap-1 max-h-[240px]">
{filteredGroups.map((group) => {
const isSelected = selectedGroups.includes(group.id);
return (
<LineItem
key={group.id}
icon={isSelected ? SvgCheck : undefined}
icon={isSelected ? SvgCheck : SvgUsers}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleGroup(group.id)}
rightChildren={<CountBadge count={group.memberCount} />}
>
@@ -247,7 +258,7 @@ export default function UserFilters({
No groups found
</Text>
)}
</div>
</ShadowDiv>
</div>
</Popover.Content>
</Popover>
@@ -266,21 +277,22 @@ export default function UserFilters({
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
<LineItem
icon={!hasStatusFilter ? SvgCheck : undefined}
icon={!hasStatusFilter ? SvgCheck : SvgUser}
selected={!hasStatusFilter}
emphasized={!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}
icon={isSelected ? SvgCheck : SvgUser}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleStatus(status)}
rightChildren={<CountBadge count={statusCounts[countKey]} />}
>

View File

@@ -2,21 +2,40 @@
import { useState } from "react";
import { Button } from "@opal/components";
import { SvgMoreHorizontal, SvgXCircle, SvgTrash, SvgCheck } from "@opal/icons";
import {
SvgMoreHorizontal,
SvgUsers,
SvgXCircle,
SvgTrash,
SvgCheck,
} from "@opal/icons";
import { Disabled } from "@opal/core";
import Popover from "@/refresh-components/Popover";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Text from "@/refresh-components/texts/Text";
import { UserStatus } from "@/lib/types";
import { toast } from "@/hooks/useToast";
import { deactivateUser, activateUser, deleteUser } from "./svc";
import {
deactivateUser,
activateUser,
deleteUser,
cancelInvite,
approveRequest,
} from "./svc";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ModalType = "deactivate" | "activate" | "delete" | null;
type ModalType =
| "deactivate"
| "activate"
| "delete"
| "cancelInvite"
| "editGroups"
| null;
interface UserRowActionsProps {
user: UserRow;
@@ -52,14 +71,111 @@ export default function UserRowActions({
}
}
// Only show actions for accepted users (active or inactive).
// Invited/requested users have no row actions in this PR.
if (
user.status !== UserStatus.ACTIVE &&
user.status !== UserStatus.INACTIVE
) {
return null;
}
const openModal = (type: ModalType) => {
setPopoverOpen(false);
setModal(type);
};
// Status-aware action menus
const actionButtons = (() => {
switch (user.status) {
case UserStatus.INVITED:
return (
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal("cancelInvite")}
>
Cancel Invite
</Button>
);
case UserStatus.REQUESTED:
return (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
handleAction(
() => approveRequest(user.email),
"Request approved"
);
}}
>
Approve
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal("cancelInvite")}
>
Reject
</Button>
</>
);
case UserStatus.ACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => openModal("deactivate")}
>
Deactivate User
</Button>
</>
);
case UserStatus.INACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => openModal("activate")}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => openModal("delete")}
>
Delete User
</Button>
</>
);
default: {
const _exhaustive: never = user.status;
return null;
}
}
})();
// SCIM-managed users cannot be modified from the UI — changes would be
// overwritten on the next IdP sync.
@@ -74,47 +190,56 @@ export default function UserRowActions({
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
</Popover.Trigger>
<Popover.Content align="end">
<div className="flex flex-col gap-0.5 p-1">
{user.status === UserStatus.ACTIVE ? (
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => {
setPopoverOpen(false);
setModal("deactivate");
}}
>
Deactivate User
</Button>
) : (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
setModal("activate");
}}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => {
setPopoverOpen(false);
setModal("delete");
}}
>
Delete User
</Button>
</>
)}
</div>
<div className="flex flex-col gap-0.5 p-1">{actionButtons}</div>
</Popover.Content>
</Popover>
{modal === "editGroups" && user.id && (
<EditGroupsModal
user={user as UserRow & { id: string }}
onClose={() => setModal(null)}
onMutate={onMutate}
/>
)}
{modal === "cancelInvite" && (
<ConfirmationModalLayout
icon={SvgXCircle}
title={
user.status === UserStatus.REQUESTED
? "Reject Request"
: "Cancel Invite"
}
onClose={() => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() => {
handleAction(
() => cancelInvite(user.email),
user.status === UserStatus.REQUESTED
? "Request rejected"
: "Invite cancelled"
);
}}
>
{user.status === UserStatus.REQUESTED ? "Reject" : "Cancel"}
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
{user.status === UserStatus.REQUESTED
? "will be removed from the pending requests list."
: "will no longer be able to join Onyx with this invite."}
</Text>
</ConfirmationModalLayout>
)}
{modal === "deactivate" && (
<ConfirmationModalLayout
icon={SvgXCircle}
@@ -141,7 +266,8 @@ export default function UserRowActions({
{user.email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will
be preserved. You can reactivate this account later.
be preserved. Their license seat will be freed. You can reactivate
this account later.
</Text>
</ConfirmationModalLayout>
)}
@@ -201,7 +327,7 @@ export default function UserRowActions({
{user.email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history
will be deleted. This cannot be undone.
will be deleted. Deletion cannot be undone.
</Text>
</ConfirmationModalLayout>
)}

View File

@@ -1,14 +1,15 @@
import { SvgArrowUpRight, SvgFilter, SvgUserSync } from "@opal/icons";
import { SvgArrowUpRight, SvgFilterPlus, SvgUserSync } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import Link from "next/link";
import { ADMIN_PATHS } from "@/lib/admin-routes";
// ---------------------------------------------------------------------------
// Stats cell — number + label
// Stats cell — number + label + hover filter icon
// ---------------------------------------------------------------------------
type StatCellProps = {
@@ -34,12 +35,18 @@ function StatCell({ value, label, onFilter }: StatCellProps) {
{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>
<IconButton
tertiary
icon={SvgFilterPlus}
tooltip="Add Filter"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-1 top-1 opacity-0 group-hover/stat:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onFilter();
}}
/>
)}
</div>
);

View File

@@ -4,6 +4,8 @@ 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 { Button } from "@opal/components";
import { SvgDownload } from "@opal/icons";
import SvgNoResult from "@opal/illustrations/no-result";
import { IllustrationContent } from "@opal/layouts";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
@@ -14,11 +16,11 @@ import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useAdminUsers from "@/hooks/useAdminUsers";
import useGroups from "@/hooks/useGroups";
import UserFilters from "./UserFilters";
import GroupsCell from "./GroupsCell";
import UserRowActions from "./UserRowActions";
import UserRoleCell from "./UserRoleCell";
import type {
UserRow,
UserGroupInfo,
GroupOption,
StatusFilter,
StatusCountMap,
@@ -40,37 +42,6 @@ function renderNameColumn(email: string, row: UserRow) {
);
}
function renderGroupsColumn(groups: UserGroupInfo[]) {
if (!groups.length) {
return (
<Text as="span" secondaryBody text03>
{"\u2014"}
</Text>
);
}
const visible = groups.slice(0, 2);
const overflow = groups.length - visible.length;
return (
<div className="flex items-center gap-1 flex-nowrap overflow-hidden min-w-0">
{visible.map((g) => (
<span
key={g.id}
className="inline-flex items-center flex-shrink-0 rounded-md bg-background-tint-02 px-2 py-0.5 whitespace-nowrap"
>
<Text as="span" secondaryBody text03>
{g.name}
</Text>
</span>
))}
{overflow > 0 && (
<Text as="span" secondaryBody text03>
+{overflow}
</Text>
)}
</div>
);
}
function renderStatusColumn(value: UserStatus, row: UserRow) {
return (
<div className="flex flex-col">
@@ -89,7 +60,7 @@ function renderStatusColumn(value: UserStatus, row: UserRow) {
function renderLastUpdatedColumn(value: string | null) {
return (
<Text as="span" secondaryBody text03>
{timeAgo(value) ?? "\u2014"}
{value ? timeAgo(value) ?? "\u2014" : "\u2014"}
</Text>
);
}
@@ -118,7 +89,9 @@ function buildColumns(onMutate: () => void) {
weight: 24,
minWidth: 200,
enableSorting: false,
cell: renderGroupsColumn,
cell: (value, row) => (
<GroupsCell groups={value} user={row} onMutate={onMutate} />
),
}),
tc.column("role", {
header: "Account Type",
@@ -254,7 +227,20 @@ export default function UsersTable({
getRowId={(row) => row.id ?? row.email}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{ mode: "summary" }}
footer={{
mode: "summary",
leftExtra: (
<Button
icon={SvgDownload}
prominence="tertiary"
size="sm"
tooltip="Download CSV"
onClick={() => {
window.open("/api/manage/users/download", "_blank");
}}
/>
),
}}
/>
)}
</div>

View File

@@ -59,6 +59,63 @@ export async function setUserRole(
}
}
export async function addUserToGroup(
groupId: number,
userId: string
): Promise<void> {
const res = await fetch(`/api/manage/admin/user-group/${groupId}/add-users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_ids: [userId] }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to add user to group"));
}
}
export async function removeUserFromGroup(
groupId: number,
currentUserIds: string[],
userIdToRemove: string,
ccPairIds: number[]
): Promise<void> {
const res = await fetch(`/api/manage/admin/user-group/${groupId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_ids: currentUserIds.filter((id) => id !== userIdToRemove),
cc_pair_ids: ccPairIds,
}),
});
if (!res.ok) {
throw new Error(
await parseErrorDetail(res, "Failed to remove user from group")
);
}
}
export async function cancelInvite(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/remove-invited-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to cancel invite"));
}
}
export async function approveRequest(email: string): Promise<void> {
const res = await fetch("/api/tenants/users/invite/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to approve request"));
}
}
export async function inviteUsers(emails: string[]): Promise<void> {
const res = await fetch("/api/manage/admin/users", {
method: "PUT",
@@ -69,3 +126,18 @@ export async function inviteUsers(emails: string[]): Promise<void> {
throw new Error(await parseErrorDetail(res, "Failed to invite users"));
}
}
export async function downloadUsersCsv(): Promise<void> {
const res = await fetch("/api/manage/users/download");
if (!res.ok) {
const detail = (await res.json()).detail;
throw new Error(detail ?? "Failed to download users CSV");
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "onyx_users.csv";
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -7,14 +7,13 @@ 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 mime from "mime";
import {
getCodeLanguage,
getDataLanguage,
getLanguageByMime,
} from "@/lib/languages";
import { getCodeLanguage, getDataLanguage } 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 {
@@ -42,7 +41,7 @@ export default function PreviewModal({
const language = useMemo(
() =>
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
getLanguageByMime(mimeType) ||
getMimeLanguage(mimeType) ||
getDataLanguage(presentingDocument.semantic_identifier || "") ||
"plaintext",
[mimeType, presentingDocument.semantic_identifier]
@@ -87,10 +86,7 @@ export default function PreviewModal({
const rawContentType =
response.headers.get("Content-Type") || "application/octet-stream";
const resolvedMime =
rawContentType === "application/octet-stream"
? mime.getType(originalFileName) ?? rawContentType
: rawContentType;
const resolvedMime = resolveMimeType(rawContentType, originalFileName);
setMimeType(resolvedMime);
const resolved = resolveVariant(
@@ -170,24 +166,24 @@ export default function PreviewModal({
onClose={onClose}
/>
{/* 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>
{/* 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>
{/* Floating footer */}
{!isLoading && !loadError && (
@@ -198,9 +194,8 @@ export default function PreviewModal({
"p-4 pointer-events-none w-full"
)}
style={{
background: `linear-gradient(to top, var(--background-${
variant.codeBackground ? "code-01" : "tint-01"
}) 40%, transparent)`,
background:
"linear-gradient(to top, var(--background-code-01) 40%, transparent)",
}}
>
{/* Left slot */}

View File

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

@@ -0,0 +1,50 @@
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,37 +1,22 @@
"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,
normalize,
}: CodePreviewProps) {
// Wrap raw content in a fenced code block for syntax highlighting. Uses ~~~
// instead of ``` to avoid conflicts with backticks in the content. Any literal
// ~~~ sequences in the content are escaped so they don't accidentally close the fence.
const markdownContent = normalize
? `~~~${language || ""}\n${content.replace(/~~~/g, "\\~\\~\\~")}\n~~~`
: content;
export function CodePreview({ content, language }: CodePreviewProps) {
const normalizedContent = content.replace(/~~~/g, "\\~\\~\\~");
const fenceHeader = language ? `~~~${language}` : "~~~";
return (
<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>
<MinimalMarkdown
content={`${fenceHeader}\n${normalizedContent}\n\n~~~`}
className="w-full h-full"
showHeader={false}
/>
);
}

View File

@@ -13,7 +13,6 @@ export const codeVariant: PreviewVariant = {
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
@@ -23,7 +22,7 @@ export const codeVariant: PreviewVariant = {
: "",
renderContent: (ctx) => (
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
<CodePreview content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (

View File

@@ -34,7 +34,6 @@ 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,7 +1,8 @@
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import { getDataLanguage, getLanguageByMime } from "@/lib/languages";
import { getDataLanguage } 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,
@@ -21,11 +22,10 @@ function formatContent(language: string, content: string): string {
export const dataVariant: PreviewVariant = {
matches: (name, mime) =>
!!getDataLanguage(name || "") || !!getLanguageByMime(mime),
!!getDataLanguage(name || "") || !!getMimeLanguage(mime),
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
@@ -36,9 +36,7 @@ export const dataVariant: PreviewVariant = {
renderContent: (ctx) => {
const formatted = formatContent(ctx.language, ctx.fileContent);
return (
<CodePreview normalize content={formatted} language={ctx.language} />
);
return <CodePreview content={formatted} language={ctx.language} />;
},
renderFooterLeft: (ctx) => (

View File

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

View File

@@ -11,7 +11,6 @@ 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,7 +1,8 @@
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,
@@ -22,11 +23,15 @@ export const markdownVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: true,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (
<CodePreview content={ctx.fileContent} language={ctx.language} />
<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>
),
renderFooterLeft: () => null,

View File

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

View File

@@ -28,7 +28,6 @@ export const textVariant: PreviewVariant = {
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
? `${ctx.lineCount} ${ctx.lineCount === 1 ? "line" : "lines"} · ${
@@ -37,7 +36,7 @@ export const textVariant: PreviewVariant = {
: "",
renderContent: (ctx) => (
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
<CodePreview content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (

View File

@@ -5,14 +5,13 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
export const unsupportedVariant: PreviewVariant = {
matches: () => true,
width: "md",
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (
<div className="flex flex-col items-center justify-center flex-1 w-full min-h-0 gap-4 p-6">
<div className="flex flex-col items-center justify-center flex-1 min-h-0 gap-4 p-6">
<Text as="p" text03 mainUiBody>
This file format is not supported for preview.
</Text>

View File

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

View File

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