diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx index 1d1349092..04afb2e38 100644 --- a/app/(main)/admin/_pages/auth.tsx +++ b/app/(main)/admin/_pages/auth.tsx @@ -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." >
- @@ -269,6 +282,16 @@ export function AuthPage() { Not available while the configuration is read-only (ADMIN_CONFIG_READONLY).

)} + {!configReadOnly && setupSignIn?.signedIn === false && ( +

+ Only works while you are signed in to{' '} + this webmail + {' '}with a Stalwart admin account in this browser. A login to the Stalwart web admin does not count. +

+ )} + {!configReadOnly && setupSignIn?.signedIn && setupSignIn.username && ( +

Runs as {setupSignIn.username}

+ )}
diff --git a/app/api/admin/oauth/setup/__tests__/route.test.ts b/app/api/admin/oauth/setup/__tests__/route.test.ts index 7046502d1..c3a416fb1 100644 --- a/app/api/admin/oauth/setup/__tests__/route.test.ts +++ b/app/api/admin/oauth/setup/__tests__/route.test.ts @@ -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]> = []; 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()), @@ -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'); + }); +}); diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts index 6727366ad..074b5cfdf 100644 --- a/app/api/admin/oauth/setup/route.ts +++ b/app/api/admin/oauth/setup/route.ts @@ -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;