Ver código fonte

feat(webui): rebuild Settings around what can and cannot change

The old page was 121 lines and entirely read-only: profile as plain
text, a users list that has moved to its own page, and a runners list.
Rebuilt around the settings API's own distinction between live,
restart-required, and secret keys, so someone can tell at a glance what
is actually changeable without clicking into each one:

- Profile: email is now editable (it is the login credential), with a
  password-change form that always asks for the current password -
  verified against the running server that the endpoint checks it even
  for an admin changing their own password, so asking for anything less
  would just fail.
- Runtime settings: the 5 live keys, each showing default vs. override,
  editable with the type-appropriate control (bounded number input, a
  select limited to the accepted load-balancing strategies), with a
  clear-override action and the backend's own rejection reason surfaced
  verbatim on an invalid value.
- Everything else: the 24 restart-required and 2 secret keys, grouped by
  which process owns them, read-only, each with its reason. A secret
  entry has no value or source field at all and the page does not
  pretend otherwise - it says the value is never exposed.

Verified against the running instance: saving a live setting, clearing
an override back to default, and a rejected out-of-range value showing
the backend's exact reason all work as intended.
fszontagh 1 mês atrás
pai
commit
9cff256b87
3 arquivos alterados com 485 adições e 37 exclusões
  1. 43 0
      webui/src/api/settings.ts
  2. 432 37
      webui/src/pages/SettingsPage.tsx
  3. 10 0
      webui/src/stores/authStore.ts

+ 43 - 0
webui/src/api/settings.ts

@@ -0,0 +1,43 @@
+import { api } from './client'
+
+/**
+ * A single configuration key as the backend sees it.
+ *
+ * A `secret` entry carries no `value` and no `source` - its value must never
+ * leave the server, in any form. Every other entry has both: `value` is
+ * whichever one currently applies (the override if there is one, otherwise
+ * the file default) and `source` says which.
+ */
+export interface SettingEntry {
+  key: string
+  type: string
+  value?: unknown
+  source?: 'default' | 'override'
+  classification: 'live' | 'restart-required' | 'secret'
+  writable: boolean
+  component: 'webserver' | 'runner'
+  reason: string
+  constraints?: string
+}
+
+export interface SettingProblem {
+  key: string
+  reason: string
+}
+
+export const settingsApi = {
+  list: async () => {
+    const { data } = await api.get('/settings')
+    return (data.settings || []) as SettingEntry[]
+  },
+
+  /**
+   * `sets` writes overrides, `clears` removes them back to the file default.
+   * At least one of the two must carry something - the backend refuses a
+   * body with neither.
+   */
+  update: async (body: { sets?: Record<string, unknown>; clears?: string[] }) => {
+    const { data } = await api.put('/settings', body)
+    return data as { settings: SettingEntry[] }
+  },
+}

+ 432 - 37
webui/src/pages/SettingsPage.tsx

