mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-05 15:02:43 +00:00
Compare commits
6 Commits
cli/v0.2.1
...
nikg/test1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538574087a | ||
|
|
f9f9ca6d0d | ||
|
|
0490f13fa6 | ||
|
|
4112687131 | ||
|
|
5f350f7ac7 | ||
|
|
940b01c3c4 |
@@ -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. */
|
||||
|
||||
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";
|
||||
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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user