Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion src/components/access/EditRoleDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PrincipalType } from 'librechat-data-provider';
import { useState, useEffect, useCallback } from 'react';
import { Button, Dialog, Tabs } from '@clickhouse/click-ui';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
Expand All @@ -8,6 +9,7 @@ import {
removeRoleMemberFn,
roleQueryOptions,
roleMembersQueryOptions,
availableScopesOptions,
updateRoleFn,
updateRolePermissionsFn,
MEMBERS_PAGE_SIZE,
Expand All @@ -16,13 +18,14 @@ import {
Avatar,
LoadingState,
Pagination,
DecorativeIcon,
SelectedMemberList,
TrashButton,
UserSearchInline,
} from '@/components/shared';
import { RolePermissionsPanel } from './RolePermissionsPanel';
import { useLocalize, useConfigFeatures } from '@/hooks';
import { cn, notifySuccess, notifyError } from '@/utils';
import { useLocalize } from '@/hooks';

type EditRoleTab = 'details' | 'permissions' | 'members';

Expand All @@ -44,6 +47,15 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
enabled: !!role,
});

const { roleRenameMovesProfile } = useConfigFeatures();
const { data: scopes } = useQuery({ ...availableScopesOptions, enabled: !!role });
const hasProfile =
role != null &&
(scopes ?? []).some(
(scope) => scope.principalType === PrincipalType.ROLE && scope.principalId === role.name,
);
const isRenaming = role != null && name.trim() !== '' && name.trim() !== role.name;

useEffect(() => {
if (roleDetail.data && permissions === null) {
setPermissions(roleDetail.data.permissions);
Expand Down Expand Up @@ -147,6 +159,9 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
if (data.roleId !== role?.id) {
queryClient.invalidateQueries({ queryKey: ['roleMembers', data.roleId] });
}
if (hasProfile && data.name !== role?.name) {
queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
}
notifySuccess(localize('com_toast_role_updated', { name: data.name }));
onClose();
},
Expand Down Expand Up @@ -223,8 +238,18 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
disabled={role?.isSystemRole || !canManage}
readOnly={!canManage}
autoFocus
aria-describedby={
isRenaming && hasProfile ? 'edit-role-rename-note' : undefined
}
className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled) disabled:cursor-not-allowed disabled:opacity-50"
/>
{isRenaming && hasProfile && (
<RenameProfileNote
id="edit-role-rename-note"
name={role.name}
moves={roleRenameMovesProfile}
/>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
Expand Down Expand Up @@ -375,6 +400,33 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
);
}

/**
* What renaming a role does to its configuration profile: LibreChat keeps a
* role's profile under the role's name, so before it moved profiles on rename
* (`roleRenameMovesProfile`) the profile stays behind and stops applying.
*/
function RenameProfileNote({ id, name, moves }: t.RenameProfileNoteProps) {
const localize = useLocalize();
return (
<p
id={id}
className={cn(
'm-0 flex items-start gap-1.5 text-xs',
moves ? 'text-(--cui-color-text-muted)' : 'text-(--settings-warn-fg)',
)}
>
<span className="mt-px flex shrink-0">
<DecorativeIcon name={moves ? 'info-in-circle' : 'warning'} size="xs" />
</span>
<span>
{moves
? localize('com_access_rename_profile_moves')
: localize('com_access_rename_profile_warning', { name })}
</span>
</p>
);
}

interface MemberListProps {
members: AdminMember[];
loading: boolean;
Expand Down
103 changes: 103 additions & 0 deletions src/components/access/__tests__/EditRoleDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { PrincipalType } from 'librechat-data-provider';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type * as t from '@/types';
import { BUNDLED_CONFIG_FEATURES } from '@/constants';
import { EditRoleDialog } from '../EditRoleDialog';

const state = vi.hoisted(() => ({
features: {} as Record<string, boolean>,
scopes: [] as Array<Pick<t.ConfigScope, 'principalType' | 'principalId'>>,
}));

vi.mock('@/hooks', () => ({
useLocalize: () => (key: string, options?: Record<string, string | number>) =>
options ? `${key}(${Object.values(options).join(',')})` : key,
useConfigFeatures: () => state.features,
}));

vi.mock('@/server', () => ({
MEMBERS_PAGE_SIZE: 20,
roleQueryOptions: (name: string) => ({
queryKey: ['role', name],
queryFn: async () => ({ ...role, permissions: {} }),
}),
roleMembersQueryOptions: (id: string, page: number) => ({
queryKey: ['roleMembers', id, page],
queryFn: async () => ({ members: [], total: 0 }),
}),
availableScopesOptions: {
queryKey: ['availableScopes'],
queryFn: async () => state.scopes,
},
updateRoleFn: vi.fn(),
addRoleMemberFn: vi.fn(),
removeRoleMemberFn: vi.fn(),
updateRolePermissionsFn: vi.fn(),
}));

vi.mock('../RolePermissionsPanel', () => ({ RolePermissionsPanel: () => null }));

const role: t.Role = {
id: 'r1',
name: 'EDITORS',
description: '',
isSystemRole: false,
isActive: true,
userCount: 0,
permissions: {},
} as t.Role;

function renderDialog() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<EditRoleDialog role={role} canManage onClose={vi.fn()} />
</QueryClientProvider>,
);
}

