feat: reorganize admin layout and enhance dashboard with settings sections
This commit is contained in:
+83
-38
@@ -19,15 +19,35 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_GROUPS = [
|
||||||
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
{
|
||||||
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
label: 'Overview',
|
||||||
{ href: '/admin/branding', label: 'Branding', icon: Palette },
|
items: [
|
||||||
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
|
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ 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 },
|
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 }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -41,9 +61,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth();
|
if (pathname !== '/admin/login') {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [pathname]);
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
@@ -84,8 +106,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-60 border-r border-border bg-secondary/30 flex flex-col">
|
<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">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<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>
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-2 px-2 space-y-0.5">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
<div className="px-2 space-y-0.5">
|
||||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
{NAV_GROUPS.map((group, groupIndex) => (
|
||||||
return (
|
<div key={group.label}>
|
||||||
<Link
|
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||||
key={href}
|
<div className="px-3 pt-2.5 pb-1">
|
||||||
href={href}
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
className={cn(
|
{group.label}
|
||||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
|
</span>
|
||||||
active
|
</div>
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
{group.items.map(({ href, label, icon: Icon }) => {
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||||
)}
|
return (
|
||||||
>
|
<Link
|
||||||
<Icon className="w-4 h-4" />
|
key={href}
|
||||||
{label}
|
href={href}
|
||||||
</Link>
|
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
|
||||||
</nav>
|
? '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
|
<Link
|
||||||
href="/admin/change-password"
|
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
|
Change Password
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+78
-116
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
|
||||||
interface AdminStatus {
|
interface AdminStatus {
|
||||||
@@ -57,7 +58,6 @@ export default function AdminDashboardPage() {
|
|||||||
setConfig(configData);
|
setConfig(configData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin/theme/policy stats
|
|
||||||
if (pluginRes?.ok) {
|
if (pluginRes?.ok) {
|
||||||
const plugins = await pluginRes.json();
|
const plugins = await pluginRes.json();
|
||||||
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
||||||
@@ -73,7 +73,6 @@ export default function AdminDashboardPage() {
|
|||||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JMAP health check
|
|
||||||
if (configData?.jmapServerUrl) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
const jmapRes = await fetch('/api/config');
|
const jmapRes = await fetch('/api/config');
|
||||||
@@ -83,7 +82,6 @@ export default function AdminDashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build warnings
|
|
||||||
const w: string[] = [];
|
const w: string[] = [];
|
||||||
if (adminConfigRes.ok) {
|
if (adminConfigRes.ok) {
|
||||||
const sources = await adminConfigRes.json();
|
const sources = await adminConfigRes.json();
|
||||||
@@ -101,55 +99,10 @@ export default function AdminDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jmapUrl = config?.jmapServerUrl || '—';
|
const jmapUrl = config?.jmapServerUrl || '—';
|
||||||
|
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="max-w-3xl space-y-8">
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Warnings */}
|
||||||
{warnings.map((msg, i) => (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent activity */}
|
{/* Server Info */}
|
||||||
<div>
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
<h2 className="text-lg font-medium text-foreground mb-3">Recent Activity</h2>
|
<SettingItem label="Application">
|
||||||
<div className="border border-border rounded-lg divide-y divide-border">
|
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
||||||
{recentActivity.length === 0 ? (
|
</SettingItem>
|
||||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
||||||
No activity recorded yet
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</div>
|
</SettingItem>
|
||||||
) : (
|
<SettingItem label="JMAP Connection">
|
||||||
recentActivity.map((entry, i) => (
|
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
||||||
<div key={i} className="px-4 py-3 flex items-center justify-between">
|
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
||||||
<div className="flex items-center gap-3">
|
}`}>
|
||||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
{entry.action}
|
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
||||||
</span>
|
}`} />
|
||||||
<span className="text-sm text-foreground">
|
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
||||||
{formatDetail(entry.detail)}
|
</span>
|
||||||
</span>
|
</SettingItem>
|
||||||
</div>
|
<SettingItem label="Last Login">
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<span className="text-sm text-foreground">
|
||||||
<span>{entry.ip}</span>
|
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||||
<span>{new Date(entry.ts).toLocaleString()}</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
))
|
</SettingItem>
|
||||||
)}
|
))
|
||||||
</div>
|
)}
|
||||||
</div>
|
</SettingsSection>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user