style(settings): polish Settings page – accent gradients, status badges, better field layout

This commit is contained in:
Brückner
2026-06-03 16:18:36 +02:00
parent f7999cbe55
commit 34c9822e42

View File

@ -2,7 +2,8 @@ import React, { useState, useEffect } from 'react';
import { authFetch } from '../lib/auth'; import { authFetch } from '../lib/auth';
import { User } from '../types'; import { User } from '../types';
import { import {
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff, Settings2, Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
Settings2, ExternalLink, KeyRound, Globe, Clock, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
const SECRET_SENTINEL = '__SET__'; const SECRET_SENTINEL = '__SET__';
@ -22,36 +23,67 @@ interface SettingsProps {
currentUser: User; currentUser: User;
} }
function FieldLabel({ children }: { children: React.ReactNode }) { function Label({ children }: { children: React.ReactNode }) {
return <label className="block text-[11px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">{children}</label>;
}
function Hint({ children }: { children: React.ReactNode }) {
return <p className="mt-1 text-[10px] text-slate-600 font-mono">{children}</p>;
}
function ConfiguredBadge() {
return ( return (
<label className="block text-xs font-semibold text-slate-300 mb-1.5">{children}</label> <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
<span className="w-1 h-1 rounded-full bg-emerald-400 inline-block" />
CONFIGURED
</span>
); );
} }
function TextInput({ function FieldRow({ label, hint, badge, children }: {
value, onChange, placeholder, disabled, monospace, label: string;
}: { hint?: string;
badge?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>{label}</Label>
{badge}
</div>
{children}
{hint && <Hint>{hint}</Hint>}
</div>
);
}
function Input({ value, onChange, placeholder, monospace, icon }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean;
monospace?: boolean; monospace?: boolean;
icon?: React.ReactNode;
}) { }) {
return ( return (
<input <div className="relative">
type="text" {icon && (
value={value} <span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-600 pointer-events-none">
onChange={e => onChange(e.target.value)} {icon}
placeholder={placeholder} </span>
disabled={disabled} )}
className={`w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed ${monospace ? 'font-mono' : ''}`} <input
/> type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={`w-full bg-slate-950 border border-slate-800 rounded-lg py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-cyan-500/40 focus:border-cyan-500/40 transition-all ${icon ? 'pl-9 pr-3' : 'px-3'} ${monospace ? 'font-mono text-xs' : ''}`}
/>
</div>
); );
} }
function SecretInput({ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
value, onChange, alreadySet, show, onToggleShow,
}: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
alreadySet: boolean; alreadySet: boolean;
@ -60,32 +92,48 @@ function SecretInput({
}) { }) {
return ( return (
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-600 pointer-events-none">
<KeyRound className="w-3.5 h-3.5" />
</span>
<input <input
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
value={value} value={value}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
placeholder={alreadySet ? 'Already configured leave blank to keep' : 'Enter value'} placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Paste secret value'}
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 font-mono focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all" className="w-full bg-slate-950 border border-slate-800 rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-white placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-cyan-500/40 focus:border-cyan-500/40 transition-all"
/> />
<button <button
type="button" type="button"
onClick={onToggleShow} onClick={onToggleShow}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-300 transition-colors"
tabIndex={-1} tabIndex={-1}
> >
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button> </button>
</div> </div>
); );
} }
function SectionCard({ accentColor, children }: {
accentColor: string;
children: React.ReactNode;
}) {
return (
<div className={`bg-[#0F172A] border border-[#1E293B] rounded-2xl overflow-hidden`}>
<div className={`h-0.5 w-full ${accentColor}`} />
<div className="p-6 space-y-5">
{children}
</div>
</div>
);
}
export default function Settings({ currentUser: _currentUser }: SettingsProps) { export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState(''); const [successMsg, setSuccessMsg] = useState('');
// Entra ID
const [azureEnabled, setAzureEnabled] = useState(false); const [azureEnabled, setAzureEnabled] = useState(false);
const [azureClientId, setAzureClientId] = useState(''); const [azureClientId, setAzureClientId] = useState('');
const [azureTenantId, setAzureTenantId] = useState(''); const [azureTenantId, setAzureTenantId] = useState('');
@ -94,7 +142,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [azureRedirectUri, setAzureRedirectUri] = useState(''); const [azureRedirectUri, setAzureRedirectUri] = useState('');
const [showAzureSecret, setShowAzureSecret] = useState(false); const [showAzureSecret, setShowAzureSecret] = useState(false);
// CheckMK
const [checkmkApiUrl, setCheckmkApiUrl] = useState(''); const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
const [checkmkApiSecret, setCheckmkApiSecret] = useState(''); const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false); const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
@ -108,7 +155,12 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setError(''); setError('');
try { try {
const res = await authFetch('/api/settings'); const res = await authFetch('/api/settings');
if (!res.ok) { setError('Failed to load settings.'); return; } if (!res.ok) {
let msg = `Failed to load settings (HTTP ${res.status}).`;
try { const d = await res.json(); if (d.error) msg = `${d.error} (HTTP ${res.status})`; } catch {}
setError(msg);
return;
}
const data: RawSettings = await res.json(); const data: RawSettings = await res.json();
setAzureEnabled(data.azure_enabled === 'true'); setAzureEnabled(data.azure_enabled === 'true');
setAzureClientId(data.azure_client_id || ''); setAzureClientId(data.azure_client_id || '');
@ -153,7 +205,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL); setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
setAzureClientSecret(''); setAzureClientSecret('');
setCheckmkApiSecret(''); setCheckmkApiSecret('');
setSuccessMsg('Settings saved.'); setSuccessMsg('Settings saved successfully.');
setTimeout(() => setSuccessMsg(''), 4000); setTimeout(() => setSuccessMsg(''), 4000);
} catch { } catch {
setError('Network error saving settings.'); setError('Network error saving settings.');
@ -165,72 +217,90 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<span className="w-5 h-5 border-2 border-slate-600 border-t-cyan-400 rounded-full animate-spin" /> <span className="w-5 h-5 border-2 border-slate-700 border-t-cyan-400 rounded-full animate-spin" />
</div> </div>
); );
} }
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-2xl">
{/* Page header */} {/* Page header */}
<div className="flex items-center gap-3"> <div>
<div className="p-2 bg-slate-900 border border-slate-800 rounded-xl"> <div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
<Settings2 className="w-5 h-5 text-cyan-400" /> <Settings2 className="w-3 h-3" />
</div> <span>SYSTEM</span>
<div> <ChevronRight className="w-3 h-3" />
<h1 className="text-lg font-bold text-white">Settings</h1> <span className="text-slate-400">SETTINGS</span>
<p className="text-xs text-slate-400">Integrations and service configuration</p>
</div> </div>
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
</div> </div>
{/* Feedback */} {/* Feedback banners */}
{error && ( {error && (
<div className="flex items-center gap-2 bg-red-950/60 border border-red-800/60 rounded-lg px-3 py-2.5 text-xs text-red-300"> <div className="flex items-start gap-2.5 bg-red-950/40 border border-red-900/50 rounded-xl px-4 py-3 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" /> <AlertCircle className="w-4 h-4 shrink-0 text-red-400 mt-0.5" />
{error} <span>{error}</span>
</div> </div>
)} )}
{successMsg && ( {successMsg && (
<div className="flex items-center gap-2 bg-emerald-950/60 border border-emerald-800/60 rounded-lg px-3 py-2.5 text-xs text-emerald-300"> <div className="flex items-center gap-2.5 bg-emerald-950/40 border border-emerald-900/50 rounded-xl px-4 py-3 text-xs text-emerald-300">
<CheckCircle className="w-4 h-4 shrink-0 text-emerald-400" /> <CheckCircle className="w-4 h-4 shrink-0 text-emerald-400" />
{successMsg} {successMsg}
</div> </div>
)} )}
{/* Microsoft Entra ID */} {/* ── Microsoft Entra ID ── */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-6 space-y-5"> <SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
{/* Card header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-1.5 bg-blue-950/50 border border-blue-900/50 rounded-lg"> <div className="p-2 bg-blue-950/60 border border-blue-900/40 rounded-xl">
<Shield className="w-4 h-4 text-blue-400" /> <Shield className="w-4 h-4 text-blue-400" />
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-white">Microsoft Entra ID</h3> <div className="flex items-center gap-2">
<p className="text-xs text-slate-500">OAuth 2.0 login for organizational accounts</p> <h2 className="text-sm font-semibold text-white">Microsoft Entra ID</h2>
{azureEnabled && azureSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-blue-950/60 border border-blue-900/50 text-blue-400">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
</div> </div>
</div> </div>
<button {/* Toggle */}
type="button" <div className="flex items-center gap-2.5">
onClick={() => setAzureEnabled(v => !v)} <span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
className={`relative w-11 h-6 rounded-full transition-colors ${azureEnabled ? 'bg-blue-600' : 'bg-slate-700'}`} {azureEnabled ? 'ENABLED' : 'DISABLED'}
title={azureEnabled ? 'Disable' : 'Enable'} </span>
> <button
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${azureEnabled ? 'translate-x-5' : 'translate-x-0'}`} /> type="button"
</button> onClick={() => setAzureEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${azureEnabled ? 'bg-blue-600 shadow-[0_0_10px_rgba(37,99,235,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${azureEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div> </div>
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-4 ${!azureEnabled ? 'opacity-50 pointer-events-none' : ''}`}> <div className="h-px bg-slate-800/60" />
<div>
<FieldLabel>Tenant ID</FieldLabel> {/* Fields */}
<TextInput value={azureTenantId} onChange={setAzureTenantId} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" monospace /> <div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
</div> <FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<div> <Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Paste Tenant ID" monospace />
<FieldLabel>Client ID (Application ID)</FieldLabel> </FieldRow>
<TextInput value={azureClientId} onChange={setAzureClientId} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" monospace /> <FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
</div> <Input value={azureClientId} onChange={setAzureClientId} placeholder="Paste Client ID" monospace />
<div> </FieldRow>
<FieldLabel>Client Secret</FieldLabel> <FieldRow
label="Client Secret"
badge={azureSecretSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput <SecretInput
value={azureClientSecret} value={azureClientSecret}
onChange={setAzureClientSecret} onChange={setAzureClientSecret}
@ -238,59 +308,84 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
show={showAzureSecret} show={showAzureSecret}
onToggleShow={() => setShowAzureSecret(v => !v)} onToggleShow={() => setShowAzureSecret(v => !v)}
/> />
</div> </FieldRow>
<div> <FieldRow label="Redirect URI" hint="Leave blank to auto-derive from APP_URL">
<FieldLabel>Redirect URI <span className="text-slate-500 font-normal">(leave blank for auto)</span></FieldLabel> <Input
<TextInput value={azureRedirectUri} onChange={setAzureRedirectUri} placeholder="https://ghostgrid.internal/api/auth/azure/callback" monospace /> value={azureRedirectUri}
</div> onChange={setAzureRedirectUri}
placeholder="https://…/api/auth/azure/callback"
monospace
icon={<ExternalLink className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div> </div>
</div> </SectionCard>
{/* CheckMK */} {/* ── CheckMK ── */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-6 space-y-5"> <SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-1.5 bg-emerald-950/50 border border-emerald-900/50 rounded-lg"> <div className="p-2 bg-emerald-950/60 border border-emerald-900/40 rounded-xl">
<Activity className="w-4 h-4 text-emerald-400" /> <Activity className="w-4 h-4 text-emerald-400" />
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-white">CheckMK</h3> <div className="flex items-center gap-2">
<p className="text-xs text-slate-500">Device status synchronization via the CheckMK REST API</p> <h2 className="text-sm font-semibold text-white">CheckMK</h2>
{checkmkApiUrl && checkmkSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
<span className="w-1 h-1 rounded-full bg-emerald-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">Device status sync via CheckMK REST API</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="h-px bg-slate-800/60" />
<div className="sm:col-span-2">
<FieldLabel>API URL</FieldLabel> <div className="space-y-5">
<TextInput <FieldRow label="API URL">
<Input
value={checkmkApiUrl} value={checkmkApiUrl}
onChange={setCheckmkApiUrl} onChange={setCheckmkApiUrl}
placeholder="https://checkmk.internal/<site>/check_mk/api/1.0" placeholder="https://checkmk.internal/<site>/check_mk/api/1.0"
monospace monospace
icon={<Globe className="w-3.5 h-3.5" />}
/> />
</div> </FieldRow>
<div> <div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldLabel>Automation Secret</FieldLabel> <FieldRow
<SecretInput label="Automation Secret"
value={checkmkApiSecret} badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
onChange={setCheckmkApiSecret} >
alreadySet={checkmkSecretSet} <SecretInput
show={showCheckmkSecret} value={checkmkApiSecret}
onToggleShow={() => setShowCheckmkSecret(v => !v)} onChange={setCheckmkApiSecret}
/> alreadySet={checkmkSecretSet}
</div> show={showCheckmkSecret}
<div> onToggleShow={() => setShowCheckmkSecret(v => !v)}
<FieldLabel>Sync Interval (ms)</FieldLabel> />
<TextInput value={checkmkSyncInterval} onChange={setCheckmkSyncInterval} placeholder="60000" monospace /> </FieldRow>
<FieldRow label="Sync Interval" hint="Milliseconds between status polls (default: 60000)">
<Input
value={checkmkSyncInterval}
onChange={setCheckmkSyncInterval}
placeholder="60000"
monospace
icon={<Clock className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div> </div>
</div> </div>
</div> </SectionCard>
{/* Save */} {/* Save bar */}
<div className="flex justify-end"> <div className="flex items-center justify-between pt-2 border-t border-slate-800/60">
<p className="text-[11px] text-slate-600">Changes are applied after saving and a server restart.</p>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white font-semibold text-sm px-5 py-2.5 rounded-lg transition-all shadow-lg shadow-cyan-900/30" className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40"
> >
{saving ? ( {saving ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />