Files
sencho/frontend/src/components/SettingsModal.tsx
T
Anso 6529a24530 feat(git-sources): harden create-from-git with LFS + submodule warnings (#609)
* feat(git-sources): surface LFS and submodule warnings on create

Creating a stack from a Git repo now detects two common anomalies and
tells the user about them rather than silently producing broken stacks.

- LFS-pointer compose/env files fail early with a clear error instead
  of writing a 130-byte pointer stub to disk as real content.
- Repositories containing .gitmodules produce a non-fatal warning so
  the user knows build contexts or volumes inside submodules will be
  empty at deploy time.

Also refines the create dialog: sr-only DialogDescription for a11y,
short commit SHA suffix on the success toast, env-path hint under the
"Sync .env" checkbox showing which path will be read, and a route-level
diagnostic log line gated on developer mode for support debugging.

* test(git-sources): cover LFS, submodule, and nested env_path paths

Adds unit coverage for the new LFS-pointer rejection and submodule
warning plumbing, plus a nested compose_path case that exercises the
default env_path resolution ("apps/web/compose.yaml" with sync_env on
and env_path unset writes "apps/web/.env" both to disk and to the DB).

Extends the E2E suite with a happy-path assertion that the full-length
commit SHA is returned in the create response, and a UI flow that
verifies the short-SHA suffix appears in the success toast.

* docs(git-sources): add troubleshooting for LFS, submodules, HTTPS-only

Adds troubleshooting entries for the newly surfaced LFS and submodule
anomalies, expands the clone-timeout entry with the bounded-fetch
explanation, and adds a dedicated HTTPS-only entry. Also consolidates
the known limitations into a single list covering LFS, submodules,
branch-tracking, and HTTPS-only.

* fix(settings): use Route icon for notification routing

The routing section in Settings previously used GitBranch, which now
clashes with the Git Source feature's icon across the editor. Switch
to Route (a branching-flow glyph) so routing rules have a distinct
visual identity and aren't visually conflated with Git-backed stacks.

* fix(git-sources): return 400 for upstream auth failures and disambiguate 404s

Upstream git-host auth failures were mapping to HTTP 401, which the frontend
apiFetch treats as a Sencho session expiry and fires the global logout event.
They now return 400 with code=AUTH_FAILED in the body so the UI can branch on
the discriminator without logging the user out. The status mapping moved into
utils/gitSourceHttp so it can be unit-tested without booting the app.

mapGitError also relied on the HttpError class alone, so any non-2xx response
(including 404) was classified as auth failure. It now inspects the numeric
status on err.data and considers whether a token was supplied, producing more
actionable messages for missing repos, private repos, and wrong-scope tokens.
2026-04-15 11:31:29 -04:00

433 lines
19 KiB
TypeScript

import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
import {
Shield, Activity, Bell, Code, Server, Package,
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route,
} from 'lucide-react';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { SSOSection } from './SSOSection';
import { ApiTokensSection } from './ApiTokensSection';
import { RegistriesSection } from './RegistriesSection';
import {
AccountSection,
LicenseSection,
UsersSection,
SystemSection,
NotificationsSection,
NotificationRoutingSection,
WebhooksSection,
DeveloperSection,
AppStoreSection,
SupportSection,
AboutSection,
LabelsSection,
DEFAULT_SETTINGS,
} from './settings';
import type { PatchableSettings, SectionId } from './settings';
const GLOBAL_ONLY_SECTIONS: ReadonlySet<SectionId> = new Set<SectionId>([
'account', 'license', 'users', 'sso', 'api-tokens', 'registries',
'labels', 'notifications', 'notification-routing', 'webhooks', 'nodes', 'appstore',
]);
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
initialSection?: SectionId;
}
export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModalProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const { license, isPaid } = useLicense();
const isRemote = activeNode?.type === 'remote';
const [activeSection, setActiveSection] = useState<SectionId>(initialSection || 'account');
const contentViewportRef = useRef<HTMLDivElement | null>(null);
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
const switchSection = (next: SectionId) => {
if (contentViewportRef.current) {
scrollPositionsRef.current[activeSection] = contentViewportRef.current.scrollTop;
}
setActiveSection(next);
};
useLayoutEffect(() => {
if (contentViewportRef.current) {
contentViewportRef.current.scrollTop = scrollPositionsRef.current[activeSection] ?? 0;
}
}, [activeSection]);
useEffect(() => {
if (isOpen && initialSection) setActiveSection(initialSection);
}, [isOpen, initialSection]);
// Remote nodes don't expose global-only sections, so bounce to a node-scoped one.
useEffect(() => {
if (isRemote && GLOBAL_ONLY_SECTIONS.has(activeSection)) {
switchSection('system');
}
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
// Auth State
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
const [isSavingPassword, setIsSavingPassword] = useState(false);
// Settings state
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
const serverSettingsRef = useRef<PatchableSettings>({ ...DEFAULT_SETTINGS });
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
const [isSavingSystem, setIsSavingSystem] = useState(false);
const [isSavingDeveloper, setIsSavingDeveloper] = useState(false);
// Unsaved changes indicators
const hasSystemChanges =
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb ||
settings.global_crash !== serverSettingsRef.current.global_crash;
const hasDeveloperChanges =
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days;
useEffect(() => {
if (isOpen) fetchSettings();
}, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchSettings = async () => {
setIsSettingsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const localData: Record<string, string> = (isRemote && localRes.ok)
? await localRes.json()
: nodeData;
const safe: PatchableSettings = {
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
template_registry_url: nodeData.template_registry_url ?? '',
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch settings', e);
} finally {
setIsSettingsLoading(false);
}
};
const handleSettingChange = <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void, localOnly = false): Promise<boolean> => {
setLoading(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(payload),
localOnly,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return false;
}
serverSettingsRef.current = { ...serverSettingsRef.current, ...payload };
return true;
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
return false;
} finally {
setLoading(false);
}
};
const saveSystemSettings = async () => {
const ok = await patchSettings({
host_cpu_limit: settings.host_cpu_limit,
host_ram_limit: settings.host_ram_limit,
host_disk_limit: settings.host_disk_limit,
docker_janitor_gb: settings.docker_janitor_gb,
global_crash: settings.global_crash,
}, setIsSavingSystem);
if (ok) toast.success('System limits saved.');
};
const saveDeveloperSettings = async () => {
const payload = {
developer_mode: settings.developer_mode,
global_logs_refresh: settings.global_logs_refresh,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
audit_retention_days: settings.audit_retention_days,
};
const ok = await patchSettings(payload, setIsSavingDeveloper, true);
if (ok) {
toast.success('Developer settings saved.');
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },
}));
}
};
const handlePasswordChange = async () => {
if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) {
toast.error('All fields are required');
return;
}
if (authData.newPassword !== authData.confirmPassword) {
toast.error('New passwords do not match');
return;
}
if (authData.newPassword.length < 8) {
toast.error('New password must be at least 8 characters');
return;
}
setIsSavingPassword(true);
try {
const res = await apiFetch('/auth/password', {
method: 'PUT',
body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword }),
});
if (res.ok) {
toast.success('Password updated successfully');
setAuthData({ oldPassword: '', newPassword: '', confirmPassword: '' });
} else {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || 'Failed to update password');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error during password change');
} finally {
setIsSavingPassword(false);
}
};
const handleRegistrySaved = (key: keyof PatchableSettings, value: string) => {
serverSettingsRef.current = { ...serverSettingsRef.current, [key]: value };
};
// --- Nav items ---
const NavButton = ({ section, icon, label, showDot, locked }: {
section: SectionId;
icon: React.ReactNode;
label: string;
showDot?: boolean;
locked?: boolean;
}) => (
<Button
variant={activeSection === section ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium relative"
onClick={() => switchSection(section)}
>
{icon}
{label}
{locked && <Lock className="w-3 h-3 ml-auto text-muted-foreground/50" />}
{showDot && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-amber-400" />
)}
</Button>
);
// --- Section rendering ---
const renderSection = () => {
switch (activeSection) {
case 'account':
return (
<AccountSection
authData={authData}
onAuthDataChange={setAuthData}
onPasswordChange={handlePasswordChange}
isSaving={isSavingPassword}
/>
);
case 'license':
return <LicenseSection />;
case 'users':
return <UsersSection />;
case 'sso':
return <SSOSection />;
case 'api-tokens':
return <ApiTokensSection />;
case 'registries':
return <RegistriesSection />;
case 'labels':
return <LabelsSection />;
case 'system':
return (
<SystemSection
settings={settings}
onSettingChange={handleSettingChange}
onSave={saveSystemSettings}
isSaving={isSavingSystem}
isLoading={isSettingsLoading}
isRemote={isRemote}
activeNodeName={activeNode?.name}
/>
);
case 'notifications':
return <NotificationsSection />;
case 'notification-routing':
return <NotificationRoutingSection />;
case 'webhooks':
return <WebhooksSection isPaid={isPaid} />;
case 'developer':
return (
<DeveloperSection
settings={settings}
onSettingChange={handleSettingChange}
onSave={saveDeveloperSettings}
isSaving={isSavingDeveloper}
isLoading={isSettingsLoading}
isRemote={isRemote}
/>
);
case 'nodes':
return <NodeManager />;
case 'appstore':
return (
<AppStoreSection
settings={settings}
onSettingChange={handleSettingChange}
isLoading={isSettingsLoading}
onSaved={handleRegistrySaved}
/>
);
case 'support':
return <SupportSection />;
case 'about':
return <AboutSection />;
}
};
const isAdmiral = isPaid && license?.variant === 'admiral';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[900px] h-[min(650px,85vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
{/* Sidebar */}
<div className="w-[200px] bg-glass border-r border-glass-border flex flex-col p-4 shrink-0 min-h-0">
<div className="font-medium text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
{isRemote ? (
<div className="text-xs text-muted-foreground mb-5 truncate">{activeNode!.name}</div>
) : (
<div className="mb-5" />
)}
<ScrollArea className="flex-1 -mr-2 pr-2">
<nav className="space-y-1.5 flex flex-col">
{/* Account / License */}
{!isRemote && (
<NavButton section="account" icon={<Shield className="w-4 h-4 mr-2" />} label="Account" />
)}
{!isRemote && (
<NavButton section="license" icon={<Crown className="w-4 h-4 mr-2" />} label="License" />
)}
{!isRemote && <Separator className="my-1.5" />}
{/* Users / SSO / API Tokens / Registries */}
{!isRemote && isAdmin && (
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPaid} />
)}
{!isRemote && isAdmin && (
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isAdmiral} />
)}
{!isRemote && isAdmin && (
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isAdmiral} />
)}
{!isRemote && isAdmin && (
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPaid} />
)}
{!isRemote && isAdmin && <Separator className="my-1.5" />}
{/* System / Notifications / Webhooks / Developer */}
<NavButton
section="system"
icon={<Activity className="w-4 h-4 mr-2" />}
label="System Limits"
showDot={hasSystemChanges}
/>
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
{!isRemote && isAdmin && (
<NavButton section="notification-routing" icon={<Route className="w-4 h-4 mr-2" />} label="Routing" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPaid} />
)}
<NavButton
section="developer"
icon={<Code className="w-4 h-4 mr-2" />}
label="Developer"
showDot={hasDeveloperChanges}
/>
<Separator className="my-1.5" />
{/* Nodes / App Store */}
{!isRemote && (
<NavButton section="nodes" icon={<Server className="w-4 h-4 mr-2" />} label="Nodes" />
)}
{!isRemote && (
<NavButton section="appstore" icon={<Package className="w-4 h-4 mr-2" />} label="App Store" />
)}
<Separator className="my-1.5" />
{/* Support / About */}
<NavButton section="support" icon={<LifeBuoy className="w-4 h-4 mr-2" />} label="Support" />
<NavButton section="about" icon={<Info className="w-4 h-4 mr-2" />} label="About" />
</nav>
</ScrollArea>
</div>
{/* Main Content Area */}
<ScrollArea viewportRef={contentViewportRef} className="flex-1">
<div className="p-6 flex flex-col gap-6">
{renderSection()}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}