feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs
This commit is contained in:
+109
-19
@@ -70,16 +70,22 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [setupRunning, setSetupRunning] = useState(false);
|
const [setupRunning, setSetupRunning] = useState(false);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const [setupOrigin, setSetupOrigin] = useState('');
|
||||||
|
const [setupIssuer, setSetupIssuer] = useState('');
|
||||||
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||||
|
|
||||||
async function handleAutoSetup() {
|
function openSetupDialog() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const oauthOnlyText = setupOauthOnly ? '\n\n • Disable password login (OAuth only)' : '';
|
const origin = window.location.origin;
|
||||||
const ok = window.confirm(
|
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||||
`Auto-configure OAuth between this webmail and the connected Stalwart server?\n\nThis will:\n • Create or update an OAuth client called "bulwark-webmail" on the Stalwart server\n • Generate a new client secret\n • Register redirect URIs for ${window.location.origin}\n • Save OAuth settings to admin config (survives env changes)${oauthOnlyText}\n\nYour Stalwart user must have admin permissions.`
|
setSetupOrigin(origin);
|
||||||
);
|
setSetupIssuer(jmapUrl || origin);
|
||||||
if (!ok) return;
|
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||||
|
setSetupOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAutoSetup() {
|
||||||
setSetupRunning(true);
|
setSetupRunning(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
@@ -87,7 +93,8 @@ export default function AdminAuthPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
origin: window.location.origin,
|
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||||
|
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||||
oauthOnly: setupOauthOnly,
|
oauthOnly: setupOauthOnly,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -95,9 +102,10 @@ export default function AdminAuthPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMessage({
|
setMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
text: `OAuth client ${data.action} on Stalwart. ${data.redirectUriCount} redirect URI(s) registered. Webmail config updated.`,
|
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||||
});
|
});
|
||||||
setEdits({});
|
setEdits({});
|
||||||
|
setSetupOpen(false);
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
} else {
|
} else {
|
||||||
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||||
@@ -110,6 +118,9 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||||
|
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -153,19 +164,9 @@ export default function AdminAuthPage() {
|
|||||||
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||||
Requires your Stalwart account to have admin permissions.
|
Requires your Stalwart account to have admin permissions.
|
||||||
</p>
|
</p>
|
||||||
<label className="inline-flex items-center gap-2 mt-3 text-xs text-foreground select-none cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={setupOauthOnly}
|
|
||||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
|
||||||
className="h-3.5 w-3.5 rounded border-input"
|
|
||||||
disabled={setupRunning}
|
|
||||||
/>
|
|
||||||
Also enable “OAuth only” (hide password login)
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAutoSetup}
|
onClick={openSetupDialog}
|
||||||
disabled={setupRunning}
|
disabled={setupRunning}
|
||||||
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -175,6 +176,95 @@ export default function AdminAuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-setup dialog */}
|
||||||
|
{setupOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="oauth-setup-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Webmail origin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-origin"
|
||||||
|
type="url"
|
||||||
|
value={setupOrigin}
|
||||||
|
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://webmail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||||
|
</p>
|
||||||
|
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Stalwart issuer URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-issuer"
|
||||||
|
type="url"
|
||||||
|
value={setupIssuer}
|
||||||
|
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://mail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||||
|
</p>
|
||||||
|
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setupOauthOnly}
|
||||||
|
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input"
|
||||||
|
disabled={setupRunning}
|
||||||
|
/>
|
||||||
|
Also enable “OAuth only” (hide password login)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupOpen(false)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoSetup}
|
||||||
|
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* OAuth */}
|
{/* OAuth */}
|
||||||
<Section title="OAuth / OpenID Connect">
|
<Section title="OAuth / OpenID Connect">
|
||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
@@ -73,10 +73,15 @@ function buildRedirectUris(origin: string, localeList: readonly string[]): Recor
|
|||||||
|
|
||||||
interface SetupRequestBody {
|
interface SetupRequestBody {
|
||||||
origin?: string;
|
origin?: string;
|
||||||
|
issuerUrl?: string;
|
||||||
locales?: string[];
|
locales?: string[];
|
||||||
oauthOnly?: boolean;
|
oauthOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidOriginUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\/[^/]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth();
|
||||||
@@ -93,9 +98,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json() as SetupRequestBody;
|
const body = await request.json() as SetupRequestBody;
|
||||||
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
||||||
if (!/^https?:\/\/[^/]+$/.test(origin)) {
|
if (!isValidOriginUrl(origin)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Origin must be a URL like "https://mail.example.com" with no path.' },
|
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
|
||||||
|
if (!isValidOriginUrl(issuerUrl)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,7 +213,7 @@ export async function POST(request: NextRequest) {
|
|||||||
oauthEnabled: true,
|
oauthEnabled: true,
|
||||||
oauthClientId: CLIENT_ID,
|
oauthClientId: CLIENT_ID,
|
||||||
oauthClientSecret: secret,
|
oauthClientSecret: secret,
|
||||||
oauthIssuerUrl: origin,
|
oauthIssuerUrl: issuerUrl,
|
||||||
};
|
};
|
||||||
if (oauthOnly) updates.oauthOnly = true;
|
if (oauthOnly) updates.oauthOnly = true;
|
||||||
await configManager.setAdminConfig(updates);
|
await configManager.setAdminConfig(updates);
|
||||||
@@ -209,7 +221,8 @@ export async function POST(request: NextRequest) {
|
|||||||
await auditLog('admin.oauth_setup', {
|
await auditLog('admin.oauth_setup', {
|
||||||
action,
|
action,
|
||||||
clientId: CLIENT_ID,
|
clientId: CLIENT_ID,
|
||||||
issuer: origin,
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
redirectUriCount: localeList.length,
|
redirectUriCount: localeList.length,
|
||||||
oauthOnly,
|
oauthOnly,
|
||||||
}, ip);
|
}, ip);
|
||||||
@@ -217,7 +230,8 @@ export async function POST(request: NextRequest) {
|
|||||||
logger.info('Admin OAuth setup', {
|
logger.info('Admin OAuth setup', {
|
||||||
action,
|
action,
|
||||||
clientId: CLIENT_ID,
|
clientId: CLIENT_ID,
|
||||||
issuer: origin,
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
locales: localeList.length,
|
locales: localeList.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,7 +239,8 @@ export async function POST(request: NextRequest) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
action,
|
action,
|
||||||
clientId: CLIENT_ID,
|
clientId: CLIENT_ID,
|
||||||
issuerUrl: origin,
|
origin,
|
||||||
|
issuerUrl,
|
||||||
redirectUriCount: localeList.length,
|
redirectUriCount: localeList.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user