mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: let every admin switch on a page with a save bar wait for Save, plugins, themes and telemetry included
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
|
||||
|
||||
// On an admin page with a save bar, every control waits for Save: the
|
||||
// per-plugin switch and the telemetry consent used to save at once, next to
|
||||
// policy switches that waited.
|
||||
|
||||
const calls: Array<{ url: string; method: string; body: Record<string, unknown> }> = [];
|
||||
const routes: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? 'GET';
|
||||
calls.push({ url, method, body: JSON.parse(String(init?.body ?? '{}')) });
|
||||
const data = method === 'GET' ? routes[url.split('?')[0]] ?? {} : { ok: true };
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
}));
|
||||
vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children }));
|
||||
vi.mock('../_pages/plugin-approvals-panel', () => ({ PluginApprovalsPanel: () => null }));
|
||||
vi.mock('../_pages/use-extension-icons', () => ({ useExtensionIcons: () => () => null }));
|
||||
|
||||
import { PluginsPage } from '../_pages/plugins';
|
||||
import { TelemetryPage } from '../_pages/telemetry';
|
||||
|
||||
const writes = () => calls.filter((c) => c.method !== 'GET');
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
routes['/api/admin/plugins'] = [
|
||||
{ id: 'jitsi', name: 'Jitsi', version: '1.0.0', enabled: true, author: 'x', description: '', permissions: [] },
|
||||
];
|
||||
routes['/api/admin/policy'] = { features: {}, themePolicy: {} };
|
||||
routes['/api/admin/telemetry'] = {
|
||||
consent: 'off', consentSource: 'file', endpoint: 'https://t.example', defaultEndpoint: 'https://t.example',
|
||||
consentedAt: null, lastSentAt: null, nextScheduledAt: null, payloadPreview: {}, accountCounts: { total: 0, active7d: 0 },
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
|
||||
});
|
||||
|
||||
describe('admin plugins page', () => {
|
||||
it('turns a plugin off only when Save is pressed', async () => {
|
||||
render(<PluginsPage />);
|
||||
const toggle = await screen.findByRole('switch', { name: 'Jitsi enabled' });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
expect(writes()).toEqual([]);
|
||||
expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(writes()).toEqual([
|
||||
expect.objectContaining({ url: '/api/admin/plugins', method: 'PATCH', body: { id: 'jitsi', enabled: false } }),
|
||||
]));
|
||||
await waitFor(() => expect(useAdminUnsavedStore.getState().dirty).toEqual({}));
|
||||
});
|
||||
|
||||
it('puts the switch back on Discard without saving', async () => {
|
||||
render(<PluginsPage />);
|
||||
const toggle = await screen.findByRole('switch', { name: 'Jitsi enabled' });
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true');
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin telemetry page', () => {
|
||||
it('sends the consent only when Save is pressed', async () => {
|
||||
render(<TelemetryPage />);
|
||||
const consent = await screen.findByRole('switch', { name: 'Send anonymous heartbeats' });
|
||||
fireEvent.click(consent);
|
||||
expect(writes()).toEqual([]);
|
||||
expect(screen.getByTestId('admin-save-bar')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(writes()).toEqual([
|
||||
expect.objectContaining({ url: '/api/admin/telemetry', body: { action: 'set-consent', consent: 'on' } }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -127,7 +127,12 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
}, [schema, savedValues]);
|
||||
|
||||
const dirty = Object.keys(savedValues).some((key) => (formValues[key] ?? '') !== savedValues[key]);
|
||||
useAdminUnsavedChanges(`${kind}-config`, dirty);
|
||||
// On/off and "force on" wait for Save like the settings below.
|
||||
const [availabilityDraft, setAvailabilityDraft] = useState<{ enabled?: boolean; forceEnabled?: boolean }>({});
|
||||
const shownForced = availabilityDraft.forceEnabled ?? !!entry?.forceEnabled;
|
||||
const shownEnabled = shownForced || (availabilityDraft.enabled ?? !!entry?.enabled);
|
||||
const availabilityDirty = !!entry && (shownEnabled !== entry.enabled || shownForced !== !!entry.forceEnabled);
|
||||
useAdminUnsavedChanges(`${kind}-config`, dirty || availabilityDirty);
|
||||
|
||||
async function saveConfig() {
|
||||
if (!schema) return;
|
||||
@@ -205,16 +210,21 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(enabled: boolean) {
|
||||
if (await patch({ enabled }, 'enabled')) setEntry((prev) => (prev ? { ...prev, enabled } : prev));
|
||||
async function saveAvailability(): Promise<boolean> {
|
||||
if (!entry) return true;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (shownForced !== !!entry.forceEnabled) body.forceEnabled = shownForced;
|
||||
if (shownEnabled !== entry.enabled) body.enabled = shownEnabled;
|
||||
const ok = await patch(body, 'availability');
|
||||
if (ok) setEntry((prev) => (prev ? { ...prev, enabled: shownEnabled, forceEnabled: shownForced } : prev));
|
||||
setAvailabilityDraft({});
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function setForced(forceEnabled: boolean) {
|
||||
const body: Record<string, unknown> = { forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
if (await patch(body, 'forced')) {
|
||||
setEntry((prev) => (prev ? { ...prev, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : prev));
|
||||
}
|
||||
async function save() {
|
||||
if (availabilityDirty && !(await saveAvailability())) return;
|
||||
if (dirty) await saveConfig();
|
||||
else setMessage({ type: 'success', text: 'Saved' });
|
||||
}
|
||||
|
||||
async function rollback(version: string) {
|
||||
@@ -326,8 +336,13 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
actions={(
|
||||
<>
|
||||
<label className="flex h-9 items-center gap-2.5 rounded-md border border-input bg-background px-3 text-sm">
|
||||
<span className={cn('font-medium', entry.enabled ? 'text-foreground' : 'text-muted-foreground')}>{entry.enabled ? 'On' : 'Off'}</span>
|
||||
<ToggleSwitch checked={entry.enabled} disabled={busy === 'enabled'} onChange={(v) => void setEnabled(v)} ariaLabel={`${entry.name} enabled`} />
|
||||
<span className={cn('font-medium', shownEnabled ? 'text-foreground' : 'text-muted-foreground')}>{shownEnabled ? 'On' : 'Off'}</span>
|
||||
<ToggleSwitch
|
||||
checked={shownEnabled}
|
||||
disabled={busy === 'availability' || shownForced}
|
||||
onChange={(v) => { setMessage(null); setAvailabilityDraft((prev) => ({ ...prev, enabled: v })); }}
|
||||
ariaLabel={`${entry.name} enabled`}
|
||||
/>
|
||||
</label>
|
||||
<Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive" disabled={busy === 'remove'} onClick={() => void remove()}>
|
||||
{busy === 'remove' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
@@ -350,7 +365,11 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
? 'Users cannot turn it off in their settings. Turning this on also enables the plugin.'
|
||||
: 'Users cannot deactivate it. Turning this on also enables the theme.'}
|
||||
>
|
||||
<ToggleSwitch checked={!!entry.forceEnabled} disabled={busy === 'forced'} onChange={(v) => void setForced(v)} />
|
||||
<ToggleSwitch
|
||||
checked={shownForced}
|
||||
disabled={busy === 'availability'}
|
||||
onChange={(v) => { setMessage(null); setAvailabilityDraft((prev) => ({ ...prev, forceEnabled: v })); }}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Source" description={entry.source?.kind === 'marketplace' ? 'Installed from the extension directory; updates show up in the marketplace.' : 'Installed from an uploaded ZIP.'}>
|
||||
{entry.source?.kind === 'marketplace' ? (
|
||||
@@ -424,11 +443,11 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
onSave={saveConfig}
|
||||
onDiscard={() => setFormValues(savedValues)}
|
||||
saveLabel="Save settings"
|
||||
dirty={dirty || availabilityDirty}
|
||||
saving={saving || busy === 'availability'}
|
||||
onSave={() => void save()}
|
||||
onDiscard={() => { setFormValues(savedValues); setAvailabilityDraft({}); }}
|
||||
saveLabel="Save"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,8 +26,9 @@ export function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<ExtensionEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
// On/off per plugin waits for Save, like the policy switches below.
|
||||
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
@@ -61,16 +62,18 @@ export function PluginsPage() {
|
||||
const pluginsUploadEnabled = features.pluginsUploadEnabled ?? true;
|
||||
const requirePluginApproval = features.requirePluginApproval ?? true;
|
||||
const policyDirty = JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
|
||||
useAdminUnsavedChanges('plugins', policyDirty);
|
||||
const isOn = (p: ExtensionEntry) => enabledDraft[p.id] ?? p.enabled;
|
||||
const switched = plugins.filter((p) => isOn(p) !== p.enabled);
|
||||
const dirty = policyDirty || switched.length > 0;
|
||||
useAdminUnsavedChanges('plugins', dirty);
|
||||
|
||||
function setFeature(key: 'pluginsEnabled' | 'pluginsUploadEnabled' | 'requirePluginApproval', value: boolean) {
|
||||
setPolicy((prev) => ({ ...prev, features: { ...prev.features, [key]: value } }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function savePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
/** Stores the policy; returns the error text, or null. */
|
||||
async function savePolicy(): Promise<string | null> {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
@@ -79,18 +82,34 @@ export function PluginsPage() {
|
||||
});
|
||||
if (res.ok) {
|
||||
setSavedPolicy(policy);
|
||||
setMessage({ type: 'success', text: 'Plugin policy saved. Users see the change on their next login.' });
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not save the plugin policy' });
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return data.error || 'Could not save the plugin policy';
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not save the plugin policy' });
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
return 'Could not save the plugin policy';
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
const errors: string[] = [];
|
||||
for (const p of switched) {
|
||||
const error = await setEnabled(p.id, isOn(p));
|
||||
if (error) errors.push(`${p.name}: ${error}`);
|
||||
}
|
||||
setEnabledDraft({});
|
||||
if (policyDirty) {
|
||||
const error = await savePolicy();
|
||||
if (error) errors.push(error);
|
||||
}
|
||||
setSavingPolicy(false);
|
||||
setMessage(errors.length > 0
|
||||
? { type: 'error', text: `Couldn't save everything. ${errors.join('; ')}` }
|
||||
: { type: 'success', text: 'Plugins saved. Users see the change on their next login.' });
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -116,7 +135,8 @@ export function PluginsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(id: string, enabled: boolean): Promise<boolean> {
|
||||
/** Turns one plugin on or off on the server; returns the error text, or null. */
|
||||
async function setEnabled(id: string, enabled: boolean): Promise<string | null> {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -124,30 +144,18 @@ export function PluginsPage() {
|
||||
});
|
||||
if (res.ok) {
|
||||
setPlugins((prev) => prev.map((p) => (p.id === id ? { ...p, enabled } : p)));
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not change the plugin' });
|
||||
return false;
|
||||
return data.error || 'Could not change the plugin';
|
||||
}
|
||||
|
||||
async function setAll(enabled: boolean) {
|
||||
const targets = plugins.filter((p) => p.enabled !== enabled);
|
||||
if (targets.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
function setAll(enabled: boolean) {
|
||||
setEnabledDraft(Object.fromEntries(plugins.map((p) => [p.id, enabled])));
|
||||
setMessage(null);
|
||||
let failed = 0;
|
||||
for (const p of targets) {
|
||||
if (!(await setEnabled(p.id, enabled))) failed++;
|
||||
}
|
||||
await fetchPlugins();
|
||||
setBulkBusy(false);
|
||||
setMessage(failed === 0
|
||||
? { type: 'success', text: `${targets.length} plugin${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
|
||||
: { type: 'error', text: `${failed} plugin${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
|
||||
}
|
||||
|
||||
const enabledCount = plugins.filter((p) => p.enabled).length;
|
||||
const enabledCount = plugins.filter(isOn).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -201,10 +209,10 @@ export function PluginsPage() {
|
||||
<>
|
||||
{plugins.length > 1 && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === plugins.length} onClick={() => setAll(true)}>
|
||||
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === plugins.length} onClick={() => setAll(true)}>
|
||||
<Power className="h-3.5 w-3.5" /> Enable all
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
|
||||
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === 0} onClick={() => setAll(false)}>
|
||||
<PowerOff className="h-3.5 w-3.5" /> Disable all
|
||||
</Button>
|
||||
</div>
|
||||
@@ -244,8 +252,9 @@ export function PluginsPage() {
|
||||
)}
|
||||
control={(
|
||||
<ToggleSwitch
|
||||
checked={plugin.enabled}
|
||||
onChange={(checked) => { setMessage(null); void setEnabled(plugin.id, checked); }}
|
||||
checked={isOn(plugin)}
|
||||
disabled={savingPolicy}
|
||||
onChange={(checked) => { setMessage(null); setEnabledDraft((prev) => ({ ...prev, [plugin.id]: checked })); }}
|
||||
ariaLabel={`${plugin.name} enabled`}
|
||||
/>
|
||||
)}
|
||||
@@ -274,11 +283,11 @@ export function PluginsPage() {
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={policyDirty}
|
||||
dirty={dirty}
|
||||
saving={savingPolicy}
|
||||
onSave={savePolicy}
|
||||
onDiscard={() => setPolicy(savedPolicy)}
|
||||
saveLabel="Save policy"
|
||||
onSave={() => void save()}
|
||||
onDiscard={() => { setPolicy(savedPolicy); setEnabledDraft({}); }}
|
||||
saveLabel="Save"
|
||||
note="Users see the change on their next login"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -46,9 +46,14 @@ export function TelemetryPage() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [endpointDraft, setEndpointDraft] = useState('');
|
||||
const [message, setMessage] = useFlash();
|
||||
// The heartbeat switch waits for Save like the endpoint (one page, one
|
||||
// save bar).
|
||||
const [consentDraft, setConsentDraft] = useState<'on' | 'off' | null>(null);
|
||||
|
||||
const endpointDirty = status !== null && endpointDraft !== status.endpoint;
|
||||
useAdminUnsavedChanges('telemetry', endpointDirty);
|
||||
const consentDirty = status !== null && consentDraft !== null && consentDraft !== status.consent;
|
||||
const dirty = endpointDirty || consentDirty;
|
||||
useAdminUnsavedChanges('telemetry', dirty);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading(true);
|
||||
@@ -66,38 +71,35 @@ export function TelemetryPage() {
|
||||
}
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
||||
setBusy('consent');
|
||||
setMessage(null);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set-consent', consent }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
setMessage({ type: 'error', text: j.error ?? 'failed' });
|
||||
}
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
/** Sends one change; returns the error text, or null. */
|
||||
async function post(body: Record<string, unknown>): Promise<string | null> {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) return null;
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
return j.error ?? 'failed';
|
||||
}
|
||||
|
||||
async function saveEndpoint(): Promise<void> {
|
||||
setBusy('endpoint');
|
||||
async function save(): Promise<void> {
|
||||
setBusy('save');
|
||||
setMessage(null);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
setMessage({ type: 'error', text: j.error ?? 'failed' });
|
||||
} else {
|
||||
setMessage({ type: 'success', text: 'Endpoint saved.' });
|
||||
const errors: string[] = [];
|
||||
if (consentDirty && consentDraft) {
|
||||
const error = await post({ action: 'set-consent', consent: consentDraft });
|
||||
if (error) errors.push(`Heartbeats: ${error}`);
|
||||
}
|
||||
if (endpointDirty) {
|
||||
const error = await post({ action: 'set-endpoint', endpoint: endpointDraft });
|
||||
if (error) errors.push(`Endpoint: ${error}`);
|
||||
}
|
||||
setConsentDraft(null);
|
||||
setMessage(errors.length > 0
|
||||
? { type: 'error', text: `Couldn't save: ${errors.join('; ')}` }
|
||||
: { type: 'success', text: 'Telemetry settings saved.' });
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
@@ -134,6 +136,7 @@ export function TelemetryPage() {
|
||||
|
||||
const envOverridden = status.consentSource === 'env';
|
||||
const isOn = status.consent === 'on';
|
||||
const shownOn = (consentDraft ?? status.consent) === 'on';
|
||||
|
||||
const consentDescription = [
|
||||
status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
|
||||
@@ -167,9 +170,9 @@ export function TelemetryPage() {
|
||||
<SettingsSection id="status" title="Status">
|
||||
<SettingItem label="Send anonymous heartbeats" description={consentDescription}>
|
||||
<ToggleSwitch
|
||||
checked={isOn}
|
||||
disabled={busy === 'consent' || envOverridden}
|
||||
onChange={(checked) => void setConsent(checked ? 'on' : 'off')}
|
||||
checked={shownOn}
|
||||
disabled={busy === 'save' || envOverridden}
|
||||
onChange={(checked) => { setConsentDraft(checked ? 'on' : 'off'); setMessage(null); }}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last sent">
|
||||
@@ -240,11 +243,11 @@ export function TelemetryPage() {
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={endpointDirty}
|
||||
saving={busy === 'endpoint'}
|
||||
onSave={() => void saveEndpoint()}
|
||||
onDiscard={() => setEndpointDraft(status.endpoint)}
|
||||
saveLabel="Save endpoint"
|
||||
dirty={dirty}
|
||||
saving={busy === 'save'}
|
||||
onSave={() => void save()}
|
||||
onDiscard={() => { setEndpointDraft(status.endpoint); setConsentDraft(null); }}
|
||||
saveLabel="Save"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,9 @@ export function ThemesPage() {
|
||||
const iconFor = useExtensionIcons();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
// On/off per theme waits for Save, like the policy switches below.
|
||||
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>(() => withThemeDefaults({ ...DEFAULT_POLICY }));
|
||||
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>(() => withThemeDefaults({ ...DEFAULT_POLICY }));
|
||||
@@ -74,7 +75,10 @@ export function ThemesPage() {
|
||||
const policyDirty =
|
||||
JSON.stringify(policy.themePolicy) !== JSON.stringify(savedPolicy.themePolicy) ||
|
||||
JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
|
||||
useAdminUnsavedChanges('themes', policyDirty);
|
||||
const isOn = (t: ExtensionEntry) => enabledDraft[t.id] ?? t.enabled;
|
||||
const switched = themes.filter((t) => isOn(t) !== t.enabled);
|
||||
const dirty = policyDirty || switched.length > 0;
|
||||
useAdminUnsavedChanges('themes', dirty);
|
||||
|
||||
function setFeature(key: 'themesEnabled' | 'userThemesEnabled', value: boolean) {
|
||||
setPolicy(prev => ({ ...prev, features: { ...prev.features, [key]: value } }));
|
||||
@@ -141,9 +145,8 @@ export function ThemesPage() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function savePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
/** Stores the policy; returns the error text, or null. */
|
||||
async function savePolicy(): Promise<string | null> {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
@@ -152,18 +155,34 @@ export function ThemesPage() {
|
||||
});
|
||||
if (res.ok) {
|
||||
setSavedPolicy(policy);
|
||||
setMessage({ type: 'success', text: 'Theme policy saved. Users see the change on their next login.' });
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not save the theme policy' });
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return data.error || 'Could not save the theme policy';
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not save the theme policy' });
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
return 'Could not save the theme policy';
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
const errors: string[] = [];
|
||||
for (const t of switched) {
|
||||
const error = await setEnabled(t.id, isOn(t));
|
||||
if (error) errors.push(`${t.name}: ${error}`);
|
||||
}
|
||||
setEnabledDraft({});
|
||||
if (policyDirty) {
|
||||
const error = await savePolicy();
|
||||
if (error) errors.push(error);
|
||||
}
|
||||
setSavingPolicy(false);
|
||||
setMessage(errors.length > 0
|
||||
? { type: 'error', text: `Couldn't save everything. ${errors.join('; ')}` }
|
||||
: { type: 'success', text: 'Themes saved. Users see the change on their next login.' });
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -189,7 +208,8 @@ export function ThemesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(id: string, enabled: boolean): Promise<boolean> {
|
||||
/** Turns one theme on or off on the server; returns the error text, or null. */
|
||||
async function setEnabled(id: string, enabled: boolean): Promise<string | null> {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -197,30 +217,18 @@ export function ThemesPage() {
|
||||
});
|
||||
if (res.ok) {
|
||||
setThemes(prev => prev.map(t => (t.id === id ? { ...t, enabled } : t)));
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not change the theme' });
|
||||
return false;
|
||||
return data.error || 'Could not change the theme';
|
||||
}
|
||||
|
||||
async function setAll(enabled: boolean) {
|
||||
const targets = themes.filter(t => t.enabled !== enabled);
|
||||
if (targets.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
function setAll(enabled: boolean) {
|
||||
setEnabledDraft(Object.fromEntries(themes.map((t) => [t.id, enabled])));
|
||||
setMessage(null);
|
||||
let failed = 0;
|
||||
for (const t of targets) {
|
||||
if (!(await setEnabled(t.id, enabled))) failed++;
|
||||
}
|
||||
await fetchThemes();
|
||||
setBulkBusy(false);
|
||||
setMessage(failed === 0
|
||||
? { type: 'success', text: `${targets.length} theme${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
|
||||
: { type: 'error', text: `${failed} theme${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
|
||||
}
|
||||
|
||||
const enabledCount = themes.filter(t => t.enabled).length;
|
||||
const enabledCount = themes.filter(isOn).length;
|
||||
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||
const lockColorMode = policy.themePolicy?.lockColorMode === true;
|
||||
@@ -277,10 +285,10 @@ export function ThemesPage() {
|
||||
<>
|
||||
{themes.length > 1 && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === themes.length} onClick={() => setAll(true)}>
|
||||
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === themes.length} onClick={() => setAll(true)}>
|
||||
<Power className="h-3.5 w-3.5" /> Enable all
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
|
||||
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === 0} onClick={() => setAll(false)}>
|
||||
<PowerOff className="h-3.5 w-3.5" /> Disable all
|
||||
</Button>
|
||||
</div>
|
||||
@@ -310,8 +318,9 @@ export function ThemesPage() {
|
||||
)}
|
||||
control={(
|
||||
<ToggleSwitch
|
||||
checked={theme.enabled}
|
||||
onChange={(checked) => { setMessage(null); void setEnabled(theme.id, checked); }}
|
||||
checked={isOn(theme)}
|
||||
disabled={savingPolicy}
|
||||
onChange={(checked) => { setMessage(null); setEnabledDraft((prev) => ({ ...prev, [theme.id]: checked })); }}
|
||||
ariaLabel={`${theme.name} enabled`}
|
||||
/>
|
||||
)}
|
||||
@@ -392,11 +401,11 @@ export function ThemesPage() {
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={policyDirty}
|
||||
dirty={dirty}
|
||||
saving={savingPolicy}
|
||||
onSave={savePolicy}
|
||||
onDiscard={() => setPolicy(savedPolicy)}
|
||||
saveLabel="Save policy"
|
||||
onSave={() => void save()}
|
||||
onDiscard={() => { setPolicy(savedPolicy); setEnabledDraft({}); }}
|
||||
saveLabel="Save"
|
||||
note="Users see the change on their next login"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user