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 { User } from '../types';
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';
const SECRET_SENTINEL = '__SET__';
@ -22,36 +23,67 @@ interface SettingsProps {
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 (
<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({
value, onChange, placeholder, disabled, monospace,
}: {
function FieldRow({ label, hint, badge, children }: {
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;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
monospace?: boolean;
icon?: React.ReactNode;
}) {
return (
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
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' : ''}`}
/>
<div className="relative">
{icon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-600 pointer-events-none">
{icon}
</span>
)}
<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({
value, onChange, alreadySet, show, onToggleShow,
}: {
function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
value: string;
onChange: (v: string) => void;
alreadySet: boolean;
@ -60,32 +92,48 @@ function SecretInput({
}) {
return (
<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
type={show ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={alreadySet ? 'Already configured leave blank to keep' : 'Enter 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"
placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Paste secret value'}
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
type="button"
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}
>
{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>
</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) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
// Entra ID
const [azureEnabled, setAzureEnabled] = useState(false);
const [azureClientId, setAzureClientId] = useState('');
const [azureTenantId, setAzureTenantId] = useState('');
@ -94,7 +142,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [azureRedirectUri, setAzureRedirectUri] = useState('');
const [showAzureSecret, setShowAzureSecret] = useState(false);
// CheckMK
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
@ -108,7 +155,12 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setError('');
try {
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();
setAzureEnabled(data.azure_enabled === 'true');
setAzureClientId(data.azure_client_id || '');
@ -153,7 +205,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
setAzureClientSecret('');
setCheckmkApiSecret('');
setSuccessMsg('Settings saved.');
setSuccessMsg('Settings saved successfully.');
setTimeout(() => setSuccessMsg(''), 4000);
} catch {
setError('Network error saving settings.');
@ -165,72 +217,90 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
if (loading) {
return (
<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>
);
}
return (
<div className="space-y-6 max-w-3xl">
<div className="space-y-6 max-w-2xl">
{/* Page header */}
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-900 border border-slate-800 rounded-xl">
<Settings2 className="w-5 h-5 text-cyan-400" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Settings</h1>
<p className="text-xs text-slate-400">Integrations and service configuration</p>
<div>
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
<Settings2 className="w-3 h-3" />
<span>SYSTEM</span>
<ChevronRight className="w-3 h-3" />
<span className="text-slate-400">SETTINGS</span>
</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>
{/* Feedback */}
{/* Feedback banners */}
{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">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
{error}
<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 mt-0.5" />
<span>{error}</span>
</div>
)}
{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" />
{successMsg}
</div>
)}
{/* Microsoft Entra ID */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-6 space-y-5">
{/* ── Microsoft Entra ID ── */}
<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 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" />
</div>
<div>
<h3 className="text-sm font-semibold text-white">Microsoft Entra ID</h3>
<p className="text-xs text-slate-500">OAuth 2.0 login for organizational accounts</p>
<div className="flex items-center gap-2">
<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>
<button
type="button"
onClick={() => setAzureEnabled(v => !v)}
className={`relative w-11 h-6 rounded-full transition-colors ${azureEnabled ? 'bg-blue-600' : 'bg-slate-700'}`}
title={azureEnabled ? 'Disable' : 'Enable'}
>
<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'}`} />
</button>
{/* Toggle */}
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
{azureEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="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 className={`grid grid-cols-1 sm:grid-cols-2 gap-4 ${!azureEnabled ? 'opacity-50 pointer-events-none' : ''}`}>
<div>
<FieldLabel>Tenant ID</FieldLabel>
<TextInput value={azureTenantId} onChange={setAzureTenantId} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" monospace />
</div>
<div>
<FieldLabel>Client ID (Application ID)</FieldLabel>
<TextInput value={azureClientId} onChange={setAzureClientId} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" monospace />
</div>
<div>
<FieldLabel>Client Secret</FieldLabel>
<div className="h-px bg-slate-800/60" />
{/* Fields */}
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Paste Tenant ID" monospace />
</FieldRow>
<FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureClientId} onChange={setAzureClientId} placeholder="Paste Client ID" monospace />
</FieldRow>
<FieldRow
label="Client Secret"
badge={azureSecretSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
value={azureClientSecret}
onChange={setAzureClientSecret}
@ -238,59 +308,84 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
show={showAzureSecret}
onToggleShow={() => setShowAzureSecret(v => !v)}
/>
</div>
<div>
<FieldLabel>Redirect URI <span className="text-slate-500 font-normal">(leave blank for auto)</span></FieldLabel>
<TextInput value={azureRedirectUri} onChange={setAzureRedirectUri} placeholder="https://ghostgrid.internal/api/auth/azure/callback" monospace />
</div>
</FieldRow>
<FieldRow label="Redirect URI" hint="Leave blank to auto-derive from APP_URL">
<Input
value={azureRedirectUri}
onChange={setAzureRedirectUri}
placeholder="https://…/api/auth/azure/callback"
monospace
icon={<ExternalLink className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div>
</div>
</SectionCard>
{/* CheckMK */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-6 space-y-5">
{/* ── CheckMK ── */}
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
<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" />
</div>
<div>
<h3 className="text-sm font-semibold text-white">CheckMK</h3>
<p className="text-xs text-slate-500">Device status synchronization via the CheckMK REST API</p>
<div className="flex items-center gap-2">
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<FieldLabel>API URL</FieldLabel>
<TextInput
<div className="h-px bg-slate-800/60" />
<div className="space-y-5">
<FieldRow label="API URL">
<Input
value={checkmkApiUrl}
onChange={setCheckmkApiUrl}
placeholder="https://checkmk.internal/<site>/check_mk/api/1.0"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
</div>
<div>
<FieldLabel>Automation Secret</FieldLabel>
<SecretInput
value={checkmkApiSecret}
onChange={setCheckmkApiSecret}
alreadySet={checkmkSecretSet}
show={showCheckmkSecret}
onToggleShow={() => setShowCheckmkSecret(v => !v)}
/>
</div>
<div>
<FieldLabel>Sync Interval (ms)</FieldLabel>
<TextInput value={checkmkSyncInterval} onChange={setCheckmkSyncInterval} placeholder="60000" monospace />
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow
label="Automation Secret"
badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
value={checkmkApiSecret}
onChange={setCheckmkApiSecret}
alreadySet={checkmkSecretSet}
show={showCheckmkSecret}
onToggleShow={() => setShowCheckmkSecret(v => !v)}
/>
</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>
</SectionCard>
{/* Save */}
<div className="flex justify-end">
{/* Save bar */}
<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
onClick={handleSave}
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 ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />