fix: load the admin panel when the config is read-only #1249

This commit is contained in:
Linus Rath
2026-10-17 19:17:48 +02:00
parent a854447346
commit deb106736e
15 changed files with 233 additions and 9 deletions
+10 -3
View File
@@ -115,6 +115,13 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
try {
let hasError = false;
let firstError: string | null = null;
const noteError = async (res: Response) => {
hasError = true;
if (firstError) return;
const data = await res.json().catch(() => null);
if (typeof data?.error === 'string') firstError = data.error;
};
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
@@ -135,7 +142,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
if (res.ok) {
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
} else {
hasError = true;
await noteError(res);
}
continue;
}
@@ -148,12 +155,12 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
if (res.ok) {
setConfig(prev => ({ ...prev, [key]: value }));
} else {
hasError = true;
await noteError(res);
}
}
setMessage(hasError
? { type: 'error', text: 'Some settings failed to save' }
? { type: 'error', text: firstError ?? 'Some settings failed to save' }
: { type: 'success', text: 'Configuration saved' }
);
} catch {
+25 -1
View File
@@ -90,6 +90,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [configReadOnly, setConfigReadOnly] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
@@ -178,6 +179,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return () => { cancelled = true; };
}, [pathname, router]);
// With ADMIN_CONFIG_READONLY the panel still shows every setting, but
// nothing can be saved; say so up front instead of after a failed save (#1249).
useEffect(() => {
if (!authenticated) return;
let cancelled = false;
apiFetch('/api/setup/status')
.then(res => (res.ok ? res.json() : null))
.then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); })
.catch(() => {});
return () => { cancelled = true; };
}, [authenticated]);
function handleLogout() {
requestLeave(async () => {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
@@ -425,7 +438,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
Loading admin panel…
</div>
) : authenticated ? (
children
<>
{configReadOnly && (
<div role="status" className="mb-4 rounded-lg border border-border bg-muted p-4 text-sm">
<p className="font-medium text-foreground">The configuration is read-only</p>
<p className="mt-1 text-muted-foreground">
ADMIN_CONFIG_READONLY is set, so you can look at the settings here but not save changes.
Change them in the mounted config files or environment instead.
</p>
</div>
)}
{children}
</>
) : null}
</div>
</main>
@@ -0,0 +1,125 @@
/**
* @vitest-environment node
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { NextRequest } from 'next/server';
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
import path from 'node:path';
// Writes from the admin panel must answer 409 with the read-only message
// instead of a bare 500, and must not change anything on the way (#1249).
// Hoisted so the dirs are set before any admin module loads.
const configDir = await vi.hoisted(async () => {
const { mkdtempSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const dir = mkdtempSync(join(tmpdir(), 'bulwark-readonly-'));
process.env.ADMIN_CONFIG_DIR = dir;
process.env.ADMIN_STATE_DIR = join(dir, 'state');
return dir;
});
let readOnly = false;
vi.mock('@/lib/admin/session', () => ({
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
getClientIP: () => '127.0.0.1',
}));
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
vi.mock('@/lib/admin/paths', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/admin/paths')>();
return {
...actual,
isConfigReadOnly: () => readOnly,
assertWritable: (operation: string) => {
if (readOnly) throw new actual.ConfigReadOnlyError(operation);
},
};
});
import { PATCH as patchConfig, DELETE as deleteConfig } from '../config/route';
import { PUT as putPolicy } from '../policy/route';
import { DELETE as deleteBranding } from '../branding/route';
import { POST as changePassword } from '../change-password/route';
import { POST as decidePluginApproval } from '../plugin-approvals/route';
import { setInitialAdminPassword, verifyAdminPassword } from '@/lib/admin/password';
import { requestApproval, listApprovals } from '@/lib/admin/plugin-approvals';
import { configManager } from '@/lib/admin/config-manager';
function req(url: string, method: string, body: unknown) {
return new NextRequest(`http://localhost${url}`, {
method,
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
});
}
async function expectReadOnly(res: Response) {
expect(res.status).toBe(409);
expect((await res.json()).error).toMatch(/configuration is read-only/);
}
beforeAll(async () => {
await setInitialAdminPassword('old-password-123');
await requestApproval('demo-plugin', 'abcdef0123456789', { name: 'Demo', version: '1.0.0' } as never, 'user@example.org');
await configManager.ensureLoaded();
});
beforeEach(() => {
readOnly = true;
});
afterAll(() => {
rmSync(configDir, { recursive: true, force: true });
});
describe('admin writes on a read-only config (#1249)', () => {
it('PATCH /api/admin/config answers 409 with the reason', async () => {
await expectReadOnly(await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Changed' })));
expect(configManager.get('appName')).not.toBe('Changed');
});
it('DELETE /api/admin/config answers 409 with the reason', async () => {
await expectReadOnly(await deleteConfig(req('/api/admin/config', 'DELETE', { key: 'appName' })));
});
it('PUT /api/admin/policy answers 409 with the reason', async () => {
await expectReadOnly(await putPolicy(req('/api/admin/policy', 'PUT', { restrictions: {} })));
});
it('DELETE /api/admin/branding refuses before deleting the file', async () => {
const dir = path.join(configDir, 'branding');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'faviconUrl.png');
writeFileSync(file, 'png');
await expectReadOnly(await deleteBranding(req('/api/admin/branding', 'DELETE', { slot: 'faviconUrl' })));
expect(existsSync(file)).toBe(true);
});
it('POST /api/admin/change-password refuses and keeps the old password', async () => {
await expectReadOnly(await changePassword(req('/api/admin/change-password', 'POST', {
currentPassword: 'old-password-123',
newPassword: 'new-password-456',
})));
expect(await verifyAdminPassword('old-password-123')).toBe(true);
expect(await verifyAdminPassword('new-password-456')).toBe(false);
});
it('POST /api/admin/plugin-approvals refuses and leaves the request pending', async () => {
await expectReadOnly(await decidePluginApproval(req('/api/admin/plugin-approvals', 'POST', {
pluginId: 'demo-plugin',
bundleHash: 'abcdef0123456789',
decision: 'approved',
})));
expect((await listApprovals()).map(e => e.status)).toEqual(['pending']);
});
it('still saves when the config is writable', async () => {
readOnly = false;
const res = await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Writable' }));
expect(res.status).toBe(200);
expect(configManager.get('appName')).toBe('Writable');
});
});
+12 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
import {
parseDomainBranding,
type DomainBrandingEntry,
@@ -12,6 +12,7 @@ import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
@@ -124,6 +125,9 @@ export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// Refuse before touching the branding dir: the config write that
// follows would fail only after the files had already changed.
assertWritable('change branding');
const ip = getClientIP(request);
const formData = await request.formData();
@@ -206,6 +210,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -224,6 +230,9 @@ export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// Refuse before touching the branding dir: the config write that
// follows would fail only after the files had already changed.
assertWritable('change branding');
const ip = getClientIP(request);
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
@@ -271,6 +280,8 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+3
View File
@@ -3,6 +3,7 @@ import { changeAdminPassword } from '@/lib/admin/password';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* POST /api/admin/change-password - Change admin password
@@ -31,6 +32,8 @@ export async function POST(request: NextRequest) {
await auditLog('admin.change-password', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+5
View File
@@ -8,6 +8,7 @@ import { parseDomainBranding } from '@/lib/admin/domain-branding';
import { initAdminPassword, isAdminEnabled } from '@/lib/admin/password';
import { logger } from '@/lib/logger';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
@@ -145,6 +146,8 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ ok: true });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -175,6 +178,8 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ ok: true });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+3
View File
@@ -23,6 +23,7 @@ import {
} from '@/lib/extension-package';
import { configManager } from '@/lib/admin/config-manager';
import { checkBundleSignature } from '@/lib/admin/directory-keys';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
@@ -303,6 +304,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate, signature: signature.status });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
}
+5
View File
@@ -3,6 +3,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
@@ -55,6 +56,8 @@ export async function POST(request: NextRequest) {
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
return NextResponse.json({ entry });
} catch (err) {
const readOnly = configReadOnlyResponse(err);
if (readOnly) return readOnly;
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -79,6 +82,8 @@ export async function DELETE(request: NextRequest) {
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
return NextResponse.json({ ok: true });
} catch (err) {
const readOnly = configReadOnlyResponse(err);
if (readOnly) return readOnly;
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+7 -2
View File
@@ -4,6 +4,7 @@ import { getDevPlugin } from '@/lib/admin/plugin-dev';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
async function resolvePlugin(id: string) {
@@ -128,7 +129,9 @@ export async function PUT(
await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true });
} catch {
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -165,7 +168,9 @@ export async function DELETE(
await deletePluginConfigKey(id, body.key);
return NextResponse.json({ ok: true });
} catch {
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+7
View File
@@ -14,6 +14,7 @@ import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup';
import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* GET /api/admin/plugins - List all admin-managed plugins
@@ -124,6 +125,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ plugin, warnings: pkg.warnings });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -175,6 +178,8 @@ export async function PATCH(request: NextRequest) {
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -206,6 +211,8 @@ export async function DELETE(request: NextRequest) {
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+3
View File
@@ -5,6 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this;
@@ -81,6 +82,8 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ ok: true });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+7
View File
@@ -13,6 +13,7 @@ import {
import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup';
import { readThemePackage } from '@/lib/extension-package';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* GET /api/admin/themes - List all admin-managed themes
@@ -76,6 +77,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ theme, warnings });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -113,6 +116,8 @@ export async function PATCH(request: NextRequest) {
await auditLog('theme.update', { id, ...updates }, ip);
return NextResponse.json({ theme: updated });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -143,6 +148,8 @@ export async function DELETE(request: NextRequest) {
await auditLog('theme.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
const readOnly = configReadOnlyResponse(error);
if (readOnly) return readOnly;
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { ConfigReadOnlyError } from './paths';
/**
* The 409 an admin route answers when a write hit ADMIN_CONFIG_READONLY,
* carrying the ConfigReadOnlyError message so the panel can show why
* nothing was saved (#1249). Returns null for any other error, which the
* route keeps reporting as a 500.
*/
export function configReadOnlyResponse(error: unknown): NextResponse | null {
if (!(error instanceof ConfigReadOnlyError)) return null;
return NextResponse.json({ error: error.message }, { status: 409 });
}
+5 -2
View File
@@ -185,9 +185,12 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
const valid = await verifyAdminPassword(currentPassword);
if (!valid) return false;
// Write first, then swap the cache: a refused write (read-only config)
// must not leave the new password active until the next restart.
const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
await writeConfigData(cachedConfig);
const next: AdminConfigData = { passwordHash: hash, passwordHashFile: undefined };
await writeConfigData(next);
cachedConfig = next;
cachedState = {
...(cachedState ?? freshState()),
+3
View File
@@ -148,6 +148,7 @@ export async function requestApproval(
const file = cached!;
const existing = findEntry(file, pluginId, bundleHash);
if (existing) return existing;
assertWritable('request plugin approval');
const pendingByUser = file.entries.filter(e => e.status === 'pending' && e.requestedBy === requestedBy).length;
if (pendingByUser >= MAX_PENDING_PER_USER) {
throw new ApprovalLimitError(`You already have ${MAX_PENDING_PER_USER} plugins waiting for approval`);
@@ -182,6 +183,7 @@ export async function decideApproval(
decision: 'approved' | 'denied',
decidedBy: string,
): Promise<ApprovalEntry> {
assertWritable('decide plugin approval');
await ensureLoaded();
const file = cached!;
const entry = findEntry(file, pluginId, bundleHash);
@@ -194,6 +196,7 @@ export async function decideApproval(
}
export async function revokeApproval(pluginId: string, bundleHash: string): Promise<void> {
assertWritable('revoke plugin approval');
await ensureLoaded();
const file = cached!;
const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash);