Frontend fixes

This commit is contained in:
Pavel committed 2026-09-29 11:11:33 +04:00
1 parent 98afa5d93c
commit 38bfb19739
42 files changed
+834 -284

No files matched your search

+7 -4
View File
@@ -77,9 +77,10 @@ def _get_provider_from_remote_data(remote_data):
def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict:
"""Add ``access`` + ``allowed_actions`` to a listed source row.
The source's behaviour ``config`` is dropped when the caller's role may
not ``view_config`` (a viewer when the owner turned
``viewers_can_see_config`` off).
The source's behaviour ``config`` is cut down to its ``kind`` when the
caller's role may not ``view_config`` (a viewer when the owner turned
``viewers_can_see_config`` off). ``kind`` stays because the UI needs it to
pick the wiki / graph view.
Args:
entry: The row as the list endpoint builds it.
@@ -91,7 +92,9 @@ def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -
"""
payload = payload_for("source", access, switches)
if "view_config" not in payload["allowed_actions"]:
entry.pop("config", None)
config = entry.get("config")
if config is not None:
entry["config"] = {"kind": (config or {}).get("kind", "classic")}
entry.update(payload)
return entry
+93 -17
View File
@@ -17,7 +17,8 @@ from the Tailwind scale, never from arbitrary `[...]` values. Runtime values
go in CSS custom properties or, when unavoidable, an inline style with a
disable comment. Conditional classes go through `cn(...)`, never a template
literal (prettier sorts the classes inside `cn`, and `cn` merges conflicts).
Every user-visible string, attributes included (`aria-label`, IconButton
What a role may do is gated with `can(item, action)` and a refused action is
not rendered (see Access and roles). Every user-visible string, attributes included (`aria-label`, IconButton
`label`, `placeholder`, `title`, `alt`, `hint`), is a `t()` key in all seven
locales (`de en es jp ru zh zh-TW`); admin pages are English by design.
Interpolated user text (a name, a timezone, a date) passes `interpolation: {
@@ -228,13 +229,14 @@ pill`, the only `outline-primary` on the agent pages themselves (the Access
`outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost
field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`)
at the end of the title row, through `SectionShell titleAction`: Access
details and Share with team on Overview, and Preview until the agent is
details and Share with team on Overview (each only when the role allows
it, see Access and roles), and Preview until the agent is
published (before that the preview only says "Publish to preview"). So the
row never holds more than three buttons and fits a phone, where the main
one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot
spans the stacked row below `sm`). The workflow builder's toolbar has no
title row and keeps its ⋯ at the row's end, holding Edit details, Access
details and Delete (see Workflow builder). The
details, Share with team and Delete (see Workflow builder). The
agent's Delete is in Overview's danger zone; only the workflow builder's
toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is
`agents/components/AgentPageToolbar` (see Page chrome). A row's common
@@ -294,7 +296,10 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
(`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`,
inset because a scrolling column clips an outset ring), so pages pass
nothing for it. Status badges go beside the button, not inside it, or the
hover underline runs under them.
hover underline runs under them. Because the ring comes from the Card,
`section-toggle` only goes inside a Card: a collapsible group in a Modal
or drawer (Share's Access settings) is the inline `link sm` disclosure
toggle above.
- Drop `text-white` on default and destructive buttons; the foreground token
already provides it. Drop `disabled:cursor-not-allowed` on buttons; the
base disables pointer events. Fields are the other way round (see Focus,
@@ -466,7 +471,10 @@ pill, including schedule and run status pills (`agents/schedules/StatusBadge.tsx
maps schedule and run statuses to Badge variants), trace chips and statuses,
token scope chips, "Disabled" and tool chips. A grey chip is `neutral`, never
a `bg-muted` pill. The admin role is `default` wherever it shows (Teams, Admin
→ Users); every other role is `neutral`. Chips that show code (token scopes) pass `font-mono`, and
→ Users); every other role is `neutral`. A shared asset's tile (agent,
source, tool, prompt) shows the caller's role with `components/RoleBadge`: a
`neutral` Badge with `Users` first (Editor, Viewer, from `roleOf()`); your own
assets show none. Chips that show code (token scopes) pass `font-mono`, and
stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their variant from
`getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST
`info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else
@@ -603,6 +611,13 @@ state assignment) is not a bare row: each of its fields has a floating
label. Never hand-build a label above a field, a
hand-positioned floating label, or a `div.flex-col` + `Label` + `<p>` stack.
A saved secret (an API key, a tool's credential, a custom header value) never
comes back from the API, for any role. Its field is empty and `type="password"`;
in a FormField the saved state is the `hint` ("Saved. Leave empty to keep
it."), because a placeholder is hidden while the label rests. Only a
label-less table cell carries it as the placeholder
(`settings.tools.savedSecretPlaceholder`). Never a `••••` placeholder.
### SettingRow (`ui/setting-row.tsx`)
A setting with a control on the right (a Switch, a short Input) is
@@ -632,7 +647,10 @@ description use a Switch in a SettingRow instead.
### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`)
`Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names
it. The track is `primary` when on and `bg-input` when off, with a white
it. A tile's own on/off (the tool tile's "In my chats") is the one exception:
a `Label text-muted-foreground text-xs font-normal` + `Switch` pair at the
tile's bottom-right. For a shared tool it is the caller's own preference,
never a switch that turns the tool off for everyone. The track is `primary` when on and `bg-input` when off, with a white
thumb in both themes (see Elevation).
`TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes,
@@ -680,6 +698,11 @@ row (the Agents filter pills) are not a ToggleGroup: they are `Button asChild
variant={active ? 'outline' : 'ghost-muted'} size="sm" shape="pill"` around
each `Link`, with `aria-current="page"` on the current one.
Filtering a list by kind (a team's shared resources, Share's People) is this
`xs` group in its muted track, each item `{label} {formatCount(n)}`, beside a
`SearchInput size="sm"` (`w-full sm:w-56`); no match is `EmptyState size="xs"
illustration="none"`.
### Separator (`ui/separator.tsx`)
A 1px `bg-border` rule, horizontal or `orientation="vertical"`, decorative
@@ -717,7 +740,9 @@ height of its own). `label` puts a muted caption under the ring (a long job:
Convert to wiki, Enable GraphRAG); it is also the ring's name. `size` takes
the Spinner sizes (`default` unless set): `sm` inside a picker (the
MultiSelect popover's list), `lg` for a full panel (a remote device's
config). Spinners
config). A role guard shows it while it resolves, then redirects
silently: `AdminRoute` at `fill="screen"`, `AgentRouteGuard` at
`fill="parent"` (it renders inside the app shell's scroll column) (see Access and roles). Spinners
inside a control (a busy Button, a picker's `sm` ring) stay `Spinner`.
Nothing to show is `EmptyState`: `size` `default | sm | xs` (128 / 96 / 64px
@@ -838,6 +863,43 @@ variant="destructive"` above the form, not text in or beside the button.
publish validation) is a destructive `Alert` floating at `z-20` on an opaque
`bg-card rounded-xl shadow-md` wrapper that stays until closed; a toast
would truncate each error to one line and dismiss itself.
- A switch moves at once and flips back when the server refuses, with a
destructive toast (an Alert inside a modal). A delete removes its row only
after the server confirms; a 403 shows `errors.forbidden`.
### Access and roles
Agents, sources, tools and prompts come to the UI with `access` and
`allowed_actions`. Gate every control with `can(item, action)`
(`utils/accessUtils`; `canAgent` in `agents/agentAccess.ts` for agents, which
also refuses Logs, Schedules and Pin on a draft), never on `ownership`,
`team_access` or the user id.
- An action the role doesn't allow is not rendered. A menu builds its
options from `can()`, and a ⋯ with no options is not drawn; a footer or
row left empty goes, with its `Separator`. Never a disabled Save or a
disabled menu item for a role.
- A control stays visible but `disabled` only when it shows state the caller
should still see (a switch's on/off, a policy's value). Then the reason is
on screen (an Alert or a FormField `hint`), never a bare grey control. A
control that can't apply to the caller at all (a tool's "In my chats" when
the grant doesn't allow it in their chats) is hidden, not disabled.
- A view-only form opens with the same fields and `ViewOnlyNotice`
(`components/ViewOnlyNotice`: an `Alert role="note"` with `Lock` first and
`common.viewOnlyNotice`) as its first child. Where only part of an editable
form is locked (a tool's credentials when the owner turned off "Editors can
change credentials"), the same notice passes its own `message`. There is no Save; Cancel becomes a lone Close.
Fields are `disabled` (a group: `<fieldset disabled className="min-w-0">`);
long text the user reads or copies (a prompt, a chunk) is `readOnly`, so it
keeps full contrast and scrolls. Titles and menu items swap Edit and
`Pencil` for View and `Eye`.
- Source views follow the same rule: every write (Add file, Sync, Add chunk,
Edit, Delete) shows only with `can(source, 'edit')`; reading, copying and
paging stay.
- A page the role can't open is refused twice: its tabs and section items
are filtered by the same action (`AgentPageHeader`, `navigation/sections`),
and its route is wrapped in a guard (`AgentRouteGuard`) that redirects to
the list as a deep-link fallback, with `LoadingState` while it resolves.
### Alert (`ui/alert.tsx`)
@@ -892,8 +954,7 @@ bordered list for one). Rows are `px-4 py-3`, the title `text-sm
font-medium`. `interactive` (with `asChild` around a `<Link>` or `<button>`)
hovers to `bg-accent` and draws an inset focus ring. `selected` marks the
row whose detail is open in a drawer beside the list (a team's shared
resources): the `bg-secondary` tint of a selected TableRow, kept on hover,
with `aria-current`. An icon square in
resources), exactly as a selected TableRow (see Table). An icon square in
`leading` is a plain `bg-muted text-muted-foreground size-8 rounded-md` span.
In a narrow side panel (the graph node panel's relationships) rows are
`size="sm"`: `px-2 py-1.5`, `gap-2.5`, `rounded-md` and top-aligned so a small
@@ -989,8 +1050,8 @@ The title is for chats only: the open conversation's name, or the agent's
name on a new agent chat, with the agent's `Avatar` in front. A plain new chat
has no title. So do section pages (settings, admin, an agent's pages), because
`SectionShell` already draws their title. When the chat has actions, the title
is a `ghost sm` Button that opens a `DropdownMenu` with Edit agent (owned
agents), Share, Rename and Delete. Rename edits the name in place, as the
is a `ghost sm` Button that opens a `DropdownMenu` with Edit agent (a role
that may open its edit page), Share, Rename and Delete. Rename edits the name in place, as the
sidebar row does. A title with no actions (a shared agent's new chat) is
plain text.
@@ -1060,8 +1121,10 @@ variant="field"` over a `CommandList` of `CommandItem`s, so the arrow keys
chunk's previous / next (`IconButton ghost-muted icon-sm pill`, and the
arrow keys), then Edit (`outline sm pill`, `Pencil` first) and an
`ActionMenu size="toolbar"` (Copy text; a chunk's also has Delete, a
destructive item). A wiki page has Edit (editors only) and the same menu
with Copy text, and no previous / next; the navigator walks them.
destructive item). A wiki page has Edit and the same menu
with Copy text, and no previous / next; the navigator walks them. Edit,
Delete and Add chunk show only to a role that may edit (see Access and
roles).
- **Read in the page, edit in a drawer**: Edit and Add chunk open
`SourceEditSheet`, a right `Sheet size="wide"` (a working surface): title
and a mono description (path, version, tokens), then any `fields` edited
@@ -1115,7 +1178,8 @@ pill`) and Save (`default lg pill`, off until the draft or a field changes, and
source chunks as `filled sm interactive` tiles. A tile opens a read drawer
(`Sheet size="detail"`, one record) with the chunk rendered and the entity's
name marked in the brand tint (`bg-secondary`), and Open in Files and Edit
(the same `SourceEditSheet`) in its footer; Cancel or Discard in that edit
(the same `SourceEditSheet`) in its footer, which is omitted when neither
applies; Cancel or Discard in that edit
drawer returns to the read drawer, Save closes both, and closing it leaves
the node selected. The relationship list is capped server-side: its heading
counts the true total (`relationships_total`) and, when the list is partial,
@@ -1158,8 +1222,8 @@ opens the workflow details. The status Badge follows (`success` Published,
`neutral` Draft until the first save), then the tabs. On the right: "Unsaved
changes" as muted `text-sm` meta while there are any, Preview, Save, and the
⋯ (`ActionMenu size="toolbar"`): Edit details first (the same drawer, for
anyone who doesn't try the name), then Access details and Delete once the
workflow is saved.
anyone who doesn't try the name), then Access details, Share with team and
Delete once the workflow is saved, each only when the role allows it.
The workflow details are a right `Sheet size="default"`
(`components/WorkflowDetailsSheet.tsx`), built like AgentPreviewSheet (header,
@@ -1253,6 +1317,13 @@ ScheduleFormModal's editable name, the search palette). A step heading
under a Back button uses the title's classes (`text-xl leading-tight
font-semibold`), not a larger size.
A list in a modal that can grow long (Share's People) shows the first three
once it passes five, a `link inline` "Show all N" (12px `ArrowRight`) in its
SectionHeader's `actions` and a muted `text-xs` "and N more" line, counts
through `formatCount`. The full list is a second step: a `ghost sm` Back with
`ArrowLeft`, the title-class heading, then the search and kind filter (see
ToggleGroup), with `hideTitle` on that step only.
Buttons go in `footer`, never in `children`. The footer stacks full width on
phones (primary on top) and sits in a right-aligned row from `sm` up. The
standard pair is `footer={<ModalActions cancelLabel onCancel submitLabel
@@ -1289,7 +1360,12 @@ one shared drawer, `agents/components/AgentPreviewSheet`, for workflow and
classic agents alike: `size="wide"`, a `SheetTitle`
and a one-line `SheetDescription` (the agent's name) in a header padded
`pr-12` to clear the close X, an `info` Running badge while it answers, a
`Separator`, then the preview. The workflow preview's Execution details and
`Separator`, then the preview. Every right drawer with a custom header
(AgentPreviewSheet, SourceEditSheet, GraphChunkSheet, a team's resource
detail) is that recipe: `SheetContent` `p-0`, a header at `px-6 pt-6 pr-12
pb-4` that never scrolls, a `Separator`, then the one scrolling body at `px-6
py-6`. The title wraps (`wrap-break-word`), it doesn't truncate. The row whose
drawer is open is `selected` (ListRow or TableRow). The workflow preview's Execution details and
Artifacts rows are the answer's step-row recipe (see Alert), each step a
`subtle sm` panel with its output in a `filled sm` well.
+25
View File
@@ -175,6 +175,31 @@ describe('AgentCard menu', () => {
]);
});
it('swaps Edit for View (Eye) when the role can open but not edit', async () => {
await render(agentWith('viewer', ['use', 'pin', 'view']), 'team');
const items = await openMenu();
const view = items.find((i) => i.textContent === 'agents.view');
expect(view).toBeDefined();
expect(view?.querySelector('svg')?.getAttribute('class')).toContain(
'lucide-eye',
);
expect(items.map((i) => i.textContent)).not.toContain('agents.edit');
});
it('shows the role on a shared tile as a neutral Users Badge', async () => {
await render(agentWith('editor', EDITOR_ACTIONS), 'team');
const badge = container.querySelector<HTMLElement>(
'[data-testid="role-badge"]',
);
expect(badge?.dataset.variant).toBe('neutral');
expect(badge?.textContent).toBe('teamAccess.editor');
});
it('shows no role badge on an own agent', async () => {
await render(agentWith('owner', OWNER_ACTIONS), 'user');
expect(container.querySelector('[data-testid="role-badge"]')).toBeNull();
});
it('gives a viewer Pin only', async () => {
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
expect(await menuLabels()).toEqual(['agents.card.pin']);
+9 -10
View File
@@ -6,6 +6,7 @@ import {
Activity,
Copy,
Download,
Eye,
Folder,
Pencil,
Pin,
@@ -15,8 +16,8 @@ import {
} from 'lucide-react';
import userService from '../api/services/userService';
import RoleBadge from '../components/RoleBadge';
import { Avatar } from '../components/ui/avatar';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
@@ -96,8 +97,13 @@ export default function AgentCard({
},
]
: []),
// Opening the editor is `view`; a role that can't `edit` gets it as View.
...(can(agent, 'view')
? [{ icon: Pencil, label: t('agents.edit'), onClick: openEditor }]
? [
can(agent, 'edit')
? { icon: Pencil, label: t('agents.edit'), onClick: openEditor }
: { icon: Eye, label: t('agents.view'), onClick: openEditor },
]
: []),
...(can(agent, 'export')
? [
@@ -333,14 +339,7 @@ export default function AgentCard({
)}
{/* Team access badge — pinned to the top row, left of the ⋯ menu
(right-11 clears the 28px trigger at right-3) so the two align. */}
{agent.ownership === 'team' && (
<Badge variant="neutral" className="absolute top-4 right-11 z-10">
<Users aria-hidden="true" />
{agent.team_access === 'editor'
? t('agents.teamBadge.editor')
: t('agents.teamBadge.viewer')}
</Badge>
)}
<RoleBadge item={agent} className="absolute top-4 right-11 z-10" />
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<Avatar
+6 -1
View File
@@ -76,8 +76,11 @@ describe('AgentRouteGuard', () => {
}),
);
await render('view', '/agents/manage/edit/a1');
// Nothing of the page while the agent loads.
// Nothing of the page while the agent loads, only the loading ring.
expect(shows('page')).toBe(false);
const loading = container.querySelector('[data-slot="loading-state"]');
expect(loading?.getAttribute('data-fill')).toBe('parent');
expect(loading?.querySelector('[role="status"]')).not.toBeNull();
await act(async () =>
resolve({
ok: true,
@@ -91,6 +94,8 @@ describe('AgentRouteGuard', () => {
);
expect(shows('page')).toBe(false);
expect(shows('list')).toBe(true);
// The redirect itself is silent.
expect(container.querySelector('[data-slot="loading-state"]')).toBeNull();
});
it('lets an editor open the page', async () => {
+4 -2
View File
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import { Navigate, useParams } from 'react-router-dom';
import userService from '../api/services/userService';
import { LoadingState } from '../components/ui/loading-state';
import {
selectAgents,
selectSelectedAgent,
@@ -24,7 +25,8 @@ type AgentRouteGuardProps = {
*
* Decides from the agent already in the store when that record carries the
* server's `allowed_actions`, else fetches the agent. Nothing of the page
* renders until it knows, so a viewer never sees a flash of the edit form.
* renders until it knows (a loading ring stands in), so a viewer never sees
* a flash of the edit form.
* A caller who may not open the page, or an agent that does not load, goes
* back to the agent list.
*
@@ -74,7 +76,7 @@ export default function AgentRouteGuard({
let agent: Agent | null | undefined = stored;
if (!agent) {
if (fetched?.id !== agentId) return null;
if (fetched?.id !== agentId) return <LoadingState fill="parent" />;
agent = fetched.agent;
}
if (!agent || !canAgent(agent, action))
-3
View File
@@ -1492,9 +1492,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
// Guardrails are policy (`edit_policy`): editors and the owner
// change them; anyone else sees them read-only.
disabled={!canEditPolicy}
disabledNotice={
canEditPolicy ? undefined : t('agents.form.guardrails.readOnly')
}
onChange={(guardrails) =>
setAgent({
...agent,
@@ -135,7 +135,6 @@ describe('GuardrailsSection', () => {
value={config}
onChange={() => undefined}
token="tok"
disabledNotice="Read only"
{...props}
/>,
);
@@ -232,9 +231,11 @@ describe('GuardrailsSection', () => {
it('shows the section notices as polite or warning alerts', async () => {
await render({ disabled: true });
// View-only: the shared quiet note, not an announced status.
const readOnly = q('guardrails-read-only');
expect(readOnly?.getAttribute('role')).toBe('status');
expect(readOnly?.textContent).toContain('Read only');
expect(readOnly?.getAttribute('role')).toBe('note');
expect(readOnly?.dataset.variant).toBe('default');
expect(readOnly?.textContent).toBe('common.viewOnlyNotice');
const instance = q('guardrails-instance-disabled');
expect(instance?.getAttribute('role')).toBe('alert');
expect(instance?.className).toContain('text-warning');
@@ -1,7 +1,8 @@
import React from 'react';
import { ChevronRight, Info, Lock, TriangleAlert } from 'lucide-react';
import { ChevronRight, Info, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ViewOnlyNotice from '@/components/ViewOnlyNotice';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -101,9 +102,11 @@ type Props = {
value?: GuardrailsConfig;
onChange: (next: GuardrailsConfig) => void;
token: string | null;
/**
* The caller's role can't change the policy: the controls still show its
* state, disabled, under the shared view-only notice.
*/
disabled?: boolean;
/** Why the controls are read-only, shown in place of a silent lockout. */
disabledNotice?: string;
};
export default function GuardrailsSection({
@@ -111,7 +114,6 @@ export default function GuardrailsSection({
onChange,
token,
disabled = false,
disabledNotice,
}: Props) {
const { t } = useTranslation();
const [expanded, setExpanded] = React.useState(false);
@@ -287,12 +289,7 @@ export default function GuardrailsSection({
/>
)}
{disabled && disabledNotice && (
<Alert role="status" data-testid="guardrails-read-only">
<Lock aria-hidden="true" className="size-4" />
<AlertDescription>{disabledNotice}</AlertDescription>
</Alert>
)}
{disabled && <ViewOnlyNotice data-testid="guardrails-read-only" />}
{instanceDisabled && (
<Alert variant="warning" data-testid="guardrails-instance-disabled">
@@ -1534,9 +1534,10 @@ function WorkflowBuilderInner() {
// Save on a saved workflow is an edit; the first save publishes it. A new
// workflow has no access fields, so it reads as the owner's.
// Without it the Save/Publish button isn't rendered at all.
const canSubmit = can(currentAgent, canManageAgent ? 'edit' : 'publish');
const isPrimaryActionDisabled =
!canSubmit || isPublishing || (canManageAgent && !hasSavableChanges);
isPublishing || (canManageAgent && !hasSavableChanges);
const primaryActionLabel = canManageAgent
? t('agents.form.buttons.save')
: t('agents.form.buttons.publish');
@@ -0,0 +1,64 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import ConfigFields from './ConfigFields';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('ConfigFields secrets', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (hasEncryptedCredentials: boolean) => {
await act(async () => {
root.render(
<ConfigFields
configRequirements={{
api_key: {
type: 'string',
label: 'API key',
description: 'Your provider key',
secret: true,
},
}}
values={{}}
onChange={() => undefined}
isEditing
hasEncryptedCredentials={hasEncryptedCredentials}
/>,
);
});
};
it('a saved secret stays empty, with the saved state as the hint', async () => {
await render(true);
const input = container.querySelector('input');
expect(input?.type).toBe('password');
expect(input?.value).toBe('');
expect(input?.placeholder).not.toContain('•');
expect(container.textContent).toContain('common.savedSecretHint');
});
it('an unsaved secret has no saved hint', async () => {
await render(false);
expect(container.textContent).not.toContain('common.savedSecretHint');
expect(container.querySelector('input')?.placeholder).toBe(
'Your provider key',
);
});
});
+6 -2
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigRequirements } from '../modals/types';
import { FormField, type FormFieldProps } from './ui/form-field';
@@ -43,6 +44,7 @@ export default function ConfigFields({
hasEncryptedCredentials = false,
labelSurface,
}: ConfigFieldsProps) {
const { t } = useTranslation();
const sortedFields = useMemo(
() =>
Object.entries(configRequirements).sort(
@@ -59,9 +61,10 @@ export default function ConfigFields({
if (!shouldShowField(spec, values)) return null;
const value = values[key] ?? spec.default ?? '';
// A saved secret never comes back: the field stays empty and the
// hint says it is saved.
const hasEncrypted =
isEditing && spec.secret && hasEncryptedCredentials;
const placeholder = hasEncrypted ? '••••••••' : '';
const error = errors[key] || undefined;
if (spec.enum) {
@@ -98,6 +101,7 @@ export default function ConfigFields({
key={key}
label={spec.label || key}
required={!!spec.required}
hint={hasEncrypted ? t('common.savedSecretHint') : undefined}
error={error}
labelSurface={labelSurface}
>
@@ -122,7 +126,7 @@ export default function ConfigFields({
onChange(key, v);
}
}}
placeholder={placeholder || spec.description || ''}
placeholder={spec.description || ''}
min={spec.type === 'number' ? 1 : undefined}
max={
spec.type === 'number' && key === 'timeout' ? 300 : undefined
@@ -0,0 +1,52 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import RoleBadge from './RoleBadge';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('RoleBadge', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('shows the role as a neutral Badge with the Users icon first', async () => {
await act(async () =>
root.render(<RoleBadge item={{ access: 'editor' }} />),
);
const badge = container.querySelector<HTMLElement>('[data-slot="badge"]');
expect(badge?.dataset.variant ?? badge?.className).toMatch(/neutral|muted/);
const icon = badge?.firstElementChild;
expect(icon?.getAttribute('class')).toContain('lucide-users');
expect(icon?.getAttribute('class')).not.toContain('size-3');
expect(badge?.textContent).toBe('teamAccess.editor');
});
it('reads the legacy team fields through roleOf', async () => {
await act(async () =>
root.render(
<RoleBadge item={{ ownership: 'team', team_access: null }} />,
),
);
expect(container.textContent).toBe('teamAccess.viewer');
});
it('renders nothing for the caller’s own item', async () => {
await act(async () => root.render(<RoleBadge item={{}} />));
expect(container.innerHTML).toBe('');
});
});
+29
View File
@@ -0,0 +1,29 @@
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { roleOf, type AccessFields } from '../utils/accessUtils';
import { Badge } from './ui/badge';
/**
* The caller's role on a team-shared asset tile (agent, source, tool): a
* neutral Badge with the Users icon and Editor or Viewer. Nothing for the
* caller's own items.
*/
export default function RoleBadge({
item,
className,
}: {
item: AccessFields;
/** Placement only (a tile pins it beside its menu). */
className?: string;
}) {
const { t } = useTranslation();
const role = roleOf(item);
if (role === 'owner') return null;
return (
<Badge variant="neutral" className={className} data-testid="role-badge">
<Users aria-hidden="true" />
{role === 'editor' ? t('teamAccess.editor') : t('teamAccess.viewer')}
</Badge>
);
}
@@ -0,0 +1,35 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import ViewOnlyNotice from './ViewOnlyNotice';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('ViewOnlyNotice', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('is a quiet default Alert (role="note") with the shared sentence', async () => {
await act(async () => root.render(<ViewOnlyNotice />));
const alert = container.querySelector('[data-slot="alert"]');
expect(alert?.getAttribute('role')).toBe('note');
expect(alert?.getAttribute('data-variant')).toBe('default');
expect(alert?.querySelector('svg')).not.toBeNull();
expect(alert?.textContent).toBe('common.viewOnlyNotice');
});
});
@@ -0,0 +1,28 @@
import { Lock } from 'lucide-react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from './ui/alert';
/**
* The first child of a form the caller's role can only view: the same
* fields, disabled, under one quiet note. Every view-only form shows this
* one sentence, so a role reads the same everywhere. `message` replaces it
* only where part of an editable form is locked (a tool's credentials).
*/
export default function ViewOnlyNotice({
message,
...props
}: Omit<React.ComponentProps<typeof Alert>, 'children' | 'variant'> & {
message?: string;
}) {
const { t } = useTranslation();
return (
<Alert role="note" {...props}>
<Lock />
<AlertDescription>
{message ?? t('common.viewOnlyNotice')}
</AlertDescription>
</Alert>
);
}
+8 -7
View File
@@ -10,6 +10,12 @@
"loading": "Wird geladen...",
"retry": "Erneut versuchen",
"cancel": "Abbrechen",
"common": {
"close": "Schließen",
"viewOnlyNotice": "Du kannst dies ansehen, aber deine Rolle kann es nicht ändern.",
"credentialsLockedNotice": "Nur der Eigentümer kann die Zugangsdaten dieses Tools ändern.",
"savedSecretHint": "Gespeichert. Leer lassen, um es zu behalten."
},
"help": "Hilfe",
"emailUs": "E-Mail senden",
"documentation": "Dokumentation",
@@ -396,7 +402,6 @@
"subtitle": "Konfiguriere, wie \"{{name}}\" in Chunks aufgeteilt und abgerufen wird.",
"subtitleGeneric": "Konfiguriere, wie diese Quelle in Chunks aufgeteilt und abgerufen wird.",
"save": "Speichern",
"readOnly": "Du hast nur Lesezugriff auf diese geteilte Quelle und kannst ihre Konfiguration nicht ändern.",
"chunkingChangeHint": "Du hast eine Chunking-Einstellung geändert. Nach dem Speichern muss diese Quelle erneut indexiert werden, damit die Änderung wirkt.",
"prescreenInvalidHint": "Prüfe die Vorfilter-Werte: Die abzurufenden Kandidaten müssen mindestens der Chunk-Anzahl entsprechen, und die zu behaltenden Chunks dürfen die Kandidatenzahl nicht überschreiten.",
"reingestRequired": "Deine Änderungen wurden gespeichert. Die geänderten Chunking-Einstellungen wirken erst nach einem erneuten Indexieren. Jetzt erneut indexieren?",
@@ -1778,6 +1783,7 @@
"agents": {
"title": "Agenten",
"edit": "Bearbeiten",
"view": "Ansehen",
"card": {
"pin": "Agent anheften",
"unpin": "Agent lösen",
@@ -1816,10 +1822,6 @@
"team": "Team",
"shared": "Mit mir geteilt"
},
"teamBadge": {
"editor": "Bearbeiter",
"viewer": "Betrachter"
},
"sections": {
"template": {
"title": "Von DocsGPT",
@@ -1998,8 +2000,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
},
"readOnly": "Du kannst diese Richtlinie sehen, aber deine Rolle kann sie nicht ändern."
}
},
"byline": {
"new": "Richten Sie den Agenten ein und veröffentlichen Sie ihn, um mit ihm zu chatten."
+8 -7
View File
@@ -10,6 +10,12 @@
"loading": "Loading...",
"retry": "Retry",
"cancel": "Cancel",
"common": {
"close": "Close",
"viewOnlyNotice": "You can view this, but your role can't change it.",
"credentialsLockedNotice": "Only the owner can change this tool's credentials.",
"savedSecretHint": "Saved. Leave empty to keep it."
},
"errorBoundary": {
"message": "Something went wrong displaying this content.",
"tryAgain": "Try again"
@@ -401,7 +407,6 @@
"subtitle": "Configure how \"{{name}}\" is chunked and retrieved.",
"subtitleGeneric": "Configure how this source is chunked and retrieved.",
"save": "Save",
"readOnly": "You have view-only access to this shared source and cannot change its config.",
"chunkingChangeHint": "You changed a chunking setting. Saving will require re-ingesting this source for it to take effect.",
"prescreenInvalidHint": "Check the prescreen values: candidates to fetch must be at least the number of chunks, and chunks to keep must not exceed candidates to fetch.",
"reingestRequired": "Your changes were saved. The chunking settings you changed only take effect after a re-ingest. Re-ingest now?",
@@ -1794,6 +1799,7 @@
"agents": {
"title": "Agents",
"edit": "Edit",
"view": "View",
"card": {
"pin": "Pin agent",
"unpin": "Unpin agent",
@@ -1832,10 +1838,6 @@
"team": "Team",
"shared": "Discovered"
},
"teamBadge": {
"editor": "Editor",
"viewer": "Viewer"
},
"sections": {
"template": {
"title": "Templates",
@@ -2016,8 +2018,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
},
"readOnly": "You can see this policy, but your role can't change it."
}
},
"byline": {
"new": "Set up the agent, then publish it to chat with it."
+8 -7
View File
@@ -10,6 +10,12 @@
"loading": "Cargando...",
"retry": "Reintentar",
"cancel": "Cancelar",
"common": {
"close": "Cerrar",
"viewOnlyNotice": "Puedes ver esto, pero tu rol no puede cambiarlo.",
"credentialsLockedNotice": "Solo el propietario puede cambiar las credenciales de esta herramienta.",
"savedSecretHint": "Guardado. Déjalo vacío para conservarlo."
},
"help": "Asistencia",
"emailUs": "Envíanos un correo",
"documentation": "Documentación",
@@ -396,7 +402,6 @@
"subtitle": "Configura cómo se fragmenta y recupera \"{{name}}\".",
"subtitleGeneric": "Configura cómo se fragmenta y recupera esta fuente.",
"save": "Guardar",
"readOnly": "Tienes acceso de solo lectura a esta fuente compartida y no puedes cambiar su configuración.",
"chunkingChangeHint": "Cambiaste un ajuste de fragmentación. Al guardar será necesario reindexar esta fuente para que surta efecto.",
"prescreenInvalidHint": "Revisa los valores de preselección: los candidatos a obtener deben ser al menos el número de fragmentos, y los fragmentos a conservar no deben superar los candidatos a obtener.",
"reingestRequired": "Tus cambios se guardaron. Los ajustes de fragmentación que cambiaste solo surten efecto tras reindexar. ¿Reindexar ahora?",
@@ -1778,6 +1783,7 @@
"agents": {
"title": "Agentes",
"edit": "Editar",
"view": "Ver",
"card": {
"pin": "Fijar agente",
"unpin": "Dejar de fijar agente",
@@ -1816,10 +1822,6 @@
"team": "Equipo",
"shared": "Compartidos conmigo"
},
"teamBadge": {
"editor": "Editor",
"viewer": "Lector"
},
"sections": {
"template": {
"title": "Por DocsGPT",
@@ -1998,8 +2000,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
},
"readOnly": "Puedes ver esta política, pero tu rol no puede cambiarla."
}
},
"byline": {
"new": "Configura el agente y publícalo para chatear con él."
+8 -7
View File
@@ -10,6 +10,12 @@
"loading": "読み込み中...",
"retry": "再試行",
"cancel": "キャンセル",
"common": {
"close": "閉じる",
"viewOnlyNotice": "表示はできますが、あなたのロールでは変更できません。",
"credentialsLockedNotice": "このツールの認証情報を変更できるのはオーナーだけです。",
"savedSecretHint": "保存済みです。空のままにすると保持されます。"
},
"help": "ヘルプ",
"emailUs": "メールを送る",
"documentation": "ドキュメント",
@@ -389,7 +395,6 @@
"subtitle": "「{{name}}」のチャンク分割と検索の方法を設定します。",
"subtitleGeneric": "このソースのチャンク分割と検索の方法を設定します。",
"save": "保存",
"readOnly": "この共有ソースには閲覧のみのアクセス権があるため、設定を変更できません。",
"chunkingChangeHint": "チャンク分割の設定を変更しました。保存後、反映にはこのソースの再インデックスが必要です。",
"prescreenInvalidHint": "プリスクリーニングの値を確認してください。取得する候補数はチャンク数以上、保持するチャンク数は取得する候補数以下である必要があります。",
"reingestRequired": "変更を保存しました。変更したチャンク分割の設定は再インデックス後にのみ反映されます。今すぐ再インデックスしますか?",
@@ -1766,6 +1771,7 @@
"agents": {
"title": "エージェント",
"edit": "編集",
"view": "表示",
"card": {
"pin": "エージェントをピン留め",
"unpin": "エージェントのピン留めを解除",
@@ -1804,10 +1810,6 @@
"team": "チーム",
"shared": "共有された"
},
"teamBadge": {
"editor": "編集者",
"viewer": "閲覧者"
},
"sections": {
"template": {
"title": "DocsGPT提供",
@@ -1985,8 +1987,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
},
"readOnly": "このポリシーは表示できますが、あなたのロールでは変更できません。"
}
},
"byline": {
"new": "エージェントを設定し、公開するとチャットできます。"
+8 -7
View File
@@ -10,6 +10,12 @@
"loading": "Загрузка...",
"retry": "Повторить",
"cancel": "Отмена",
"common": {
"close": "Закрыть",
"viewOnlyNotice": "Вы можете это просматривать, но ваша роль не позволяет это изменить.",
"credentialsLockedNotice": "Только владелец может изменить учётные данные этого инструмента.",
"savedSecretHint": "Сохранено. Оставьте поле пустым, чтобы сохранить значение."
},
"help": "Помощь",
"emailUs": "Напишите нам",
"documentation": "Документация",
@@ -420,7 +426,6 @@
"subtitle": "Настройте, как «{{name}}» разбивается на фрагменты и извлекается.",
"subtitleGeneric": "Настройте, как этот источник разбивается на фрагменты и извлекается.",
"save": "Сохранить",
"readOnly": "У вас доступ только для просмотра этого общего источника, вы не можете изменять его настройки.",
"chunkingChangeHint": "Вы изменили настройку разбиения на фрагменты. После сохранения потребуется переиндексация источника, чтобы изменения вступили в силу.",
"prescreenInvalidHint": "Проверьте значения предварительного отбора: число кандидатов должно быть не меньше числа фрагментов, а число оставляемых фрагментов не должно превышать число кандидатов.",
"reingestRequired": "Изменения сохранены. Изменённые настройки разбиения вступят в силу только после переиндексации. Переиндексировать сейчас?",
@@ -1840,6 +1845,7 @@
"agents": {
"title": "Агенты",
"edit": "Редактировать",
"view": "Просмотр",
"card": {
"pin": "Закрепить агента",
"unpin": "Открепить агента",
@@ -1878,10 +1884,6 @@
"team": "Команда",
"shared": "Поделились со мной"
},
"teamBadge": {
"editor": "Редактор",
"viewer": "Читатель"
},
"sections": {
"template": {
"title": "От DocsGPT",
@@ -2062,8 +2064,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
},
"readOnly": "Вы видите эту политику, но ваша роль не позволяет её изменить."
}
},
"byline": {
"new": "Настройте агента и опубликуйте его, чтобы с ним общаться."
+8 -7
View File
@@ -10,6 +10,12 @@
"loading": "載入中...",
"retry": "重試",
"cancel": "取消",
"common": {
"close": "關閉",
"viewOnlyNotice": "你可以檢視此內容,但你的角色無法變更它。",
"credentialsLockedNotice": "只有擁有者可以變更此工具的憑證。",
"savedSecretHint": "已儲存。留空即可保留。"
},
"help": "幫助",
"emailUs": "給我們發電郵",
"documentation": "文件",
@@ -389,7 +395,6 @@
"subtitle": "設定「{{name}}」的分塊與檢索方式。",
"subtitleGeneric": "設定此來源的分塊與檢索方式。",
"save": "儲存",
"readOnly": "您對此共享來源僅有檢視權限,無法變更其設定。",
"chunkingChangeHint": "您變更了分塊設定。儲存後需重新索引此來源才會生效。",
"prescreenInvalidHint": "請檢查預篩選數值:擷取候選數不得少於區塊數,且保留區塊數不得超過擷取候選數。",
"reingestRequired": "變更已儲存。您變更的分塊設定需重新索引後才會生效。要立即重新索引嗎?",
@@ -1766,6 +1771,7 @@
"agents": {
"title": "代理",
"edit": "編輯",
"view": "檢視",
"card": {
"pin": "釘選代理",
"unpin": "取消釘選代理",
@@ -1804,10 +1810,6 @@
"team": "團隊",
"shared": "與我共享"
},
"teamBadge": {
"editor": "編輯者",
"viewer": "檢視者"
},
"sections": {
"template": {
"title": "由DocsGPT提供",
@@ -1985,8 +1987,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
},
"readOnly": "你可以檢視此政策,但你的角色無法變更它。"
}
},
"byline": {
"new": "設定好代理後發佈,即可與它對話。"
+8 -7
View File
@@ -10,6 +10,12 @@
"loading": "加载中...",
"retry": "重试",
"cancel": "取消",
"common": {
"close": "关闭",
"viewOnlyNotice": "你可以查看此内容,但你的角色无法更改它。",
"credentialsLockedNotice": "只有所有者可以更改此工具的凭据。",
"savedSecretHint": "已保存。留空即可保留。"
},
"help": "帮助",
"emailUs": "给我们发邮件",
"documentation": "文档",
@@ -389,7 +395,6 @@
"subtitle": "配置“{{name}}”的分块与检索方式。",
"subtitleGeneric": "配置此来源的分块与检索方式。",
"save": "保存",
"readOnly": "您对此共享来源仅有查看权限,无法更改其配置。",
"chunkingChangeHint": "您更改了分块设置。保存后需要重新索引此来源才能生效。",
"prescreenInvalidHint": "请检查预筛选参数:获取的候选数量至少应等于文本块数量,且保留的文本块数不得超过获取的候选数量。",
"reingestRequired": "您的更改已保存。修改的分块设置需重新索引后才会生效。立即重新索引?",
@@ -1766,6 +1771,7 @@
"agents": {
"title": "代理",
"edit": "编辑",
"view": "查看",
"card": {
"pin": "固定代理",
"unpin": "取消固定代理",
@@ -1804,10 +1810,6 @@
"team": "团队",
"shared": "与我共享"
},
"teamBadge": {
"editor": "编辑者",
"viewer": "查看者"
},
"sections": {
"template": {
"title": "由DocsGPT提供",
@@ -1985,8 +1987,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
},
"readOnly": "你可以查看此策略,但你的角色无法更改它。"
}
},
"byline": {
"new": "设置好智能体后发布,即可与它对话。"
@@ -105,6 +105,9 @@ describe('AgentDetailsModal', () => {
await act(async () => generateButtons()[0].click());
const alert = document.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Not allowed');
// Destructive Alerts lead with CircleAlert, like every other one.
expect(alert?.querySelector('svg.lucide-circle-alert')).not.toBeNull();
expect(alert?.querySelector('svg.lucide-circle-x')).toBeNull();
});
it('shows a failed webhook in an alert with a fallback message', async () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { envVar } from '@/env';
import { CircleX, ExternalLink } from 'lucide-react';
import { CircleAlert, ExternalLink } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -140,7 +140,7 @@ export default function AgentDetailsModal({
<div>
{error && (
<Alert variant="destructive" className="mt-6">
<CircleX />
<CircleAlert />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
+14 -3
View File
@@ -108,12 +108,23 @@ describe('MCPServerModal', () => {
expect(text()).toContain(
'settings.tools.mcp.sharedByEditor:{"owner":"Lena Fischer"}',
);
const info = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="alert"]'),
// Informative, not announced: a quiet default Alert with role="note".
const note = Array.from(
document.body.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((a) =>
a.textContent?.includes('settings.tools.mcp.sharedCredentialsNotice'),
);
expect(info?.dataset.variant ?? info?.className).toMatch(/info/);
expect(note?.getAttribute('role')).toBe('note');
expect(note?.dataset.variant).toBe('default');
});
it('masks the API key and bearer token fields', async () => {
await render();
const key = document.body.querySelector<HTMLInputElement>(
'input[placeholder="settings.tools.mcp.placeholders.apiKey"]',
);
expect(key?.type).toBe('password');
expect(key?.value).toBe('');
});
it('falls back to "a teammate" without an owner label', async () => {
+7 -5
View File
@@ -1,4 +1,4 @@
import { CircleAlert, CircleCheck, Info, TriangleAlert } from 'lucide-react';
import { CircleAlert, CircleCheck, Lock, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -509,7 +509,8 @@ export default function MCPServerModal({
}
>
<Input
type="text"
type="password"
autoComplete="off"
value={formData.api_key}
onChange={(e) => handleInputChange('api_key', e.target.value)}
placeholder={t('settings.tools.mcp.placeholders.apiKey')}
@@ -538,7 +539,8 @@ export default function MCPServerModal({
}
>
<Input
type="text"
type="password"
autoComplete="off"
value={formData.bearer_token}
onChange={(e) =>
handleInputChange('bearer_token', e.target.value)
@@ -656,8 +658,8 @@ export default function MCPServerModal({
>
<div className="flex flex-col gap-5">
{isShared && (
<Alert variant="info">
<Info aria-hidden="true" />
<Alert role="note">
<Lock />
<AlertDescription>
{t('settings.tools.mcp.sharedCredentialsNotice')}
{oauthOwnerOnly &&
@@ -166,5 +166,48 @@ describe('PromptsModal', () => {
(b) => b.textContent,
);
expect(labels).not.toContain('modals.prompts.save');
// A shared prompt gets the shared view-only notice, not the built-in copy.
expect(document.body.textContent).toContain('common.viewOnlyNotice');
expect(document.body.textContent).not.toContain(
'modals.prompts.viewDescription',
);
expect(
document.body.querySelector('[data-slot="alert"]')?.getAttribute('role'),
).toBe('note');
expect(labels).toContain('common.close');
expect(labels).not.toContain('modals.prompts.cancel');
});
it('keeps the built-in copy for a built-in prompt', async () => {
await act(async () => {
root.render(
<PromptsModal
existingPrompts={[]}
modalState="ACTIVE"
setModalState={() => undefined}
type="EDIT"
newPromptName=""
setNewPromptName={() => undefined}
newPromptContent=""
setNewPromptContent={() => undefined}
editPromptName="Default"
setEditPromptName={() => undefined}
editPromptContent="You are a helpful assistant."
setEditPromptContent={() => undefined}
currentPromptEdit={{ name: 'Default', id: 'd1', type: 'public' }}
handleEditPrompt={() => undefined}
onDuplicate={() => undefined}
/>,
);
});
expect(document.body.textContent).toContain(
'modals.prompts.viewDescription',
);
expect(document.body.textContent).not.toContain('common.viewOnlyNotice');
const labels = Array.from(document.body.querySelectorAll('button')).map(
(b) => b.textContent,
);
expect(labels).toContain('modals.prompts.duplicate');
expect(labels).toContain('common.close');
});
});
+21 -12
View File
@@ -4,6 +4,7 @@ import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Textarea } from '../components/ui/textarea';
import { FormField } from '../components/ui/form-field';
import ViewOnlyNotice from '../components/ViewOnlyNotice';
import { Link } from 'react-router-dom';
import React from 'react';
@@ -377,12 +378,15 @@ function EditPrompt({
editPromptContent,
setEditPromptContent,
isReadOnly,
showViewOnlyNotice = false,
}: {
editPromptName: string;
setEditPromptName: (name: string) => void;
editPromptContent: string;
setEditPromptContent: (content: string) => void;
isReadOnly: boolean;
/** A shared prompt the caller's role can't change (not a built-in one). */
showViewOnlyNotice?: boolean;
}) {
const { t } = useTranslation();
const systemVariableOptions = React.useMemo(
@@ -394,6 +398,7 @@ function EditPrompt({
return (
<div>
<div className="flex flex-col gap-5">
{showViewOnlyNotice && <ViewOnlyNotice />}
<FormField label={t('modals.prompts.promptName')}>
<Input
type="text"
@@ -517,13 +522,16 @@ export default function PromptsModal({
]);
const { t } = useTranslation();
const isReadOnly =
type === 'EDIT' && (readOnly || currentPromptEdit.type === 'public');
const isBuiltIn = currentPromptEdit.type === 'public';
const isReadOnly = type === 'EDIT' && (readOnly || isBuiltIn);
// A built-in prompt explains itself in the description (duplicate it to
// edit); a shared one the role can't change gets the shared notice.
const showViewOnlyNotice = isReadOnly && !isBuiltIn;
const closeModal = () => setModalState('INACTIVE');
let view;
let title: string;
let description: string;
let description: string | undefined;
if (type === 'ADD') {
title = duplicateSourceName
@@ -546,11 +554,11 @@ export default function PromptsModal({
title = t(
isReadOnly ? 'modals.prompts.viewPrompt' : 'modals.prompts.editPrompt',
);
description = t(
isReadOnly
? 'modals.prompts.viewDescription'
: 'modals.prompts.editDescription',
);
description = isBuiltIn
? t('modals.prompts.viewDescription')
: isReadOnly
? undefined
: t('modals.prompts.editDescription');
view = (
<EditPrompt
editPromptName={editPromptName}
@@ -558,6 +566,7 @@ export default function PromptsModal({
editPromptContent={editPromptContent}
setEditPromptContent={setEditPromptContent}
isReadOnly={isReadOnly}
showViewOnlyNotice={showViewOnlyNotice}
/>
);
}
@@ -610,19 +619,19 @@ export default function PromptsModal({
footer = (
<ModalActions
footerStart={learnLink}
cancelLabel={t('modals.prompts.cancel')}
cancelLabel={t('common.close')}
onCancel={closeModal}
submitLabel={t('modals.prompts.duplicate')}
onSubmit={onDuplicate}
/>
);
} else {
// A public prompt with nothing to do but close: the link and a lone
// Cancel.
// A prompt to view with nothing to do but close: the link and a lone
// Close.
footer = (
<ModalActions
footerStart={learnLink}
cancelLabel={t('modals.prompts.cancel')}
cancelLabel={t('common.close')}
onCancel={closeModal}
/>
);
@@ -58,8 +58,11 @@ describe('SourceConfigModal access', () => {
};
const readOnlyNotice = () =>
document.body.textContent?.includes(
'settings.sources.configModal.readOnly',
document.body.textContent?.includes('common.viewOnlyNotice');
const buttonLabels = () =>
Array.from(document.body.querySelectorAll('button')).map(
(b) => b.textContent,
);
it('a viewer with view_config only sees the read-only notice', async () => {
@@ -72,6 +75,12 @@ describe('SourceConfigModal access', () => {
}),
);
expect(readOnlyNotice()).toBe(true);
const note = document.body.querySelector('[data-slot="alert"]');
expect(note?.getAttribute('role')).toBe('note');
// View-only: no Save, and Cancel becomes a lone Close.
expect(buttonLabels()).not.toContain('settings.sources.configModal.save');
expect(buttonLabels()).not.toContain('cancel');
expect(buttonLabels()).toContain('common.close');
});
it('an editor can edit (no read-only notice)', async () => {
@@ -84,6 +93,8 @@ describe('SourceConfigModal access', () => {
}),
);
expect(readOnlyNotice()).toBe(false);
expect(buttonLabels()).toContain('settings.sources.configModal.save');
expect(buttonLabels()).toContain('cancel');
});
it('follows allowed_actions over the legacy team_access', async () => {
+5 -6
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import ViewOnlyNotice from '../components/ViewOnlyNotice';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Modal, ModalActions } from '../components/ui/modal';
import { ActiveState, Doc } from '../models/misc';
@@ -148,6 +149,8 @@ export default function SourceConfigModal({
submitLabel={t('settings.sources.reingest')}
onSubmit={handleConfirmReingest}
/>
) : isReadOnly ? (
<ModalActions cancelLabel={t('common.close')} onCancel={closeModal} />
) : (
<ModalActions
cancelLabel={t('cancel')}
@@ -156,7 +159,7 @@ export default function SourceConfigModal({
submitLabel={t('settings.sources.configModal.save')}
onSubmit={handleSave}
pending={saving}
disabled={isReadOnly || !hasChanges || !prescreenValid}
disabled={!hasChanges || !prescreenValid}
/>
);
@@ -189,11 +192,7 @@ export default function SourceConfigModal({
</div>
) : (
<div className="flex flex-col gap-4">
{isReadOnly && (
<div className="bg-muted text-muted-foreground rounded-xl p-3 text-sm">
{t('settings.sources.configModal.readOnly')}
</div>
)}
{isReadOnly && <ViewOnlyNotice />}
<RetrievalOptions
value={options}
onChange={setOptions}
+9
View File
@@ -126,6 +126,12 @@ describe('Sources access', () => {
).map((el) => el.textContent);
};
const menuIcon = (label: string) =>
Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((el) => el.textContent === label)
?.querySelector('svg')
?.getAttribute('class') ?? '';
const clickItem = async (label: string) => {
const item = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
@@ -177,6 +183,7 @@ describe('Sources access', () => {
'settings.sources.testRetrieval.action',
'settings.sources.wiki.convert.action',
]);
expect(menuIcon('settings.sources.editConfig')).not.toContain('lucide-eye');
});
it('editors_can_share / editors_can_delete widen the editor menu', async () => {
@@ -206,6 +213,8 @@ describe('Sources access', () => {
'settings.sources.viewConfig',
'settings.sources.testRetrieval.action',
]);
// View swaps the config item's icon for Eye, like every View label.
expect(menuIcon('settings.sources.viewConfig')).toContain('lucide-eye');
});
it('viewer without view_config: no config item', async () => {
+5 -14
View File
@@ -19,6 +19,7 @@ import userService from '../api/services/userService';
import modelService from '../api/services/modelService';
import PageToolbar from '../components/PageToolbar';
import RoleBadge from '../components/RoleBadge';
import SearchInput from '../components/SearchInput';
import SkeletonLoader from '../components/SkeletonLoader';
import { Badge } from '../components/ui/badge';
@@ -46,7 +47,7 @@ import {
selectUploadTasks,
updateUploadTask,
} from '../upload/uploadSlice';
import { can, roleOf } from '../utils/accessUtils';
import { can } from '../utils/accessUtils';
import { formatDate } from '../utils/dateTimeUtils';
import FileTree from '../components/FileTree';
import ConnectorTree from '../components/ConnectorTree';
@@ -412,7 +413,7 @@ export default function Sources({
// Editors edit the config; a viewer may read it (view_config).
if (document.id && !isWiki && (canEdit || can(document, 'view_config'))) {
actions.push({
icon: SlidersHorizontal,
icon: canEdit ? SlidersHorizontal : Eye,
label: canEdit
? t('settings.sources.editConfig')
: t('settings.sources.viewConfig'),
@@ -693,14 +694,7 @@ export default function Sources({
</div>
<div className="flex flex-col items-start justify-start gap-1">
{roleOf(document) !== 'owner' && (
<Badge variant="neutral">
<Users className="size-3" aria-hidden="true" />
{roleOf(document) === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer')}
</Badge>
)}
<RoleBadge item={document} />
{document.ingestStatus === 'failed' && (
<Badge variant="destructive">
{t('settings.sources.ingestFailed')}
@@ -728,10 +722,7 @@ export default function Sources({
: null;
return (
<Badge variant="neutral">
<Network
className="size-3"
aria-hidden="true"
/>
<Network aria-hidden="true" />
{isBuilding
? pct !== null
? t(
+58 -6
View File
@@ -81,6 +81,12 @@ vi.mock('../api/services/teamsService', () => ({
},
}));
// Mark formatted counts so a raw number in the UI shows up in a test.
vi.mock('../utils/dateTimeUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/dateTimeUtils')>()),
formatCount: (value: number) => `#${value}`,
}));
import Teams from './Teams';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -217,7 +223,7 @@ describe('Teams page', () => {
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('Key Accounts');
expect(rows[0].textContent).toContain(
'settings.teams.sharedList.badgeWithEditors(level=viewer,count=1)',
'settings.teams.sharedList.badgeWithEditors(level=viewer,count=#1)',
);
expect(rows[0].textContent).toContain(
'settings.teams.sharedList.meta(type=settings.teams.resourceType.source,owner=Lena Fischer)',
@@ -225,12 +231,58 @@ describe('Teams page', () => {
// Filter pills with counts.
const pills = Array.from(body().querySelectorAll('[role="radio"]'));
expect(pills.map((p) => p.textContent)).toEqual([
'settings.teams.sharedList.filter.all 2',
'settings.teams.sharedList.filter.agent 0',
'settings.teams.sharedList.filter.source 1',
'settings.teams.sharedList.filter.tool 0',
'settings.teams.sharedList.filter.prompt 1',
'settings.teams.sharedList.filter.all #2',
'settings.teams.sharedList.filter.agent #0',
'settings.teams.sharedList.filter.source #1',
'settings.teams.sharedList.filter.tool #0',
'settings.teams.sharedList.filter.prompt #1',
]);
expect(body().textContent).toContain('settings.teams.sharedResources · #2');
});
it('shows the no-results line when the search matches nothing', async () => {
listGrants.mockResolvedValue({
team_role: 'team_admin',
grants: [grant()],
});
await render();
const search = body().querySelector<HTMLInputElement>(
'input[aria-label="settings.teams.sharedList.search"]',
)!;
act(() => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!;
setter.call(search, 'nothing like this');
search.dispatchEvent(new Event('input', { bubbles: true }));
});
const empty = Array.from(
body().querySelectorAll('[data-slot="empty-state"]'),
).find((e) =>
e.textContent?.includes('settings.teams.sharedList.noMatches'),
);
expect(empty?.getAttribute('data-size')).toBe('xs');
expect(empty?.querySelector('img')).toBeNull();
});
it('keeps the drawer header fixed above one scrolling body', async () => {
listGrants.mockResolvedValue({
team_role: 'team_admin',
grants: [grant()],
});
await render();
await openDrawer();
const sheet = body().querySelector('[data-slot="sheet-content"]')!;
const title = sheet.querySelector('[data-slot="sheet-title"]')!;
expect(title.className).toContain('wrap-break-word');
expect(title.className).not.toContain('truncate');
const scrollers = sheet.querySelectorAll('.overflow-y-auto');
expect(scrollers).toHaveLength(1);
// The title and the Open button stay put; the details scroll.
expect(scrollers[0].contains(title)).toBe(false);
expect(scrollers[0].textContent).toContain('settings.teams.drawer.owner');
expect(scrollers[0].className).toContain('px-6 py-6');
});
it('shows role selects, remove and Manage sharing to a caller who can share', async () => {
+60 -56
View File
@@ -74,6 +74,7 @@ import {
} from '../components/ui/sheet';
import { Textarea } from '../components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { cn } from '../lib/utils';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
@@ -101,7 +102,7 @@ import {
Team,
} from '../teams/teamsSlice';
import { can } from '../utils/accessUtils';
import { formatDateOnly } from '../utils/dateTimeUtils';
import { formatCount, formatDateOnly } from '../utils/dateTimeUtils';
import { decodeJwtPayload } from '../utils/jwtUtils';
type Member = {
@@ -696,7 +697,7 @@ export default function Teams() {
? t('settings.teams.sharedList.badgeWithEditors', {
interpolation: { escapeValue: false },
level,
count: memberEditors,
count: formatCount(memberEditors),
})
: level;
}
@@ -866,13 +867,13 @@ export default function Teams() {
(team.member_count ?? 0) === 1
? 'settings.teams.memberCountOne'
: 'settings.teams.memberCountOther',
{ count: team.member_count ?? 0 },
{ count: formatCount(team.member_count ?? 0) },
)}
</span>
<span aria-hidden>·</span>
<span>
{t('settings.teams.sharedCount', {
count: team.shared_count ?? 0,
count: formatCount(team.shared_count ?? 0),
})}
</span>
</CardFooter>
@@ -945,7 +946,7 @@ export default function Teams() {
<SectionHeader
as="h4"
size="sm"
title={`${t('settings.teams.members')} · ${members.length}`}
title={`${t('settings.teams.members')} · ${formatCount(members.length)}`}
actions={
isAdmin && (
<Button
@@ -1026,7 +1027,7 @@ export default function Teams() {
<SectionHeader
as="h4"
size="sm"
title={`${t('settings.teams.sharedResources')} · ${sharedResources.length}`}
title={`${t('settings.teams.sharedResources')} · ${formatCount(sharedResources.length)}`}
/>
{sharedResources.length === 0 ? (
<EmptyState size="sm" title={t('settings.teams.nothingShared')} />
@@ -1047,7 +1048,7 @@ export default function Teams() {
(value) => (
<ToggleGroupItem key={value} value={value}>
{t(`settings.teams.sharedList.filter.${value}`)}{' '}
{resourceCounts[value]}
{formatCount(resourceCounts[value])}
</ToggleGroupItem>
),
)}
@@ -1063,7 +1064,8 @@ export default function Teams() {
</div>
{visibleResources.length === 0 ? (
<EmptyState
size="sm"
size="xs"
illustration="none"
title={t('settings.teams.sharedList.noMatches')}
/>
) : (
@@ -1133,62 +1135,64 @@ export default function Teams() {
className="p-0"
closeLabel={t('settings.teams.drawer.close')}
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{/* pr-12 keeps the header clear of the close X. */}
<div className="flex items-center gap-3 px-6 pt-6 pr-12 pb-4">
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
>
{resourceTypeIcon(openResource.type)}
</span>
<div className="flex min-w-0 flex-col gap-1">
<SheetTitle className="truncate">
{resourceName(openResource)}
</SheetTitle>
<SheetDescription>
{t('settings.teams.drawer.subtitle', {
interpolation: { escapeValue: false },
type: resourceTypeLabel(openResource.type),
owner: ownerLabel(openResource),
})}
</SheetDescription>
<div className="flex min-h-0 flex-1 flex-col">
{/* A fixed header: pr-12 keeps it clear of the close X. */}
<div className="flex flex-col gap-4 px-6 pt-6 pr-12 pb-4">
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
>
{resourceTypeIcon(openResource.type)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<SheetTitle className="wrap-break-word">
{resourceName(openResource)}
</SheetTitle>
<SheetDescription>
{t('settings.teams.drawer.subtitle', {
interpolation: { escapeValue: false },
type: resourceTypeLabel(openResource.type),
owner: ownerLabel(openResource),
})}
</SheetDescription>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 px-6 pb-4">
<Button
variant="outline"
size="sm"
shape="pill"
onClick={() => {
const path = openAssetPath(openResource);
closeDrawer();
navigate(path);
}}
>
<ArrowUpRight aria-hidden />
{t('settings.teams.drawer.open', {
interpolation: { escapeValue: false },
type: resourceTypeLabel(openResource.type),
})}
</Button>
{callerCanShare && (
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
shape="pill"
onClick={() => {
setDrawerOpen(false);
setShareTarget(openResource);
const path = openAssetPath(openResource);
closeDrawer();
navigate(path);
}}
>
<Users aria-hidden />
{t('settings.teams.drawer.manageSharing')}
<ArrowUpRight aria-hidden />
{t('settings.teams.drawer.open', {
interpolation: { escapeValue: false },
type: resourceTypeLabel(openResource.type),
})}
</Button>
)}
{callerCanShare && (
<Button
variant="outline"
size="sm"
shape="pill"
onClick={() => {
setDrawerOpen(false);
setShareTarget(openResource);
}}
>
<Users aria-hidden />
{t('settings.teams.drawer.manageSharing')}
</Button>
)}
</div>
</div>
<Separator />
<div className="flex flex-col gap-6 px-6 py-6">
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-6">
<DescriptionList size="sm">
<DescriptionItem label={t('settings.teams.drawer.owner')}>
{ownerLabel(openResource)}
@@ -1337,9 +1341,9 @@ export default function Teams() {
/>
)}
<span
className={
line.allowed ? undefined : 'text-muted-foreground'
}
className={cn(
!line.allowed && 'text-muted-foreground',
)}
>
{line.text}
</span>
+30 -4
View File
@@ -426,11 +426,17 @@ describe('ToolConfig', () => {
it('opens read-only without edit or edit_credentials: no Save, every field disabled', async () => {
await render({ ...configTool, ...viewer } as UserToolType);
expect(buttonByText('settings.tools.save')).toBeUndefined();
const note = container.querySelector('[data-slot="alert"]');
expect(note?.getAttribute('role')).toBe('note');
expect(note?.textContent).toBe('common.viewOnlyNotice');
expect(nameInput().disabled).toBe(true);
const secret = Array.from(
container.querySelectorAll<HTMLInputElement>('input'),
).find((i) => i.placeholder === '••••••••');
const secret = container.querySelector<HTMLInputElement>(
'input[type="password"]',
);
expect(secret && disabled(secret)).toBe(true);
expect(secret?.value).toBe('');
expect(secret?.placeholder).not.toContain('•');
expect(container.textContent).toContain('common.savedSecretHint');
container
.querySelectorAll<HTMLButtonElement>('[role="switch"]')
.forEach((sw) => expect(disabled(sw)).toBe(true));
@@ -461,13 +467,33 @@ describe('ToolConfig', () => {
it('lets an editor without edit_credentials rename but not touch credentials', async () => {
await render({ ...configTool, ...editorNoCreds } as UserToolType);
expect(nameInput().disabled).toBe(false);
// Not a view-only form: the editor can still save a rename.
expect(container.textContent).not.toContain('common.viewOnlyNotice');
const authInputs = Array.from(
container.querySelectorAll<HTMLInputElement>('input'),
).filter((i) => i !== nameInput() && !i.closest('table'));
const credential = authInputs.find((i) => i.placeholder === '••••••••');
const credential = authInputs.find((i) => i.type === 'password');
expect(credential && disabled(credential)).toBe(true);
});
it('says why the credentials are locked for an editor without edit_credentials', async () => {
await render({ ...configTool, ...editorNoCreds } as UserToolType);
const note = container.querySelector('[data-slot="alert"]');
expect(note?.getAttribute('role')).toBe('note');
expect(note?.textContent).toBe('common.credentialsLockedNotice');
});
it('shows no credentials note to a role that may change them', async () => {
await render({
...configTool,
access: 'editor',
allowed_actions: ['edit', 'edit_credentials', 'use'],
} as UserToolType);
expect(container.textContent).not.toContain(
'common.credentialsLockedNotice',
);
});
it("disables an API tool's URL and header values without edit_credentials", async () => {
await render({ ...apiTool, ...editorNoCreds } as APIToolType);
await expandFirstAction();
+5
View File
@@ -5,6 +5,7 @@ import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import ConfigFields from '../components/ConfigFields';
import ViewOnlyNotice from '../components/ViewOnlyNotice';
import SearchInput from '../components/SearchInput';
import { Alert, AlertDescription } from '../components/ui/alert';
import {
@@ -395,6 +396,10 @@ export default function ToolConfig({
</Button>
)}
</div>
{readOnly && <ViewOnlyNotice />}
{!readOnly && !canEditCredentials && (
<ViewOnlyNotice message={t('common.credentialsLockedNotice')} />
)}
{saveError && (
<Alert variant="destructive" className="mb-2">
<AlertDescription>{saveError}</AlertDescription>
+18 -6
View File
@@ -229,13 +229,25 @@ describe('Tools', () => {
);
});
it('disables the switch, keeping its label, for a shared tool without use_in_own', async () => {
// The tool can't be in the caller's chats at all (the composer picker
// hides it too), so there is no state to show: no switch, no bare grey one.
it('hides the switch and its label for a shared tool without use_in_own', async () => {
await render([viewerTool]);
const sw = switchOf('vw');
expect(sw.disabled).toBe(true);
expect(card('vw').querySelector(`label[for="${sw.id}"]`)?.textContent).toBe(
'settings.tools.inMyChats',
);
expect(card('vw').querySelector('[role="switch"]')).toBeNull();
expect(card('vw').textContent).not.toContain('settings.tools.inMyChats');
});
it('shows the role on a shared tile as a neutral Users Badge', async () => {
await render([ownTool, editorTool, viewerTool]);
const badge = (id: string) =>
card(id).querySelector<HTMLElement>('[data-testid="role-badge"]');
expect(badge('own')).toBeNull();
expect(badge('ed')?.dataset.variant).toBe('neutral');
expect(badge('ed')?.textContent).toBe('teamAccess.editor');
expect(badge('vw')?.textContent).toBe('teamAccess.viewer');
expect(
badge('ed')?.querySelector('svg')?.getAttribute('class'),
).not.toContain('size-3');
});
it('reverts the switch and shows an error toast when the update fails', async () => {
+27 -30
View File
@@ -7,6 +7,7 @@ import devicesService from '../api/services/devicesService';
import userService from '../api/services/userService';
import PageToolbar from '../components/PageToolbar';
import SearchInput from '../components/SearchInput';
import RoleBadge from '../components/RoleBadge';
import SkeletonLoader from '../components/SkeletonLoader';
import ToolIcon from '../components/ToolIcon';
import { Badge } from '../components/ui/badge';
@@ -24,7 +25,7 @@ import { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import ShareToTeamModal from '../teams/ShareToTeamModal';
import { can, isOwner } from '../utils/accessUtils';
import { can, isOwner, roleOf } from '../utils/accessUtils';
import { canAddToolToOwn, toolInChat } from '../utils/toolUtils';
import RemoteDeviceConfig from './RemoteDeviceConfig';
import ToolConfig from './ToolConfig';
@@ -116,7 +117,7 @@ export default function Tools() {
timeout: config.timeout || 30,
oauth_scopes: oauthScopes,
has_encrypted_credentials: !!config.has_encrypted_credentials,
access: tool.access ?? (isOwner(tool) ? 'owner' : tool.team_access),
access: roleOf(tool),
owner_label: tool.owner_label ?? null,
});
setReconnectModalState('ACTIVE');
@@ -395,14 +396,7 @@ export default function Tools() {
)}
</Badge>
)}
{tool.ownership === 'team' && (
<Badge variant="neutral">
<Users className="size-3" aria-hidden="true" />
{tool.team_access === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer')}
</Badge>
)}
<RoleBadge item={tool} />
</div>
<div className="mt-[9px] px-1">
<CardTitle
@@ -421,26 +415,29 @@ export default function Tools() {
</CardDescription>
</div>
</div>
<div className="absolute right-4 bottom-4 flex items-center gap-2">
<Label
htmlFor={`toolToggle-${index}`}
className="text-muted-foreground text-xs font-normal"
>
{t('settings.tools.inMyChats')}
</Label>
<Switch
checked={toolInChat(tool)}
onCheckedChange={(checked) =>
updateToolStatus(tool.id, checked)
}
disabled={!canAddToolToOwn(tool)}
id={`toolToggle-${index}`}
aria-label={t('settings.tools.useInMyChatsAria', {
interpolation: { escapeValue: false },
toolName: tool.customName || tool.displayName,
})}
/>
</div>
{/* A shared tool without use_in_own can't be in the
caller's chats at all, so there is no switch. */}
{canAddToolToOwn(tool) && (
<div className="absolute right-4 bottom-4 flex items-center gap-2">
<Label
htmlFor={`toolToggle-${index}`}
className="text-muted-foreground text-xs font-normal"
>
{t('settings.tools.inMyChats')}
</Label>
<Switch
checked={toolInChat(tool)}
onCheckedChange={(checked) =>
updateToolStatus(tool.id, checked)
}
id={`toolToggle-${index}`}
aria-label={t('settings.tools.useInMyChatsAria', {
interpolation: { escapeValue: false },
toolName: tool.customName || tool.displayName,
})}
/>
</div>
)}
</Card>
))}
</div>
+40 -3
View File
@@ -47,6 +47,12 @@ vi.mock('../api/services/teamsService', () => ({
},
}));
// Mark formatted counts so a raw number in the UI shows up in a test.
vi.mock('../utils/dateTimeUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/dateTimeUtils')>()),
formatCount: (value: number) => `#${value}`,
}));
import ShareToTeamModal from './ShareToTeamModal';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -131,7 +137,11 @@ describe('ShareToTeamModal', () => {
const toggle = buttonByText('settings.teams.accessSettings.title');
expect(toggle).toBeDefined();
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
expect(toggle?.getAttribute('data-variant')).toBe('section-toggle');
// An inline disclosure (no Card around it to draw section-toggle's
// ring), so the button shows its own keyboard focus.
expect(toggle?.getAttribute('data-variant')).toBe('link');
expect(toggle?.className).toContain('focus-visible:ring-3');
expect(toggle?.className).not.toContain('focus-visible:ring-0');
expect(body().querySelectorAll('[role="switch"]')).toHaveLength(0);
act(() => toggle!.click());
expect(body().querySelectorAll('[role="switch"]')).toHaveLength(4);
@@ -249,8 +259,11 @@ describe('ShareToTeamModal', () => {
expect(rows).toHaveLength(4);
// Most recent first: user-7, user-6, user-5.
expect(rows[1].textContent).toContain('user-7');
expect(text()).toContain('settings.teams.share.andMore(count=5)');
expect(text()).toContain('settings.teams.share.andMore(count=#5)');
const showAll = buttonByText('settings.teams.share.showAll');
expect(showAll?.textContent).toContain(
'settings.teams.share.showAll(count=#8)',
);
expect(showAll?.getAttribute('data-variant')).toBe('link');
expect(body().querySelector('.max-h-72')).toBeNull();
});
@@ -260,11 +273,21 @@ describe('ShareToTeamModal', () => {
await render();
act(() => buttonByText('settings.teams.share.showAll')!.click());
expect(text()).toContain(
'settings.teams.share.allSummary(name=QBR Report Builder,teams=2,people=6)',
'settings.teams.share.allSummary(name=QBR Report Builder,teams=#2,people=#6)',
);
expect(buttonByText('settings.teams.share.back')).toBeDefined();
// You + all 8.
expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(9);
expect(
Array.from(body().querySelectorAll('[role="radio"]')).map(
(p) => p.textContent,
),
).toEqual([
'settings.teams.share.filter.all #8',
'settings.teams.share.filter.teams #2',
'settings.teams.share.filter.people #6',
'settings.teams.share.filter.editors #2',
]);
const teamsPill = Array.from(
body().querySelectorAll<HTMLButtonElement>('[role="radio"]'),
@@ -294,6 +317,20 @@ describe('ShareToTeamModal', () => {
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('user-6');
// No match: the "no results" EmptyState line, not a bare paragraph.
act(() => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!;
setter.call(search, 'nobody-here');
search!.dispatchEvent(new Event('input', { bubbles: true }));
});
const empty = body().querySelector('[data-slot="empty-state"]');
expect(empty?.getAttribute('data-size')).toBe('xs');
expect(empty?.querySelector('img')).toBeNull();
expect(empty?.textContent).toContain('settings.teams.share.noMatches');
act(() => buttonByText('settings.teams.share.back')!.click());
expect(buttonByText('settings.teams.share.showAll')).toBeDefined();
});
+33 -30
View File
@@ -21,6 +21,7 @@ import SearchInput from '../components/SearchInput';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
import { EmptyState } from '../components/ui/empty-state';
import {
Command,
CommandEmpty,
@@ -52,6 +53,7 @@ import { cn } from '../lib/utils';
import { selectToken } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import { can } from '../utils/accessUtils';
import { formatCount } from '../utils/dateTimeUtils';
import { decodeJwtPayload } from '../utils/jwtUtils';
import {
anyChanged,
@@ -688,8 +690,8 @@ export default function ShareToTeamModal({
{t('settings.teams.share.allSummary', {
interpolation: { escapeValue: false },
name: resourceName ?? '',
teams: teamGrantCount,
people: personGrantCount,
teams: formatCount(teamGrantCount),
people: formatCount(personGrantCount),
})}
</p>
</div>
@@ -712,7 +714,8 @@ export default function ShareToTeamModal({
>
{filterOptions.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{t(`settings.teams.share.filter.${option.value}`)} {option.count}
{t(`settings.teams.share.filter.${option.value}`)}{' '}
{formatCount(option.count)}
</ToggleGroupItem>
))}
</ToggleGroup>
@@ -722,36 +725,36 @@ export default function ShareToTeamModal({
{filteredShares.map(renderShareRow)}
</ListRows>
{filteredShares.length === 0 && (
<p className="text-muted-foreground px-4 text-sm">
{t('settings.teams.share.noMatches')}
</p>
<EmptyState
size="xs"
illustration="none"
title={t('settings.teams.share.noMatches')}
/>
)}
</div>
);
const accessSettings = canManageSettings && (
<section className="flex flex-col gap-3">
<h3>
<Button
type="button"
variant="section-toggle"
size="sm"
aria-expanded={settingsOpen}
className="-ml-3 w-fit justify-start"
onClick={() => setSettingsOpen((open) => !open)}
>
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform duration-200',
settingsOpen && 'rotate-90',
)}
/>
<span className="text-sm font-medium">
{t('settings.teams.accessSettings.title')}
</span>
</Button>
</h3>
{/* An inline disclosure: no Card around it draws section-toggle's
ring, so the link button shows its own focus. */}
<Button
type="button"
variant="link"
size="sm"
aria-expanded={settingsOpen}
className="-ml-3 w-fit justify-start"
onClick={() => setSettingsOpen((open) => !open)}
>
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform duration-200',
settingsOpen && 'rotate-90',
)}
/>
{t('settings.teams.accessSettings.title')}
</Button>
{settingsOpen && (
<SettingRows>
{settings.map((setting) => {
@@ -893,7 +896,7 @@ export default function ShareToTeamModal({
onValueChange={(value) => setAccessLevel(value as AccessLevel)}
>
<SelectTrigger
className="h-9 w-28 shrink-0"
className="w-28 shrink-0"
aria-label={t('settings.teams.share.access')}
>
<SelectValue />
@@ -931,7 +934,7 @@ export default function ShareToTeamModal({
onClick={openAllStep}
>
{t('settings.teams.share.showAll', {
count: shares.length,
count: formatCount(shares.length),
})}
<ArrowRight className="size-3" aria-hidden />
</Button>
@@ -945,7 +948,7 @@ export default function ShareToTeamModal({
{isLongList && (
<p className="text-muted-foreground px-4 text-xs">
{t('settings.teams.share.andMore', {
count: shares.length - previewShares.length,
count: formatCount(shares.length - previewShares.length),
})}
</p>
)}
+14 -3
View File
@@ -134,10 +134,21 @@ class TestListPayload:
v = next(r for r in viewer.json if r["id"] == sid)
e = next(r for r in editor.json if r["id"] == sid)
vp = next(r for r in vpage.json["paginated"] if r["id"] == sid)
assert "config" not in v
# Only the behaviour selector survives: the UI needs it to pick the view.
assert v["config"] == {"kind": "classic"}
assert v["allowed_actions"] == ["use"]
assert "config" not in vp
assert "config" in e
assert vp["config"] == {"kind": "classic"}
assert "retrieval" in e["config"]
def test_viewer_keeps_graphrag_kind_when_config_hidden(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson
sid = _shared_source(pg_conn, config={"kind": "graphrag"})
_set(pg_conn, sid, viewers_can_see_config=False)
with _patch_db(pg_conn, ROUTES):
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
v = next(r for r in viewer.json if r["id"] == sid)
assert v["config"] == {"kind": "graphrag"}
def test_paginated_rows_carry_access(self, app, pg_conn):
from docsgpt.api.user.sources.routes import PaginatedSources