async function rename(name: string) {
const input = await screen.findByLabelText('com_access_col_name');
fireEvent.change(input, { target: { value: name } });
}

beforeEach(() => {
state.features = { ...BUNDLED_CONFIG_FEATURES };
state.scopes = [{ principalType: PrincipalType.ROLE, principalId: 'EDITORS' }];
});

describe('EditRoleDialog rename note', () => {
it('warns that the profile stays under the old name on a server that does not move it', async () => {
renderDialog();
await rename('WRITERS');
expect(
await screen.findByText('com_access_rename_profile_warning(EDITORS)'),
).toBeInTheDocument();
});

it('says the profile moves with the role when the server moves it', async () => {
state.features = { ...BUNDLED_CONFIG_FEATURES, roleRenameMovesProfile: true };
renderDialog();
await rename('WRITERS');
expect(await screen.findByText('com_access_rename_profile_moves')).toBeInTheDocument();
expect(screen.queryByText(/com_access_rename_profile_warning/)).not.toBeInTheDocument();
});

it('says nothing once the name is back to the current one', async () => {
renderDialog();
await rename('WRITERS');
expect(await screen.findByText(/com_access_rename_profile_warning/)).toBeInTheDocument();
await rename('EDITORS');
expect(screen.queryByText(/com_access_rename_profile/)).not.toBeInTheDocument();
});

it('says nothing for a role without a profile', async () => {
state.scopes = [{ principalType: PrincipalType.GROUP, principalId: 'EDITORS' }];
renderDialog();
await rename('WRITERS');
await act(async () => {});
expect(screen.queryByText(/com_access_rename_profile/)).not.toBeInTheDocument();
});
});
14 changes: 11 additions & 3 deletions src/components/configuration/ManagedNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@ import { useLocalize } from '@/hooks';

/**
* Explains why a field is read-only because LibreChat only applies it from
* librechat.yaml. When the edit target still holds an override for the field
* (written before it became read-only), offers to queue its removal.
* librechat.yaml (`kind` `startup`: reads it only while it starts; `count`: the
* settings below it the notice covers). When the edit target still holds an
* override for them (written before they became read-only), offers to queue
* its removal.
*/
export function ManagedNotice({
kind = 'yaml',
count = 1,
hasOverride,
pendingRemoval,
onRemoveOverride,
}: t.ManagedNoticeProps) {
const localize = useLocalize();
const text =
kind === 'startup'
? localize('com_config_startup_only_field', { count })
: localize('com_config_yaml_managed_field');
return (
<div className="mb-2 flex flex-col gap-2">
<InfoBanner text={localize('com_config_yaml_managed_field')} dismissible={false} />
<InfoBanner text={text} dismissible={false} />
{hasOverride && (
<div
role="status"
Expand Down
40 changes: 34 additions & 6 deletions src/components/configuration/edits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,9 @@ describe('identity-keyed collection ops (SV-1)', () => {
expect(ops).toEqual([
expect.objectContaining({ op: 'upsertEntry', key: 'Moonshot', identity: 'Moonshot' }),
]);
expect(planConfigChanges(ops, { stored: {}, lower: root, lowerKind: 'yaml' }).issues).toEqual(
[],
);
expect(
planConfigChanges(ops, { stored: {}, tombstones: [], lower: root, lowerKind: 'yaml' }).issues,
).toEqual([]);
});

it('restores a renamed entry deleted and then undone as the entry it was loaded as', () => {
Expand Down Expand Up @@ -225,7 +225,12 @@ describe('identity-keyed collection ops (SV-1)', () => {
expect.objectContaining({ op: 'upsertEntry', identity: 'a', index: 1 }),
expect.objectContaining({ op: 'upsertEntry', identity: 'b', index: 2 }),
]);
const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' });
const plan = planConfigChanges(ops, {
stored: {},
tombstones: [],
lower: specRoot,
lowerKind: 'yaml',
});
expect(plan.sets.map((set) => set.fieldPath)).toEqual(['modelSpecs.list']);
const saved = plan.sets[0].value as t.ConfigRecord[];
expect(saved.map((spec) => spec.name)).toEqual(['c', 'a', 'b']);
Expand All @@ -247,7 +252,12 @@ describe('identity-keyed collection ops (SV-1)', () => {
schemaPaths,
context({ root: specRoot, collections }),
);
const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' });
const plan = planConfigChanges(ops, {
stored: {},
tombstones: [],
lower: specRoot,
lowerKind: 'yaml',
});
expect(plan.issues).toEqual([]);
const saved = plan.sets[0].value as t.ConfigRecord[];
expect(saved.map((entry) => entry.name)).toEqual(['b', 'c', 'x']);
Expand All @@ -273,7 +283,12 @@ describe('identity-keyed collection ops (SV-1)', () => {
schemaPaths,
context({ root: specRoot, collections }),
);
const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' });
const plan = planConfigChanges(ops, {
stored: {},
tombstones: [],
lower: specRoot,
lowerKind: 'yaml',
});
expect(plan.issues).toEqual([]);
const saved = plan.sets[0].value as t.ConfigRecord[];
expect(saved.map((spec) => [spec.name, spec.label])).toEqual([
Expand Down Expand Up @@ -461,6 +476,17 @@ describe('issue display and summary', () => {
});
});

it('words an issue the admin API reported without parameters without placeholders', () => {
const issue: t.ConfigIssue = {
path: 'balance.startBalance',
code: 'too_small',
message: 'is too small',
};
const text = formatIssue(issue, issue.path, localize).inline;
expect(text).toMatch(/is too small$/);
expect(text).not.toContain('{{');
});

it('names an entry without a name as such, not with an empty identity', () => {
const issue: t.ConfigIssue = {
path: 'endpoints.custom',
Expand Down Expand Up @@ -521,6 +547,7 @@ describe('issue display and summary', () => {
const changeSet = buildConfigChanges(new Set(Object.keys(values)), values, schemaPaths, ctx);
const plan = planConfigChanges(changeSet.ops, {
stored: specsRoot,
tombstones: [],
lower: {},
lowerKind: 'yaml',
});
Expand Down Expand Up @@ -549,6 +576,7 @@ describe('issue display and summary', () => {
);
const plan = planConfigChanges(changeSet.ops, {
stored: {},
tombstones: [],
lower: { ...root, balance: { refillAmount: 1000000 } },
lowerKind: 'yaml',
});
Expand Down
11 changes: 5 additions & 6 deletions src/components/configuration/fields/KeyValueField.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { useRef, useState, useEffect, useContext, useLayoutEffect } from 'react';
import type { ReactNode } from 'react';
import type * as t from '@/types';
import { useDraftValue, useListKeys, useLocalize, useConfigKeyProblem } from '@/hooks';
import { RowProblemsContext } from '@/contexts/RowProblemsContext';
import { useDraftValue, useListKeys, useLocalize } from '@/hooks';
import { AddItemButton, TrashButton } from '@/components/shared';
import { cn, isBlankText, isUnsafeConfigKey } from '@/utils';
import { BLOCK_UNSAFE_CONFIG_KEYS } from '@/constants';
import { TextareaField } from './TextareaField';
import { SelectField } from './SelectField';
import { cn, isBlankText } from '@/utils';
import { INPUT_CLASSES } from './display';
import { FieldNotice } from './Notice';

Expand Down Expand Up @@ -121,6 +120,7 @@ export function KeyValueField({
'aria-label': ariaLabel,
}: t.KeyValueFieldProps) {
const localize = useLocalize();
const unsafeKeyProblem = useConfigKeyProblem();
const availableTypes = valueTypes ?? DEFAULT_TYPES;
const keys = useListKeys({ items: pairs, identity: pairKey, prefix: `${id}-row` });
const [drafts, setDrafts] = useState<Array<{ id: number; pair: t.KeyValuePair }>>([]);
Expand Down Expand Up @@ -230,9 +230,8 @@ export function KeyValueField({
if (isDraft && key === '' && !isBlankText(pair.value)) {
return localize('com_config_ctl_kv_key_missing');
}
if (BLOCK_UNSAFE_CONFIG_KEYS && isUnsafeConfigKey(key)) {
return localize('com_config_unsafe_key_error');
}
const unsafe = unsafeKeyProblem(key);
if (unsafe) return unsafe;
const taken = isDraft ? pairs.some((existing) => existing.key === key) : isTaken(key, index);
if (key !== '' && taken) return localize('com_config_ctl_kv_duplicate');
return null;
Expand Down
Loading
Loading