feat: fix: enforce admin theme locks and repair theme ZIP bundles

This commit is contained in:
Linus Rath
2026-03-27 19:42:30 +01:00
parent f084b484b7
commit b5d471e9c8
9 changed files with 328 additions and 55 deletions
+53 -21
View File
@@ -14,24 +14,31 @@ export function ThemesSettings() {
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isFeatureEnabled, isThemeDisabled, getThemePolicy, isThemeForceEnabled } = usePolicyStore();
const { isFeatureEnabled, isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore();
const canUpload = isFeatureEnabled('userThemesEnabled');
const themePolicy = getThemePolicy();
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
// Filter out themes disabled by admin policy
const visibleThemes = installedThemes.filter(
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
);
useEffect(() => {
if (forcedThemeId && activeThemeId !== forcedThemeId) {
activateTheme(forcedThemeId);
}
}, [activeThemeId, activateTheme, forcedThemeId]);
// 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);
activateTheme(forcedThemeId ?? null);
}
}
}, [activeThemeId, installedThemes, isThemeDisabled, activateTheme]);
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -58,12 +65,23 @@ export function ThemesSettings() {
};
const handleActivate = (id: string | null) => {
if (forcedThemeId && id !== forcedThemeId) {
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`);
return;
}
activateTheme(id);
toast.success(id ? 'Theme activated' : 'Default theme restored');
};
const handleUninstall = (theme: InstalledTheme) => {
if (theme.builtIn) return;
if (theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id)) {
toast.info(`Theme "${theme.name}" is forced by admin and cannot be removed`);
return;
}
uninstallTheme(theme.id);
toast.success('Theme removed');
};
@@ -71,6 +89,12 @@ export function ThemesSettings() {
return (
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets." experimental experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates. Built-in presets are stable, but uploaded themes may require updates after application upgrades.">
{forcedThemeId && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
Theme selection is locked by an administrator.
</div>
)}
{/* Theme Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{/* Default theme card */}
@@ -80,25 +104,30 @@ export function ThemesSettings() {
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
disabled={Boolean(forcedThemeId)}
onActivate={() => handleActivate(null)}
/>
{/* Installed themes */}
{visibleThemes.map(theme => (
<ThemeCard
key={theme.id}
name={theme.name}
author={theme.author}
preview={theme.preview}
isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn}
isDefault={themePolicy.defaultThemeId === theme.id}
isForceEnabled={isThemeForceEnabled(theme.id)}
variants={theme.variants}
onActivate={() => handleActivate(theme.id)}
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
/>
))}
{visibleThemes.map(theme => {
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
return (
<ThemeCard
key={theme.id}
name={theme.name}
author={theme.author}
preview={theme.preview}
isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn}
isDefault={themePolicy.defaultThemeId === theme.id}
isForceEnabled={isForceEnabled}
disabled={Boolean(forcedThemeId) && !isForceEnabled}
variants={theme.variants}
onActivate={() => handleActivate(theme.id)}
onRemove={!theme.builtIn && !isForceEnabled ? () => handleUninstall(theme) : undefined}
/>
);
})}
</div>
{/* Upload */}
@@ -137,20 +166,23 @@ interface ThemeCardProps {
isBuiltIn: boolean;
isDefault?: boolean;
isForceEnabled?: boolean;
disabled?: boolean;
variants?: ('light' | 'dark')[];
onActivate: () => void;
onRemove?: () => void;
}
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, variants, onActivate, onRemove }: ThemeCardProps) {
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
return (
<button
onClick={onActivate}
disabled={disabled}
className={cn(
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full',
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card'
: 'border-border hover:border-primary/40 bg-card',
disabled && !isActive && 'hover:border-border'
)}
>
{/* Preview / Placeholder */}