diff --git a/docsgpt/api/user/sources/routes.py b/docsgpt/api/user/sources/routes.py
index abbb0492..fab6968a 100644
--- a/docsgpt/api/user/sources/routes.py
+++ b/docsgpt/api/user/sources/routes.py
@@ -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
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 4e918b93..82c412cb 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -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` + `
` 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: `