mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: load the admin panel when the config is read-only #1249
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user