feat: add plugin/theme disable gates and move policy controls to their admin pages

- Add `pluginsEnabled` and `themesEnabled` master feature gates to FeatureGates
- Move theme policy UI (default theme, built-in/admin theme toggles, user uploads toggle) from policy page to themes admin page
- Add plugin policy UI (plugins enabled toggle) to plugins admin page
- Remove theme policy section and plugin/theme gates from policy page (with note directing to respective pages)
- Hide Themes and Plugins settings tabs when their feature gate is disabled
- Fix dark mode visibility of all admin toggle switches (bg-white → bg-background, increase off-state track opacity)
This commit is contained in:
Linus Rath
2026-03-25 00:44:04 +01:00
parent 76b21147e4
commit 29a222eef4
24 changed files with 1729 additions and 42 deletions
+9 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
@@ -15,6 +16,7 @@ export function AdvancedSettings() {
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const handleExport = () => {
const settingsJson = exportSettings();
@@ -64,9 +66,11 @@ export function AdvancedSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')}>
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
@@ -81,13 +85,16 @@ export function AdvancedSettings() {
</SettingItem>
{/* Export Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
)}
{/* Import Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
@@ -102,6 +109,7 @@ export function AdvancedSettings() {
</Button>
</>
</SettingItem>
)}
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
+11 -3
View File
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -68,6 +69,7 @@ export function AppearanceSettings() {
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -90,7 +92,8 @@ export function AppearanceSettings() {
</SettingItem>
{/* Font Size */}
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
{!isSettingHidden('fontSize') && (
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
<RadioGroup
value={fontSize}
onChange={(value) => updateSetting('fontSize', value as 'small' | 'medium' | 'large')}
@@ -101,9 +104,11 @@ export function AppearanceSettings() {
]}
/>
</SettingItem>
)}
{/* Density */}
<SettingItem label={t('list_density.label')} description={t('list_density.description')}>
{!isSettingHidden('density') && (
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
<RadioGroup
value={density}
onChange={(value) =>
@@ -118,6 +123,7 @@ export function AppearanceSettings() {
/>
<DensityPreview density={density} />
</SettingItem>
)}
{/* Toolbar Position */}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
@@ -140,12 +146,14 @@ export function AppearanceSettings() {
</SettingItem>
{/* Animations */}
<SettingItem label={t('animations.label')} description={t('animations.description')}>
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch
checked={animationsEnabled}
onChange={(checked) => updateSetting('animationsEnabled', checked)}
/>
</SettingItem>
)}
{/* Restart Tour */}
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
+19 -5
View File
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
@@ -20,6 +21,7 @@ export function EmailSettings() {
const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
@@ -122,7 +124,8 @@ export function EmailSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')}>
{!isSettingHidden('markAsReadDelay') && (
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
<Select
value={markAsReadDelay.toString()}
onChange={(value) => updateSetting('markAsReadDelay', parseInt(value))}
@@ -134,9 +137,11 @@ export function EmailSettings() {
]}
/>
</SettingItem>
)}
{/* Delete Action */}
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
{!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2">
<Select
value={deleteAction}
@@ -154,6 +159,7 @@ export function EmailSettings() {
)}
</div>
</SettingItem>
)}
{/* Archive Mode */}
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
@@ -198,11 +204,14 @@ export function EmailSettings() {
</SettingItem>
{/* Show Preview */}
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
{!isSettingHidden('showPreview') && (
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
)}
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
<div>
<label className="text-sm font-medium text-foreground">{t('hover_actions.label')}</label>
@@ -234,6 +243,7 @@ export function EmailSettings() {
})}
</div>
</div>
)}
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
<Select
@@ -258,7 +268,8 @@ export function EmailSettings() {
</SettingItem>
{/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
{!isSettingHidden('emailsPerPage') && (
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
<Select
value={emailsPerPage.toString()}
onChange={(value) => updateSetting('emailsPerPage', parseInt(value))}
@@ -270,6 +281,7 @@ export function EmailSettings() {
]}
/>
</SettingItem>
)}
{/* Always Light Mode for Emails */}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
@@ -280,7 +292,8 @@ export function EmailSettings() {
</SettingItem>
{/* External Content */}
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
@@ -293,6 +306,7 @@ export function EmailSettings() {
]}
/>
</SettingItem>
)}
{/* Default Mail Program */}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
@@ -7,6 +7,7 @@ import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-s
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
@@ -19,6 +20,7 @@ export function NotificationSettings() {
calendarInvitationParsingEnabled,
updateSetting,
} = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id,
@@ -56,15 +58,18 @@ export function NotificationSettings() {
</SettingsSection>
<SettingsSection title={t('email.title')} description={t('email.description')}>
{!isSettingHidden('emailNotificationsEnabled') && (
<SettingItem
label={t('email.enabled')}
description={t('email.enabled_desc')}
locked={isSettingLocked('emailNotificationsEnabled')}
>
<ToggleSwitch
checked={emailNotificationsEnabled}
onChange={(checked) => updateSetting('emailNotificationsEnabled', checked)}
/>
</SettingItem>
)}
<SettingItem
label={t('email.sound')}
@@ -79,15 +84,18 @@ export function NotificationSettings() {
</SettingsSection>
<SettingsSection title={t('calendar.title')} description={t('calendar.description')}>
{!isSettingHidden('calendarNotificationsEnabled') && (
<SettingItem
label={t('calendar.enabled')}
description={t('calendar.enabled_desc')}
locked={isSettingLocked('calendarNotificationsEnabled')}
>
<ToggleSwitch
checked={calendarNotificationsEnabled}
onChange={(checked) => updateSetting('calendarNotificationsEnabled', checked)}
/>
</SettingItem>
)}
<SettingItem
label={t('calendar.sound')}
+33 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';
import { SettingsSection, SettingItem } from './settings-section';
import { cn } from '@/lib/utils';
@@ -8,11 +8,30 @@ import { Upload, Trash2, Check, Palette } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/stores/toast-store';
import type { InstalledTheme } from '@/lib/plugin-types';
import { usePolicyStore } from '@/stores/policy-store';
export function ThemesSettings() {
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isFeatureEnabled, isThemeDisabled, getThemePolicy } = usePolicyStore();
const canUpload = isFeatureEnabled('userThemesEnabled');
const themePolicy = getThemePolicy();
// Filter out themes disabled by admin policy
const visibleThemes = installedThemes.filter(
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
);
// If the active theme was disabled by admin, fall back to default
useEffect(() => {
if (activeThemeId) {
const activeTheme = installedThemes.find(t => t.id === activeThemeId);
if (activeTheme && isThemeDisabled(activeThemeId, !!activeTheme.builtIn)) {
activateTheme(null);
}
}
}, [activeThemeId, installedThemes, isThemeDisabled, activateTheme]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -59,11 +78,12 @@ export function ThemesSettings() {
author="Bulwark"
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
onActivate={() => handleActivate(null)}
/>
{/* Installed themes */}
{installedThemes.map(theme => (
{visibleThemes.map(theme => (
<ThemeCard
key={theme.id}
name={theme.name}
@@ -71,6 +91,7 @@ export function ThemesSettings() {
preview={theme.preview}
isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn}
isDefault={themePolicy.defaultThemeId === theme.id}
variants={theme.variants}
onActivate={() => handleActivate(theme.id)}
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
@@ -79,6 +100,7 @@ export function ThemesSettings() {
</div>
{/* Upload */}
{canUpload && (
<SettingItem label="Upload Theme" description="Install a custom theme from a .zip file containing manifest.json and theme.css">
<input
ref={fileInputRef}
@@ -98,6 +120,7 @@ export function ThemesSettings() {
{isUploading ? 'Installing...' : 'Upload .zip'}
</Button>
</SettingItem>
)}
</SettingsSection>
);
}
@@ -110,12 +133,13 @@ interface ThemeCardProps {
preview?: string;
isActive: boolean;
isBuiltIn: boolean;
isDefault?: boolean;
variants?: ('light' | 'dark')[];
onActivate: () => void;
onRemove?: () => void;
}
function ThemeCard({ name, author, preview, isActive, variants, onActivate, onRemove }: ThemeCardProps) {
function ThemeCard({ name, author, preview, isActive, isDefault, variants, onActivate, onRemove }: ThemeCardProps) {
return (
<button
onClick={onActivate}
@@ -139,7 +163,12 @@ function ThemeCard({ name, author, preview, isActive, variants, onActivate, onRe
<div className="w-full">
<div className="flex items-center justify-between gap-1">
<span className="text-sm font-medium text-foreground truncate">{name}</span>
{isActive && <Check className="w-4 h-4 text-primary flex-shrink-0" />}
<div className="flex items-center gap-1 flex-shrink-0">
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (