mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 12:14:00 +00:00
Frontend fixes
This commit is contained in:
1 parent
98afa5d93c
commit
38bfb19739
42 files changed
+834
-284
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "エージェントを設定し、公開するとチャットできます。"
|
||||
|
||||
@@ -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": "Настройте агента и опубликуйте его, чтобы с ним общаться."
|
||||
|
||||
@@ -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": "設定好代理後發佈,即可與它對話。"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user