mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: tell admins the OAuth setup needs a webmail sign-in and disable it until there is one
This commit is contained in:
@@ -34,12 +34,25 @@ export function AuthPage() {
|
||||
const setMessage = useAdminFeedback();
|
||||
const [configReadOnly, setConfigReadOnly] = useState(false);
|
||||
|
||||
// The setup runs as the mail account signed in to this webmail. Asked again
|
||||
// on focus, since the admin signs in from another tab and comes back.
|
||||
const [setupSignIn, setSetupSignIn] = useState<{ signedIn: boolean; username?: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
apiFetch('/api/setup/status')
|
||||
.then(res => (res.ok ? res.json() : null))
|
||||
.then(data => setConfigReadOnly(data?.readOnly === true))
|
||||
.catch(() => {});
|
||||
const checkSignIn = () => {
|
||||
apiFetch('/api/admin/oauth/setup')
|
||||
.then(res => (res.ok ? res.json() : null))
|
||||
.then(data => { if (data) setSetupSignIn(data); })
|
||||
.catch(() => {});
|
||||
};
|
||||
checkSignIn();
|
||||
window.addEventListener('focus', checkSignIn);
|
||||
return () => window.removeEventListener('focus', checkSignIn);
|
||||
}, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
@@ -260,7 +273,7 @@ export function AuthPage() {
|
||||
description="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."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button type="button" size="sm" className="gap-2" onClick={openSetupDialog} disabled={setupRunning || configReadOnly}>
|
||||
<Button type="button" size="sm" className="gap-2" onClick={openSetupDialog} disabled={setupRunning || configReadOnly || setupSignIn?.signedIn === false}>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||
</Button>
|
||||
@@ -269,6 +282,16 @@ export function AuthPage() {
|
||||
Not available while the configuration is read-only (ADMIN_CONFIG_READONLY).
|
||||
</p>
|
||||
)}
|
||||
{!configReadOnly && setupSignIn?.signedIn === false && (
|
||||
<p className="max-w-xs text-xs text-muted-foreground sm:text-end">
|
||||
Only works while you are signed in to{' '}
|
||||
<a href="/" target="_blank" rel="noopener" className="text-primary hover:underline">this webmail</a>
|
||||
{' '}with a Stalwart admin account in this browser. A login to the Stalwart web admin does not count.
|
||||
</p>
|
||||
)}
|
||||
{!configReadOnly && setupSignIn?.signedIn && setupSignIn.username && (
|
||||
<p className="text-xs text-muted-foreground">Runs as {setupSignIn.username}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
<ConfigToggleItem label="Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest } from 'next/server';
|
||||
let readOnly = false;
|
||||
let saveError: Error | null = null;
|
||||
let stalwart016 = false;
|
||||
let signedIn = true;
|
||||
const jmapCalls: Array<[string, Record<string, unknown>]> = [];
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
@@ -14,12 +15,14 @@ vi.mock('@/lib/admin/session', () => ({
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: async () => ({
|
||||
serverUrl: 'https://mail.example.org',
|
||||
authHeader: 'Basic x',
|
||||
trusted: true,
|
||||
username: 'admin@example.org',
|
||||
}),
|
||||
getStalwartCredentials: async () => (signedIn
|
||||
? {
|
||||
serverUrl: 'https://mail.example.org',
|
||||
authHeader: 'Basic x',
|
||||
trusted: true,
|
||||
username: 'admin@example.org',
|
||||
}
|
||||
: null),
|
||||
}));
|
||||
vi.mock('@/lib/admin/paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/admin/paths')>()),
|
||||
@@ -49,7 +52,7 @@ vi.mock('@/lib/stalwart/server-fetch', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from '../route';
|
||||
import { GET, POST } from '../route';
|
||||
|
||||
function setup() {
|
||||
return POST(new NextRequest('http://localhost/api/admin/oauth/setup', {
|
||||
@@ -62,6 +65,7 @@ beforeEach(() => {
|
||||
readOnly = false;
|
||||
saveError = null;
|
||||
stalwart016 = false;
|
||||
signedIn = true;
|
||||
jmapCalls.length = 0;
|
||||
});
|
||||
|
||||
@@ -89,6 +93,14 @@ describe('POST /api/admin/oauth/setup', () => {
|
||||
expect(create).not.toHaveProperty('firstParty');
|
||||
});
|
||||
|
||||
it('tells the admin to sign in to the webmail when no mail account is signed in here', async () => {
|
||||
signedIn = false;
|
||||
const res = await setup();
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/Sign in to this webmail.*Stalwart web admin does not count/);
|
||||
expect(jmapCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses in read-only mode without sending anything to Stalwart (#1250)', async () => {
|
||||
readOnly = true;
|
||||
const res = await setup();
|
||||
@@ -104,3 +116,23 @@ describe('POST /api/admin/oauth/setup', () => {
|
||||
expect(jmapCalls.at(-1)).toEqual(['x:OAuthClient/set', { accountId: 'a', destroy: ['h'] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/oauth/setup', () => {
|
||||
const check = () => GET(new NextRequest('http://localhost/api/admin/oauth/setup'));
|
||||
|
||||
it('names the mail account the setup would run as', async () => {
|
||||
const res = await check();
|
||||
expect(await res.json()).toEqual({ signedIn: true, username: 'admin@example.org' });
|
||||
});
|
||||
|
||||
it('says so when no mail account is signed in to this webmail', async () => {
|
||||
signedIn = false;
|
||||
const res = await check();
|
||||
expect(await res.json()).toEqual({ signedIn: false });
|
||||
});
|
||||
|
||||
it('never hands the credential to the page', async () => {
|
||||
const body = JSON.stringify(await (await check()).json());
|
||||
expect(body).not.toContain('Basic');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,20 @@ function isValidOriginUrl(value: string): boolean {
|
||||
return /^https?:\/\/[^/]+$/.test(value);
|
||||
}
|
||||
|
||||
// The setup acts as the mail account signed in to this webmail, not as the
|
||||
// admin: a login to the Stalwart web admin sets no cookie here.
|
||||
const NOT_SIGNED_IN =
|
||||
'Sign in to this webmail with a Stalwart admin account in this browser, then retry. '
|
||||
+ 'A login to the Stalwart web admin does not count.';
|
||||
|
||||
/** Which mail account the setup would run as, so the page can say before it starts. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
const creds = await getStalwartCredentials(request);
|
||||
return NextResponse.json(creds ? { signedIn: true, username: creds.username } : { signedIn: false });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
@@ -120,10 +134,7 @@ export async function POST(request: NextRequest) {
|
||||
const ip = getClientIP(request);
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: NOT_SIGNED_IN }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json() as SetupRequestBody;
|
||||
|
||||
Reference in New Issue
Block a user