|
|
@@ -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>
|
|
|
+ )
|
|
|
+}
|