Compare commits

..

7 Commits

Author SHA1 Message Date
Nik
538574087a fix(groups): address Greptile review feedback on edit page
- Update initialAgentIdsRef/initialDocSetIdsRef after successful save
- Guard against NaN groupId from non-numeric URL params
2026-03-24 17:33:04 -07:00
Nik
f9f9ca6d0d fix(groups): wire doc set sharing to edit save flow
- Import and call updateDocSetGroupSharing in handleSave
- Track initialDocSetIdsRef to diff changes on save
2026-03-24 17:26:45 -07:00
Nik
0490f13fa6 fix(groups): restore non-ee groups2 route stubs
Non-ee route files are needed for Next.js route type generation.
The proxy rewrites /admin/groups2 to /ee/admin/groups2 at runtime.
2026-03-24 17:26:45 -07:00
Nik
4112687131 fix(groups): remove non-ee groups2 routes
Groups is an EE-only feature — routes should only exist under
app/ee/admin/groups2/, not duplicated under app/admin/groups2/.
2026-03-24 17:26:45 -07:00
Nik
5f350f7ac7 refactor(groups): DRY up EditGroupPage with shared imports
Import types, columns, renderers, and helpers from shared.tsx and
interfaces.ts instead of duplicating them. Replace raw flex divs
with Section components. Moves TokenRateLimitDisplay to interfaces.ts.
2026-03-24 17:26:45 -07:00
Nik
940b01c3c4 feat(groups): add edit group page
Add the edit group page with full CRUD support:
- Edit group name, members, connectors, agents, doc sets, and token limits
- Table with initialRowSelection and initialViewSelected for pre-selecting
  existing members with view-selected mode
- Agent sharing managed via persona share API (add/remove group from agents)
- Token rate limit lifecycle (create/update/delete) with proper diffing
- Sync-aware save: polls while group is syncing, blocks save during sync
- Race condition guard: ref-based double-submit prevention on save
- Add /admin/groups2 to EE_ROUTES in proxy.ts for middleware rewriting
- Add CE route stubs for typed route resolution
- Fix pre-existing backend bug: token limit query used implicit cross-join
  on User__UserGroup, duplicating results by number of group members
2026-03-24 17:26:45 -07:00
Bo-Onyx
8645adb807 fix(width): UI update model width definition. (#9613) 2026-03-25 00:11:32 +00:00
37 changed files with 556 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -44,7 +44,7 @@ function GroupsPage() {
/>
<Button
icon={SvgPlusCircle}
onClick={() => router.push("/admin/groups/create")}
onClick={() => router.push("/admin/groups2/create")}
>
New Group
</Button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import {
export const codeVariant: PreviewVariant = {
matches: (name) => !!getCodeLanguage(name || ""),
width: "md",
width: "xl",
height: "lg",
needsTextContent: true,
codeBackground: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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