mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
feat: operator-configurable settings visibility via PAPERCLIP_HIDDEN_SETTINGS (#11823)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The instance settings surface (Access, Plugins, Adapters, General,
Experimental) assumes the person at the keyboard operates the whole
instance
> - Operators who host Paperclip for others — a managed cloud or an
internal shared server — expose settings pages and toggles that do not
apply to their deployment, and the related mutation APIs stay open
> - A hosted tenant can open Plugins or Adapters, try an action, and hit
a confusing failure, because only a few hardcoded platform floors exist
> - This pull request adds a generic, operator-configured visibility
mechanism: one env var hides declared settings surfaces in the UI and
floors their mutation routes with a stable 403 code
> - The benefit is a clean hosted-tenant settings surface for any
operator, with zero behavior change for normal self-hosted instances
## Linked Issues or Issue Description
**Subsystem affected**
Instance settings (server routes and UI), the shared settings registry
in `packages/shared`, and the `/api/health` bootstrap payload.
**Problem or motivation**
An operator who hosts Paperclip for other people cannot hide settings
surfaces that the platform manages. Tenants see Access, Plugins, and
Adapters pages, backup retention, and host-level experimental toggles
that do nothing useful for them. The mutation APIs behind these surfaces
also stay open, so a tenant admin can attempt actions the platform must
control. ROADMAP.md names a cleaner shared deployment story as a goal
("Teams should be able to run the same product in hosted or semi-hosted
environments without changing the mental model").
**Proposed solution**
Add a declarative registry of hideable settings surfaces and one env
var, `PAPERCLIP_HIDDEN_SETTINGS`. The server parses the list at boot,
reports it on `/api/health`, and rejects value-changing writes to hidden
surfaces with a stable `settings_operator_managed` 403 code. The UI
reads the list from the health payload and removes the hidden pages,
sections, and toggles from navigation, routes, and page content. Unknown
keys warn and are ignored, so one list can roll across a fleet with
mixed app versions. With the variable unset, behavior is byte-identical
to today.
**Alternatives considered**
- Hardcode the hidden set for cloud instances in this repo: rejected,
because each hosting operator needs a different policy, and policy does
not belong in shared code.
- Deliver the hidden set through the managed-config document: rejected,
because that channel is cloud-specific and fail-closed on unknown
fields; a plain env var works for any operator, including self-hosted
shared servers.
- Lock the controls with a badge instead of hiding them: rejected for
these surfaces, because they are meaningless to tenants, not merely
platform-controlled; the existing managed-overlay lock stays the right
tool for controlled flags.
**Roadmap alignment**
Supports the "shared deployment story" item in ROADMAP.md: hosted and
semi-hosted deployments keep the same product with a settings surface
that matches what the tenant can actually do.
## What Changed
- New `packages/shared/src/settings-visibility.ts`: registry of hideable
surfaces (every instance settings page — profile, environments, access,
heartbeats, experimental, plugins, adapters; every Instance → General
section; every experimental flag as `instance.experimental.<key>`), the
`PAPERCLIP_HIDDEN_SETTINGS` parser, and the `settings_operator_managed`
error code. The General page stays visible as the settings root and
redirect target.
- New `server/src/services/settings-visibility.ts`: parse-once accessor;
unknown keys log one warning and are ignored.
- `/api/health` reports `hiddenSettings` on every response shape; the
field is omitted when nothing is hidden.
- Server floors on hidden surfaces, with same-value echo tolerance (the
`executionMode` precedent): field-backed general sections and
experimental keys reject value-changing PATCHes, and hiding the whole
Experimental page floors every toggle; plugin lifecycle and config
writes, adapter management writes, and the Access admin routes (reads
included) return 403 `settings_operator_managed`. Reads the app itself
needs (plugin `ui-contributions`, adapter metadata, plugin job trigger)
stay open. Pages without instance-scoped mutation routes are hidden in
the UI only.
- UI: new `useHiddenSettings` hook and `HiddenSettingsPageGate` route
gate (hidden pages redirect to the settings root); the settings sidebar
and tab bar drop hidden entries; remembered settings paths remap to the
default page; `InstanceGeneralSettings` skips hidden sections; every
`ExperimentalToggleCard` now carries its flag key and renders nothing
when hidden.
- Removed the dead `InstanceSidebar` component (referenced only by its
own test).
- Docs: `docs/deploy/environment-variables.md` documents the variable
and the key registry.
## Verification
- `pnpm vitest run` over the new and extended suites: shared registry
and parser, representative floor tests per route class (changed-value
403, same-value echo 200, unset env 200, page-level Experimental
hiding), the health field, the route gate, nav filtering, and
section/card hiding with one hidden example per surface kind — 168 tests
pass.
- Full root `pnpm typecheck` passes.
- Manual: booted a server with the variable set. `/api/health` lists the
keys; an unknown key logs one warning and the server boots; hidden pages
redirect; hidden sections and cards do not render; hidden-field PATCH
returns 403 with `details.code = "settings_operator_managed"`; a
same-value echo returns 200. Unset the variable: the full settings
surface returns and responses are byte-identical to master.
## Risks
- Low risk for self-hosted instances: with the variable unset, the
hidden set is empty, the health field is omitted, and no floor
activates.
- Flooring plugin config writes assumes hosted deployments configure
plugins through the platform. If a future bundled plugin needs
tenant-entered config, the floor needs a narrow carve-out.
- Hidden-key floors tolerate same-value echoes, so API clients that
round-trip full GET responses keep working.
- Hiding a toggle does not change its value; operators pair hiding with
the desired default where the value matters.
## Model Used
Claude Fable 5 (Anthropic, `claude-fable-5`) with extended thinking and
agentic tool use, driven through the Claude Code CLI (file edits, test
execution, and live-server verification loops).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
1 parent
0fa318b8da
commit
db4defdfbf
32 files changed
+1328
-414
No files matched your search
@@ -931,3 +931,55 @@ describe("InstanceExperimentalSettings — card ordering and headings (PAP-393)"
|
||||
expect(badges).not.toContain("Experimental");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceExperimentalSettings — operator-hidden cards", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
queryClient?.clear();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderPage(hiddenSettings?: string[]) {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue(defaultExperimentalSettings());
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
...(hiddenSettings ? { hiddenSettings } : {}),
|
||||
});
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InstanceExperimentalSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders nothing for an operator-hidden toggle and keeps the rest", async () => {
|
||||
await renderPage(["instance.experimental.enableEnvironments"]);
|
||||
|
||||
expect(container.textContent).not.toContain("Enable Environments");
|
||||
expect(container.textContent).toContain("Beta skills");
|
||||
expect(container.textContent).toContain("Task Watchdogs");
|
||||
});
|
||||
|
||||
it("shows every toggle when nothing is hidden", async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Enable Environments");
|
||||
expect(container.textContent).toContain("Beta skills");
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,14 @@ import { AlertTriangle, Clock, FlaskConical, Lock, Play, Search } from "lucide-r
|
||||
import type {
|
||||
InstanceExperimentalSettings,
|
||||
InstanceExperimentalSettingsWithManaged,
|
||||
InstanceFeatureKey,
|
||||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
ManagedSettingMetadata,
|
||||
PatchInstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import { experimentalSettingKey } from "@paperclipai/shared";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { getWorktreeInstanceId, isWorktreeRuntime } from "../lib/worktree-branding";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -92,6 +95,7 @@ function ExperimentalToggleCard({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
settingKey,
|
||||
managed,
|
||||
ariaLabel,
|
||||
}: {
|
||||
@@ -101,10 +105,14 @@ function ExperimentalToggleCard({
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled: boolean;
|
||||
/** Flag key backing this card; operator-hidden keys render nothing. */
|
||||
settingKey: InstanceFeatureKey;
|
||||
managed?: ManagedSettingMetadata;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const isManaged = managed?.managed === true;
|
||||
if (hiddenSettings.has(experimentalSettingKey(settingKey))) return null;
|
||||
return (
|
||||
<Card className="block p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -465,6 +473,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableApps}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableApps: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableApps"
|
||||
managed={managedKeys.enableApps}
|
||||
ariaLabel="Toggle apps experimental setting"
|
||||
/>
|
||||
@@ -565,6 +574,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={autoRestartDevServerWhenIdle}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="autoRestartDevServerWhenIdle"
|
||||
managed={managedKeys.autoRestartDevServerWhenIdle}
|
||||
ariaLabel="Toggle guarded dev-server auto-restart"
|
||||
/>
|
||||
@@ -575,6 +585,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableBetaSkills}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableBetaSkills: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableBetaSkills"
|
||||
managed={managedKeys.enableBetaSkills}
|
||||
ariaLabel="Toggle beta skills experimental setting"
|
||||
/>
|
||||
@@ -585,6 +596,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableBuiltInAgents}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableBuiltInAgents: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableBuiltInAgents"
|
||||
managed={managedKeys.enableBuiltInAgents}
|
||||
ariaLabel="Toggle built-in agents experimental setting"
|
||||
/>
|
||||
@@ -596,6 +608,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableCases}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableCases: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableCases"
|
||||
managed={managedKeys.enableCases}
|
||||
ariaLabel="Toggle cases experimental setting"
|
||||
/>
|
||||
@@ -607,6 +620,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableClassicTaskInterface}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableClassicTaskInterface: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableClassicTaskInterface"
|
||||
managed={managedKeys.enableClassicTaskInterface}
|
||||
ariaLabel="Toggle classic task interface experimental setting"
|
||||
/>
|
||||
@@ -618,6 +632,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableConferenceRoomChat}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableConferenceRoomChat: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableConferenceRoomChat"
|
||||
managed={managedKeys.enableConferenceRoomChat}
|
||||
ariaLabel="Toggle conference room chat experimental setting"
|
||||
/>
|
||||
@@ -629,6 +644,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableDecisions}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableDecisions: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableDecisions"
|
||||
managed={managedKeys.enableDecisions}
|
||||
ariaLabel="Toggle decisions experimental setting"
|
||||
/>
|
||||
@@ -639,6 +655,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableEnvironments}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableEnvironments: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableEnvironments"
|
||||
managed={managedKeys.enableEnvironments}
|
||||
ariaLabel="Toggle environments experimental setting"
|
||||
/>
|
||||
@@ -649,6 +666,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableExternalObjects}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableExternalObjects: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableExternalObjects"
|
||||
managed={managedKeys.enableExternalObjects}
|
||||
ariaLabel="Toggle external objects experimental setting"
|
||||
/>
|
||||
@@ -659,6 +677,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableIsolatedWorkspaces}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableIsolatedWorkspaces: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableIsolatedWorkspaces"
|
||||
managed={managedKeys.enableIsolatedWorkspaces}
|
||||
ariaLabel="Toggle isolated workspaces experimental setting"
|
||||
/>
|
||||
@@ -669,6 +688,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableExperimentalFileViewer}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableExperimentalFileViewer: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableExperimentalFileViewer"
|
||||
managed={managedKeys.enableExperimentalFileViewer}
|
||||
ariaLabel="Toggle experimental file viewer setting"
|
||||
/>
|
||||
@@ -679,6 +699,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableGoalsSidebarLink}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableGoalsSidebarLink: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableGoalsSidebarLink"
|
||||
managed={managedKeys.enableGoalsSidebarLink}
|
||||
ariaLabel="Toggle goals sidebar link experimental setting"
|
||||
/>
|
||||
@@ -689,6 +710,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableManagedSandboxOnly}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableManagedSandboxOnly: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableManagedSandboxOnly"
|
||||
managed={managedKeys.enableManagedSandboxOnly}
|
||||
ariaLabel="Toggle managed sandbox only experimental setting"
|
||||
/>
|
||||
@@ -756,6 +778,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableServerInfoDebugView}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableServerInfoDebugView: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableServerInfoDebugView"
|
||||
managed={managedKeys.enableServerInfoDebugView}
|
||||
ariaLabel="Toggle server info debug view experimental setting"
|
||||
/>
|
||||
@@ -768,6 +791,7 @@ export function InstanceExperimentalSettings() {
|
||||
toggleMutation.mutate({ enableSimplifiedEnglishInteractions: checked })
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableSimplifiedEnglishInteractions"
|
||||
managed={managedKeys.enableSimplifiedEnglishInteractions}
|
||||
ariaLabel="Toggle simplified english interactions experimental setting"
|
||||
/>
|
||||
@@ -778,6 +802,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableSmokeLab}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableSmokeLab: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableSmokeLab"
|
||||
managed={managedKeys.enableSmokeLab}
|
||||
ariaLabel="Toggle smoke lab experimental setting"
|
||||
/>
|
||||
@@ -795,6 +820,7 @@ export function InstanceExperimentalSettings() {
|
||||
)
|
||||
}
|
||||
disabled={toggleMutation.isPending || statusCardsBlockedByManagedSummaries}
|
||||
settingKey="enableStatusCards"
|
||||
managed={managedKeys.enableStatusCards}
|
||||
ariaLabel="Toggle status cards experimental setting"
|
||||
/>
|
||||
@@ -812,6 +838,7 @@ export function InstanceExperimentalSettings() {
|
||||
)
|
||||
}
|
||||
disabled={toggleMutation.isPending || summariesRequiredByManagedStatusCards}
|
||||
settingKey="enableSummaries"
|
||||
managed={managedKeys.enableSummaries}
|
||||
ariaLabel="Toggle summaries experimental setting"
|
||||
/>
|
||||
@@ -822,6 +849,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableIssuePlanDecompositions}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableIssuePlanDecompositions: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableIssuePlanDecompositions"
|
||||
managed={managedKeys.enableIssuePlanDecompositions}
|
||||
ariaLabel="Toggle task plan decomposition panel experimental setting"
|
||||
/>
|
||||
@@ -832,6 +860,7 @@ export function InstanceExperimentalSettings() {
|
||||
checked={enableTaskWatchdogs}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableTaskWatchdogs: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableTaskWatchdogs"
|
||||
managed={managedKeys.enableTaskWatchdogs}
|
||||
ariaLabel="Toggle task watchdogs experimental setting"
|
||||
/>
|
||||
|
||||
@@ -205,3 +205,67 @@ describe("InstanceGeneralSettings sign-out", () => {
|
||||
await vi.waitFor(() => expect(signOutButton()?.disabled).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceGeneralSettings operator-hidden sections", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: "not_allowed",
|
||||
backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderPage(health: Record<string, unknown>) {
|
||||
mockHealthApi.get.mockResolvedValue(health);
|
||||
queryClient.setQueryData(queryKeys.health, health);
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InstanceGeneralSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Keyboard shortcuts"));
|
||||
}
|
||||
|
||||
it("hides an operator-hidden field-backed section and a UI-only section", async () => {
|
||||
await renderPage({
|
||||
...SELF_HOSTED_HEALTH,
|
||||
hiddenSettings: [
|
||||
"instance.general.censorUsernameInLogs",
|
||||
"instance.general.deploymentStatus",
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Censor username in logs");
|
||||
expect(container.textContent).not.toContain("Deployment and auth");
|
||||
expect(container.textContent).toContain("Backup retention");
|
||||
expect(container.textContent).toContain("AI feedback sharing");
|
||||
expect(container.textContent).toContain("Sign out");
|
||||
});
|
||||
|
||||
it("shows every section when nothing is hidden", async () => {
|
||||
await renderPage(SELF_HOSTED_HEALTH);
|
||||
|
||||
expect(container.textContent).toContain("Deployment and auth");
|
||||
expect(container.textContent).toContain("Censor username in logs");
|
||||
expect(container.textContent).toContain("Backup retention");
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
},
|
||||
});
|
||||
|
||||
if (generalQuery.isLoading) {
|
||||
if (generalQuery.isLoading || healthQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,22 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
|
||||
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
||||
const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION;
|
||||
const hiddenSettings = new Set(healthQuery.data?.hiddenSettings ?? []);
|
||||
const showDeploymentStatus = !hiddenSettings.has("instance.general.deploymentStatus");
|
||||
const showCensorUsernameInLogs = !hiddenSettings.has("instance.general.censorUsernameInLogs");
|
||||
const showKeyboardShortcuts = !hiddenSettings.has("instance.general.keyboardShortcuts");
|
||||
const showBackupRetention = !hiddenSettings.has("instance.general.backupRetention");
|
||||
const showFeedbackDataSharing = !hiddenSettings.has("instance.general.feedbackDataSharingPreference");
|
||||
const showSignOut = !hiddenSettings.has("instance.general.signOut");
|
||||
const visibleTopics = [
|
||||
...(showCensorUsernameInLogs ? ["log display"] : []),
|
||||
...(showKeyboardShortcuts ? ["keyboard shortcuts"] : []),
|
||||
...(showBackupRetention ? ["backup retention"] : []),
|
||||
...(showFeedbackDataSharing ? ["data sharing"] : []),
|
||||
];
|
||||
const topicSummary = visibleTopics.length > 2
|
||||
? `${visibleTopics.slice(0, -1).join(", ")}, and ${visibleTopics[visibleTopics.length - 1]}`
|
||||
: visibleTopics.join(" and ");
|
||||
const visibleActionError = signOutMutation.error instanceof Error
|
||||
? signOutMutation.error.message
|
||||
: signOutMutation.error
|
||||
@@ -94,8 +110,8 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
<h1 className="text-lg font-semibold">General</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure instance-wide preferences including log display, keyboard shortcuts, backup
|
||||
retention, and data sharing.
|
||||
Configure instance-wide preferences
|
||||
{visibleTopics.length > 0 ? <> including {topicSummary}</> : null}.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -106,6 +122,7 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDeploymentStatus && (
|
||||
<section>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -138,7 +155,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showCensorUsernameInLogs && (
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
@@ -157,7 +176,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showKeyboardShortcuts && (
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
@@ -175,7 +196,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showBackupRetention && (
|
||||
<section>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
@@ -277,7 +300,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showFeedbackDataSharing && (
|
||||
<section>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
@@ -354,6 +379,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
</div>
|
||||
</section>
|
||||
|
||||
)}
|
||||
|
||||
{showSignOut && (
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
@@ -376,6 +404,7 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user