docker-mailserver LXC für Proxmox: Stack + Admin-UI + Webmail + Hardening
- dms-lxc.sh: Proxmox-Host-Installer (unprivilegierter LXC, Debian 13, Docker), curl-Self-Download, Multi-Domain-DKIM, SnappyMail-Provisionierung, PVE-Firewall - Stack: docker-mailserver, Node-Admin-API (Supabase-Auth), React-Admin-UI (OPENBUREAU-Look), SnappyMail (Shibui-Theme), Rspamd-Web-UI, docker-socket-proxy - Admin: Postfächer/Aliase/Catch-all/Quota, editierbare Domains+Settings, Server (Quota/Queue über abgesicherte Bridge), Status & DNS - Hardening: no-new-privileges, Whitelisted exec-Bridge, Rspamd-Passwort, .env chmod 600, PVE-CT-Firewall, generisch/teilbar (keine festen Domains) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { Admin, Resource, CustomRoutes } from 'react-admin';
|
||||
import { Route } from 'react-router-dom';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import AltRouteIcon from '@mui/icons-material/AltRoute';
|
||||
|
||||
import { dataProvider } from './dataProvider';
|
||||
import { authProvider } from './authProvider';
|
||||
import { AccountList, AccountCreate, AccountEdit } from './resources/accounts';
|
||||
import { AliasList, AliasCreate, AliasEdit } from './resources/aliases';
|
||||
import { StatusPage } from './Status';
|
||||
import { ServerPage } from './Server';
|
||||
import { SettingsPage } from './Settings';
|
||||
import { Layout } from './Layout';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { openbureauTheme } from './theme';
|
||||
|
||||
const App = () => (
|
||||
<Admin
|
||||
title="Mail Admin"
|
||||
dataProvider={dataProvider}
|
||||
authProvider={authProvider}
|
||||
layout={Layout}
|
||||
dashboard={Dashboard}
|
||||
theme={openbureauTheme}
|
||||
requireAuth
|
||||
>
|
||||
<Resource
|
||||
name="accounts"
|
||||
options={{ label: 'Postfächer' }}
|
||||
icon={EmailIcon}
|
||||
list={AccountList}
|
||||
create={AccountCreate}
|
||||
edit={AccountEdit}
|
||||
/>
|
||||
<Resource
|
||||
name="aliases"
|
||||
options={{ label: 'Aliase' }}
|
||||
icon={AltRouteIcon}
|
||||
list={AliasList}
|
||||
create={AliasCreate}
|
||||
edit={AliasEdit}
|
||||
/>
|
||||
<CustomRoutes>
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/server" element={<ServerPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</CustomRoutes>
|
||||
</Admin>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useGetOne, useGetList, Link } from 'react-admin';
|
||||
import { Card, CardContent, Typography, Box, Stack, Chip, Button } from '@mui/material';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import AltRouteIcon from '@mui/icons-material/AltRoute';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
|
||||
const Stat = ({ icon, value, label, color }) => (
|
||||
<Card sx={{ flex: 1, minWidth: 160 }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box sx={{ color: color || 'primary.main', display: 'flex' }}>{icon}</Box>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ lineHeight: 1 }}>{value ?? '–'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { data: status } = useGetOne('status', { id: 'status' });
|
||||
const { total: accountsTotal } = useGetList('accounts', { pagination: { page: 1, perPage: 1 } });
|
||||
const { total: aliasesTotal } = useGetList('aliases', { pagination: { page: 1, perPage: 1 } });
|
||||
const domains = status?.domains || [];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 2 } }}>
|
||||
<Typography variant="h3" sx={{ mb: 0.5 }}>{status?.brand || 'Mailserver'}</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{status?.fqdn ? `Host ${status.fqdn}` : 'Übersicht'}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Stat icon={<EmailIcon />} value={accountsTotal ?? status?.accounts} label="Postfächer" />
|
||||
<Stat icon={<AltRouteIcon />} value={aliasesTotal ?? status?.aliases} label="Aliase" color="secondary.main" />
|
||||
<Stat icon={<LanguageIcon />} value={domains.length} label="Domains" color="success.main" />
|
||||
</Stack>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Domains</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
|
||||
{domains.length
|
||||
? domains.map((d) => <Chip key={d} label={d} variant="outlined" />)
|
||||
: <Typography color="text.secondary">Keine Domains konfiguriert.</Typography>}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', gap: 1 }}>
|
||||
<Button component={Link} to="/accounts" variant="contained" startIcon={<EmailIcon />}>
|
||||
Postfächer verwalten
|
||||
</Button>
|
||||
<Button component={Link} to="/aliases" variant="outlined" startIcon={<AltRouteIcon />}>
|
||||
Aliase
|
||||
</Button>
|
||||
<Button component={Link} to="/status" variant="outlined" startIcon={<DnsIcon />}>
|
||||
Status & DNS
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Layout as RaLayout, Menu } from 'react-admin';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
|
||||
// Eigenes Menü: Standard-Ressourcen + Status-, Server- und Einstellungen-Seite.
|
||||
const AppMenu = () => (
|
||||
<Menu>
|
||||
<Menu.ResourceItems />
|
||||
<Menu.Item to="/status" primaryText="Status & DNS" leftIcon={<DnsIcon />} />
|
||||
<Menu.Item to="/server" primaryText="Server" leftIcon={<StorageIcon />} />
|
||||
<Menu.Item to="/settings" primaryText="Einstellungen" leftIcon={<SettingsIcon />} />
|
||||
</Menu>
|
||||
);
|
||||
|
||||
export const Layout = (props) => <RaLayout {...props} menu={AppMenu} />;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Title } from 'react-admin';
|
||||
import {
|
||||
Card, CardContent, Typography, Box, Stack, Chip, LinearProgress,
|
||||
Table, TableHead, TableRow, TableCell, TableBody, Button, Alert,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { apiFetch } from './dataProvider';
|
||||
|
||||
const fmtBytes = (b) => {
|
||||
if (b == null) return '–';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0; let n = b;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
};
|
||||
|
||||
export const ServerPage = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
apiFetch('/mailserver/overview')
|
||||
.then((r) => { setData(r.json); setError(null); })
|
||||
.catch((e) => setError(e?.body?.error || e?.message || 'Bridge nicht erreichbar'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 2 } }}>
|
||||
<Title title="Server" />
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h3">Server</Typography>
|
||||
<Button onClick={load} startIcon={<RefreshIcon />} variant="outlined">Aktualisieren</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Mailserver-Bridge nicht erreichbar: {error}
|
||||
</Alert>
|
||||
)}
|
||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Card sx={{ flex: 1, minWidth: 200 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Mail-Queue</Typography>
|
||||
{data.queue?.empty
|
||||
? <Chip color="success" label="Leer" />
|
||||
: <Chip color="warning" label={`${data.queue?.count ?? '?'} in Warteschlange`} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card sx={{ flex: 1, minWidth: 200 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Aktive Sessions</Typography>
|
||||
<Typography variant="h4">{data.who?.sessions?.length ?? 0}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">IMAP/POP-Verbindungen gerade</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Quota-Auslastung</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Postfach</TableCell>
|
||||
<TableCell align="right">Belegt</TableCell>
|
||||
<TableCell align="right">Limit</TableCell>
|
||||
<TableCell sx={{ width: 180 }}>Auslastung</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(data.quota || []).map((q) => (
|
||||
<TableRow key={q.user}>
|
||||
<TableCell>{q.user}</TableCell>
|
||||
<TableCell align="right">{fmtBytes(q.usedBytes)}</TableCell>
|
||||
<TableCell align="right">{q.limitBytes == null ? '∞' : fmtBytes(q.limitBytes)}</TableCell>
|
||||
<TableCell>
|
||||
{q.limitBytes == null
|
||||
? <Typography variant="body2" color="text.secondary">unbegrenzt</Typography>
|
||||
: <Stack direction="row" spacing={1} alignItems="center">
|
||||
<LinearProgress variant="determinate" value={Math.min(q.percent || 0, 100)}
|
||||
sx={{ flex: 1, height: 8, borderRadius: 4 }} />
|
||||
<Typography variant="caption">{q.percent ?? 0}%</Typography>
|
||||
</Stack>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Title, useNotify } from 'react-admin';
|
||||
import {
|
||||
Card, CardContent, Typography, Box, Stack, TextField, Button, Chip,
|
||||
Divider, InputAdornment,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import { apiFetch } from './dataProvider';
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const notify = useNotify();
|
||||
const [s, setS] = useState(null);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiFetch('/settings').then((r) => setS(r.json)).catch(() => notify('Einstellungen nicht ladbar', { type: 'warning' }));
|
||||
}, [notify]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const set = (k) => (e) => setS((v) => ({ ...v, [k]: e.target.value }));
|
||||
|
||||
const save = () => {
|
||||
apiFetch('/settings/settings', { method: 'PUT', body: JSON.stringify(s) })
|
||||
.then((r) => { setS(r.json); notify('Gespeichert', { type: 'success' }); })
|
||||
.catch((e) => notify(e?.body?.error || 'Speichern fehlgeschlagen', { type: 'warning' }));
|
||||
};
|
||||
|
||||
const addDomain = () => {
|
||||
const d = newDomain.trim().toLowerCase();
|
||||
if (!d) return;
|
||||
apiFetch('/settings/domains', { method: 'POST', body: JSON.stringify({ domain: d }) })
|
||||
.then((r) => { setS(r.json); setNewDomain(''); notify(`${d} hinzugefügt – DKIM wird erzeugt`, { type: 'success' }); })
|
||||
.catch((e) => notify(e?.body?.error || 'Domain konnte nicht hinzugefügt werden', { type: 'warning' }));
|
||||
};
|
||||
|
||||
const removeDomain = (d) => {
|
||||
apiFetch(`/settings/domains/${encodeURIComponent(d)}`, { method: 'DELETE' })
|
||||
.then((r) => { setS(r.json); notify(`${d} entfernt`, { type: 'info' }); })
|
||||
.catch(() => notify('Entfernen fehlgeschlagen', { type: 'warning' }));
|
||||
};
|
||||
|
||||
if (!s) return <Card><CardContent>Lädt …</CardContent></Card>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 2 }, maxWidth: 760 }}>
|
||||
<Title title="Einstellungen" />
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>Einstellungen</Typography>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Mail-Domains</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Domains, für die Postfächer/Adressen angelegt werden können. Hinzufügen erzeugt direkt den DKIM-Schlüssel.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
{(s.domains || []).map((d) => (
|
||||
<Chip key={d} label={d} onDelete={() => removeDomain(d)}
|
||||
color={d === s.primaryDomain ? 'primary' : 'default'}
|
||||
variant={d === s.primaryDomain ? 'filled' : 'outlined'} />
|
||||
))}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField size="small" placeholder="neue-domain.tld" value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">@</InputAdornment> }} />
|
||||
<Button onClick={addDomain} startIcon={<AddIcon />} variant="contained">Hinzufügen</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Allgemein</Typography>
|
||||
<Stack spacing={2} sx={{ maxWidth: 480 }}>
|
||||
<TextField label="Anzeigename / Brand" value={s.brand || ''} onChange={set('brand')} fullWidth />
|
||||
<TextField label="Mailserver-FQDN" value={s.fqdn || ''} onChange={set('fqdn')} fullWidth
|
||||
helperText="Host, zu dem sich Clients/Webmail verbinden (= MX-Ziel)" />
|
||||
<TextField label="Primäre Domain" value={s.primaryDomain || ''} onChange={set('primaryDomain')} fullWidth />
|
||||
<Divider />
|
||||
<TextField label="Webmail-Domain (NPM)" value={s.webmailFqdn || ''} onChange={set('webmailFqdn')} fullWidth />
|
||||
<TextField label="Admin-UI-Domain (NPM)" value={s.adminFqdn || ''} onChange={set('adminFqdn')} fullWidth />
|
||||
</Stack>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button onClick={save} startIcon={<SaveIcon />} variant="contained">Speichern</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { useGetOne, Title, useNotify } from 'react-admin';
|
||||
import { Card, CardContent, Typography, Box, Chip, Stack, Divider, Button } from '@mui/material';
|
||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||
import { apiFetch } from './dataProvider';
|
||||
|
||||
const Mono = ({ children }) => (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
background: '#1e1e1e', color: '#e0e0e0', p: 1.5, borderRadius: 1, m: 0,
|
||||
overflowX: 'auto', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const DkimButton = ({ domain, onDone }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const notify = useNotify();
|
||||
const gen = () => {
|
||||
setBusy(true);
|
||||
apiFetch('/mailserver/dkim', { method: 'POST', body: JSON.stringify({ domain }) })
|
||||
.then(() => { notify(`DKIM für ${domain} erzeugt`, { type: 'success' }); onDone?.(); })
|
||||
.catch((e) => notify(e?.body?.error || 'DKIM-Erzeugung fehlgeschlagen', { type: 'warning' }))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
return (
|
||||
<Button size="small" startIcon={<VpnKeyIcon />} onClick={gen} disabled={busy} sx={{ mt: 1 }}>
|
||||
{busy ? 'Erzeuge …' : 'DKIM erzeugen / erneuern'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusPage = () => {
|
||||
const { data, isLoading, error, refetch } = useGetOne('status', { id: 'status' });
|
||||
|
||||
if (isLoading) return <Card><CardContent>Lädt …</CardContent></Card>;
|
||||
if (error) return <Card><CardContent>Fehler beim Laden des Status.</CardContent></Card>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title title="Status & DNS" />
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Mailserver</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Chip label={`Host: ${data.fqdn}`} />
|
||||
<Chip color="primary" label={`${data.accounts} Postfächer`} />
|
||||
<Chip color="secondary" label={`${data.aliases} Aliase`} />
|
||||
{(data.domains || []).map((d) => <Chip key={d} variant="outlined" label={d} />)}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="h6" gutterBottom>DNS — Mailhost (einmalig)</Typography>
|
||||
<Mono>{[data.host?.a, data.host?.ptr].filter(Boolean).join('\n')}</Mono>
|
||||
|
||||
{(data.records || []).map((r) => (
|
||||
<Box key={r.domain} sx={{ mt: 3 }}>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>{r.domain}</Typography>
|
||||
<Mono>{[r.mx, r.spf, r.dmarc].join('\n')}</Mono>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>DKIM (TXT)</Typography>
|
||||
{r.dkim
|
||||
? <Mono>{r.dkim}</Mono>
|
||||
: <Typography color="text.secondary" variant="body2">Noch kein DKIM-Schlüssel.</Typography>}
|
||||
<DkimButton domain={r.domain} onDone={refetch} />
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
// Dev-Bypass: nur lokal (config.js / window.__CONFIG__.AUTH_DISABLED), nie im Deploy.
|
||||
const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {};
|
||||
const AUTH_DISABLED = String(cfg.AUTH_DISABLED) === 'true';
|
||||
|
||||
// AuthProvider auf Basis von Supabase (wie OPENBUREAU).
|
||||
export const authProvider = {
|
||||
async login({ username, password }) {
|
||||
if (AUTH_DISABLED) return;
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: username,
|
||||
password,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
},
|
||||
|
||||
async logout() {
|
||||
if (!AUTH_DISABLED && supabase) await supabase.auth.signOut();
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
if (AUTH_DISABLED) return;
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (!data?.session) throw new Error('Nicht angemeldet');
|
||||
},
|
||||
|
||||
async checkError(error) {
|
||||
if (AUTH_DISABLED) return;
|
||||
const status = error?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
await supabase.auth.signOut();
|
||||
throw new Error('Sitzung abgelaufen');
|
||||
}
|
||||
},
|
||||
|
||||
async getIdentity() {
|
||||
if (AUTH_DISABLED) return { id: 'dev', fullName: 'Dev (Auth aus)' };
|
||||
const { data } = await supabase.auth.getUser();
|
||||
const user = data?.user;
|
||||
return { id: user?.id || 'me', fullName: user?.email || 'Admin' };
|
||||
},
|
||||
|
||||
async getPermissions() {
|
||||
return 'admin';
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import simpleRestProvider from 'ra-data-simple-rest';
|
||||
import { fetchUtils } from 'react-admin';
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
// HTTP-Client, der das Supabase-Access-Token als Bearer mitschickt.
|
||||
const httpClient = async (url, options = {}) => {
|
||||
const headers = new Headers(options.headers || { Accept: 'application/json' });
|
||||
if (supabase) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data?.session?.access_token;
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return fetchUtils.fetchJson(url, { ...options, headers });
|
||||
};
|
||||
|
||||
// nginx proxyt /api -> admin-api (Prod); Vite proxyt /api -> :3000 (Dev).
|
||||
export const dataProvider = simpleRestProvider('/api', httpClient);
|
||||
|
||||
// Helfer für eigene Endpunkte (Mailserver-Bridge): apiFetch('/mailserver/overview')
|
||||
export const apiFetch = (path, options) => httpClient('/api' + path, options);
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
List, Datagrid, TextField, EmailField,
|
||||
Create, Edit, SimpleForm, TextInput, PasswordInput,
|
||||
required, email, EditButton, DeleteButton, SearchInput,
|
||||
} from 'react-admin';
|
||||
|
||||
const accountFilters = [<SearchInput key="q" source="q" alwaysOn />];
|
||||
|
||||
// Validatoren
|
||||
const quotaValidate = (v) =>
|
||||
!v || /^\d+\s*[KMGT]?$/i.test(v) ? undefined : 'Format z.B. 5G, 500M (leer = unbegrenzt)';
|
||||
const min8 = (v) => (v && v.length >= 8 ? undefined : 'Mindestens 8 Zeichen');
|
||||
const min8Optional = (v) => (!v || v.length >= 8 ? undefined : 'Mindestens 8 Zeichen');
|
||||
|
||||
export const AccountList = () => (
|
||||
<List filters={accountFilters} sort={{ field: 'email', order: 'ASC' }} perPage={25}>
|
||||
<Datagrid rowClick="edit" bulkActionButtons={false}>
|
||||
<EmailField source="email" label="Postfach" />
|
||||
<TextField source="quota" label="Quota" emptyText="∞" />
|
||||
<EditButton />
|
||||
<DeleteButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export const AccountCreate = () => (
|
||||
<Create redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="email" label="E-Mail-Adresse" validate={[required(), email()]} fullWidth
|
||||
helperText="z.B. max@example.com" />
|
||||
<PasswordInput source="password" label="Passwort" validate={[required(), min8]}
|
||||
helperText="Mindestens 8 Zeichen" />
|
||||
<TextInput source="quota" label="Quota" validate={quotaValidate}
|
||||
helperText="z.B. 5G, 500M – leer = unbegrenzt" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export const AccountEdit = () => (
|
||||
<Edit redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="email" label="E-Mail-Adresse" disabled fullWidth />
|
||||
<PasswordInput source="password" label="Neues Passwort (leer = unverändert)" validate={min8Optional} />
|
||||
<TextInput source="quota" label="Quota" validate={quotaValidate}
|
||||
helperText="z.B. 5G, 500M – leer = unbegrenzt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
List, Datagrid, TextField,
|
||||
Create, Edit, SimpleForm, TextInput,
|
||||
required, EditButton, DeleteButton, SearchInput,
|
||||
} from 'react-admin';
|
||||
|
||||
const aliasFilters = [<SearchInput key="q" source="q" alwaysOn />];
|
||||
|
||||
// Quelle: vollständige E-Mail ODER @domain.tld (Catch-all)
|
||||
const aliasSource = (v) =>
|
||||
/^([^@\s]+)?@[^@\s]+\.[^@\s]+$/.test(v || '') ? undefined
|
||||
: 'Vollständige E-Mail oder @domain.tld (Catch-all)';
|
||||
|
||||
export const AliasList = () => (
|
||||
<List filters={aliasFilters} sort={{ field: 'source', order: 'ASC' }} perPage={25}>
|
||||
<Datagrid rowClick="edit" bulkActionButtons={false}>
|
||||
<TextField source="source" label="Alias" />
|
||||
<TextField source="destination" label="Ziel(e)" />
|
||||
<EditButton />
|
||||
<DeleteButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export const AliasCreate = () => (
|
||||
<Create redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="source" label="Alias-Adresse" validate={[required(), aliasSource]} fullWidth
|
||||
helperText="z.B. info@example.com — oder @example.com als Catch-all (fängt alle unbekannten Adressen der Domain)" />
|
||||
<TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth
|
||||
helperText="Eine oder mehrere Zieladressen, mit Komma getrennt" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export const AliasEdit = () => (
|
||||
<Edit redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="source" label="Alias-Adresse" disabled fullWidth />
|
||||
<TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth
|
||||
helperText="Mehrere mit Komma getrennt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// Konfiguration kommt zur Laufzeit aus /config.js (window.__CONFIG__),
|
||||
// im Dev-Modus aus den VITE_* Umgebungsvariablen.
|
||||
const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {};
|
||||
const url = cfg.SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL || '';
|
||||
const key = cfg.SUPABASE_ANON_KEY || import.meta.env.VITE_SUPABASE_ANON_KEY || '';
|
||||
|
||||
if (!url || !key) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[admin] Supabase URL/Key fehlen — Login wird nicht funktionieren.');
|
||||
}
|
||||
|
||||
// Kein Client, wenn keine Konfiguration vorhanden ist (z.B. lokaler Dev-Bypass).
|
||||
export const supabase = (url && key)
|
||||
? createClient(url, key, { auth: { persistSession: true, autoRefreshToken: true } })
|
||||
: null;
|
||||
@@ -0,0 +1,138 @@
|
||||
// ============================================================
|
||||
// Admin-Theme im OPENBUREAU-Look (warmes Cream-Design-System)
|
||||
// Akzent: petrol #7BA89B (auf Wunsch, statt OPENBUREAU-Terrakotta)
|
||||
// ============================================================
|
||||
const accent = '#7BA89B'; // petrol
|
||||
const accentDeep = '#557A6D';
|
||||
const accentRing = 'rgba(123,168,155,0.20)';
|
||||
|
||||
const fonts = {
|
||||
sans: '"Inter", system-ui, -apple-system, sans-serif',
|
||||
serif: '"Newsreader", Georgia, serif',
|
||||
display: '"Space Grotesk", "Inter", sans-serif',
|
||||
mono: '"IBM Plex Mono", ui-monospace, monospace',
|
||||
};
|
||||
|
||||
const BG = 'hsl(35, 14%, 96%)'; // warmes Off-White
|
||||
const PANEL = '#fffdf9';
|
||||
const PANEL2 = 'hsl(35, 14%, 93%)';
|
||||
const LINE = 'hsl(35, 14%, 86%)';
|
||||
const TEXT = 'hsl(25, 18%, 12%)';
|
||||
const MUTED = 'hsl(25, 8%, 42%)';
|
||||
const DARK = '#191919';
|
||||
const SHADOW = '0 10px 34px -22px rgba(40,20,10,.5)';
|
||||
|
||||
export const openbureauTheme = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: accent, dark: accentDeep, contrastText: '#ffffff' },
|
||||
secondary: { main: accentDeep, contrastText: '#ffffff' },
|
||||
background: { default: BG, paper: PANEL },
|
||||
text: { primary: TEXT, secondary: MUTED },
|
||||
divider: LINE,
|
||||
success: { main: '#5d7d4b' },
|
||||
warning: { main: '#b8902f' },
|
||||
error: { main: '#b54a2c' },
|
||||
},
|
||||
shape: { borderRadius: 11 },
|
||||
typography: {
|
||||
fontFamily: fonts.sans,
|
||||
fontWeightMedium: 500,
|
||||
h1: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h2: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h3: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h4: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h5: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h6: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
},
|
||||
components: {
|
||||
// dunkle Topbar wie OPENBUREAU
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: DARK,
|
||||
color: '#f0f0f0',
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
borderBottom: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
RaAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .RaAppBar-title': {
|
||||
fontFamily: fonts.display,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Pill-Buttons (22px), keine Versalien
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 22, textTransform: 'none', fontWeight: 500, paddingLeft: 16, paddingRight: 16 },
|
||||
containedPrimary: { color: '#fff', '&:hover': { backgroundColor: accentDeep } },
|
||||
outlined: { borderColor: LINE, backgroundColor: PANEL },
|
||||
},
|
||||
},
|
||||
MuiPaper: { styleOverrides: { root: { backgroundImage: 'none' } } },
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: { border: `1px solid ${LINE}`, boxShadow: SHADOW, borderRadius: 11, backgroundColor: PANEL },
|
||||
},
|
||||
},
|
||||
// Inputs: Radius 9, Petrol-Fokusring
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 9,
|
||||
backgroundColor: PANEL,
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: LINE },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: accent, borderWidth: 1 },
|
||||
'&.Mui-focused': { boxShadow: `0 0 0 3px ${accentRing}` },
|
||||
},
|
||||
},
|
||||
},
|
||||
// Sidebar warm, aktives Item mit Petrol-Kante
|
||||
RaSidebar: {
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: PANEL2, borderRight: `1px solid ${LINE}` },
|
||||
},
|
||||
},
|
||||
RaMenuItemLink: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 11,
|
||||
margin: '3px 6px',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
color: MUTED,
|
||||
'&:hover': { backgroundColor: 'rgba(0,0,0,0.05)' },
|
||||
// klarer Petrol-Akzent im aktiven Zustand (auch eingeklappt sichtbar)
|
||||
'&.RaMenuItemLink-active': {
|
||||
backgroundColor: accentRing,
|
||||
color: accentDeep,
|
||||
fontWeight: 600,
|
||||
},
|
||||
// Icon in der eingeklappten Sidebar mittig
|
||||
'& .RaMenuItemLink-icon': {
|
||||
color: 'inherit',
|
||||
minWidth: 32,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RaLayout: { styleOverrides: { root: { backgroundColor: BG } } },
|
||||
RaDatagrid: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .RaDatagrid-headerCell': { fontWeight: 600, color: MUTED, backgroundColor: 'transparent' },
|
||||
'& .RaDatagrid-rowCell': { borderBottomColor: LINE },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user