style(settings): polish Settings page – accent gradients, status badges, better field layout
This commit is contained in:
@ -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 (
|
||||
<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}
|
||||
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' : ''}`}
|
||||
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 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>
|
||||
{/* 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-11 h-6 rounded-full transition-colors ${azureEnabled ? 'bg-blue-600' : 'bg-slate-700'}`}
|
||||
title={azureEnabled ? 'Disable' : 'Enable'}
|
||||
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 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${azureEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
<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,38 +308,56 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
show={showAzureSecret}
|
||||
onToggleShow={() => setShowAzureSecret(v => !v)}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
</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}
|
||||
@ -277,20 +365,27 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
show={showCheckmkSecret}
|
||||
onToggleShow={() => setShowCheckmkSecret(v => !v)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Sync Interval (ms)</FieldLabel>
|
||||
<TextInput value={checkmkSyncInterval} onChange={setCheckmkSyncInterval} placeholder="60000" monospace />
|
||||
</div>
|
||||
</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>
|
||||
</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" />
|
||||
|
||||
Reference in New Issue
Block a user