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:
@@ -182,8 +182,8 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ScrollText,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
@@ -23,6 +25,8 @@ const NAV_ITEMS = [
|
||||
{ href: '/admin/branding', label: 'Branding', icon: Palette },
|
||||
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
|
||||
{ href: '/admin/policy', label: 'Policy', icon: Scale },
|
||||
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
|
||||
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
|
||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||
];
|
||||
|
||||
|
||||
+71
-4
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Server, AlertTriangle, Clock, Globe } from 'lucide-react';
|
||||
import { Server, AlertTriangle, Clock, Globe, Package, Palette, Shield, Activity } from 'lucide-react';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
|
||||
interface AdminStatus {
|
||||
@@ -26,17 +26,24 @@ export default function AdminDashboardPage() {
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [themeCount, setThemeCount] = useState(0);
|
||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
async function fetchDashboardData() {
|
||||
const [statusRes, auditRes, configRes, adminConfigRes] = await Promise.all([
|
||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||
fetch('/api/admin/auth'),
|
||||
fetch('/api/admin/audit?limit=10'),
|
||||
fetch('/api/config'),
|
||||
fetch('/api/admin/config'),
|
||||
fetch('/api/admin/plugins').catch(() => null),
|
||||
fetch('/api/admin/themes').catch(() => null),
|
||||
fetch('/api/admin/policy').catch(() => null),
|
||||
]);
|
||||
|
||||
if (statusRes.ok) setStatus(await statusRes.json());
|
||||
@@ -44,7 +51,37 @@ export default function AdminDashboardPage() {
|
||||
const data = await auditRes.json();
|
||||
setRecentActivity(data.entries || []);
|
||||
}
|
||||
if (configRes.ok) setConfig(await configRes.json());
|
||||
let configData: ConfigData | null = null;
|
||||
if (configRes.ok) {
|
||||
configData = await configRes.json();
|
||||
setConfig(configData);
|
||||
}
|
||||
|
||||
// Plugin/theme/policy stats
|
||||
if (pluginRes?.ok) {
|
||||
const plugins = await pluginRes.json();
|
||||
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
||||
}
|
||||
if (themeRes?.ok) {
|
||||
const themes = await themeRes.json();
|
||||
setThemeCount(Array.isArray(themes) ? themes.length : 0);
|
||||
}
|
||||
if (policyRes?.ok) {
|
||||
const policy = await policyRes.json();
|
||||
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
|
||||
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||
}
|
||||
|
||||
// JMAP health check
|
||||
if (configData?.jmapServerUrl) {
|
||||
try {
|
||||
const jmapRes = await fetch('/api/config');
|
||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||
} catch {
|
||||
setJmapHealth('error');
|
||||
}
|
||||
}
|
||||
|
||||
// Build warnings
|
||||
const w: string[] = [];
|
||||
@@ -55,6 +92,10 @@ export default function AdminDashboardPage() {
|
||||
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||
}
|
||||
const adminPassword = sources?.adminPassword;
|
||||
if (adminPassword?.value && adminPassword.source === 'env') {
|
||||
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
||||
}
|
||||
}
|
||||
setWarnings(w);
|
||||
}
|
||||
@@ -78,7 +119,7 @@ export default function AdminDashboardPage() {
|
||||
<StatusCard
|
||||
icon={<Globe className="w-4 h-4" />}
|
||||
label="JMAP Server"
|
||||
value={jmapUrl ? new URL(jmapUrl).hostname : '—'}
|
||||
value={jmapUrl ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—'}
|
||||
detail={jmapUrl}
|
||||
/>
|
||||
<StatusCard
|
||||
@@ -96,6 +137,19 @@ export default function AdminDashboardPage() {
|
||||
<FeaturePill label="Stalwart" active={config?.stalwartFeaturesEnabled !== false} />
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard icon={<Package className="w-4 h-4" />} label="Plugins" value={pluginCount} />
|
||||
<StatCard icon={<Palette className="w-4 h-4" />} label="Themes" value={themeCount} />
|
||||
<StatCard icon={<Shield className="w-4 h-4" />} label="Policy Rules" value={policyRuleCount} />
|
||||
<StatCard
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="JMAP Health"
|
||||
value={jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : '—'}
|
||||
status={jmapHealth === 'ok' ? 'success' : jmapHealth === 'error' ? 'error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-4">
|
||||
@@ -173,6 +227,19 @@ function FeaturePill({ label, active }: { label: string; active: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, status }: { icon: React.ReactNode; label: string; value: string | number; status?: 'success' | 'error' }) {
|
||||
const statusColor = status === 'success' ? 'text-green-600 dark:text-green-400' : status === 'error' ? 'text-red-600 dark:text-red-400' : 'text-foreground';
|
||||
return (
|
||||
<div className="border border-border rounded-md px-3 py-2 bg-secondary/20">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
{icon}
|
||||
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${statusColor}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '';
|
||||
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Upload, Trash2, Power, AlertTriangle, Loader2, Package, Save, Shield } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
interface PluginEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
permissions: string[];
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function AdminPluginsPage() {
|
||||
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
|
||||
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function togglePluginsEnabled() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
||||
setPolicyDirty(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlugins() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/plugins');
|
||||
if (res.ok) setPlugins(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||
await fetchPlugins();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Upload failed' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlugin(id: string, name: string) {
|
||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
disabled={savingPolicy}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Policy
|
||||
</button>
|
||||
)}
|
||||
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload Plugin
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin Policy */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Plugins Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
||||
</div>
|
||||
<button onClick={togglePluginsEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployed Plugins */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
||||
</div>
|
||||
{plugins.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{plugins.map(plugin => (
|
||||
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
{plugin.permissions.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-500" />
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Permissions: {plugin.permissions.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+22
-10
@@ -5,9 +5,11 @@ import { Save, Loader2, Lock } from 'lucide-react';
|
||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
const FEATURE_GATE_LABELS: Record<keyof FeatureGates, { label: string; description: string }> = {
|
||||
// Feature gates managed on their own admin pages (excluded from this list)
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
userThemesEnabled: { label: 'User Themes', description: 'Allow user-uploaded theme files' },
|
||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
@@ -47,9 +49,15 @@ export default function AdminPolicyPage() {
|
||||
|
||||
async function fetchPolicy() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/policy');
|
||||
if (res.ok) setPolicy(await res.json());
|
||||
setLoading(false);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFeature(key: keyof FeatureGates) {
|
||||
@@ -145,11 +153,15 @@ export default function AdminPolicyPage() {
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]).map(key => {
|
||||
const { label, description } = FEATURE_GATE_LABELS[key];
|
||||
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
|
||||
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
|
||||
.map(key => {
|
||||
const meta = FEATURE_GATE_LABELS[key];
|
||||
if (!meta) return null;
|
||||
const { label, description } = meta;
|
||||
const enabled = policy.features[key];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
@@ -158,8 +170,8 @@ export default function AdminPolicyPage() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleFeature(key)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -197,9 +197,9 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Upload, Trash2, Power, Loader2, Palette, Save, Shield } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, DEFAULT_FEATURE_GATES } from '@/lib/admin/types';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||
];
|
||||
|
||||
interface ThemeEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
variants: string[];
|
||||
enabled: boolean;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function AdminThemesPage() {
|
||||
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
|
||||
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy({
|
||||
...data,
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function toggleThemesEnabled() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleUserThemeUploads() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleBuiltinTheme(themeId: string) {
|
||||
setPolicy(prev => {
|
||||
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
|
||||
const isDisabled = disabled.includes(themeId);
|
||||
return {
|
||||
...prev,
|
||||
themePolicy: {
|
||||
...DEFAULT_THEME_POLICY,
|
||||
...prev.themePolicy,
|
||||
disabledBuiltinThemes: isDisabled
|
||||
? disabled.filter((id: string) => id !== themeId)
|
||||
: [...disabled, themeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleAdminTheme(themeId: string) {
|
||||
setPolicy(prev => {
|
||||
const disabled = prev.themePolicy?.disabledThemes || [];
|
||||
const isDisabled = disabled.includes(themeId);
|
||||
return {
|
||||
...prev,
|
||||
themePolicy: {
|
||||
...DEFAULT_THEME_POLICY,
|
||||
...prev.themePolicy,
|
||||
disabledThemes: isDisabled
|
||||
? disabled.filter((id: string) => id !== themeId)
|
||||
: [...disabled, themeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setDefaultTheme(themeId: string | null) {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
themePolicy: {
|
||||
...DEFAULT_THEME_POLICY,
|
||||
...prev.themePolicy,
|
||||
defaultThemeId: themeId,
|
||||
},
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
|
||||
setPolicyDirty(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchThemes() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes');
|
||||
if (res.ok) setThemes(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
|
||||
await fetchThemes();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Upload failed' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTheme(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTheme(id: string, name: string) {
|
||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setThemes(prev => prev.filter(t => t.id !== id));
|
||||
setMessage({ type: 'success', text: `Theme "${name}" removed` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
disabled={savingPolicy}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Policy
|
||||
</button>
|
||||
)}
|
||||
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload Theme
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Policy */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* Master toggle */}
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Themes Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
||||
</div>
|
||||
<button onClick={toggleThemesEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User uploads toggle */}
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">User Theme Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
||||
</div>
|
||||
<button onClick={toggleUserThemeUploads}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Default Theme */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Default Theme</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
||||
</div>
|
||||
<select
|
||||
value={policy.themePolicy?.defaultThemeId || ''}
|
||||
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
||||
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
<optgroup label="Built-in">
|
||||
{BUILTIN_THEME_OPTIONS
|
||||
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{themes.length > 0 && (
|
||||
<optgroup label="Admin-deployed">
|
||||
{themes
|
||||
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Built-in themes */}
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{BUILTIN_THEME_OPTIONS.map(theme => {
|
||||
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
|
||||
return (
|
||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{theme.name}</span>
|
||||
<button onClick={() => toggleBuiltinTheme(theme.id)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin-deployed themes */}
|
||||
{themes.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{themes.map(theme => {
|
||||
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
|
||||
return (
|
||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{theme.name}</span>
|
||||
<button onClick={() => toggleAdminTheme(theme.id)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployed Themes */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
|
||||
</div>
|
||||
{themes.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No themes installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{themes.map(theme => (
|
||||
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{theme.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{theme.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleTheme(theme.id, !theme.enabled)}
|
||||
title={theme.enabled ? 'Disable' : 'Enable'}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTheme(theme.id, theme.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user