Files
sencho/frontend/src/components/settings/DeveloperSection.tsx
T
Anso a3026f47a8 feat(rbac): make Settings authorization permission-aware (#1738)
* feat(rbac): make Settings authorization permission-aware

Align Settings visibility and mutations with the existing permission matrix so Node Admin can edit node-scoped operational settings while system and credential surfaces stay Admin-protected.

* fix(rbac): tighten settings permission buckets and tests

Collapse settings key permission maps into one source of truth, and cover mixed PATCH atomicity plus image-update enabled writes.

* fix(rbac): tighten Settings scoped grants and CI assertions

Empty settings PATCH fails closed, node:manage is scoped to the active
node, system-only Settings stay hidden without system:settings, and
Check updates / webhooks mutate gates follow the permission matrix.

* fix(rbac): defer Settings section fallback until authz is ready

Keep deep links to permission-gated sections (e.g. license) intact while
can() is still fail-closed during permission metadata load.

* docs(settings): clarify Notifications channels vs routing authz

Channels use node:manage via /api/agents; routing and mute stay Admin-only.
2026-07-30 10:25:13 -04:00

151 lines
5.8 KiB
TypeScript

import { useState, useEffect } from 'react';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
interface DeveloperSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
</div>
);
}
type DeveloperFields = Pick<PatchableSettings, 'developer_mode'>;
const DEFAULT_DEVELOPER: DeveloperFields = {
developer_mode: DEFAULT_SETTINGS.developer_mode,
};
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { can, permissionsReady } = useAuth();
const { activeNode } = useNodes();
const readOnly = !permissionsReady || !can('system:settings');
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
!isCurrentNodeLoaded
? null
: [
{
label: 'DEV MODE',
value: settings.developer_mode === '1' ? 'on' : 'off',
tone: settings.developer_mode === '1' ? 'warn' : 'subtitle',
},
],
);
useEffect(() => {
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
};
reset(safe);
})();
return () => {
cancelled = true;
};
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof DeveloperFields>(key: K, value: DeveloperFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
const payload = {
developer_mode: submitted.developer_mode,
};
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(payload),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
markSaved(submitted);
toast.success('Developer settings saved.');
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },
}));
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Diagnostics">
<SettingsField
label="Developer mode"
helper="Enable real-time metrics streams and verbose debug diagnostics in the UI."
>
<TogglePill
id="developer_mode"
checked={settings.developer_mode === '1'}
onChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}