sso fuer inbox config

This commit is contained in:
2026-08-14 16:50:02 +02:00
parent 730c03370d
commit a5f8d987b7
14 changed files with 452 additions and 150 deletions

View File

@@ -19,12 +19,33 @@ import DomainQuotaModal from './components/DomainQuotaModal';
import BillingModal from './components/BillingModal';
import HealthModal from './components/HealthModal';
import HealthBanner from './components/HealthBanner';
import WebmailSettingsPage from './components/WebmailSettingsPage';
import { authAPI, domainsAPI, mailboxesAPI, healthAPI } from './services/api';
const MAILBOX_USER_ROLE = 'mailbox_user';
// Reads ?email=&expires=&signature= — the link the Roundcube plugin builds.
// Returns null when this is a normal admin visit.
function readWebmailLink() {
const params = new URLSearchParams(window.location.search);
const email = params.get('email');
const expires = params.get('expires');
const signature = params.get('signature');
if (!email || !expires || !signature) return null;
return { email, expires, signature };
}
// Drop the credentials from the address bar once they have been exchanged for
// a cookie, so the signature does not linger in history, bookmarks or referers.
function stripWebmailLink() {
window.history.replaceState({}, '', window.location.pathname);
}
function App() {
const [user, setUser] = useState(null);
const [bootChecked, setBootChecked] = useState(false);
const [ssoError, setSsoError] = useState(null);
const [domains, setDomains] = useState([]);
const [selectedDomain, setSelectedDomain] = useState(null);
@@ -52,6 +73,7 @@ function App() {
}, []);
const isSuperAdmin = user?.role === 'super_admin';
const isMailboxUser = user?.role === MAILBOX_USER_ROLE;
const hideDomainList = !isSuperAdmin && domains.length <= 1;
const loadDomains = useCallback(async (resync = false) => {
@@ -82,6 +104,23 @@ function App() {
useEffect(() => {
(async () => {
const link = readWebmailLink();
if (link) {
// Signed link from webmail: exchange it for a session and go straight
// to that mailbox's settings — no login mask.
try {
const me = await authAPI.webmailSso(link);
stripWebmailLink();
setUser(me);
} catch (err) {
stripWebmailLink();
setSsoError(err.message || 'This link is no longer valid.');
setUser(null);
} finally {
setBootChecked(true);
}
return;
}
try {
const me = await authAPI.me();
setUser(me);
@@ -94,7 +133,9 @@ function App() {
}, []);
useEffect(() => {
if (!user) return;
// Webmail users have no domain view — and the admin endpoints below would
// (correctly) answer 403 for them.
if (!user || isMailboxUser) return;
(async () => {
setBusyMessage('Loading domains...');
try {
@@ -203,6 +244,22 @@ function App() {
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
}
// Bad or expired signature: say so instead of dropping the webmail user on
// an admin login mask they have no credentials for.
if (!user && ssoError) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="card max-w-md text-center">
<h1 className="text-lg font-semibold text-gray-900">Link no longer valid</h1>
<p className="text-sm text-gray-600 mt-2">{ssoError}</p>
<p className="text-sm text-gray-500 mt-4">
Please go back to webmail and open <span className="font-medium">Email Configuration</span> again.
</p>
</div>
</div>
);
}
if (!user) {
return (
<>
@@ -212,6 +269,15 @@ function App() {
);
}
if (isMailboxUser) {
return (
<>
<WebmailSettingsPage email={user.email} onToast={showToast} />
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
</>
);
}
return (
<div className="min-h-screen">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import { FiCornerUpRight, FiCalendar, FiSlash } from 'react-icons/fi';
import LoadingOverlay from './LoadingOverlay';
import Forwarding from './Forwarding';
import OutOfOffice from './OutOfOffice';
import BlockedSenders from './BlockedSenders';
import { mailboxesAPI } from '../services/api';
const TABS = [
{ id: 'fwd', label: 'Forwarding', icon: FiCornerUpRight },
{ id: 'ooo', label: 'Out of Office', icon: FiCalendar },
{ id: 'block', label: 'Blocklist', icon: FiSlash },
];
const emptyRule = (email) => ({
email_address: email,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
});
// Tabs + loading + saving for one mailbox's forwarding, auto-reply and
// blocklist. Rendered inside a Modal for admins (MailboxSettingsModal) and
// full page for webmail users arriving through a signed link.
const MailboxSettings = ({ active, email, initialTab = 'fwd', onToast }) => {
const [activeTab, setActiveTab] = useState(initialTab);
const [rule, setRule] = useState(null);
const [blocklist, setBlocklist] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => { setActiveTab(initialTab); }, [initialTab, email]);
// Load both /rules and /blocklist in parallel.
useEffect(() => {
if (!active || !email) return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const [r, b] = await Promise.all([
mailboxesAPI.getRules(email).catch(() => emptyRule(email)),
mailboxesAPI.getBlocklist(email).catch(() => ({
email_address: email, blocked_patterns: [],
})),
]);
if (cancelled) return;
setRule(r ? { ...emptyRule(email), ...r } : emptyRule(email));
setBlocklist(b || { email_address: email, blocked_patterns: [] });
} catch (err) {
if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [active, email, onToast]);
// Merge updates with existing rule and persist.
const saveRule = async (updates) => {
const base = rule || emptyRule(email);
const merged = {
ooo_active: base.ooo_active ?? false,
ooo_message: base.ooo_message ?? '',
ooo_content_type: base.ooo_content_type ?? 'text',
forwards: base.forwards ?? [],
...updates,
};
try {
const saved = await mailboxesAPI.putRules(email, merged);
setRule({ email_address: email, ...merged, ...saved });
onToast?.('Rule saved', 'success');
} catch (err) {
onToast?.(`Failed to save: ${err.message}`, 'error');
throw err;
}
};
const saveBlocklist = async (patterns) => {
try {
const saved = await mailboxesAPI.putBlocklist(email, patterns);
setBlocklist({ email_address: email, blocked_patterns: patterns, ...saved });
onToast?.('Block list saved', 'success');
} catch (err) {
onToast?.(`Failed to save: ${err.message}`, 'error');
throw err;
}
};
return (
<div className="relative min-h-[400px]">
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex gap-1">
{TABS.map((t) => {
const Icon = t.icon;
const isActive = activeTab === t.id;
return (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
</div>
{/* Content */}
{loading || !rule || !blocklist ? (
<div className="py-16 flex items-center justify-center">
<LoadingOverlay message="Loading settings..." />
</div>
) : (
<>
{activeTab === 'fwd' && <Forwarding rule={rule} onSave={saveRule} />}
{activeTab === 'ooo' && <OutOfOffice rule={rule} onSave={saveRule} />}
{activeTab === 'block' && <BlockedSenders blocklist={blocklist} onSave={saveBlocklist} />}
</>
)}
</div>
);
};
export default MailboxSettings;

View File

@@ -1,138 +1,22 @@
import React, { useEffect, useState } from 'react';
import { FiCornerUpRight, FiCalendar, FiSlash } from 'react-icons/fi';
import React from 'react';
import Modal from './Modal';
import LoadingOverlay from './LoadingOverlay';
import Forwarding from './Forwarding';
import OutOfOffice from './OutOfOffice';
import BlockedSenders from './BlockedSenders';
import { mailboxesAPI } from '../services/api';
import MailboxSettings from './MailboxSettings';
const TABS = [
{ id: 'fwd', label: 'Forwarding', icon: FiCornerUpRight },
{ id: 'ooo', label: 'Out of Office', icon: FiCalendar },
{ id: 'block', label: 'Blocklist', icon: FiSlash },
];
const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => (
<Modal
open={open}
onClose={onClose}
title={email || 'Mailbox settings'}
subtitle="Forwarding, auto-reply and blocklist"
size="md"
>
<MailboxSettings
active={open}
email={email}
initialTab={initialTab}
onToast={onToast}
/>
</Modal>
);
const emptyRule = (email) => ({
email_address: email,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
});
const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => {
const [activeTab, setActiveTab] = useState(initialTab);
const [rule, setRule] = useState(null);
const [blocklist, setBlocklist] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => { setActiveTab(initialTab); }, [initialTab, email]);
// Load both /rules and /blocklist in parallel when the modal opens.
useEffect(() => {
if (!open || !email) return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const [r, b] = await Promise.all([
mailboxesAPI.getRules(email).catch(() => emptyRule(email)),
mailboxesAPI.getBlocklist(email).catch(() => ({
email_address: email, blocked_patterns: [],
})),
]);
if (cancelled) return;
setRule(r ? { ...emptyRule(email), ...r } : emptyRule(email));
setBlocklist(b || { email_address: email, blocked_patterns: [] });
} catch (err) {
if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [open, email, onToast]);
// Merge updates with existing rule and persist.
const saveRule = async (updates) => {
const base = rule || emptyRule(email);
const merged = {
ooo_active: base.ooo_active ?? false,
ooo_message: base.ooo_message ?? '',
ooo_content_type: base.ooo_content_type ?? 'text',
forwards: base.forwards ?? [],
...updates,
};
try {
const saved = await mailboxesAPI.putRules(email, merged);
setRule({ email_address: email, ...merged, ...saved });
onToast?.('Rule saved', 'success');
} catch (err) {
onToast?.(`Failed to save: ${err.message}`, 'error');
throw err;
}
};
const saveBlocklist = async (patterns) => {
try {
const saved = await mailboxesAPI.putBlocklist(email, patterns);
setBlocklist({ email_address: email, blocked_patterns: patterns, ...saved });
onToast?.('Block list saved', 'success');
} catch (err) {
onToast?.(`Failed to save: ${err.message}`, 'error');
throw err;
}
};
return (
<Modal
open={open}
onClose={onClose}
title={email || 'Mailbox settings'}
subtitle="Forwarding, auto-reply and blocklist"
size="md"
>
<div className="relative min-h-[400px]">
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex gap-1">
{TABS.map((t) => {
const Icon = t.icon;
const isActive = activeTab === t.id;
return (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
</div>
{/* Content */}
{loading || !rule || !blocklist ? (
<div className="py-16 flex items-center justify-center">
<LoadingOverlay message="Loading settings..." />
</div>
) : (
<>
{activeTab === 'fwd' && <Forwarding rule={rule} onSave={saveRule} />}
{activeTab === 'ooo' && <OutOfOffice rule={rule} onSave={saveRule} />}
{activeTab === 'block' && <BlockedSenders blocklist={blocklist} onSave={saveBlocklist} />}
</>
)}
</div>
</Modal>
);
};
export default MailboxSettingsModal;
export default MailboxSettingsModal;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { FiMail } from 'react-icons/fi';
import MailboxSettings from './MailboxSettings';
// Standalone page for users who came in from the Roundcube "EMail
// Configuration" plugin. Same settings as the admin modal, but nothing else:
// no domain list, no mailbox table, no admin actions.
const WebmailSettingsPage = ({ email, onToast }) => (
<div className="min-h-screen">
<header className="bg-white border-b border-gray-200">
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center gap-3">
<FiMail className="w-5 h-5 text-primary-600 shrink-0" />
<div className="min-w-0">
<h1 className="text-xl font-bold text-gray-900">Email Configuration</h1>
<p className="text-xs text-gray-500 truncate">{email}</p>
</div>
</div>
</header>
<main className="max-w-3xl mx-auto px-6 py-6">
<section className="card">
<MailboxSettings active email={email} initialTab="fwd" onToast={onToast} />
</section>
<p className="text-xs text-gray-400 text-center mt-6">
Changes are saved immediately. You can close this tab when you are done.
</p>
</main>
</div>
);
export default WebmailSettingsPage;

View File

@@ -24,6 +24,10 @@ export const authAPI = {
login: async (email, password) =>
(await api.post('/api/auth/login', { email, password })).data,
logout: async () => (await api.post('/api/auth/logout')).data,
// Exchanges a signed link from the Roundcube plugin for a session cookie
// that is limited to that one mailbox.
webmailSso: async ({ email, expires, signature }) =>
(await api.post('/api/auth/webmail-sso', { email, expires, signature })).data,
changePassword: async (current_password, new_password) =>
(await api.post('/api/auth/change-password', { current_password, new_password })).data,
};