| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- import {
- ApiOutlined,
- CheckCircleOutlined,
- CloudServerOutlined,
- CloseOutlined,
- DashboardOutlined,
- GlobalOutlined,
- InboxOutlined,
- KeyOutlined,
- MailOutlined,
- MenuOutlined,
- SafetyCertificateOutlined,
- SendOutlined,
- SettingOutlined,
- UserOutlined,
- WarningOutlined
- } from '@ant-design/icons';
- import {
- Avatar,
- Button,
- Drawer,
- Dropdown,
- Layout,
- Menu,
- Select,
- Space,
- Tag,
- Tooltip,
- Typography
- } from 'antd';
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
- import { Outlet, useLocation, useNavigate } from 'react-router-dom';
- import { useAppContext } from '../frontend/app-context';
- import { useI18n } from '../frontend/i18n/react';
- import { useMediaQuery } from '../frontend/use-media-query';
- const { Header, Sider, Content } = Layout;
- interface NavigationItem {
- path: string;
- labelKey: string;
- icon: ReactNode;
- adminOnly?: boolean;
- }
- interface NavigationGroup {
- key: string;
- labelKey: string;
- items: NavigationItem[];
- }
- export const navigationGroups: NavigationGroup[] = [
- {
- key: 'operations',
- labelKey: 'nav.group.operations',
- items: [
- { path: '/overview', labelKey: 'nav.overview', icon: <DashboardOutlined /> },
- { path: '/activity', labelKey: 'nav.activity', icon: <SendOutlined /> },
- { path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
- { path: '/inbox', labelKey: 'nav.inbox', icon: <InboxOutlined /> }
- ]
- },
- {
- key: 'integrations',
- labelKey: 'nav.group.integrations',
- items: [
- { path: '/integrations/smtp', labelKey: 'nav.smtpIntegration', icon: <MailOutlined /> },
- { path: '/integrations/api-keys', labelKey: 'nav.apiKeys', icon: <KeyOutlined /> },
- { path: '/integrations/webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> },
- { path: '/integrations/dns', labelKey: 'nav.dnsIntegration', icon: <CloudServerOutlined /> }
- ]
- },
- {
- key: 'system',
- labelKey: 'nav.group.system',
- items: [
- { path: '/admin/users', labelKey: 'nav.adminCenter', icon: <SafetyCertificateOutlined />, adminOnly: true },
- { path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined />, adminOnly: true }
- ]
- }
- ];
- export function visibleNavigation(isAdmin: boolean) {
- return navigationGroups
- .map((group) => ({
- ...group,
- items: group.items.filter((item) => !item.adminOnly || isAdmin)
- }))
- .filter((group) => group.items.length > 0);
- }
- export function navigationSelection(pathname: string) {
- if (pathname.startsWith('/activity')) return '/activity';
- if (pathname.startsWith('/domains')) return '/domains';
- if (pathname.startsWith('/inbox')) return '/inbox';
- if (pathname.startsWith('/integrations/smtp')) return '/integrations/smtp';
- if (pathname.startsWith('/integrations/api-keys')) return '/integrations/api-keys';
- if (pathname.startsWith('/integrations/webhooks')) return '/integrations/webhooks';
- if (pathname.startsWith('/integrations/dns')) return '/integrations/dns';
- if (pathname.startsWith('/admin')) return '/admin/users';
- if (pathname.startsWith('/settings')) return '/settings';
- return '/overview';
- }
- export function AdminLayout() {
- const { locale, locales, setLocale, t } = useI18n();
- const { user, config, logout } = useAppContext();
- const location = useLocation();
- const navigate = useNavigate();
- const isDesktop = useMediaQuery('(min-width: 1024px)');
- const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false);
- const mainRef = useRef<HTMLElement>(null);
- const isAdmin = user?.role === 'admin';
- useEffect(() => {
- setMobileNavigationOpen(false);
- const frame = window.requestAnimationFrame(() => mainRef.current?.focus({ preventScroll: true }));
- return () => window.cancelAnimationFrame(frame);
- }, [location.pathname]);
- useEffect(() => {
- if (isDesktop) setMobileNavigationOpen(false);
- }, [isDesktop]);
- const menuItems = useMemo(() => visibleNavigation(isAdmin).map((group) => ({
- type: 'group' as const,
- key: group.key,
- label: t(group.labelKey),
- children: group.items.map((item) => ({
- key: item.path,
- icon: item.icon,
- label: t(item.labelKey)
- }))
- })), [isAdmin, t]);
- const menu = (
- <Menu
- theme="dark"
- mode="inline"
- selectedKeys={[navigationSelection(location.pathname)]}
- items={menuItems}
- onClick={({ key }) => navigate(key)}
- className="admin-menu"
- />
- );
- const environmentReady = Boolean(config?.mailHostname && config?.sendingIp);
- const environmentDetails = config
- ? `${config.mailHostname} · ${config.sendingIp || t('common.unsetSendingIp')}`
- : t('common.loadingConfig');
- return (
- <Layout className="admin-layout">
- <a className="skip-link" href="#main-content">{t('shell.skipToMain')}</a>
- <Sider width={252} className="admin-sider desktop-sider" trigger={null}>
- <NavigationPanel menu={menu} userName={user?.username} role={user?.role} />
- </Sider>
- <Drawer
- className="mobile-navigation-drawer"
- placement="left"
- width={288}
- open={mobileNavigationOpen && !isDesktop}
- onClose={() => setMobileNavigationOpen(false)}
- closable={false}
- styles={{ body: { padding: 0, background: '#0F172A' } }}
- aria-label={t('shell.mainNavigation')}
- >
- <NavigationPanel
- menu={menu}
- userName={user?.username}
- role={user?.role}
- onClose={() => setMobileNavigationOpen(false)}
- closeLabel={t('shell.closeNavigation')}
- />
- </Drawer>
- <Layout className="admin-main-layout">
- <Header className="admin-header">
- <Space size={12} className="header-status">
- <Button
- className="mobile-nav-trigger"
- type="text"
- icon={<MenuOutlined />}
- aria-label={t('shell.openNavigation')}
- onClick={() => setMobileNavigationOpen(true)}
- />
- <Tooltip title={environmentDetails}>
- <Tag
- className="environment-status"
- aria-label={environmentReady ? t('shell.envReady') : t('shell.envNeedsSetup')}
- icon={environmentReady ? <CheckCircleOutlined /> : <WarningOutlined />}
- color={environmentReady ? 'success' : 'warning'}
- >
- <span className="environment-status__label">
- {environmentReady ? t('shell.envReady') : t('shell.envNeedsSetup')}
- </span>
- </Tag>
- </Tooltip>
- </Space>
- <Space size={8} className="header-actions">
- <Select
- aria-label="Language"
- value={locale}
- options={locales}
- onChange={setLocale}
- className="language-select"
- />
- <Dropdown
- trigger={['click']}
- menu={{
- items: [
- {
- key: 'account',
- label: (
- <div className="account-menu-summary">
- <Typography.Text strong>{user?.username || t('common.user')}</Typography.Text>
- <Typography.Text type="secondary">{user?.email || '—'}</Typography.Text>
- </div>
- ),
- disabled: true
- },
- ...(isAdmin ? [{ key: 'settings', label: t('nav.settings'), onClick: () => navigate('/settings') }] : []),
- { type: 'divider' as const },
- { key: 'logout', label: t('common.logout'), onClick: () => void logout() }
- ]
- }}
- >
- <Button className="user-button" aria-label={t('shell.accountMenu')}>
- <Space size={8}>
- <Avatar size={24} icon={<UserOutlined />} />
- <span className="user-button__name" title={user?.username || t('common.user')}>
- {user?.username || t('common.user')}
- </span>
- </Space>
- </Button>
- </Dropdown>
- </Space>
- </Header>
- <Content
- id="main-content"
- ref={mainRef}
- tabIndex={-1}
- className="admin-content"
- >
- <Outlet />
- </Content>
- </Layout>
- </Layout>
- );
- }
- function NavigationPanel({
- menu,
- userName,
- role,
- onClose,
- closeLabel
- }: {
- menu: ReactNode;
- userName?: string;
- role?: string;
- onClose?: () => void;
- closeLabel?: string;
- }) {
- return (
- <div className="admin-sider-inner">
- <div className="brand">
- <div className="brand-logo" aria-hidden="true">MH</div>
- <div>
- <div className="brand-title">MailHub</div>
- <div className="brand-subtitle">Delivery Operations</div>
- </div>
- {onClose ? (
- <Button
- type="text"
- className="mobile-nav-close"
- icon={<CloseOutlined />}
- aria-label={closeLabel}
- style={{ minHeight: 44, minWidth: 44 }}
- onClick={onClose}
- />
- ) : null}
- </div>
- {menu}
- {userName ? (
- <div className="admin-sider-footer">
- <Avatar size={28} icon={<UserOutlined />} className="sider-user-avatar" />
- <div className="sider-user-meta">
- <div className="sider-user-name">{userName}</div>
- <div className="sider-user-role">{role || '—'}</div>
- </div>
- </div>
- ) : null}
- </div>
- );
- }
|