mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let admins export and import the whole user policy as one file
This commit is contained in:
@@ -7,8 +7,23 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-li
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
const filePosts: string[] = [];
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url.startsWith('/api/admin/policy/file') && init?.method === 'POST') {
|
||||
filePosts.push(url);
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
summary: {
|
||||
scopes: { added: ['Two (two)'], changed: [], removed: [] },
|
||||
organisation: { added: [], changed: ['session'], removed: [] },
|
||||
restrictions: { added: [], changed: [], removed: [] },
|
||||
features: { added: [], changed: [], removed: [] },
|
||||
defaults: { added: [], changed: [], removed: [] },
|
||||
other: [], unchanged: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
@@ -34,6 +49,7 @@ const POLICY = {
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
filePosts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
@@ -94,4 +110,19 @@ describe('OrganisationPage', () => {
|
||||
expect(table.textContent).toContain('restrictions.density');
|
||||
expect(table.textContent).toContain('organisation.session.idleMinutes');
|
||||
});
|
||||
|
||||
it('shows what an imported file changes and replaces the policy only when asked', async () => {
|
||||
render(<OrganisationPage />);
|
||||
await screen.findByTestId('org-scope-team');
|
||||
const file = new File(['{"scopes":[]}'], 'policy.json', { type: 'application/json' });
|
||||
fireEvent.change(screen.getByLabelText('Policy file to import'), { target: { files: [file] } });
|
||||
const preview = await screen.findByTestId('policy-import-preview');
|
||||
expect(preview.textContent).toContain('Domains and groups: 1 added');
|
||||
expect(preview.textContent).toContain('Organisation settings: 1 changed');
|
||||
expect(filePosts).toEqual(['/api/admin/policy/file?preview=1']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace the current policy' }));
|
||||
await waitFor(() => expect(filePosts).toEqual(['/api/admin/policy/file?preview=1', '/api/admin/policy/file']));
|
||||
await waitFor(() => expect(screen.queryByTestId('policy-import-preview')).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { inputClass } from '@/components/admin/config-items';
|
||||
import { OrgPage, scopeMatchSummary } from '@/components/admin/org/org-page';
|
||||
import { PolicyFileSection } from '@/components/admin/org/policy-file-section';
|
||||
import { EVERYONE, useOrgPolicyDraft, type OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
import { resolvePolicy, type PolicyScope } from '@/lib/admin/policy-scopes';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -260,6 +261,8 @@ export function OrganisationPage() {
|
||||
>
|
||||
<EffectivePolicy draft={draft} />
|
||||
</SettingsSection>
|
||||
|
||||
<PolicyFileSection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// The whole user policy goes out and comes back as one policy.json. An
|
||||
// import is previewed first and goes through the same sanitising as the
|
||||
// file on disk.
|
||||
|
||||
const audit = vi.hoisted(() => [] as Array<{ action: string; detail: Record<string, unknown> }>);
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({
|
||||
auditLog: async (action: string, detail: Record<string, unknown>) => { audit.push({ action, detail }); },
|
||||
}));
|
||||
|
||||
import { GET, POST } from '@/app/api/admin/policy/file/route';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
let dir: string;
|
||||
const url = (query = '') => `https://mail.example/api/admin/policy/file${query}`;
|
||||
const post = (body: string, query = '') => POST(new NextRequest(url(query), { method: 'POST', body }));
|
||||
const stored = async () => JSON.parse(await readFile(path.join(dir, 'policy.json'), 'utf-8'));
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-file-'));
|
||||
process.env.ADMIN_CONFIG_DIR = dir;
|
||||
audit.length = 0;
|
||||
await writeFile(path.join(dir, 'policy.json'), JSON.stringify({
|
||||
restrictions: { density: { locked: true } },
|
||||
organisation: { session: { idleMinutes: 30 } },
|
||||
scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }],
|
||||
}));
|
||||
await configManager.reload();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.ADMIN_CONFIG_DIR;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('policy file', () => {
|
||||
it('exports the stored policy as a dated download and logs it', async () => {
|
||||
const res = await GET(new NextRequest(url()));
|
||||
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="bulwark-policy-\d{4}-\d{2}-\d{2}\.json"$/);
|
||||
const body = JSON.parse(await res.text());
|
||||
expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']);
|
||||
expect(body.organisation.session.idleMinutes).toBe(30);
|
||||
expect(audit.map((a) => a.action)).toEqual(['policy.export']);
|
||||
});
|
||||
|
||||
it('previews an import without writing anything', async () => {
|
||||
const file = JSON.stringify({
|
||||
restrictions: {},
|
||||
organisation: { session: { idleMinutes: 10 }, signature: { html: '<p>{name}</p>' } },
|
||||
scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
});
|
||||
const res = await post(file, '?preview=1');
|
||||
const { summary } = await res.json();
|
||||
expect(summary.scopes).toEqual({ added: ['Two (two)'], changed: [], removed: ['Team (team)'] });
|
||||
expect(summary.restrictions.removed).toEqual(['density']);
|
||||
expect(summary.organisation.changed.sort()).toEqual(['session', 'signature']);
|
||||
expect((await stored()).organisation.session.idleMinutes).toBe(30);
|
||||
expect(audit).toEqual([]);
|
||||
});
|
||||
|
||||
it('imports the sanitised policy and logs it', async () => {
|
||||
const res = await post(JSON.stringify({
|
||||
organisation: { session: { idleMinutes: 'soon' } },
|
||||
scopes: [{ id: 'Bad id!' }, { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
const policy = await stored();
|
||||
expect(policy.scopes.map((s: { id: string }) => s.id)).toEqual(['two']);
|
||||
expect(policy.organisation.session.idleMinutes).toBe(0);
|
||||
expect(configManager.getPolicy().scopes?.map((s) => s.id)).toEqual(['two']);
|
||||
expect(audit.map((a) => a.action)).toEqual(['policy.import']);
|
||||
});
|
||||
|
||||
it('refuses what isn\'t a policy object', async () => {
|
||||
expect((await post('{nope')).status).toBe(400);
|
||||
expect((await post('[1,2]')).status).toBe(400);
|
||||
expect((await stored()).scopes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { completePolicy, configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { summarizePolicyChange } from '@/lib/admin/policy-diff';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
|
||||
/** Largest policy file an import accepts. */
|
||||
const MAX_IMPORT_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* GET /api/admin/policy/file - the whole user policy as a policy.json
|
||||
* download: the same file the server reads, so it can go into git, into
|
||||
* another instance's ADMIN_CONFIG_DIR or next to a Lite build.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
await auditLog('policy.export', { scopeCount: policy.scopes?.length ?? 0 }, getClientIP(request));
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(`${JSON.stringify(policy, null, 2)}\n`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="bulwark-policy-${date}.json"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/policy/file - import a policy file. With `?preview=1` it
|
||||
* only says what would change; otherwise it replaces the policy. The file
|
||||
* goes through the same defaults and sanitising as policy.json on load, and
|
||||
* the preview shows the result of that, not the raw file.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.length > MAX_IMPORT_BYTES) {
|
||||
return NextResponse.json({ error: 'The file is larger than 2 MB' }, { status: 413 });
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'The file is not valid JSON' }, { status: 400 });
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return NextResponse.json({ error: 'The file does not hold a policy object' }, { status: 400 });
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const next = completePolicy(raw as Partial<SettingsPolicy>);
|
||||
const summary = summarizePolicyChange(configManager.getPolicy(), next);
|
||||
if (request.nextUrl.searchParams.get('preview') === '1') {
|
||||
return NextResponse.json({ summary });
|
||||
}
|
||||
|
||||
await configManager.setPolicy(next);
|
||||
await auditLog('policy.import', {
|
||||
scopeCount: next.scopes?.length ?? 0,
|
||||
scopesAdded: summary.scopes.added.length,
|
||||
scopesRemoved: summary.scopes.removed.length,
|
||||
restrictionsChanged: summary.restrictions.added.length + summary.restrictions.changed.length + summary.restrictions.removed.length,
|
||||
}, getClientIP(request));
|
||||
return NextResponse.json({ ok: true, summary });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Policy import error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Download, Loader2, Upload } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { KeyChanges, PolicyChangeSummary } from '@/lib/admin/policy-diff';
|
||||
import type { OrgPolicyDraft } from './use-org-policy-draft';
|
||||
|
||||
function describe(label: string, changes: KeyChanges): string | null {
|
||||
const parts = [
|
||||
changes.added.length ? `${changes.added.length} added` : null,
|
||||
changes.changed.length ? `${changes.changed.length} changed` : null,
|
||||
changes.removed.length ? `${changes.removed.length} removed` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length ? `${label}: ${parts.join(', ')}` : null;
|
||||
}
|
||||
|
||||
function summaryLines(summary: PolicyChangeSummary): string[] {
|
||||
return [
|
||||
describe('Domains and groups', summary.scopes),
|
||||
describe('Organisation settings', summary.organisation),
|
||||
describe('Locked and hidden settings', summary.restrictions),
|
||||
describe('Features', summary.features),
|
||||
describe('Defaults for new users', summary.defaults),
|
||||
summary.other.length ? `Also: ${summary.other.join(', ')}` : null,
|
||||
].filter((line): line is string => !!line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export and import of the whole user policy as policy.json, so the policy
|
||||
* can live in git, move to another instance or go next to a Lite build.
|
||||
* An import shows what it changes before it replaces anything.
|
||||
*/
|
||||
export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [pending, setPending] = useState<{ name: string; text: string; summary: PolicyChangeSummary } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function exportFile() {
|
||||
const res = await apiFetch('/api/admin/policy/file');
|
||||
if (!res.ok) {
|
||||
draft.setMessage({ type: 'error', text: 'Could not export the policy' });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const name = /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'bulwark-policy.json';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function pick(file: File) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const res = await apiFetch('/api/admin/policy/file?preview=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
draft.setMessage({ type: 'error', text: data.error || 'Could not read the file' });
|
||||
return;
|
||||
}
|
||||
setPending({ name: file.name, text, summary: data.summary });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (input.current) input.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: pending.text });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
draft.setMessage({ type: 'error', text: data.error || 'Could not import the file' });
|
||||
return;
|
||||
}
|
||||
setPending(null);
|
||||
await draft.reload();
|
||||
draft.setMessage({ type: 'success', text: `Imported ${pending.name}.` });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="policy-file"
|
||||
title="Policy file"
|
||||
description="Everything on the Organisation and User policy pages is one file, policy.json. Export it to keep it in git or copy it to another Bulwark or a Lite build; importing replaces the current policy."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => void exportFile()}>
|
||||
<Download className="h-3.5 w-3.5" /> Export policy.json
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
disabled={busy || draft.dirty}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />} Import a file
|
||||
</Button>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
aria-label="Policy file to import"
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) void pick(file); }}
|
||||
/>
|
||||
</div>
|
||||
{draft.dirty && <p className="text-xs text-muted-foreground">Save or discard your changes first.</p>}
|
||||
{pending && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2" data-testid="policy-import-preview">
|
||||
<p className="text-sm font-medium text-foreground">Importing {pending.name}</p>
|
||||
{pending.summary.unchanged ? (
|
||||
<p className="text-sm text-muted-foreground">It matches the current policy; nothing would change.</p>
|
||||
) : (
|
||||
<ul className="list-disc ps-5 text-sm text-foreground">
|
||||
{summaryLines(pending.summary).map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" className="h-8 text-xs" disabled={busy || pending.summary.unchanged} onClick={() => void apply()}>
|
||||
Replace the current policy
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" disabled={busy} onClick={() => setPending(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -38,12 +38,7 @@ class ConfigManager {
|
||||
this.adminConfig = await this.readJsonFile('config.json') || {};
|
||||
const policy = await this.readJsonFile('policy.json');
|
||||
if (policy) {
|
||||
this.policyCache = ConfigManager.normalizePolicy({
|
||||
...DEFAULT_POLICY,
|
||||
...policy,
|
||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||
});
|
||||
this.policyCache = ConfigManager.completePolicy(policy as Partial<SettingsPolicy>);
|
||||
} else {
|
||||
this.policyCache = { ...DEFAULT_POLICY };
|
||||
}
|
||||
@@ -184,13 +179,23 @@ class ConfigManager {
|
||||
*/
|
||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||
assertWritable('update settings policy');
|
||||
this.policyCache = ConfigManager.normalizePolicy({
|
||||
this.policyCache = ConfigManager.completePolicy(policy);
|
||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy as it would be stored: defaults filled in, migrated and
|
||||
* sanitised, exactly as policy.json is on load. For previewing an import.
|
||||
*/
|
||||
static completePolicy(policy: Partial<SettingsPolicy>): SettingsPolicy {
|
||||
return ConfigManager.normalizePolicy({
|
||||
...DEFAULT_POLICY,
|
||||
...policy,
|
||||
restrictions: { ...(policy.restrictions || {}) },
|
||||
defaults: { ...(policy.defaults || {}) },
|
||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||
});
|
||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,3 +286,6 @@ type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager };
|
||||
const g = globalThis as GlobalWithConfig;
|
||||
export const configManager: ConfigManager =
|
||||
g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager());
|
||||
|
||||
/** See ConfigManager.completePolicy. */
|
||||
export const completePolicy = (policy: Partial<SettingsPolicy>): SettingsPolicy => ConfigManager.completePolicy(policy);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SettingsPolicy } from './types';
|
||||
|
||||
export interface KeyChanges {
|
||||
added: string[];
|
||||
changed: string[];
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
/** What importing a policy file would change, for the preview before applying it. */
|
||||
export interface PolicyChangeSummary {
|
||||
scopes: KeyChanges;
|
||||
features: KeyChanges;
|
||||
restrictions: KeyChanges;
|
||||
defaults: KeyChanges;
|
||||
/** Organisation sections, by name (signature, session...). */
|
||||
organisation: KeyChanges;
|
||||
/** Other top-level parts (theme policy, plugins, push relays, sidebar apps). */
|
||||
other: string[];
|
||||
/** Nothing would change. */
|
||||
unchanged: boolean;
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
function keyChanges(before: Record<string, unknown> = {}, after: Record<string, unknown> = {}): KeyChanges {
|
||||
const out: KeyChanges = { added: [], changed: [], removed: [] };
|
||||
for (const key of Object.keys(after)) {
|
||||
if (!(key in before)) out.added.push(key);
|
||||
else if (!same(before[key], after[key])) out.changed.push(key);
|
||||
}
|
||||
for (const key of Object.keys(before)) if (!(key in after)) out.removed.push(key);
|
||||
return out;
|
||||
}
|
||||
|
||||
const empty = (c: KeyChanges) => c.added.length + c.changed.length + c.removed.length === 0;
|
||||
|
||||
/** Compares two complete policies (both as configManager stores them). */
|
||||
export function summarizePolicyChange(current: SettingsPolicy, next: SettingsPolicy): PolicyChangeSummary {
|
||||
const byId = (policy: SettingsPolicy) => Object.fromEntries((policy.scopes ?? []).map((s) => [s.label ? `${s.label} (${s.id})` : s.id, s]));
|
||||
const summary: PolicyChangeSummary = {
|
||||
scopes: keyChanges(byId(current), byId(next)),
|
||||
features: keyChanges(current.features as unknown as Record<string, unknown>, next.features as unknown as Record<string, unknown>),
|
||||
restrictions: keyChanges(current.restrictions, next.restrictions),
|
||||
defaults: keyChanges(current.defaults, next.defaults),
|
||||
organisation: keyChanges(
|
||||
current.organisation as unknown as Record<string, unknown>,
|
||||
next.organisation as unknown as Record<string, unknown>,
|
||||
),
|
||||
other: [],
|
||||
unchanged: false,
|
||||
};
|
||||
const covered = new Set(['scopes', 'features', 'restrictions', 'defaults', 'organisation']);
|
||||
for (const key of new Set([...Object.keys(current), ...Object.keys(next)])) {
|
||||
if (covered.has(key)) continue;
|
||||
if (!same((current as unknown as Record<string, unknown>)[key], (next as unknown as Record<string, unknown>)[key])) summary.other.push(key);
|
||||
}
|
||||
summary.unchanged = [summary.scopes, summary.features, summary.restrictions, summary.defaults, summary.organisation].every(empty)
|
||||
&& summary.other.length === 0;
|
||||
return summary;
|
||||
}
|
||||
Reference in New Issue
Block a user