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:
2026-06-02 02:26:28 +02:00
commit 1d3818e725
36 changed files with 5523 additions and 0 deletions
+51
View File
@@ -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;
+65
View File
@@ -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>
);
};
+16
View File
@@ -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} />;
+105
View File
@@ -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>
);
};
+92
View File
@@ -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>
);
};
+72
View File
@@ -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>
);
};
+47
View File
@@ -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';
},
};
+20
View File
@@ -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);
+9
View File
@@ -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>
);
+48
View File
@@ -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>
);
+44
View File
@@ -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>
);
+17
View File
@@ -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;
+138
View File
@@ -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 },
},
},
},
},
};