@@ -1,12 +1,54 @@
-import { UsersSection } from '../components/settings/UsersSection'
-import { useQuery } from '@tanstack/react-query'
+import { useEffect, useMemo, useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { api } from '../api/client'
+import { usersApi } from '../api/users'
+import { settingsApi, SettingEntry } from '../api/settings'
 import { useAuthStore } from '../stores/authStore'
-import { Server, User, Shield, Palette } from 'lucide-react'
+import {
+  Server,
+  User,
+  Shield,
+  Palette,
+  Lock,
+  RotateCcw,
+  Check,
+  Cpu,
+  AlertCircle,
+} from 'lucide-react'
 import { ThemeToggle } from '../components/ThemeToggle'
 
+const LOAD_BALANCING_STRATEGIES = ['round-robin', 'least-connections', 'weighted', 'random']
+
+const COMPONENT_LABEL: Record<string, string> = {
+  webserver: 'WebServer',
+  runner: 'Runner',
+}
+
+function classificationBadge(entry: SettingEntry) {
+  if (entry.classification === 'live') {
+    return (
+      <span className="text-[11px] px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium">
+        editable
+      </span>
+    )
+  }
+  if (entry.classification === 'secret') {
+    return (
+      <span className="text-[11px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium">
+        secret - never exposed
+      </span>
+    )
+  }
+  return (
+    <span className="text-[11px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-slate-600 text-gray-600 dark:text-gray-300 font-medium">
+      restart required
+    </span>
+  )
+}
+
 export default function SettingsPage() {
-  const { user } = useAuthStore()
+  const { user, updateProfile } = useAuthStore()
+  const queryClient = useQueryClient()
 
   const { data: runners } = useQuery({
     queryKey: ['runners'],
@@ -16,37 +58,27 @@ export default function SettingsPage() {
     },
   })
 
+  const { data: settings = [], isLoading: settingsLoading, isError: settingsError } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => settingsApi.list(),
+  })
+
+  const live = settings.filter((s) => s.classification === 'live')
+  const readOnly = settings.filter((s) => s.classification !== 'live')
+  const readOnlyByComponent = useMemo(() => {
+    const groups: Record<string, SettingEntry[]> = {}
+    for (const entry of readOnly) {
+      if (!groups[entry.component]) groups[entry.component] = []
+      groups[entry.component].push(entry)
+    }
+    return groups
+  }, [readOnly])
+
   return (
     <div className="h-full overflow-auto p-6 max-w-4xl">
       <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Settings</h1>
 
-      {/* Profile section */}
-      <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
-        <div className="flex items-center gap-3 mb-4">
-          <User className="w-5 h-5 text-gray-400 dark:text-gray-500" />
-          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Profile</h2>
-        </div>
-        <div className="grid gap-4 md:grid-cols-2">
-          <div>
-            <label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
-              Username
-            </label>
-            <p className="text-gray-900 dark:text-gray-100">{user?.username}</p>
-          </div>
-          <div>
-            <label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
-              Email
-            </label>
-            <p className="text-gray-900 dark:text-gray-100">{user?.email}</p>
-          </div>
-          <div>
-            <label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
-              Role
-            </label>
-            <p className="text-gray-900 dark:text-gray-100 capitalize">{user?.role}</p>
-          </div>
-        </div>
-      </section>
+      <ProfileSection user={user} updateProfile={updateProfile} />
 
       {/* Appearance section */}
       <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
@@ -63,9 +95,84 @@ export default function SettingsPage() {
         </div>
       </section>
 
-      {/* Users - only shown to somebody who can read them; the section hides
-          itself when the endpoint refuses. */}
-      <UsersSection />
+      {/* Runtime settings - only shown to somebody who can read them; the
+          section hides itself when the endpoint refuses (a non-admin). */}
+      {!settingsError && (
+        <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
+          <div className="flex items-center gap-3 mb-1">
+            <Cpu className="w-5 h-5 text-gray-400 dark:text-gray-500" />
+            <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Runtime settings</h2>
+          </div>
+          <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
+            These take effect immediately - no restart needed. Everything else this instance runs with is
+            listed further down as read-only.
+          </p>
+          {settingsLoading ? (
+            <p className="text-sm text-gray-500 dark:text-gray-400">Reading settings...</p>
+          ) : (
+            <div className="space-y-3">
+              {live.map((entry) => (
+                <LiveSettingRow
+                  key={entry.key}
+                  entry={entry}
+                  onSaved={() => queryClient.invalidateQueries({ queryKey: ['settings'] })}
+                />
+              ))}
+            </div>
+          )}
+        </section>
+      )}
+
+      {/* Everything else - read-only, grouped by which process owns it. This
+          is deliberately a description of the running instance: someone
+          should not have to click anything to find out what can and cannot
+          be changed here. */}
+      {!settingsError && !settingsLoading && (
+        <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
+          <div className="flex items-center gap-3 mb-1">
+            <Lock className="w-5 h-5 text-gray-400 dark:text-gray-500" />
+            <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Everything else</h2>
+          </div>
+          <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
+            Fixed for the life of the running process. A secret's value is never sent to this page at all -
+            not even whether it has been overridden.
+          </p>
+          <div className="space-y-6">
+            {Object.entries(readOnlyByComponent).map(([component, entries]) => (
+              <div key={component}>
+                <h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">
+                  {COMPONENT_LABEL[component] || component}
+                </h3>
+                <div className="divide-y divide-gray-100 dark:divide-slate-700">
+                  {entries.map((entry) => (
+                    <div key={entry.key} className="py-2.5">
+                      <div className="flex items-center gap-2 flex-wrap">
+                        <code className="text-sm font-medium text-gray-900 dark:text-gray-100">{entry.key}</code>
+                        {classificationBadge(entry)}
+                        {entry.classification !== 'secret' && (
+                          <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-gray-400">
+                            {entry.source === 'override' ? 'overridden' : 'default'}
+                          </span>
+                        )}
+                      </div>
+                      {entry.classification === 'secret' ? (
+                        <p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 italic">
+                          Value never exposed.
+                        </p>
+                      ) : (
+                        <p className="text-sm text-gray-700 dark:text-gray-300 mt-0.5 font-mono">
+                          {formatValue(entry.value)}
+                        </p>
+                      )}
+                      <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{entry.reason}</p>
+                    </div>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </section>
+      )}
 
       {/* Runners section */}
       <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
@@ -112,10 +219,298 @@ export default function SettingsPage() {
           <Shield className="w-5 h-5 text-gray-400 dark:text-gray-500" />
           <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Security</h2>
         </div>
-        <button className="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700">
-          Change Password
-        </button>
+        <p className="text-sm text-gray-500 dark:text-gray-400">
+          Change your password from the Profile section above.
+        </p>
       </section>
     </div>
   )
 }
+
+function formatValue(value: unknown): string {
+  if (value === undefined || value === null) return '(none)'
+  if (typeof value === 'string') return value || '(empty)'
+  return JSON.stringify(value)
+}
+
+/**
+ * One live setting: shows the current value, whether it is a default or an
+ * override, and lets it be changed or cleared back to the default. A number
+ * field carries the stated bounds so someone does not have to guess and get
+ * refused; the load-balancing key gets a select limited to the accepted
+ * strategies instead of a free-text box.
+ */
+function LiveSettingRow({ entry, onSaved }: { entry: SettingEntry; onSaved: () => void }) {
+  const [value, setValue] = useState<string>(String(entry.value ?? ''))
+  const [error, setError] = useState<string | null>(null)
+  const [success, setSuccess] = useState(false)
+
+  useEffect(() => {
+    setValue(String(entry.value ?? ''))
+    setError(null)
+  }, [entry.value])
+
+  const saveMutation = useMutation({
+    mutationFn: (v: unknown) => settingsApi.update({ sets: { [entry.key]: v } }),
+    onSuccess: () => {
+      setError(null)
+      setSuccess(true)
+      setTimeout(() => setSuccess(false), 2000)
+      onSaved()
+    },
+    onError: (e: any) => {
+      const details = e?.response?.data?.details as { key: string; reason: string }[] | undefined
+      const mine = details?.find((d) => d.key === entry.key)
+      setError(mine?.reason || e?.response?.data?.error || e.message || 'That did not work')
+    },
+  })
+
+  const clearMutation = useMutation({
+    mutationFn: () => settingsApi.update({ clears: [entry.key] }),
+    onSuccess: () => {
+      setError(null)
+      onSaved()
+    },
+    onError: (e: any) => {
+      const details = e?.response?.data?.details as { key: string; reason: string }[] | undefined
+      const mine = details?.find((d) => d.key === entry.key)
+      setError(mine?.reason || e?.response?.data?.error || e.message || 'That did not work')
+    },
+  })
+
+  const isDirty = value !== String(entry.value ?? '')
+  const isOverride = entry.source === 'override'
+  const busy = saveMutation.isPending || clearMutation.isPending
+
+  const handleSave = () => {
+    if (entry.type === 'int' || entry.type === 'int64') {
+      const n = Number(value)
+      if (!Number.isFinite(n) || !Number.isInteger(n)) {
+        setError('Must be a whole number')
+        return
+      }
+      saveMutation.mutate(n)
+    } else if (entry.type === 'bool') {
+      saveMutation.mutate(value === 'true')
+    } else {
+      saveMutation.mutate(value)
+    }
+  }
+
+  return (
+    <div className="p-3 rounded-lg border border-gray-200 dark:border-slate-700">
+      <div className="flex items-center gap-2 flex-wrap mb-1">
+        <code className="text-sm font-medium text-gray-900 dark:text-gray-100">{entry.key}</code>
+        <span
+          className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
+            isOverride
+              ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
+              : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-gray-400'
+          }`}
+        >
+          {isOverride ? 'overridden' : 'default'}
+        </span>
+      </div>
+      <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{entry.reason}</p>
+      {entry.constraints && (
+        <p className="text-xs text-gray-400 dark:text-gray-500 mb-2">Allowed: {entry.constraints}</p>
+      )}
+
+      {error && (
+        <div className="mb-2 px-2.5 py-1.5 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs flex items-start gap-1.5">
+          <AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
+          <span>{error}</span>
+        </div>
+      )}
+
+      <div className="flex items-center gap-2">
+        {entry.key === 'runners.load_balancing' ? (
+          <select
+            value={value}
+            onChange={(e) => setValue(e.target.value)}
+            className="flex-1 max-w-xs px-3 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+          >
+            {LOAD_BALANCING_STRATEGIES.map((s) => (
+              <option key={s} value={s}>{s}</option>
+            ))}
+          </select>
+        ) : (
+          <input
+            type={entry.type === 'int' || entry.type === 'int64' ? 'number' : 'text'}
+            value={value}
+            onChange={(e) => setValue(e.target.value)}
+            className="flex-1 max-w-xs px-3 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+          />
+        )}
+
+        <button
+          onClick={handleSave}
+          disabled={!isDirty || busy}
+          className="flex items-center gap-1 px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
+        >
+          {success ? <Check className="w-3.5 h-3.5" /> : null}
+          {saveMutation.isPending ? 'Saving...' : success ? 'Saved' : 'Save'}
+        </button>
+
+        {isOverride && (
+          <button
+            onClick={() => clearMutation.mutate()}
+            disabled={busy}
+            className="flex items-center gap-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-40"
+            title="Clear the override and go back to the file default"
+          >
+            <RotateCcw className="w-3.5 h-3.5" />
+            {clearMutation.isPending ? 'Clearing...' : 'Reset to default'}
+          </button>
+        )}
+      </div>
+    </div>
+  )
+}
+
+/**
+ * The signed-in account's own profile. Email is the login credential today,
+ * so it is editable here, not just displayed. Username is kept as a display
+ * name only - there is no endpoint that renames it and this page does not
+ * pretend otherwise.
+ */
+function ProfileSection({
+  user,
+  updateProfile,
+}: {
+  user: { id: string; username: string; email: string; role: string } | null
+  updateProfile: (changes: { email?: string }) => void
+}) {
+  const [email, setEmail] = useState(user?.email || '')
+  const [emailError, setEmailError] = useState<string | null>(null)
+  const [emailSaved, setEmailSaved] = useState(false)
+
+  const [oldPassword, setOldPassword] = useState('')
+  const [newPassword, setNewPassword] = useState('')
+  const [passwordError, setPasswordError] = useState<string | null>(null)
+  const [passwordSaved, setPasswordSaved] = useState(false)
+
+  useEffect(() => {
+    setEmail(user?.email || '')
+  }, [user?.email])
+
+  const emailMutation = useMutation({
+    mutationFn: (newEmail: string) => usersApi.update(user!.id, { email: newEmail }),
+    onSuccess: (updated) => {
+      setEmailError(null)
+      setEmailSaved(true)
+      updateProfile({ email: updated.email })
+      setTimeout(() => setEmailSaved(false), 2000)
+    },
+    onError: (e: any) => setEmailError(e?.response?.data?.error || e.message || 'That did not work'),
+  })
+
+  const passwordMutation = useMutation({
+    mutationFn: () => usersApi.changeOwnPassword(user!.id, oldPassword, newPassword),
+    onSuccess: () => {
+      setPasswordError(null)
+      setPasswordSaved(true)
+      setOldPassword('')
+      setNewPassword('')
+      setTimeout(() => setPasswordSaved(false), 2000)
+    },
+    onError: (e: any) => setPasswordError(e?.response?.data?.error || e.message || 'That did not work'),
+  })
+
+  if (!user) return null
+
+  const emailDirty = email.trim() !== user.email
+
+  return (
+    <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
+      <div className="flex items-center gap-3 mb-4">
+        <User className="w-5 h-5 text-gray-400 dark:text-gray-500" />
+        <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Profile</h2>
+      </div>
+
+      <div className="grid gap-4 md:grid-cols-2 mb-5">
+        <div>
+          <label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
+            Username
+          </label>
+          <p className="text-gray-900 dark:text-gray-100">{user.username}</p>
+          <p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Display name only - cannot be changed here.</p>
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
+            Role
+          </label>
+          <p className="text-gray-900 dark:text-gray-100 capitalize">{user.role}</p>
+        </div>
+      </div>
+
+      <div className="mb-6">
+        <label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
+          Email
+        </label>
+        <p className="text-xs text-gray-400 dark:text-gray-500 mb-2">This is what you log in with.</p>
+        {emailError && (
+          <div className="mb-2 px-2.5 py-1.5 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs">
+            {emailError}
+          </div>
+        )}
+        <div className="flex items-center gap-2 max-w-md">
+          <input
+            type="email"
+            value={email}
+            onChange={(e) => setEmail(e.target.value)}
+            className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+          />
+          <button
+            onClick={() => emailMutation.mutate(email.trim())}
+            disabled={!emailDirty || !email.trim() || emailMutation.isPending}
+            className="flex items-center gap-1 px-3 py-2 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40 shrink-0"
+          >
+            {emailSaved ? <Check className="w-3.5 h-3.5" /> : null}
+            {emailMutation.isPending ? 'Saving...' : emailSaved ? 'Saved' : 'Save'}
+          </button>
+        </div>
+      </div>
+
+      <div>
+        <label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
+          Change password
+        </label>
+        <p className="text-xs text-gray-400 dark:text-gray-500 mb-2">
+          Your current password is required, even for an admin account.
+        </p>
+        {passwordError && (
+          <div className="mb-2 px-2.5 py-1.5 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs">
+            {passwordError}
+          </div>
+        )}
+        <div className="grid gap-2 max-w-md">
+          <input
+            type="password"
+            placeholder="Current password"
+            value={oldPassword}
+            onChange={(e) => setOldPassword(e.target.value)}
+            className="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+          />
+          <input
+            type="password"
+            placeholder="New password"
+            value={newPassword}
+            onChange={(e) => setNewPassword(e.target.value)}
+            className="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+          />
+          <div>
+            <button
+              onClick={() => passwordMutation.mutate()}
+              disabled={!oldPassword || newPassword.length < 6 || passwordMutation.isPending}
+              className="flex items-center gap-1 px-3 py-2 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
+            >
+              {passwordSaved ? <Check className="w-3.5 h-3.5" /> : null}
+              {passwordMutation.isPending ? 'Changing...' : passwordSaved ? 'Changed' : 'Change password'}
+            </button>
+          </div>
+        </div>
+      </div>
+    </section>
+  )
+}

+ 10 - 0
webui/src/stores/authStore.ts

@@ -17,6 +17,10 @@ interface AuthState {
   login: (username: string, password: string) => Promise<void>
   logout: () => void
   refreshTokens: () => Promise<void>
+  // Patches the signed-in user's own profile fields locally, so the sidebar
+  // and profile form reflect an edit immediately rather than waiting for the
+  // next full page load to re-fetch it.
+  updateProfile: (changes: Partial<Pick<User, 'email' | 'username'>>) => void
 }
 
 export const useAuthStore = create<AuthState>()(
@@ -60,6 +64,12 @@ export const useAuthStore = create<AuthState>()(
           refreshToken: response.data.refreshToken,
         })
       },
+
+      updateProfile: (changes) => {
+        const { user } = get()
+        if (!user) return
+        set({ user: { ...user, ...changes } })
+      },
     }),
     {
       name: 'smartbotic-auth',