feat: reorganize admin layout and enhance dashboard with settings sections

This commit is contained in:
Linus Rath
2026-03-25 10:58:05 +01:00
parent 0174051f3e
commit 05f6d61cea
2 changed files with 161 additions and 154 deletions
+83 -38
View File
@@ -19,15 +19,35 @@ import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
const NAV_ITEMS = [
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/branding', label: 'Branding', icon: Palette },
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
{ href: '/admin/policy', label: 'Policy', icon: Scale },
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
const NAV_GROUPS = [
{
label: 'Overview',
items: [
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Configuration',
items: [
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/branding', label: 'Branding', icon: Palette },
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
{ href: '/admin/policy', label: 'Policy', icon: Scale },
],
},
{
label: 'Extensions',
items: [
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
],
},
{
label: 'System',
items: [
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -41,9 +61,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
useEffect(() => {
checkAuth();
if (pathname !== '/admin/login') {
checkAuth();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [pathname]);
async function checkAuth() {
try {
@@ -84,8 +106,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return (
<div className="min-h-screen flex bg-background">
{/* Sidebar */}
<aside className="w-60 border-r border-border bg-secondary/30 flex flex-col">
<div className="h-14 flex items-center px-4 border-b border-border">
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
@@ -94,40 +116,63 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
<nav className="flex-1 py-2 px-2 space-y-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
active
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
<Icon className="w-4 h-4" />
{label}
</Link>
);
})}
</nav>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{label}
</Link>
);
})}
</div>
))}
</div>
</div>
<div className="p-2 border-t border-border space-y-0.5">
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
<Link
href="/admin/change-password"
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className="w-4 h-4" />
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
<button
onClick={handleLogout}
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors w-full text-left"
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4" />
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
+78 -116
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { Server, AlertTriangle, Clock, Globe, Package, Palette, Shield, Activity } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { AuditEntry } from '@/lib/admin/types';
interface AdminStatus {
@@ -57,7 +58,6 @@ export default function AdminDashboardPage() {
setConfig(configData);
}
// Plugin/theme/policy stats
if (pluginRes?.ok) {
const plugins = await pluginRes.json();
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
@@ -73,7 +73,6 @@ export default function AdminDashboardPage() {
setPolicyRuleCount(restrictionCount + disabledGates);
}
// JMAP health check
if (configData?.jmapServerUrl) {
try {
const jmapRes = await fetch('/api/config');
@@ -83,7 +82,6 @@ export default function AdminDashboardPage() {
}
}
// Build warnings
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
@@ -101,55 +99,10 @@ export default function AdminDashboardPage() {
}
const jmapUrl = config?.jmapServerUrl || '—';
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Server overview and recent activity</p>
</div>
{/* Status cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatusCard
icon={<Server className="w-4 h-4" />}
label="Application"
value={config?.appName || '—'}
/>
<StatusCard
icon={<Globe className="w-4 h-4" />}
label="JMAP Server"
value={jmapUrl ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—'}
detail={jmapUrl}
/>
<StatusCard
icon={<Clock className="w-4 h-4" />}
label="Last Login"
value={status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
/>
</div>
{/* Feature status row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<FeaturePill label="Admin" active={!!status?.enabled} />
<FeaturePill label="Settings Sync" active={!!config?.settingsSyncEnabled} />
<FeaturePill label="OAuth" active={!!config?.oauthEnabled} />
<FeaturePill label="Stalwart" active={config?.stalwartFeaturesEnabled !== false} />
</div>
{/* Quick stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon={<Package className="w-4 h-4" />} label="Plugins" value={pluginCount} />
<StatCard icon={<Palette className="w-4 h-4" />} label="Themes" value={themeCount} />
<StatCard icon={<Shield className="w-4 h-4" />} label="Policy Rules" value={policyRuleCount} />
<StatCard
icon={<Activity className="w-4 h-4" />}
label="JMAP Health"
value={jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : '—'}
status={jmapHealth === 'ok' ? 'success' : jmapHealth === 'error' ? 'error' : undefined}
/>
</div>
<div className="max-w-3xl space-y-8">
{/* Warnings */}
{warnings.map((msg, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-4">
@@ -170,72 +123,81 @@ export default function AdminDashboardPage() {
</div>
)}
{/* Recent activity */}
<div>
<h2 className="text-lg font-medium text-foreground mb-3">Recent Activity</h2>
<div className="border border-border rounded-lg divide-y divide-border">
{recentActivity.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No activity recorded yet
</div>
) : (
recentActivity.map((entry, i) => (
<div key={i} className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
{entry.action}
</span>
<span className="text-sm text-foreground">
{formatDetail(entry.detail)}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{entry.ip}</span>
<span>{new Date(entry.ts).toLocaleString()}</span>
</div>
{/* Server Info */}
<SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
</SettingItem>
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="JMAP Connection">
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
}`}>
<span className={`w-2 h-2 rounded-full ${
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
}`} />
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
</span>
</SettingItem>
</SettingsSection>
{/* Features */}
<SettingsSection title="Features" description="Enabled integrations and modules">
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="OAuth" description="OAuth authentication provider">
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
</SettingItem>
</SettingsSection>
{/* Extensions */}
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins">
<span className="text-sm text-foreground">{pluginCount}</span>
</SettingItem>
<SettingItem label="Themes">
<span className="text-sm text-foreground">{themeCount}</span>
</SettingItem>
<SettingItem label="Policy Rules">
<span className="text-sm text-foreground">{policyRuleCount}</span>
</SettingItem>
</SettingsSection>
{/* Recent Activity */}
<SettingsSection title="Recent Activity" description="Latest administrative actions">
{recentActivity.length === 0 ? (
<div className="py-4 text-sm text-muted-foreground">
No activity recorded yet
</div>
) : (
recentActivity.map((entry, i) => (
<SettingItem
key={i}
label={entry.action}
description={formatDetail(entry.detail) || undefined}
>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{entry.ip}</span>
<span>{new Date(entry.ts).toLocaleString()}</span>
</div>
))
)}
</div>
</div>
</div>
);
}
function StatusCard({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail?: string }) {
return (
<div className="border border-border rounded-lg p-4 bg-secondary/20">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
{icon}
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<div className="text-lg font-semibold text-foreground truncate">{value}</div>
{detail && <p className="text-xs text-muted-foreground mt-1 truncate">{detail}</p>}
</div>
);
}
function FeaturePill({ label, active }: { label: string; active: boolean }) {
return (
<div className="flex items-center gap-2 border border-border rounded-md px-3 py-2">
<span className={`w-2 h-2 rounded-full ${active ? 'bg-green-500' : 'bg-muted-foreground/40'}`} />
<span className="text-xs font-medium text-foreground">{label}</span>
<span className={`text-xs ml-auto ${active ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
{active ? 'On' : 'Off'}
</span>
</div>
);
}
function StatCard({ icon, label, value, status }: { icon: React.ReactNode; label: string; value: string | number; status?: 'success' | 'error' }) {
const statusColor = status === 'success' ? 'text-green-600 dark:text-green-400' : status === 'error' ? 'text-red-600 dark:text-red-400' : 'text-foreground';
return (
<div className="border border-border rounded-md px-3 py-2 bg-secondary/20">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
{icon}
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<div className={`text-sm font-semibold ${statusColor}`}>{value}</div>
</SettingItem>
))
)}
</SettingsSection>
</div>
);
}