mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-25 01:22:45 +00:00
Compare commits
7 Commits
jamison/wo
...
nikg/test1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538574087a | ||
|
|
f9f9ca6d0d | ||
|
|
0490f13fa6 | ||
|
|
4112687131 | ||
|
|
5f350f7ac7 | ||
|
|
940b01c3c4 | ||
|
|
8645adb807 |
@@ -115,8 +115,14 @@ def fetch_user_group_token_rate_limits_for_user(
|
||||
ordered: bool = True,
|
||||
get_editable: bool = True,
|
||||
) -> Sequence[TokenRateLimit]:
|
||||
stmt = select(TokenRateLimit)
|
||||
stmt = stmt.where(User__UserGroup.user_group_id == group_id)
|
||||
stmt = (
|
||||
select(TokenRateLimit)
|
||||
.join(
|
||||
TokenRateLimit__UserGroup,
|
||||
TokenRateLimit.id == TokenRateLimit__UserGroup.rate_limit_id,
|
||||
)
|
||||
.where(TokenRateLimit__UserGroup.user_group_id == group_id)
|
||||
)
|
||||
stmt = _add_user_filters(stmt, user, get_editable)
|
||||
|
||||
if enabled_only:
|
||||
|
||||
@@ -145,6 +145,8 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
initialRowSelection,
|
||||
initialViewSelected,
|
||||
draggable,
|
||||
footer,
|
||||
size = "lg",
|
||||
@@ -221,6 +223,8 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
pageSize: effectivePageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
initialRowSelection,
|
||||
initialViewSelected,
|
||||
getRowId,
|
||||
onSelectionChange,
|
||||
searchTerm,
|
||||
|
||||
@@ -103,6 +103,10 @@ interface UseDataTableOptions<TData extends RowData> {
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. @default {} */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. @default {} */
|
||||
initialRowSelection?: RowSelectionState;
|
||||
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode (filtered to selected rows). @default false */
|
||||
initialViewSelected?: boolean;
|
||||
/** Called whenever the set of selected row IDs changes. */
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
/** Search term for global text filtering. Rows are filtered to those containing
|
||||
@@ -195,6 +199,8 @@ export default function useDataTable<TData extends RowData>(
|
||||
columnResizeMode = "onChange",
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
initialRowSelection = {},
|
||||
initialViewSelected = false,
|
||||
getRowId,
|
||||
onSelectionChange,
|
||||
searchTerm,
|
||||
@@ -206,7 +212,8 @@ export default function useDataTable<TData extends RowData>(
|
||||
|
||||
// ---- internal state -----------------------------------------------------
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [rowSelection, setRowSelection] =
|
||||
useState<RowSelectionState>(initialRowSelection);
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility
|
||||
@@ -216,8 +223,12 @@ export default function useDataTable<TData extends RowData>(
|
||||
pageSize: pageSizeOption,
|
||||
});
|
||||
/** Combined global filter: view-mode (selected IDs) + text search. */
|
||||
const initialSelectedIds =
|
||||
initialViewSelected && Object.keys(initialRowSelection).length > 0
|
||||
? new Set(Object.keys(initialRowSelection))
|
||||
: null;
|
||||
const [globalFilter, setGlobalFilter] = useState<GlobalFilterValue>({
|
||||
selectedIds: null,
|
||||
selectedIds: initialSelectedIds,
|
||||
searchTerm: "",
|
||||
});
|
||||
|
||||
@@ -384,6 +395,31 @@ export default function useDataTable<TData extends RowData>(
|
||||
: data.length;
|
||||
const isPaginated = isFinite(pagination.pageSize);
|
||||
|
||||
// ---- keep view-mode filter in sync with selection ----------------------
|
||||
// When in view-selected mode, deselecting a row should remove it from
|
||||
// the visible set so it disappears immediately.
|
||||
useEffect(() => {
|
||||
if (isServerSide) return;
|
||||
if (globalFilter.selectedIds == null) return;
|
||||
|
||||
const currentIds = new Set(Object.keys(rowSelection));
|
||||
// Remove any ID from the filter that is no longer selected
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
globalFilter.selectedIds.forEach((id) => {
|
||||
if (currentIds.has(id)) {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
setGlobalFilter((prev) => ({ ...prev, selectedIds: next }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to
|
||||
// selection changes while in view mode
|
||||
}, [rowSelection, isServerSide]);
|
||||
|
||||
// ---- selection change callback ------------------------------------------
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
@@ -392,6 +428,10 @@ export default function useDataTable<TData extends RowData>(
|
||||
useEffect(() => {
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
// Still fire the callback on first render if there's an initial selection
|
||||
if (selectedRowIds.length > 0) {
|
||||
onSelectionChangeRef.current?.(selectedRowIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSelectionChangeRef.current?.(selectedRowIds);
|
||||
|
||||
@@ -146,6 +146,10 @@ export interface DataTableProps<TData> {
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. */
|
||||
initialRowSelection?: Record<string, boolean>;
|
||||
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode. @default false */
|
||||
initialViewSelected?: boolean;
|
||||
/** Enable drag-and-drop row reordering. */
|
||||
draggable?: DataTableDraggableConfig;
|
||||
/** Footer configuration. */
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function IndexAttemptErrorsModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Indexing Errors"
|
||||
|
||||
17
web/src/app/admin/groups2/[id]/page.tsx
Normal file
17
web/src/app/admin/groups2/[id]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import EditGroupPage from "@/refresh-pages/admin/GroupsPage/EditGroupPage";
|
||||
|
||||
export default function EditGroupRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const groupId = Number(id);
|
||||
if (Number.isNaN(groupId)) {
|
||||
return null;
|
||||
}
|
||||
return <EditGroupPage groupId={groupId} />;
|
||||
}
|
||||
1
web/src/app/admin/groups2/create/page.tsx
Normal file
1
web/src/app/admin/groups2/create/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage/CreateGroupPage";
|
||||
1
web/src/app/admin/groups2/page.tsx
Normal file
1
web/src/app/admin/groups2/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage";
|
||||
@@ -151,7 +151,7 @@ export default function ConfigureConnectorModal({
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={onClose}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgPlug}
|
||||
title={getStepTitle()}
|
||||
|
||||
@@ -263,7 +263,7 @@ export default function CredentialStep({
|
||||
open
|
||||
onOpenChange={() => setCreateCredentialFormToggle(false)}
|
||||
>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title={`Create a ${getSourceDisplayName(
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function UserLibraryModal({
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgFileText}
|
||||
title="Your Files"
|
||||
|
||||
13
web/src/app/ee/admin/groups2/[id]/page.tsx
Normal file
13
web/src/app/ee/admin/groups2/[id]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import EditGroupPage from "@/refresh-pages/admin/GroupsPage/EditGroupPage";
|
||||
|
||||
export default function EditGroupRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <EditGroupPage groupId={Number(id)} />;
|
||||
}
|
||||
1
web/src/app/ee/admin/groups2/create/page.tsx
Normal file
1
web/src/app/ee/admin/groups2/create/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage/CreateGroupPage";
|
||||
@@ -178,7 +178,7 @@ function PreviousQueryHistoryExportsModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={() => setShowModal(false)}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgFileText}
|
||||
title="Previous Query History Exports"
|
||||
|
||||
@@ -43,6 +43,7 @@ export const config = {
|
||||
|
||||
// Enterprise Edition specific routes (ONLY these get /ee rewriting)
|
||||
const EE_ROUTES = [
|
||||
"/admin/groups2",
|
||||
"/admin/groups",
|
||||
"/admin/performance/usage",
|
||||
"/admin/performance/query-history",
|
||||
|
||||
@@ -67,7 +67,7 @@ function LargeModalDemo() {
|
||||
<div style={{ padding: 32 }}>
|
||||
<Button onClick={() => setOpen(true)}>Open Large Modal</Button>
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgInfoSmall}
|
||||
title="Large Modal"
|
||||
|
||||
@@ -76,10 +76,11 @@ const useModalContext = () => {
|
||||
};
|
||||
|
||||
const widthClasses = {
|
||||
lg: "w-[80dvw]",
|
||||
md: "w-[60rem]",
|
||||
"md-sm": "w-[50rem]",
|
||||
sm: "w-[32rem]",
|
||||
full: "w-[80dvw]",
|
||||
xl: "w-[60rem]",
|
||||
lg: "w-[50rem]",
|
||||
md: "w-[40rem]",
|
||||
sm: "w-[30rem]",
|
||||
};
|
||||
|
||||
const heightClasses = {
|
||||
@@ -97,20 +98,20 @@ const heightClasses = {
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Using width and height props
|
||||
* <Modal.Content width="lg" height="full">
|
||||
* {/* Large modal: w-[80dvw] h-[80dvh] *\/}
|
||||
* <Modal.Content width="full" height="full">
|
||||
* {/* Full modal: w-[80dvw] h-[80dvh] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content width="md" height="fit">
|
||||
* {/* Medium modal: w-[60rem] h-fit *\/}
|
||||
* <Modal.Content width="xl" height="fit">
|
||||
* {/* XL modal: w-[60rem] h-fit *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content width="sm" height="sm">
|
||||
* {/* Small modal: w-[32rem] max-h-[30rem] *\/}
|
||||
* {/* Small modal: w-[30rem] max-h-[30rem] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content width="sm" height="lg">
|
||||
* {/* Tall modal: w-[32rem] max-h-[calc(100dvh-4rem)] *\/}
|
||||
* {/* Tall modal: w-[30rem] max-h-[calc(100dvh-4rem)] *\/}
|
||||
* </Modal.Content>
|
||||
* ```
|
||||
*/
|
||||
@@ -138,7 +139,7 @@ const ModalContent = React.forwardRef<
|
||||
(
|
||||
{
|
||||
children,
|
||||
width = "md",
|
||||
width = "xl",
|
||||
height = "fit",
|
||||
position = "center",
|
||||
preventAccidentalClose = true,
|
||||
|
||||
@@ -321,7 +321,7 @@ export default function ExpandableTextDisplay({
|
||||
|
||||
{/* Expanded Modal */}
|
||||
<Modal open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Modal.Content height="lg" width="md-sm" preventAccidentalClose={false}>
|
||||
<Modal.Content height="lg" width="lg" preventAccidentalClose={false}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -767,7 +767,7 @@ function ChatPreferencesForm() {
|
||||
open={systemPromptModalOpen}
|
||||
onOpenChange={setSystemPromptModalOpen}
|
||||
>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgAddLines}
|
||||
title="System Prompt"
|
||||
|
||||
@@ -64,7 +64,7 @@ function CreateGroupPage() {
|
||||
try {
|
||||
await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
|
||||
toast.success(`Group "${trimmed}" created`);
|
||||
router.push("/admin/groups");
|
||||
router.push("/admin/groups2");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to create group");
|
||||
} finally {
|
||||
@@ -76,7 +76,7 @@ function CreateGroupPage() {
|
||||
<Section flexDirection="row" gap={0.5} width="auto" height="auto">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={() => router.push("/admin/groups")}
|
||||
onClick={() => router.push("/admin/groups2")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
397
web/src/refresh-pages/admin/GroupsPage/EditGroupPage.tsx
Normal file
397
web/src/refresh-pages/admin/GroupsPage/EditGroupPage.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { Table, Button } from "@opal/components";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import { SvgUsers, SvgTrash } from "@opal/icons";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
import type {
|
||||
ApiKeyDescriptor,
|
||||
MemberRow,
|
||||
TokenRateLimitDisplay,
|
||||
} from "./interfaces";
|
||||
import { apiKeyToMemberRow, memberTableColumns, PAGE_SIZE } from "./shared";
|
||||
import {
|
||||
USER_GROUP_URL,
|
||||
renameGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
} from "./svc";
|
||||
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
|
||||
import TokenLimitSection from "./TokenLimitSection";
|
||||
import type { TokenLimit } from "./TokenLimitSection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditGroupPageProps {
|
||||
groupId: number;
|
||||
}
|
||||
|
||||
function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
// Fetch the group data — poll every 5s while syncing so the UI updates
|
||||
// automatically when the backend finishes processing the previous edit.
|
||||
const {
|
||||
data: groups,
|
||||
isLoading: groupLoading,
|
||||
error: groupError,
|
||||
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher, {
|
||||
refreshInterval: (latestData) => {
|
||||
const g = latestData?.find((g) => g.id === groupId);
|
||||
return g && !g.is_up_to_date ? 5000 : 0;
|
||||
},
|
||||
});
|
||||
|
||||
const group = useMemo(
|
||||
() => groups?.find((g) => g.id === groupId) ?? null,
|
||||
[groups, groupId]
|
||||
);
|
||||
|
||||
const isSyncing = group != null && !group.is_up_to_date;
|
||||
|
||||
// Fetch token rate limits for this group
|
||||
const { data: tokenRateLimits, isLoading: tokenLimitsLoading } = useSWR<
|
||||
TokenRateLimitDisplay[]
|
||||
>(`/api/admin/token-rate-limits/user-group/${groupId}`, errorHandlingFetcher);
|
||||
|
||||
// Form state
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [selectedCcPairIds, setSelectedCcPairIds] = useState<number[]>([]);
|
||||
const [selectedDocSetIds, setSelectedDocSetIds] = useState<number[]>([]);
|
||||
const [selectedAgentIds, setSelectedAgentIds] = useState<number[]>([]);
|
||||
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
|
||||
{ tokenBudget: null, periodHours: null },
|
||||
]);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const initialAgentIdsRef = useRef<number[]>([]);
|
||||
const initialDocSetIdsRef = useRef<number[]>([]);
|
||||
|
||||
// Users and API keys
|
||||
const { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
|
||||
|
||||
const {
|
||||
data: apiKeys,
|
||||
isLoading: apiKeysLoading,
|
||||
error: apiKeysError,
|
||||
} = useSWR<ApiKeyDescriptor[]>("/api/admin/api-key", errorHandlingFetcher);
|
||||
|
||||
const isLoading =
|
||||
groupLoading || usersLoading || apiKeysLoading || tokenLimitsLoading;
|
||||
const error = groupError ?? usersError ?? apiKeysError;
|
||||
|
||||
// Pre-populate form when group data loads
|
||||
useEffect(() => {
|
||||
if (group && !initialized) {
|
||||
setGroupName(group.name);
|
||||
setSelectedUserIds(group.users.map((u) => u.id));
|
||||
setSelectedCcPairIds(group.cc_pairs.map((cc) => cc.id));
|
||||
const docSetIds = group.document_sets.map((ds) => ds.id);
|
||||
setSelectedDocSetIds(docSetIds);
|
||||
initialDocSetIdsRef.current = docSetIds;
|
||||
const agentIds = group.personas.map((p) => p.id);
|
||||
setSelectedAgentIds(agentIds);
|
||||
initialAgentIdsRef.current = agentIds;
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [group, initialized]);
|
||||
|
||||
// Pre-populate token limits when fetched
|
||||
useEffect(() => {
|
||||
if (tokenRateLimits && tokenRateLimits.length > 0) {
|
||||
setTokenLimits(
|
||||
tokenRateLimits.map((trl) => ({
|
||||
tokenBudget: trl.token_budget,
|
||||
periodHours: trl.period_hours,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [tokenRateLimits]);
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
const activeUsers = users.filter((u) => u.is_active);
|
||||
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
|
||||
return [...activeUsers, ...serviceAccountRows];
|
||||
}, [users, apiKeys]);
|
||||
|
||||
const initialRowSelection = useMemo(() => {
|
||||
if (!group) return {};
|
||||
const sel: Record<string, boolean> = {};
|
||||
for (const u of group.users) {
|
||||
sel[u.id] = true;
|
||||
}
|
||||
return sel;
|
||||
}, [group]);
|
||||
|
||||
// Guard onSelectionChange: ignore updates until the form is fully initialized.
|
||||
// Without this, TanStack fires onSelectionChange before all rows are loaded,
|
||||
// which overwrites selectedUserIds with a partial set.
|
||||
const handleSelectionChange = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (!initialized) return;
|
||||
setSelectedUserIds(ids);
|
||||
},
|
||||
[initialized]
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (isSubmittingRef.current) return;
|
||||
|
||||
const trimmed = groupName.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Group name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-fetch group to check sync status before saving
|
||||
const freshGroups = await fetch(USER_GROUP_URL).then((r) => r.json());
|
||||
const freshGroup = freshGroups.find((g: UserGroup) => g.id === groupId);
|
||||
if (freshGroup && !freshGroup.is_up_to_date) {
|
||||
toast.error(
|
||||
"This group is currently syncing. Please wait a moment and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Rename if name changed
|
||||
if (group && trimmed !== group.name) {
|
||||
await renameGroup(group.id, trimmed);
|
||||
}
|
||||
|
||||
// Update members and cc_pairs
|
||||
await updateGroup(groupId, selectedUserIds, selectedCcPairIds);
|
||||
|
||||
// Update agent sharing (add/remove this group from changed agents)
|
||||
await updateAgentGroupSharing(
|
||||
groupId,
|
||||
initialAgentIdsRef.current,
|
||||
selectedAgentIds
|
||||
);
|
||||
|
||||
// Update document set sharing (add/remove this group from changed doc sets)
|
||||
await updateDocSetGroupSharing(
|
||||
groupId,
|
||||
initialDocSetIdsRef.current,
|
||||
selectedDocSetIds
|
||||
);
|
||||
|
||||
// Save token rate limits (create/update/delete)
|
||||
await saveTokenLimits(groupId, tokenLimits, tokenRateLimits ?? []);
|
||||
|
||||
// Update refs so subsequent saves diff correctly
|
||||
initialAgentIdsRef.current = selectedAgentIds;
|
||||
initialDocSetIdsRef.current = selectedDocSetIds;
|
||||
|
||||
mutate(USER_GROUP_URL);
|
||||
mutate(`/api/admin/token-rate-limits/user-group/${groupId}`);
|
||||
toast.success(`Group "${trimmed}" updated`);
|
||||
router.push("/admin/groups2");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update group");
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteGroup(groupId);
|
||||
mutate(USER_GROUP_URL);
|
||||
toast.success(`Group "${group?.name}" deleted`);
|
||||
router.push("/admin/groups2");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete group");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 404 state
|
||||
if (!isLoading && !error && !group) {
|
||||
return (
|
||||
<SettingsLayouts.Root width="sm">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgUsers}
|
||||
title="Group Not Found"
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="Group not found"
|
||||
description="This group doesn't exist or may have been deleted."
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const headerActions = (
|
||||
<Section flexDirection="row" gap={0.5} width="auto" height="auto">
|
||||
<Button
|
||||
variant="danger"
|
||||
prominence="tertiary"
|
||||
icon={SvgTrash}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
tooltip="Delete group"
|
||||
/>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={() => router.push("/admin/groups2")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!groupName.trim() || isSubmitting || isSyncing}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : isSyncing ? "Syncing..." : "Save"}
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayouts.Root width="sm">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgUsers}
|
||||
title="Edit Group"
|
||||
separator
|
||||
rightChildren={headerActions}
|
||||
/>
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading && <SimpleLoader />}
|
||||
|
||||
{error && (
|
||||
<Text as="p" secondaryBody text03>
|
||||
Failed to load group data.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && group && (
|
||||
<>
|
||||
{/* Group Name */}
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Text mainUiBody text04>
|
||||
Group Name
|
||||
</Text>
|
||||
<InputTypeIn
|
||||
placeholder="Name your group"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Separator noPadding />
|
||||
|
||||
{/* Members table */}
|
||||
<Section
|
||||
gap={0.75}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search users and accounts..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
<Table
|
||||
data={allRows as MemberRow[]}
|
||||
columns={memberTableColumns}
|
||||
getRowId={(row) => row.id ?? row.email}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
selectionBehavior="multi-select"
|
||||
initialRowSelection={initialRowSelection}
|
||||
initialViewSelected
|
||||
onSelectionChange={handleSelectionChange}
|
||||
footer={{}}
|
||||
emptyState={
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No users found"
|
||||
description="No users match your search."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<SharedGroupResources
|
||||
selectedCcPairIds={selectedCcPairIds}
|
||||
onCcPairIdsChange={setSelectedCcPairIds}
|
||||
selectedDocSetIds={selectedDocSetIds}
|
||||
onDocSetIdsChange={setSelectedDocSetIds}
|
||||
selectedAgentIds={selectedAgentIds}
|
||||
onAgentIdsChange={setSelectedAgentIds}
|
||||
/>
|
||||
|
||||
<TokenLimitSection
|
||||
limits={tokenLimits}
|
||||
onLimitsChange={setTokenLimits}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
|
||||
{showDeleteModal && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete Group"
|
||||
description={`Are you sure you want to delete "${group?.name}"? This action cannot be undone.`}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
submit={
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditGroupPage;
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
import { SvgChevronRight, SvgUserManage, SvgUsers } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
@@ -21,6 +22,7 @@ interface GroupCardProps {
|
||||
}
|
||||
|
||||
function GroupCard({ group }: GroupCardProps) {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const builtIn = isBuiltInGroup(group);
|
||||
const isAdmin = group.name === "Admin";
|
||||
@@ -60,6 +62,7 @@ function GroupCard({ group }: GroupCardProps) {
|
||||
icon={SvgChevronRight}
|
||||
prominence="tertiary"
|
||||
tooltip="View group"
|
||||
onClick={() => router.push(`/admin/groups2/${group.id}`)}
|
||||
/>
|
||||
</Section>
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function GroupsPage() {
|
||||
/>
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
onClick={() => router.push("/admin/groups/create")}
|
||||
onClick={() => router.push("/admin/groups2/create")}
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
|
||||
@@ -13,3 +13,10 @@ export interface ApiKeyDescriptor {
|
||||
export interface MemberRow extends UserRow {
|
||||
api_key_display?: string;
|
||||
}
|
||||
|
||||
export interface TokenRateLimitDisplay {
|
||||
token_id: number;
|
||||
enabled: boolean;
|
||||
token_budget: number;
|
||||
period_hours: number;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,27 @@ async function createGroup(
|
||||
return group.id;
|
||||
}
|
||||
|
||||
async function updateGroup(
|
||||
groupId: number,
|
||||
userIds: string[],
|
||||
ccPairIds: number[]
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_ids: userIds,
|
||||
cc_pair_ids: ccPairIds,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to update group: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(groupId: number): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
|
||||
method: "DELETE",
|
||||
@@ -262,6 +283,7 @@ export {
|
||||
USER_GROUP_URL,
|
||||
renameGroup,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
|
||||
onOpenChange={agentViewerModal.toggle}
|
||||
>
|
||||
<Modal.Content
|
||||
width="md-sm"
|
||||
width="lg"
|
||||
height="lg"
|
||||
bottomSlot={<AgentChatInput agent={agent} onSubmit={handleStartChat} />}
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ExceptionTraceModal({
|
||||
}: ExceptionTraceModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onOutsideClick}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Full Exception Trace"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
export const codeVariant: PreviewVariant = {
|
||||
matches: (name) => !!getCodeLanguage(name || ""),
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
@@ -31,7 +31,7 @@ function parseCsv(content: string): CsvData {
|
||||
export const csvVariant: PreviewVariant = {
|
||||
matches: (name, mime) =>
|
||||
mime.startsWith("text/csv") || (name || "").toLowerCase().endsWith(".csv"),
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -22,7 +22,7 @@ function formatContent(language: string, content: string): string {
|
||||
export const dataVariant: PreviewVariant = {
|
||||
matches: (name, mime) =>
|
||||
!!getDataLanguage(name || "") || !!getLanguageByMime(mime),
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
@@ -127,7 +127,7 @@ export const docxVariant: PreviewVariant = {
|
||||
const lower = (name || "").toLowerCase();
|
||||
return lower.endsWith(".docx") || lower.endsWith(".doc");
|
||||
},
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
export const imageVariant: PreviewVariant = {
|
||||
matches: (_name, mime) => mime.startsWith("image/"),
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const markdownVariant: PreviewVariant = {
|
||||
if (MARKDOWN_MIMES.some((m) => mime.startsWith(m))) return true;
|
||||
return isMarkdownFile(name || "");
|
||||
},
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
|
||||
|
||||
export const pdfVariant: PreviewVariant = {
|
||||
matches: (_name, mime) => mime === "application/pdf",
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const textVariant: PreviewVariant = {
|
||||
const lowerName = (name || "").toLowerCase();
|
||||
return TEXT_EXTENSIONS.some((extension) => lowerName.endsWith(extension));
|
||||
},
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
|
||||
|
||||
export const unsupportedVariant: PreviewVariant = {
|
||||
matches: () => true,
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -633,7 +633,7 @@ export function LLMConfigurationModalWrapper({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content width="md-sm" height="lg">
|
||||
<Modal.Content width="lg" height="lg">
|
||||
<Form className="flex flex-col h-full min-h-0">
|
||||
<Modal.Header
|
||||
icon={providerIcon}
|
||||
|
||||
Reference in New Issue
Block a user