Browse Source

Enhance site settings and header controls

Jax Docker 1 month ago
parent
commit
b23f861d51

File diff suppressed because it is too large
+ 499 - 569
docs/web-post-api-contract.md


+ 3 - 2
web/src/App.tsx

@@ -23,6 +23,7 @@ const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage").then((mo
 
 export default function App() {
   const { t } = useTranslation();
+  const defaultRoute = useUiStore((state) => state.siteSettings.defaultRoute);
   return (
     <>
       <ThemeSync />
@@ -32,7 +33,7 @@ export default function App() {
         <Suspense fallback={<LoadingSpinner label={t("app.loadingStudio")} />}>
           <Routes>
             <Route path="/login" element={<LoginPage />} />
-            <Route path="/" element={<Navigate to="/dashboard" replace />} />
+            <Route path="/" element={<Navigate to={defaultRoute || "/dashboard"} replace />} />
             <Route
               element={
                 <ProtectedRoute>
@@ -52,7 +53,7 @@ export default function App() {
               <Route path="/skills" element={<SkillsPage />} />
               <Route path="/settings" element={<SettingsPage />} />
             </Route>
-            <Route path="*" element={<Navigate to="/dashboard" replace />} />
+            <Route path="*" element={<Navigate to={defaultRoute || "/dashboard"} replace />} />
           </Routes>
         </Suspense>
       </ErrorBoundary>

+ 3 - 0
web/src/api/client.ts

@@ -1,5 +1,6 @@
 import axios from "axios";
 import { useAuthStore } from "@/stores/auth";
+import { useUiStore } from "@/stores/ui";
 import { mockAxiosResponse, mockMode } from "./mock";
 
 export const apiClient = axios.create({
@@ -8,6 +9,8 @@ export const apiClient = axios.create({
 });
 
 apiClient.interceptors.request.use((config) => {
+  const { gatewayBasePath } = useUiStore.getState().siteSettings;
+  config.baseURL = gatewayBasePath || "/gateway";
   if (mockMode) {
     config.adapter = async (adapterConfig) => mockAxiosResponse(adapterConfig);
   }

+ 12 - 3
web/src/components/layout/AppLayout.tsx

@@ -2,19 +2,28 @@ import { Outlet } from "react-router-dom";
 import { useTranslation } from "react-i18next";
 import { Header } from "./Header";
 import { MobileSidebar, Sidebar } from "./Sidebar";
+import { cn } from "@/lib/utils";
+import { useUiStore } from "@/stores/ui";
 
 export function AppLayout() {
   const { t } = useTranslation();
+  const density = useUiStore((state) => state.siteSettings.density);
   return (
-    <div className="flex min-h-screen bg-surface-base">
+    <div className="flex h-dvh overflow-hidden bg-surface-base">
       <a href="#main-content" className="skip-link">
         {t("nav.skipToContent")}
       </a>
       <Sidebar />
       <MobileSidebar />
-      <div className="min-w-0 flex-1">
+      <div className="min-w-0 flex-1 overflow-y-auto">
         <Header />
-        <main id="main-content" className="mx-auto w-full max-w-[1600px] px-4 py-6 md:px-6">
+        <main
+          id="main-content"
+          className={cn(
+            "mx-auto w-full max-w-[1600px] px-4 md:px-6",
+            density === "compact" ? "py-4" : "py-6",
+          )}
+        >
           <Outlet />
         </main>
       </div>

+ 158 - 29
web/src/components/layout/Header.tsx

@@ -1,40 +1,32 @@
-import { LogOut, Menu, Moon, Sun, User } from "lucide-react";
+import * as React from "react";
+import { Check, ChevronDown, Globe2, LogOut, Menu, Moon, Settings, Sun } from "lucide-react";
 import { useNavigate } from "react-router-dom";
 import { useTranslation } from "react-i18next";
 import { Button } from "@/components/ui/button";
 import { Breadcrumb } from "./Breadcrumb";
-import { LanguageSelector } from "@/components/shared/LanguageSelector";
 import { useAuthStore } from "@/stores/auth";
 import { useUiStore } from "@/stores/ui";
 import { mockMode } from "@/api/mock";
+import { LANGUAGE_LABELS, SUPPORTED_LANGUAGES, type SupportedLanguage } from "@/i18n";
+import { cn } from "@/lib/utils";
 
 export function Header() {
   const { t } = useTranslation();
   const navigate = useNavigate();
-  const { displayName, username, logout } = useAuthStore();
-  const { theme, toggleTheme, toggleMobileSidebar } = useUiStore();
+  const { displayName, username, userId, logout } = useAuthStore();
+  const { theme, toggleTheme, toggleMobileSidebar, siteSettings, language, setLanguage } = useUiStore();
+  const operatorName = displayName || username || userId || "user";
+
   return (
-    <header className="glass sticky top-0 z-30 flex h-16 items-center justify-between px-4 md:px-6">
-      <div className="flex items-center gap-3">
+    <header className="glass sticky top-0 z-30 flex h-16 items-center justify-between gap-3 px-4 md:px-6">
+      <div className="flex min-w-0 items-center gap-3">
         <Button className="md:hidden" variant="ghost" size="icon" onClick={toggleMobileSidebar} aria-label={t("nav.openNavigation")}>
           <Menu className="h-4 w-4" />
         </Button>
         <Breadcrumb />
       </div>
-      <div className="flex items-center gap-3">
-        {mockMode ? (
-          <div className="hidden items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200 sm:flex">
-            {t("auth.mockData")}
-          </div>
-        ) : null}
-        <div className="hidden items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground sm:flex">
-          <span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.8)]" />
-          {t("auth.gateway")}
-        </div>
-        <div className="hidden items-center gap-2 text-sm text-muted-foreground sm:flex">
-          <User className="h-4 w-4" />
-          <span>{displayName || username || "user"}</span>
-        </div>
+      <div className="flex shrink-0 items-center gap-2">
+        <StatusCluster showMock={mockMode && siteSettings.showMockBanner} gatewayLabel={t("auth.gateway")} mockLabel={t("auth.mockData")} />
         <Button
           variant="ghost"
           size="icon"
@@ -43,19 +35,156 @@ export function Header() {
         >
           {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
         </Button>
-        <LanguageSelector />
-        <Button
-          variant="ghost"
-          size="icon"
-          onClick={() => {
+        <UserMenu
+          operatorName={operatorName}
+          username={username}
+          language={language}
+          onLanguageChange={setLanguage}
+          onSettings={() => navigate("/settings")}
+          onLogout={() => {
             logout();
             navigate("/login", { replace: true });
           }}
-          aria-label={t("auth.logout")}
-        >
-          <LogOut className="h-4 w-4" />
-        </Button>
+        />
       </div>
     </header>
   );
 }
+
+function StatusCluster({ showMock, gatewayLabel, mockLabel }: { showMock: boolean; gatewayLabel: string; mockLabel: string }) {
+  return (
+    <div className="hidden items-center overflow-hidden rounded-md border border-border bg-muted/35 text-xs text-muted-foreground sm:flex">
+      <div className="flex h-9 items-center gap-2 px-3">
+        <span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.8)]" />
+        <span className="hidden lg:inline">{gatewayLabel}</span>
+      </div>
+      {showMock ? (
+        <div className="flex h-9 items-center border-l border-border bg-amber-500/10 px-3 text-amber-700 dark:text-amber-200">
+          {mockLabel}
+        </div>
+      ) : null}
+    </div>
+  );
+}
+
+function UserMenu({
+  operatorName,
+  username,
+  language,
+  onLanguageChange,
+  onSettings,
+  onLogout,
+}: {
+  operatorName: string;
+  username: string;
+  language: SupportedLanguage;
+  onLanguageChange: (language: SupportedLanguage) => void;
+  onSettings: () => void;
+  onLogout: () => void;
+}) {
+  const { t } = useTranslation();
+  const [open, setOpen] = React.useState(false);
+  const containerRef = React.useRef<HTMLDivElement>(null);
+  const initials = operatorName.trim().slice(0, 1).toUpperCase() || "U";
+
+  React.useEffect(() => {
+    function handlePointerDown(event: PointerEvent) {
+      if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
+    }
+    function handleKeyDown(event: KeyboardEvent) {
+      if (event.key === "Escape") setOpen(false);
+    }
+    document.addEventListener("pointerdown", handlePointerDown);
+    document.addEventListener("keydown", handleKeyDown);
+    return () => {
+      document.removeEventListener("pointerdown", handlePointerDown);
+      document.removeEventListener("keydown", handleKeyDown);
+    };
+  }, []);
+
+  return (
+    <div ref={containerRef} className="relative">
+      <button
+        type="button"
+        aria-haspopup="menu"
+        aria-expanded={open}
+        onClick={() => setOpen((value) => !value)}
+        className="flex min-h-11 touch-manipulation items-center gap-2 rounded-md border border-border bg-muted/35 px-2.5 text-sm transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+      >
+        <span className="grid h-7 w-7 place-items-center rounded-md bg-primary/15 text-xs font-semibold text-primary">{initials}</span>
+        <span className="hidden max-w-36 truncate text-left md:block">
+          <span className="block truncate font-medium">{operatorName}</span>
+          {username && username !== operatorName ? <span className="block truncate text-xs text-muted-foreground">{username}</span> : null}
+        </span>
+        <ChevronDown className={cn("h-4 w-4 text-muted-foreground transition", open && "rotate-180")} />
+      </button>
+
+      {open ? (
+        <div
+          role="menu"
+          className="absolute right-0 top-full z-40 mt-2 w-72 rounded-md border border-border bg-surface-elevated p-2 shadow-glow"
+        >
+          <div className="border-b border-border px-3 py-2">
+            <div className="flex items-center gap-3">
+              <span className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-sm font-semibold text-primary">{initials}</span>
+              <div className="min-w-0">
+                <p className="truncate text-sm font-medium">{operatorName}</p>
+                {username ? <p className="truncate text-xs text-muted-foreground">{username}</p> : null}
+              </div>
+            </div>
+          </div>
+
+          <div className="py-2">
+            <p className="px-3 py-1 text-xs font-medium text-muted-foreground">{t("settings.language")}</p>
+            {SUPPORTED_LANGUAGES.map((item) => (
+              <button
+                key={item}
+                type="button"
+                role="menuitemradio"
+                aria-checked={language === item}
+                onClick={() => {
+                  onLanguageChange(item);
+                  setOpen(false);
+                }}
+                className="flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+              >
+                <span className="flex items-center gap-2">
+                  <Globe2 className="h-4 w-4 text-muted-foreground" />
+                  {LANGUAGE_LABELS[item]}
+                </span>
+                {language === item ? <Check className="h-4 w-4 text-primary" /> : null}
+              </button>
+            ))}
+          </div>
+
+          <div className="border-t border-border pt-2">
+            <button
+              type="button"
+              role="menuitem"
+              onClick={() => {
+                setOpen(false);
+                onSettings();
+              }}
+              className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+            >
+              <Settings className="h-4 w-4 text-muted-foreground" />
+              {t("settings.title")}
+            </button>
+            <button
+              type="button"
+              role="menuitem"
+              onClick={() => {
+                setOpen(false);
+                onLogout();
+              }}
+              className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-700 hover:bg-red-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:text-red-300"
+            >
+              <LogOut className="h-4 w-4" />
+              {t("auth.logout")}
+            </button>
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 6 - 6
web/src/components/layout/Sidebar.tsx

@@ -36,21 +36,21 @@ function NavItems({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?:
 
 export function Sidebar() {
   const { t } = useTranslation();
-  const { sidebarCollapsed, toggleSidebar } = useUiStore();
+  const { sidebarCollapsed, toggleSidebar, siteSettings } = useUiStore();
   return (
     <aside
       className={cn(
-        "hidden shrink-0 border-r border-border bg-surface-deep md:flex md:flex-col",
+        "hidden h-dvh shrink-0 border-r border-border bg-surface-deep md:flex md:flex-col",
         sidebarCollapsed ? "w-16" : "w-60")}
     >
       <div className="flex h-16 items-center gap-3 px-4">
         <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
           <Workflow className="h-5 w-5" />
         </div>
-        {!sidebarCollapsed ? <span className="text-sm font-semibold">{t("app.name")}</span> : null}
+        {!sidebarCollapsed ? <span className="truncate text-sm font-semibold">{siteSettings.workspaceName || t("app.name")}</span> : null}
       </div>
       <Separator />
-      <nav className="flex flex-1 flex-col gap-1 p-3">
+      <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3">
         <NavItems collapsed={sidebarCollapsed} />
       </nav>
       <Separator />
@@ -66,7 +66,7 @@ export function Sidebar() {
 
 export function MobileSidebar() {
   const { t } = useTranslation();
-  const { mobileSidebarOpen, setMobileSidebarOpen } = useUiStore();
+  const { mobileSidebarOpen, setMobileSidebarOpen, siteSettings } = useUiStore();
   if (!mobileSidebarOpen) return null;
   return (
     <div className="fixed inset-0 z-50 md:hidden">
@@ -80,7 +80,7 @@ export function MobileSidebar() {
           <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
             <Workflow className="h-5 w-5" />
           </div>
-          <span className="text-sm font-semibold">{t("app.name")}</span>
+          <span className="truncate text-sm font-semibold">{siteSettings.workspaceName || t("app.name")}</span>
         </div>
         <Separator />
         <nav className="flex flex-1 flex-col gap-1 p-3">

+ 12 - 0
web/src/components/shared/ConfirmDialog.tsx

@@ -1,6 +1,8 @@
+import * as React from "react";
 import { Dialog } from "@/components/ui/dialog";
 import { Button } from "@/components/ui/button";
 import { useTranslation } from "react-i18next";
+import { useUiStore } from "@/stores/ui";
 
 export function ConfirmDialog({
   open,
@@ -16,6 +18,16 @@ export function ConfirmDialog({
   onConfirm: () => void;
 }) {
   const { t } = useTranslation();
+  const requireConfirmations = useUiStore((state) => state.siteSettings.requireConfirmations);
+
+  React.useEffect(() => {
+    if (!open || requireConfirmations) return;
+    onConfirm();
+    onOpenChange(false);
+  }, [onConfirm, onOpenChange, open, requireConfirmations]);
+
+  if (!requireConfirmations) return null;
+
   return (
     <Dialog open={open} onOpenChange={onOpenChange} title={title} description={description}>
       <div className="flex justify-end gap-2">

+ 40 - 2
web/src/locales/en.json

@@ -989,7 +989,7 @@
   },
   "settings": {
     "title": "Settings",
-    "description": "Manage identity, model providers, and gateway API keys.",
+    "description": "Configure workspace behavior, gateway defaults, safety policy, and operator API keys.",
     "newApiKey": "New API Key",
     "user": "User",
     "unset": "Unset",
@@ -1013,7 +1013,45 @@
     "optionalScopes": "Optional comma-separated scopes",
     "create": "Create",
     "creating": "Creating...",
-    "apiKeyCreated": "API key created"
+    "apiKeyCreated": "API key created",
+    "siteSettingsSaved": "Site settings saved",
+    "siteSettingsReset": "Site settings reset",
+    "resetSite": "Reset",
+    "saveSite": "Save Settings",
+    "workspace": "Workspace",
+    "gateway": "Gateway",
+    "sitePreferences": "Site Preferences",
+    "sitePreferencesDescription": "Control the workspace identity, navigation, language, and visual density.",
+    "workspaceName": "Workspace Name",
+    "defaultLanding": "Default Landing Page",
+    "theme": "Theme",
+    "dark": "Dark",
+    "light": "Light",
+    "language": "Language",
+    "density": "Density",
+    "comfortable": "Comfortable",
+    "compact": "Compact",
+    "sidebar": "Sidebar",
+    "expanded": "Expanded",
+    "collapsed": "Collapsed",
+    "gatewayRuntime": "Gateway & Runtime",
+    "gatewayRuntimeDescription": "Defaults used by the frontend client and operational dashboards.",
+    "gatewayBasePath": "Gateway Base Path",
+    "dashboardRefresh": "Dashboard Refresh",
+    "runRetention": "Run Retention",
+    "seconds": "seconds",
+    "days": "days",
+    "safetyPolicy": "Safety Policy",
+    "safetyPolicyDescription": "Operator guardrails for destructive actions and demo-mode visibility.",
+    "requireConfirmations": "Require Confirmations",
+    "requireConfirmationsDescription": "Keep confirmation dialogs enabled for destructive actions.",
+    "showMockBanner": "Show Mock Banner",
+    "showMockBannerDescription": "Display a visible banner when the frontend is using mock fallback data.",
+    "mockModeOff": "Mock fallback is not active in this session.",
+    "environment": "Environment",
+    "environmentDescription": "Read-only context for the current browser session.",
+    "tokenExpires": "Token Expires",
+    "gatewayApiKeysDescription": "Create and revoke gateway API keys for automation, local testing, and operator access."
   },
   "modelProviders": {
     "title": "Model Providers",

+ 40 - 2
web/src/locales/zh.json

@@ -989,7 +989,7 @@
   },
   "settings": {
     "title": "设置",
-    "description": "管理身份、模型供应商和网关 API Key。",
+    "description": "配置工作区行为、网关默认值、安全策略和运维 API Key。",
     "newApiKey": "新建 API Key",
     "user": "用户",
     "unset": "未设置",
@@ -1013,7 +1013,45 @@
     "optionalScopes": "可选,多个权限用英文逗号分隔",
     "create": "创建",
     "creating": "创建中...",
-    "apiKeyCreated": "API Key 已创建"
+    "apiKeyCreated": "API Key 已创建",
+    "siteSettingsSaved": "站点配置已保存",
+    "siteSettingsReset": "站点配置已重置",
+    "resetSite": "重置",
+    "saveSite": "保存配置",
+    "workspace": "工作区",
+    "gateway": "网关",
+    "sitePreferences": "站点偏好",
+    "sitePreferencesDescription": "配置工作区名称、默认入口、语言和界面密度。",
+    "workspaceName": "工作区名称",
+    "defaultLanding": "默认进入页面",
+    "theme": "主题",
+    "dark": "深色",
+    "light": "浅色",
+    "language": "语言",
+    "density": "界面密度",
+    "comfortable": "舒适",
+    "compact": "紧凑",
+    "sidebar": "侧边栏",
+    "expanded": "展开",
+    "collapsed": "收起",
+    "gatewayRuntime": "网关与运行",
+    "gatewayRuntimeDescription": "前端请求和运维仪表盘使用的默认配置。",
+    "gatewayBasePath": "网关基础路径",
+    "dashboardRefresh": "仪表盘刷新",
+    "runRetention": "运行记录保留",
+    "seconds": "秒",
+    "days": "天",
+    "safetyPolicy": "安全策略",
+    "safetyPolicyDescription": "用于危险操作和演示数据提示的操作员保护策略。",
+    "requireConfirmations": "需要二次确认",
+    "requireConfirmationsDescription": "对删除、吊销等危险操作保留确认弹窗。",
+    "showMockBanner": "显示 Mock 提示",
+    "showMockBannerDescription": "当前端使用 Mock 回退数据时显示醒目标识。",
+    "mockModeOff": "当前会话未启用 Mock 回退。",
+    "environment": "环境信息",
+    "environmentDescription": "当前浏览器会话的只读上下文。",
+    "tokenExpires": "Token 过期时间",
+    "gatewayApiKeysDescription": "创建和吊销用于自动化、本地测试和运维访问的网关 API Key。"
   },
   "modelProviders": {
     "title": "模型供应商",

+ 3 - 1
web/src/pages/dashboard/DashboardPage.tsx

@@ -7,6 +7,7 @@ import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { Button } from "@/components/ui/button";
 import { useInterval } from "@/hooks";
 import { getServicesHealth, listAgents, listRuns, listSessions } from "@/api";
+import { useUiStore } from "@/stores/ui";
 import { StatsCards } from "./components/StatsCards";
 import { ExecutionTrendChart } from "./components/ExecutionTrendChart";
 import { RecentRunsTable } from "./components/RecentRunsTable";
@@ -24,6 +25,7 @@ export function DashboardPage() {
   const [runs, setRuns] = React.useState<WorkflowRun[]>([]);
   const [services, setServices] = React.useState<DownstreamServiceHealth[]>([]);
   const [error, setError] = React.useState<string>();
+  const dashboardRefreshSeconds = useUiStore((state) => state.siteSettings.dashboardRefreshSeconds);
 
   const load = React.useCallback(async (showRefreshing = false) => {
     if (showRefreshing) setRefreshing(true);
@@ -48,7 +50,7 @@ export function DashboardPage() {
   React.useEffect(() => {
     void load();
   }, [load]);
-  useInterval(() => void load(), 30000);
+  useInterval(() => void load(), Math.max(5, dashboardRefreshSeconds) * 1000);
 
   const today = new Date().toDateString();
   const runsToday = runs.filter((run) => new Date(run.created_time).toDateString() === today).length;

+ 356 - 23
web/src/pages/settings/SettingsPage.tsx

@@ -1,7 +1,21 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
-import { KeyRound, UserRound } from "lucide-react";
+import {
+  Database,
+  Globe2,
+  KeyRound,
+  LayoutDashboard,
+  Palette,
+  PanelLeftClose,
+  RotateCcw,
+  Save,
+  Server,
+  ShieldCheck,
+  Timer,
+  UserRound,
+} from "lucide-react";
 import { createApiKey, listApiKeys, revokeApiKey } from "@/api";
+import { mockMode } from "@/api/mock";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -9,33 +23,63 @@ import { MetricCard } from "@/components/shared/MetricCard";
 import { PageHeader } from "@/components/shared/PageHeader";
 import { StatusBadge } from "@/components/shared/StatusBadge";
 import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
-import { formatDateTime, truncateMiddle } from "@/lib/utils";
+import { LANGUAGE_LABELS, SUPPORTED_LANGUAGES, type SupportedLanguage } from "@/i18n";
+import { NAV_ITEMS } from "@/lib/constants";
+import { cn, formatDateTime, truncateMiddle } from "@/lib/utils";
 import { useAuthStore } from "@/stores/auth";
+import { type SiteSettings, useUiStore } from "@/stores/ui";
 import type { ApiKeyCreateResponse, ApiKeyResponse } from "@/types";
 
+type SettingsDraft = SiteSettings & {
+  theme: "light" | "dark";
+  language: SupportedLanguage;
+  sidebarCollapsed: boolean;
+};
+
 export function SettingsPage() {
   const { t } = useTranslation();
-  const { userId } = useAuthStore();
+  const { userId, username, displayName, tokenExpiresTime } = useAuthStore();
+  const {
+    theme,
+    language,
+    sidebarCollapsed,
+    siteSettings,
+    setTheme,
+    setLanguage,
+    setSidebarCollapsed,
+    setSiteSettings,
+    resetSiteSettings,
+  } = useUiStore();
+  const [draft, setDraft] = React.useState<SettingsDraft>(() => ({
+    ...siteSettings,
+    theme,
+    language,
+    sidebarCollapsed,
+  }));
+  const [formError, setFormError] = React.useState<string>();
   const [apiKeys, setApiKeys] = React.useState<ApiKeyResponse[]>([]);
   const [newKey, setNewKey] = React.useState<ApiKeyCreateResponse>();
-  const [loading, setLoading] = React.useState(true);
-  const [error, setError] = React.useState<string>();
+  const [keysLoading, setKeysLoading] = React.useState(true);
+  const [keysError, setKeysError] = React.useState<string>();
   const [createOpen, setCreateOpen] = React.useState(false);
 
+  const c = React.useCallback((key: string, fallback: string) => t(key, { defaultValue: fallback }), [t]);
+
   const load = React.useCallback(async () => {
-    setLoading(true);
-    setError(undefined);
+    setKeysLoading(true);
+    setKeysError(undefined);
     try {
       const keys = await listApiKeys();
       setApiKeys(keys);
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setKeysError(err instanceof Error ? err.message : t("errors.failedToLoad"));
     } finally {
-      setLoading(false);
+      setKeysLoading(false);
     }
   }, [t]);
 
@@ -43,33 +87,222 @@ export function SettingsPage() {
     void load();
   }, [load]);
 
+  React.useEffect(() => {
+    setDraft({ ...siteSettings, theme, language, sidebarCollapsed });
+  }, [language, sidebarCollapsed, siteSettings, theme]);
+
   async function revoke(id: string) {
-    await revokeApiKey(id);
-    toast.success(t("settings.apiKeyRevoked"));
-    void load();
+    try {
+      await revokeApiKey(id);
+      toast.success(t("settings.apiKeyRevoked"));
+      void load();
+    } catch {
+      toast.error(t("errors.failedToSave"));
+    }
+  }
+
+  function saveSettings() {
+    const validation = validateSettings(draft);
+    if (validation) {
+      setFormError(validation);
+      return;
+    }
+    setFormError(undefined);
+    setTheme(draft.theme);
+    setLanguage(draft.language);
+    setSidebarCollapsed(draft.sidebarCollapsed);
+    setSiteSettings({
+      workspaceName: draft.workspaceName.trim(),
+      defaultRoute: draft.defaultRoute,
+      density: draft.density,
+      dashboardRefreshSeconds: Number(draft.dashboardRefreshSeconds),
+      runRetentionDays: Number(draft.runRetentionDays),
+      requireConfirmations: draft.requireConfirmations,
+      showMockBanner: draft.showMockBanner,
+      gatewayBasePath: draft.gatewayBasePath.trim(),
+    });
+    toast.success(c("settings.siteSettingsSaved", "Site settings saved"));
+  }
+
+  function resetDraft() {
+    resetSiteSettings();
+    const current = useUiStore.getState();
+    setDraft({
+      ...current.siteSettings,
+      theme: current.theme,
+      language: current.language,
+      sidebarCollapsed: current.sidebarCollapsed,
+    });
+    setFormError(undefined);
+    toast.info(c("settings.siteSettingsReset", "Site settings reset"));
   }
 
-  if (loading) return <LoadingSpinner label={t("common.loading")} />;
-  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+  const routeOptions = NAV_ITEMS.map((item) => ({ value: item.path, label: t(item.labelKey) }));
 
   return (
     <div className="space-y-6">
       <PageHeader
         title={t("settings.title")}
-        description={t("settings.description")}
-        actions={<Button onClick={() => setCreateOpen(true)}><KeyRound className="h-4 w-4" /> {t("settings.newApiKey")}</Button>}
+        description={c("settings.description", "Configure workspace behavior, gateway defaults, safety policy, and operator API keys.")}
+        actions={
+          <>
+            <Button variant="outline" onClick={resetDraft}>
+              <RotateCcw className="h-4 w-4" /> {c("settings.resetSite", "Reset")}
+            </Button>
+            <Button variant="secondary" onClick={saveSettings}>
+              <Save className="h-4 w-4" /> {c("settings.saveSite", "Save Settings")}
+            </Button>
+            <Button onClick={() => setCreateOpen(true)}>
+              <KeyRound className="h-4 w-4" /> {t("settings.newApiKey")}
+            </Button>
+          </>
+        }
       />
-      <div className="grid gap-4 md:grid-cols-2">
-        <MetricCard label={t("settings.user")} value={userId || t("settings.unset")} icon={UserRound} />
+
+      {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm">{formError}</p> : null}
+
+      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
+        <MetricCard label={c("settings.workspace", "Workspace")} value={draft.workspaceName || t("settings.unset")} icon={LayoutDashboard} />
+        <MetricCard label={t("settings.user")} value={displayName || username || userId || t("settings.unset")} icon={UserRound} />
         <MetricCard label={t("settings.apiKeys")} value={apiKeys.length} icon={KeyRound} />
+        <MetricCard label={c("settings.gateway", "Gateway")} value={draft.gatewayBasePath || "/gateway"} icon={Server} />
+      </div>
+
+      <div className="grid gap-6 xl:grid-cols-[1fr_420px]">
+        <div className="space-y-6">
+          <Card>
+            <CardHeader>
+              <CardTitle>{c("settings.sitePreferences", "Site Preferences")}</CardTitle>
+              <CardDescription>{c("settings.sitePreferencesDescription", "Control the workspace identity, navigation, language, and visual density.")}</CardDescription>
+            </CardHeader>
+            <CardContent className="grid gap-4 md:grid-cols-2">
+              <SettingField label={c("settings.workspaceName", "Workspace Name")}>
+                <Input value={draft.workspaceName} onChange={(event) => setDraft({ ...draft, workspaceName: event.target.value })} />
+              </SettingField>
+              <SettingField label={c("settings.defaultLanding", "Default Landing Page")}>
+                <Select value={draft.defaultRoute} onChange={(event) => setDraft({ ...draft, defaultRoute: event.target.value })} options={routeOptions} />
+              </SettingField>
+              <SettingField label={c("settings.theme", "Theme")}>
+                <Select
+                  value={draft.theme}
+                  onChange={(event) => setDraft({ ...draft, theme: event.target.value as SettingsDraft["theme"] })}
+                  options={[
+                    { value: "dark", label: c("settings.dark", "Dark") },
+                    { value: "light", label: c("settings.light", "Light") },
+                  ]}
+                />
+              </SettingField>
+              <SettingField label={c("settings.language", "Language")}>
+                <Select
+                  value={draft.language}
+                  onChange={(event) => setDraft({ ...draft, language: event.target.value as SupportedLanguage })}
+                  options={SUPPORTED_LANGUAGES.map((item) => ({ value: item, label: LANGUAGE_LABELS[item] }))}
+                />
+              </SettingField>
+              <SettingField label={c("settings.density", "Density")}>
+                <Select
+                  value={draft.density}
+                  onChange={(event) => setDraft({ ...draft, density: event.target.value as SiteSettings["density"] })}
+                  options={[
+                    { value: "comfortable", label: c("settings.comfortable", "Comfortable") },
+                    { value: "compact", label: c("settings.compact", "Compact") },
+                  ]}
+                />
+              </SettingField>
+              <SettingField label={c("settings.sidebar", "Sidebar")}>
+                <Select
+                  value={draft.sidebarCollapsed ? "collapsed" : "expanded"}
+                  onChange={(event) => setDraft({ ...draft, sidebarCollapsed: event.target.value === "collapsed" })}
+                  options={[
+                    { value: "expanded", label: c("settings.expanded", "Expanded") },
+                    { value: "collapsed", label: c("settings.collapsed", "Collapsed") },
+                  ]}
+                />
+              </SettingField>
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader>
+              <CardTitle>{c("settings.gatewayRuntime", "Gateway & Runtime")}</CardTitle>
+              <CardDescription>{c("settings.gatewayRuntimeDescription", "Defaults used by the frontend client and operational dashboards.")}</CardDescription>
+            </CardHeader>
+            <CardContent className="grid gap-4 md:grid-cols-3">
+              <SettingField label={c("settings.gatewayBasePath", "Gateway Base Path")}>
+                <Input value={draft.gatewayBasePath} onChange={(event) => setDraft({ ...draft, gatewayBasePath: event.target.value })} />
+              </SettingField>
+              <SettingField label={c("settings.dashboardRefresh", "Dashboard Refresh")}>
+                <NumberInput
+                  value={draft.dashboardRefreshSeconds}
+                  suffix={c("settings.seconds", "seconds")}
+                  min={5}
+                  max={300}
+                  onChange={(value) => setDraft({ ...draft, dashboardRefreshSeconds: value })}
+                />
+              </SettingField>
+              <SettingField label={c("settings.runRetention", "Run Retention")}>
+                <NumberInput
+                  value={draft.runRetentionDays}
+                  suffix={c("settings.days", "days")}
+                  min={1}
+                  max={365}
+                  onChange={(value) => setDraft({ ...draft, runRetentionDays: value })}
+                />
+              </SettingField>
+            </CardContent>
+          </Card>
+        </div>
+
+        <div className="space-y-6">
+          <Card>
+            <CardHeader>
+              <CardTitle>{c("settings.safetyPolicy", "Safety Policy")}</CardTitle>
+              <CardDescription>{c("settings.safetyPolicyDescription", "Operator guardrails for destructive actions and demo-mode visibility.")}</CardDescription>
+            </CardHeader>
+            <CardContent className="space-y-3">
+              <ToggleRow
+                icon={ShieldCheck}
+                title={c("settings.requireConfirmations", "Require Confirmations")}
+                description={c("settings.requireConfirmationsDescription", "Keep confirmation dialogs enabled for destructive actions.")}
+                checked={draft.requireConfirmations}
+                onChange={(value) => setDraft({ ...draft, requireConfirmations: value })}
+              />
+              <ToggleRow
+                icon={Database}
+                title={c("settings.showMockBanner", "Show Mock Banner")}
+                description={mockMode ? c("settings.showMockBannerDescription", "Display a visible banner when the frontend is using mock fallback data.") : c("settings.mockModeOff", "Mock fallback is not active in this session.")}
+                checked={draft.showMockBanner}
+                onChange={(value) => setDraft({ ...draft, showMockBanner: value })}
+              />
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader>
+              <CardTitle>{c("settings.environment", "Environment")}</CardTitle>
+              <CardDescription>{c("settings.environmentDescription", "Read-only context for the current browser session.")}</CardDescription>
+            </CardHeader>
+            <CardContent className="space-y-3 text-sm">
+              <InfoRow icon={Globe2} label={c("settings.language", "Language")} value={LANGUAGE_LABELS[language]} />
+              <InfoRow icon={Palette} label={c("settings.theme", "Theme")} value={c(`settings.${theme}`, theme)} />
+              <InfoRow icon={PanelLeftClose} label={c("settings.sidebar", "Sidebar")} value={sidebarCollapsed ? c("settings.collapsed", "Collapsed") : c("settings.expanded", "Expanded")} />
+              <InfoRow icon={Timer} label={c("settings.tokenExpires", "Token Expires")} value={formatDateTime(tokenExpiresTime)} />
+            </CardContent>
+          </Card>
+        </div>
       </div>
 
       <Card>
         <CardHeader>
           <CardTitle>{t("settings.gatewayApiKeys")}</CardTitle>
+          <CardDescription>{c("settings.gatewayApiKeysDescription", "Create and revoke gateway API keys for automation, local testing, and operator access.")}</CardDescription>
         </CardHeader>
         <CardContent>
-          {apiKeys.length ? (
+          {keysLoading ? (
+            <LoadingSpinner label={t("common.loading")} />
+          ) : keysError ? (
+            <ApiErrorState message={keysError} onRetry={() => void load()} />
+          ) : apiKeys.length ? (
             <div className="overflow-x-auto">
               <table className="w-full text-left text-sm">
                 <thead className="text-xs text-muted-foreground">
@@ -101,7 +334,7 @@ export function SettingsPage() {
               </table>
             </div>
           ) : (
-            <EmptyState icon={KeyRound} title={t("settings.noApiKeys")} description={t("settings.createGatewayKey")} />
+            <EmptyState icon={KeyRound} title={t("settings.noApiKeys")} description={t("settings.createGatewayKey")} actionLabel={t("settings.newApiKey")} onAction={() => setCreateOpen(true)} />
           )}
         </CardContent>
       </Card>
@@ -122,6 +355,106 @@ export function SettingsPage() {
   );
 }
 
+function SettingField({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <label className="block space-y-2 text-sm">
+      <span className="text-muted-foreground">{label}</span>
+      {children}
+    </label>
+  );
+}
+
+function NumberInput({
+  value,
+  suffix,
+  min,
+  max,
+  onChange,
+}: {
+  value: number;
+  suffix: string;
+  min: number;
+  max: number;
+  onChange: (value: number) => void;
+}) {
+  return (
+    <div className="flex items-center gap-2">
+      <Input
+        type="number"
+        min={min}
+        max={max}
+        value={value}
+        onChange={(event) => onChange(Number(event.target.value))}
+      />
+      <span className="shrink-0 text-sm text-muted-foreground">{suffix}</span>
+    </div>
+  );
+}
+
+function ToggleRow({
+  icon: Icon,
+  title,
+  description,
+  checked,
+  onChange,
+}: {
+  icon: typeof ShieldCheck;
+  title: string;
+  description: string;
+  checked: boolean;
+  onChange: (checked: boolean) => void;
+}) {
+  return (
+    <button
+      type="button"
+      role="switch"
+      aria-checked={checked}
+      onClick={() => onChange(!checked)}
+      className="flex w-full items-center justify-between gap-4 rounded-md border border-border bg-muted/30 p-3 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+    >
+      <span className="flex min-w-0 items-start gap-3">
+        <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
+          <Icon className="h-4 w-4" />
+        </span>
+        <span className="min-w-0">
+          <span className="block text-sm font-medium">{title}</span>
+          <span className="mt-1 block text-sm leading-5 text-muted-foreground">{description}</span>
+        </span>
+      </span>
+      <span className={cn("relative h-6 w-11 shrink-0 rounded-full border transition", checked ? "border-primary bg-primary" : "border-border bg-muted")}>
+        <span className={cn("absolute top-0.5 h-5 w-5 rounded-full bg-white transition", checked ? "left-5" : "left-0.5")} />
+      </span>
+    </button>
+  );
+}
+
+function InfoRow({ icon: Icon, label, value }: { icon: typeof Globe2; label: string; value: string }) {
+  return (
+    <div className="flex items-center gap-3 rounded-md border border-border bg-muted/30 p-3">
+      <Icon className="h-4 w-4 text-muted-foreground" />
+      <div className="min-w-0">
+        <p className="text-xs text-muted-foreground">{label}</p>
+        <p className="mt-1 truncate font-medium">{value}</p>
+      </div>
+    </div>
+  );
+}
+
+function validateSettings(settings: SettingsDraft) {
+  if (!settings.workspaceName.trim()) return "Workspace name is required.";
+  const gateway = settings.gatewayBasePath.trim();
+  if (!gateway.startsWith("/") && !gateway.startsWith("http://") && !gateway.startsWith("https://")) {
+    return "Gateway base path must start with /, http://, or https://.";
+  }
+  if (!Number.isFinite(settings.dashboardRefreshSeconds) || settings.dashboardRefreshSeconds < 5 || settings.dashboardRefreshSeconds > 300) {
+    return "Dashboard refresh must be between 5 and 300 seconds.";
+  }
+  if (!Number.isFinite(settings.runRetentionDays) || settings.runRetentionDays < 1 || settings.runRetentionDays > 365) {
+    return "Run retention must be between 1 and 365 days.";
+  }
+  return undefined;
+}
+
 function CreateApiKeyDialog({
   open,
   onOpenChange,
@@ -158,10 +491,10 @@ function CreateApiKeyDialog({
     <Dialog open={open} onOpenChange={onOpenChange} title={t("settings.createApiKey")}>
       {createdKey ? (
         <div className="space-y-4">
-          <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-100">
+          <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-100">
             {t("settings.copyKeyNow")}
           </div>
-          <div className="rounded-md border border-border bg-muted/60 p-3 font-mono text-xs break-all">{createdKey.api_key}</div>
+          <div className="break-all rounded-md border border-border bg-muted/60 p-3 font-mono text-xs">{createdKey.api_key}</div>
           <p className="text-sm text-muted-foreground">{t("settings.prefix")}: {truncateMiddle(createdKey.key_prefix, 20)}</p>
           <div className="flex justify-end">
             <Button onClick={() => onOpenChange(false)}>{t("settings.done")}</Button>

+ 31 - 0
web/src/stores/ui.ts

@@ -3,11 +3,34 @@ import { persist } from "zustand/middleware";
 import i18n from "@/i18n";
 import type { SupportedLanguage } from "@/i18n";
 
+export interface SiteSettings {
+  workspaceName: string;
+  defaultRoute: string;
+  density: "comfortable" | "compact";
+  dashboardRefreshSeconds: number;
+  runRetentionDays: number;
+  requireConfirmations: boolean;
+  showMockBanner: boolean;
+  gatewayBasePath: string;
+}
+
+const DEFAULT_SITE_SETTINGS: SiteSettings = {
+  workspaceName: "Auto Platform",
+  defaultRoute: "/dashboard",
+  density: "comfortable",
+  dashboardRefreshSeconds: 30,
+  runRetentionDays: 30,
+  requireConfirmations: true,
+  showMockBanner: true,
+  gatewayBasePath: "/gateway",
+};
+
 interface UiState {
   sidebarCollapsed: boolean;
   mobileSidebarOpen: boolean;
   theme: "light" | "dark";
   language: SupportedLanguage;
+  siteSettings: SiteSettings;
   setSidebarCollapsed: (value: boolean) => void;
   toggleSidebar: () => void;
   setMobileSidebarOpen: (value: boolean) => void;
@@ -15,6 +38,8 @@ interface UiState {
   setTheme: (theme: "light" | "dark") => void;
   toggleTheme: () => void;
   setLanguage: (language: SupportedLanguage) => void;
+  setSiteSettings: (settings: Partial<SiteSettings>) => void;
+  resetSiteSettings: () => void;
 }
 
 export const useUiStore = create<UiState>()(
@@ -24,6 +49,7 @@ export const useUiStore = create<UiState>()(
       mobileSidebarOpen: false,
       theme: "dark",
       language: "en",
+      siteSettings: DEFAULT_SITE_SETTINGS,
       setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
       toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
       setMobileSidebarOpen: (mobileSidebarOpen) => set({ mobileSidebarOpen }),
@@ -34,5 +60,10 @@ export const useUiStore = create<UiState>()(
         i18n.changeLanguage(language);
         set({ language });
       },
+      setSiteSettings: (settings) =>
+        set((state) => ({
+          siteSettings: { ...state.siteSettings, ...settings },
+        })),
+      resetSiteSettings: () => set({ siteSettings: DEFAULT_SITE_SETTINGS }),
     }),
     { name: "auto-platform-ui" }));

Some files were not shown because too many files changed in this diff