feat: move configuration from .env to DB with Admin UI management

Replace hardcoded .env configuration with database-backed settings
manageable through the Admin web interface. This reduces .env to
bootstrap-only variables (DB, Keycloak, encryption keys).

Backend:
- Add SystemSetting Prisma model with category, valueType, isSecret
- Add system-settings NestJS module (CRUD, 60s cache, encryption)
- Refactor all 7 connectors to lazy-load credentials from DB via
  CredentialsService.findActiveByType() instead of ConfigService
- Add event-driven credential reload (@nestjs/event-emitter)
- Dynamic CORS origins and conditional Swagger from DB settings
- Fix JWT strategy: use Keycloak JWKS (RS256) instead of symmetric secret
- Add SYSTEM_SETTINGS_VIEW/MANAGE permissions
- Seed 13 default settings (sync intervals, features, branding, CORS)
- Add env-to-db migration script (prisma/migrate-env-to-db.ts)

Frontend:
- Add use-credentials hook (full CRUD for integration credentials)
- Add use-system-settings hook (read/update system settings)
- Wire admin-integrations page to real API (create/update/test/toggle)
- Add admin system-settings page with 4 tabs (Branding, CORS, Sync, Features)
- Fix sidebar double-highlighting with exactMatch flag
- Fix integration detail fallback when API unavailable
- Fix API client to unwrap backend's {success, data} envelope
- Update NEXT_PUBLIC_API_URL to include /v1 version prefix
- Fix activity-widget hydration error
- Add i18n keys for systemSettings (de + en)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:07:39 +01:00
parent 068446fbbf
commit 6a8265d3dc
46 changed files with 2972 additions and 1149 deletions

View File

@@ -77,7 +77,8 @@
"departments": "Abteilungen",
"overview": "Uebersicht",
"plentyOne": "PlentyONE",
"zulip": "ZULIP"
"zulip": "ZULIP",
"systemSettings": "Einstellungen"
},
"dashboard": {
"title": "Dashboard",
@@ -1048,5 +1049,29 @@
"minutes": "Minuten",
"seconds": "Sekunden",
"days": "Tage"
},
"systemSettings": {
"title": "Systemeinstellungen",
"description": "Globale Anwendungskonfiguration verwalten",
"general": "Allgemein",
"cors": "CORS",
"sync": "Synchronisation",
"features": "Features",
"branding": "Branding",
"appName": "Anwendungsname",
"companyName": "Firmenname",
"logoUrl": "Logo-URL",
"corsOrigins": "Erlaubte Origins",
"corsOriginsDesc": "Kommagetrennte Liste von erlaubten Origins fuer Cross-Origin-Anfragen",
"syncInterval": "Sync-Intervall",
"minutes": "Minuten",
"enableSyncJobs": "Hintergrund-Sync-Jobs",
"enableSyncJobsDesc": "Automatische Synchronisation der Integrationen im Hintergrund",
"enableSwagger": "Swagger API-Dokumentation",
"enableSwaggerDesc": "Interaktive API-Dokumentation unter /api/docs verfuegbar (Neustart erforderlich)",
"saved": "Einstellungen gespeichert",
"saveError": "Fehler beim Speichern der Einstellungen",
"requiresRestart": "Aenderung erfordert einen Neustart des Backends",
"save": "Speichern"
}
}

View File

@@ -77,7 +77,8 @@
"departments": "Departments",
"overview": "Overview",
"plentyOne": "PlentyONE",
"zulip": "ZULIP"
"zulip": "ZULIP",
"systemSettings": "Settings"
},
"dashboard": {
"title": "Dashboard",
@@ -1048,5 +1049,29 @@
"minutes": "minutes",
"seconds": "seconds",
"days": "days"
},
"systemSettings": {
"title": "System Settings",
"description": "Manage global application configuration",
"general": "General",
"cors": "CORS",
"sync": "Synchronization",
"features": "Features",
"branding": "Branding",
"appName": "Application Name",
"companyName": "Company Name",
"logoUrl": "Logo URL",
"corsOrigins": "Allowed Origins",
"corsOriginsDesc": "Comma-separated list of allowed origins for cross-origin requests",
"syncInterval": "Sync Interval",
"minutes": "minutes",
"enableSyncJobs": "Background Sync Jobs",
"enableSyncJobsDesc": "Automatic synchronization of integrations in the background",
"enableSwagger": "Swagger API Documentation",
"enableSwaggerDesc": "Interactive API documentation available at /api/docs (restart required)",
"saved": "Settings saved",
"saveError": "Failed to save settings",
"requiresRestart": "Change requires a backend restart",
"save": "Save"
}
}

View File

@@ -1,10 +1,9 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import {
Plug,
Shield,
Building2,
MessageSquare,
@@ -16,6 +15,7 @@ import {
Settings,
Eye,
EyeOff,
Loader2,
type LucideIcon,
} from 'lucide-react';
@@ -41,8 +41,16 @@ import {
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IntegrationStatusBadge, ConnectionTestButton } from '@/components/integrations';
import { IntegrationStatusBadge } from '@/components/integrations';
import { useAllIntegrationStatuses } from '@/hooks/integrations';
import {
useCredentials,
useCredentialDetail,
useCreateCredential,
useUpdateCredential,
useTestCredentialConnection,
} from '@/hooks/integrations/use-credentials';
import { useUpdateSetting } from '@/hooks/use-system-settings';
import { useToast } from '@/hooks/use-toast';
import type { IntegrationType } from '@/types/integrations';
@@ -50,7 +58,11 @@ interface AdminIntegrationsContentProps {
locale: string;
}
/** Integration metadata */
// ============================================================
// Static configuration
// ============================================================
/** Display metadata per integration */
const integrationMeta: Record<
IntegrationType,
{ icon: LucideIcon; nameKey: string; descKey: string }
@@ -64,77 +76,394 @@ const integrationMeta: Record<
gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' },
};
/** Credential field configuration per integration */
const credentialFields: Record<IntegrationType, { key: string; labelKey: string; type: 'text' | 'password' | 'url' }[]> = {
/** Credential field definitions per integration.
* Keys must match what the backend validates (credentials.service.ts). */
const credentialFields: Record<
IntegrationType,
Array<{ key: string; label: string; type: 'text' | 'password' | 'url'; placeholder?: string }>
> = {
plentyone: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'username', labelKey: 'username', type: 'text' },
{ key: 'password', labelKey: 'password', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://your-shop.plentymarkets-cloud01.com' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
zulip: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'email', labelKey: 'username', type: 'text' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'zulipUrl', label: 'Server URL', type: 'url', placeholder: 'https://your-org.zulipchat.com' },
{ key: 'botEmail', label: 'Bot E-Mail', type: 'text' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
todoist: [
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiToken', label: 'API Token', type: 'password' },
],
freescout: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://helpdesk.example.com' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
nextcloud: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'username', labelKey: 'username', type: 'text' },
{ key: 'password', labelKey: 'password', type: 'password' },
{ key: 'serverUrl', label: 'Server URL', type: 'url', placeholder: 'https://cloud.example.com' },
{ key: 'username', label: 'Benutzername', type: 'text' },
{ key: 'password', label: 'Passwort', type: 'password' },
],
ecodms: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://ecodms.example.com' },
{ key: 'username', label: 'Benutzername', type: 'text' },
{ key: 'password', label: 'Passwort', type: 'password' },
],
gembadocs: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://gembadocs.example.com' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
};
// ============================================================
// Sub-component: single integration tab panel
// ============================================================
interface IntegrationPanelProps {
integrationType: IntegrationType;
}
function IntegrationPanel({ integrationType }: IntegrationPanelProps) {
const t = useTranslations('integrations');
const { toast } = useToast();
const meta = integrationMeta[integrationType];
const Icon = meta.icon;
const fields = credentialFields[integrationType];
// ---- API queries / mutations ----
const { data: allCredentials } = useCredentials();
const createCredential = useCreateCredential();
const updateCredential = useUpdateCredential();
const testConnection = useTestCredentialConnection();
const updateSetting = useUpdateSetting();
// Find the saved credential for this integration type (UPPERCASE match)
const savedCredential = allCredentials?.data.find(
(c) => c.type === integrationType.toUpperCase(),
) ?? null;
// Fetch decrypted values only when a credential exists
const { data: credentialDetail, isLoading: isLoadingDetail } =
useCredentialDetail(savedCredential?.id ?? null);
// ---- Local UI state ----
const [formData, setFormData] = useState<Record<string, string>>({});
const [isEnabled, setIsEnabled] = useState<boolean>(
savedCredential?.isActive ?? false,
);
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>({});
const [syncInterval, setSyncInterval] = useState<string>('15');
// Pre-fill form fields whenever decrypted credential values arrive
useEffect(() => {
if (credentialDetail?.credentials) {
setFormData(
Object.fromEntries(
Object.entries(credentialDetail.credentials).map(([k, v]) => [k, String(v)]),
),
);
}
}, [credentialDetail]);
// Keep enabled toggle in sync with stored value
useEffect(() => {
if (savedCredential !== null) {
setIsEnabled(savedCredential.isActive);
}
}, [savedCredential]);
// ---- Handlers ----
const handleFieldChange = (key: string, value: string) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const togglePassword = (fieldId: string) => {
setVisiblePasswords((prev) => ({ ...prev, [fieldId]: !prev[fieldId] }));
};
const handleSave = async () => {
try {
const integrationName = t(meta.nameKey as never);
if (!savedCredential) {
// Create new credential
await createCredential.mutateAsync({
type: integrationType.toUpperCase(),
name: 'Default',
credentials: formData,
});
} else {
// Update existing credential
await updateCredential.mutateAsync({
id: savedCredential.id,
credentials: formData,
});
}
toast({
title: t('saveSettings'),
description: t('settingsSaved' as never, { name: integrationName }),
});
} catch (error) {
toast({
title: 'Fehler beim Speichern',
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
variant: 'destructive',
});
}
};
const handleToggleEnabled = async (checked: boolean) => {
setIsEnabled(checked);
if (!savedCredential) return; // Nothing to update yet
try {
await updateCredential.mutateAsync({
id: savedCredential.id,
isActive: checked,
});
toast({
title: checked ? 'Integration aktiviert' : 'Integration deaktiviert',
description: `${t(meta.nameKey as never)} wurde ${checked ? 'aktiviert' : 'deaktiviert'}.`,
});
} catch (error) {
// Revert on failure
setIsEnabled(!checked);
toast({
title: 'Fehler',
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
variant: 'destructive',
});
}
};
const handleTestConnection = async () => {
if (!savedCredential) return;
try {
const result = await testConnection.mutateAsync(savedCredential.id);
toast({
title: result.success ? t('testSuccess') : t('testFailed'),
description: result.message,
variant: result.success ? 'default' : 'destructive',
});
} catch (error) {
toast({
title: t('testFailed'),
description: error instanceof Error ? error.message : t('testError'),
variant: 'destructive',
});
}
};
const handleSyncIntervalChange = async (value: string) => {
setSyncInterval(value);
try {
await updateSetting.mutateAsync({
key: `sync.interval.${integrationType}`,
value,
});
} catch {
// Non-critical; sync interval is a best-effort setting
}
};
// ---- Loading skeleton while fetching decrypted values ----
if (savedCredential && isLoadingDetail) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{fields.map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
const isSaving =
createCredential.isPending || updateCredential.isPending;
const isTesting = testConnection.isPending;
const hasCredential = !!savedCredential;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
{/* Left: icon + title */}
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
isEnabled
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground',
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="flex items-center gap-2">
{t(meta.nameKey as never)}
{/* Show status badge only when a credential is saved */}
{hasCredential && (
<IntegrationStatusBadge
status={
savedCredential.syncStatus === 'SUCCESS'
? 'connected'
: savedCredential.syncStatus === 'ERROR'
? 'error'
: 'disconnected'
}
/>
)}
</CardTitle>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
</div>
{/* Right: enable/disable toggle */}
<div className="flex items-center gap-2">
<Label htmlFor={`enable-${integrationType}`}>
{isEnabled ? t('disable') : t('enable')}
</Label>
<Switch
id={`enable-${integrationType}`}
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Credentials section */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('credentials')}</h3>
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => {
const fieldId = `${integrationType}-${field.key}`;
const isPassword = field.type === 'password';
const isVisible = visiblePasswords[fieldId];
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={fieldId}>{field.label}</Label>
<div className="relative">
<Input
id={fieldId}
type={isPassword && !isVisible ? 'password' : 'text'}
placeholder={field.placeholder ?? (isPassword ? '••••••••' : '')}
value={formData[field.key] ?? ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
className={cn(isPassword && 'pr-10')}
/>
{isPassword && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => togglePassword(fieldId)}
>
{isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Sync settings section */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('synchronization' as never)}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`sync-interval-${integrationType}`}>
{t('syncInterval')}
</Label>
<Select value={syncInterval} onValueChange={handleSyncIntervalChange}>
<SelectTrigger id={`sync-interval-${integrationType}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 {t('minutes')}</SelectItem>
<SelectItem value="5">5 {t('minutes')}</SelectItem>
<SelectItem value="15">15 {t('minutes')}</SelectItem>
<SelectItem value="30">30 {t('minutes')}</SelectItem>
<SelectItem value="60">60 {t('minutes')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between border-t pt-6">
{/* Connection test button */}
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={!hasCredential || isTesting}
>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('testing')}
</>
) : (
t('test')
)}
</Button>
{/* Save button */}
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Speichern...
</>
) : (
<>
<Settings className="mr-2 h-4 w-4" />
{t('saveSettings')}
</>
)}
</Button>
</CardFooter>
</Card>
);
}
// ============================================================
// Main page component
// ============================================================
/**
* Admin integrations management content
* Admin integrations management page.
* Lists each integration as a tab; each tab loads its own credential
* state so queries are isolated and only triggered when the tab is visited.
*/
export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentProps) {
export function AdminIntegrationsContent({ locale: _locale }: AdminIntegrationsContentProps) {
const t = useTranslations('integrations');
const tAdmin = useTranslations('admin');
const { toast } = useToast();
const { data: integrations, isLoading } = useAllIntegrationStatuses();
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>({});
const [enabledState, setEnabledState] = useState<Record<string, boolean>>({});
const [formData, setFormData] = useState<Record<string, Record<string, string>>>({});
const togglePasswordVisibility = (fieldKey: string) => {
setVisiblePasswords((prev) => ({
...prev,
[fieldKey]: !prev[fieldKey],
}));
};
const handleFieldChange = (integrationType: IntegrationType, fieldKey: string, value: string) => {
setFormData((prev) => ({
...prev,
[integrationType]: {
...prev[integrationType],
[fieldKey]: value,
},
}));
};
const handleSave = (integrationType: IntegrationType) => {
toast({
title: t('saveSettings'),
description: t('settingsSaved' as never, { name: t(integrationMeta[integrationType].nameKey as never) }),
});
};
return (
<div className="container mx-auto space-y-8 py-6">
{/* Header */}
@@ -143,14 +472,16 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
<Shield className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{tAdmin('integrationManagement')}</h1>
<h1 className="text-3xl font-bold tracking-tight">
{tAdmin('integrationManagement')}
</h1>
<p className="text-muted-foreground">
{tAdmin('integrationManagementDesc')}
</p>
</div>
</div>
{/* Integration Tabs */}
{/* Loading state */}
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
@@ -184,139 +515,17 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
})}
</TabsList>
{integrations?.map((config) => {
const meta = integrationMeta[config.type];
const Icon = meta.icon;
const fields = credentialFields[config.type];
return (
<TabsContent key={config.type} value={config.type}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
(enabledState[config.type] ?? config.enabled) ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="flex items-center gap-2">
{t(meta.nameKey as never)}
<IntegrationStatusBadge status={config.status} />
</CardTitle>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor={`enable-${config.type}`}>
{(enabledState[config.type] ?? config.enabled) ? t('disable') : t('enable')}
</Label>
<Switch
id={`enable-${config.type}`}
checked={enabledState[config.type] ?? config.enabled}
onCheckedChange={(checked) =>
setEnabledState((prev) => ({ ...prev, [config.type]: checked }))
}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Credentials */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('credentials')}</h3>
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => {
const fieldId = `${config.type}-${field.key}`;
const isPassword = field.type === 'password';
const isVisible = visiblePasswords[fieldId];
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={fieldId}>{t(field.labelKey as never)}</Label>
<div className="relative">
<Input
id={fieldId}
type={isPassword && !isVisible ? 'password' : 'text'}
placeholder={isPassword ? '********' : ''}
value={formData[config.type]?.[field.key] ?? ''}
onChange={(e) =>
handleFieldChange(config.type, field.key, e.target.value)
}
className={cn(isPassword && 'pr-10')}
/>
{isPassword && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => togglePasswordVisibility(fieldId)}
>
{isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Sync Settings */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('synchronization' as never)}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`sync-interval-${config.type}`}>
{t('syncInterval')}
</Label>
<Select defaultValue={config.syncInterval.toString()}>
<SelectTrigger id={`sync-interval-${config.type}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 {t('minutes')}</SelectItem>
<SelectItem value="5">5 {t('minutes')}</SelectItem>
<SelectItem value="15">15 {t('minutes')}</SelectItem>
<SelectItem value="30">30 {t('minutes')}</SelectItem>
<SelectItem value="60">60 {t('minutes')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between border-t pt-6">
<ConnectionTestButton integrationType={config.type} />
<Button onClick={() => handleSave(config.type)}>
<Settings className="mr-2 h-4 w-4" />
{t('saveSettings')}
</Button>
</CardFooter>
</Card>
</motion.div>
</TabsContent>
);
})}
{integrations?.map((config) => (
<TabsContent key={config.type} value={config.type}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<IntegrationPanel integrationType={config.type} />
</motion.div>
</TabsContent>
))}
</Tabs>
)}
</div>

View File

@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import { SystemSettingsContent } from './system-settings-content';
export const metadata: Metadata = {
title: 'Systemeinstellungen | Admin | tOS',
description: 'Globale Anwendungskonfiguration verwalten',
};
interface SystemSettingsPageProps {
params: {
locale: string;
};
}
/**
* Admin page for managing global system settings
*/
export default function SystemSettingsPage({ params }: SystemSettingsPageProps) {
return <SystemSettingsContent locale={params.locale} />;
}

View File

@@ -0,0 +1,470 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import {
Settings,
Globe,
RefreshCw,
Zap,
Building2,
Save,
AlertTriangle,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import {
useSystemSettings,
useUpdateSetting,
useBulkUpdateSettings,
type SystemSetting,
} from '@/hooks/use-system-settings';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
interface SystemSettingsContentProps {
locale: string;
}
/** Integration types that appear in sync settings */
const INTEGRATION_TYPES = [
'plentyone',
'zulip',
'todoist',
'freescout',
'nextcloud',
'ecodms',
'gembadocs',
] as const;
const SYNC_INTERVAL_OPTIONS = ['1', '5', '10', '15', '30', '60'] as const;
// ---------------------------------------------------------------------------
// Helper: build a lookup map from the flat settings array
// ---------------------------------------------------------------------------
function buildSettingsMap(settings: SystemSetting[]): Record<string, string> {
return settings.reduce<Record<string, string>>((acc, s) => {
acc[s.key] = s.value;
return acc;
}, {});
}
// ---------------------------------------------------------------------------
// Loading skeleton
// ---------------------------------------------------------------------------
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-96" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</CardHeader>
<CardContent className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
/**
* Admin page for managing global system settings.
* Groups settings by category into tabs: Branding, CORS, Sync, Features.
*/
export function SystemSettingsContent({ locale: _locale }: SystemSettingsContentProps) {
const t = useTranslations('systemSettings');
const { toast } = useToast();
const { data, isLoading } = useSystemSettings();
const updateSetting = useUpdateSetting();
const bulkUpdate = useBulkUpdateSettings();
// Local form state keyed by setting key
const [formValues, setFormValues] = useState<Record<string, string>>({});
// Populate form values once data is loaded
useEffect(() => {
if (data?.settings) {
setFormValues(buildSettingsMap(data.settings));
}
}, [data]);
// -------------------------------------------------------------------------
// Generic helpers
// -------------------------------------------------------------------------
function handleChange(key: string, value: string) {
setFormValues((prev) => ({ ...prev, [key]: value }));
}
async function handleBulkSave(keys: string[]) {
const settings = keys.map((key) => ({ key, value: formValues[key] ?? '' }));
try {
await bulkUpdate.mutateAsync({ settings });
toast({ title: t('saved') });
} catch {
toast({ title: t('saveError'), variant: 'destructive' });
}
}
async function handleSingleToggle(key: string, checked: boolean) {
const value = checked ? 'true' : 'false';
handleChange(key, value);
try {
await updateSetting.mutateAsync({ key, value });
toast({ title: t('saved') });
} catch {
toast({ title: t('saveError'), variant: 'destructive' });
}
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div className="container mx-auto space-y-8 py-6">
{/* Page header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-3"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Settings className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">{t('description')}</p>
</div>
</motion.div>
{/* Content */}
{isLoading ? (
<SettingsSkeleton />
) : (
<Tabs defaultValue="branding" className="space-y-4">
<TabsList>
<TabsTrigger value="branding" className="gap-2">
<Building2 className="h-4 w-4" />
{t('branding')}
</TabsTrigger>
<TabsTrigger value="cors" className="gap-2">
<Globe className="h-4 w-4" />
{t('cors')}
</TabsTrigger>
<TabsTrigger value="sync" className="gap-2">
<RefreshCw className="h-4 w-4" />
{t('sync')}
</TabsTrigger>
<TabsTrigger value="features" className="gap-2">
<Zap className="h-4 w-4" />
{t('features')}
</TabsTrigger>
</TabsList>
{/* ---------------------------------------------------------------- */}
{/* Branding tab */}
{/* ---------------------------------------------------------------- */}
<TabsContent value="branding">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('branding')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
{/* App name */}
<div className="space-y-2">
<Label htmlFor="branding-appName">{t('appName')}</Label>
<Input
id="branding-appName"
value={formValues['branding.appName'] ?? ''}
onChange={(e) => handleChange('branding.appName', e.target.value)}
placeholder="tOS"
/>
</div>
{/* Company name */}
<div className="space-y-2">
<Label htmlFor="branding-companyName">{t('companyName')}</Label>
<Input
id="branding-companyName"
value={formValues['branding.companyName'] ?? ''}
onChange={(e) => handleChange('branding.companyName', e.target.value)}
placeholder="Mein Unternehmen GmbH"
/>
</div>
{/* Logo URL */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="branding-logoUrl">{t('logoUrl')}</Label>
<Input
id="branding-logoUrl"
type="url"
value={formValues['branding.logoUrl'] ?? ''}
onChange={(e) => handleChange('branding.logoUrl', e.target.value)}
placeholder="https://example.com/logo.png"
/>
</div>
</div>
<Separator />
<div className="flex justify-end">
<Button
onClick={() =>
handleBulkSave([
'branding.appName',
'branding.companyName',
'branding.logoUrl',
])
}
disabled={bulkUpdate.isPending}
>
<Save className="mr-2 h-4 w-4" />
{t('save')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</TabsContent>
{/* ---------------------------------------------------------------- */}
{/* CORS tab */}
{/* ---------------------------------------------------------------- */}
<TabsContent value="cors">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('cors')}</CardTitle>
<CardDescription>{t('corsOriginsDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="cors-origins">{t('corsOrigins')}</Label>
<Textarea
id="cors-origins"
rows={6}
value={(formValues['cors.origins'] ?? '')
.split(',')
.map((s) => s.trim())
.join('\n')}
onChange={(e) => {
// Store as comma-separated
const joined = e.target.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.join(',');
handleChange('cors.origins', joined);
}}
placeholder="https://app.example.com&#10;http://localhost:3000"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Einen Origin pro Zeile eingeben.
</p>
</div>
{/* Restart notice */}
<div className="flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{t('requiresRestart')}</span>
</div>
<Separator />
<div className="flex justify-end">
<Button
onClick={() => handleBulkSave(['cors.origins'])}
disabled={bulkUpdate.isPending}
>
<Save className="mr-2 h-4 w-4" />
{t('save')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</TabsContent>
{/* ---------------------------------------------------------------- */}
{/* Sync tab */}
{/* ---------------------------------------------------------------- */}
<TabsContent value="sync">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('sync')}</CardTitle>
<CardDescription>{t('enableSyncJobsDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{INTEGRATION_TYPES.map((type) => {
const settingKey = `sync.interval.${type}`;
const currentValue = formValues[settingKey] ?? '15';
return (
<div key={type} className="space-y-2">
<Label htmlFor={`sync-${type}`} className="capitalize">
{type}
</Label>
<div className="flex items-center gap-2">
<Select
value={currentValue}
onValueChange={(val) => handleChange(settingKey, val)}
>
<SelectTrigger id={`sync-${type}`} className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SYNC_INTERVAL_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt} {t('minutes')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
<Separator />
<div className="flex justify-end">
<Button
onClick={() =>
handleBulkSave(
INTEGRATION_TYPES.map((type) => `sync.interval.${type}`),
)
}
disabled={bulkUpdate.isPending}
>
<Save className="mr-2 h-4 w-4" />
{t('save')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</TabsContent>
{/* ---------------------------------------------------------------- */}
{/* Features tab */}
{/* ---------------------------------------------------------------- */}
<TabsContent value="features">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('features')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Sync jobs toggle */}
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
<div className="space-y-1">
<Label htmlFor="feature-syncJobs" className="text-sm font-medium">
{t('enableSyncJobs')}
</Label>
<p className="text-sm text-muted-foreground">{t('enableSyncJobsDesc')}</p>
</div>
<Switch
id="feature-syncJobs"
checked={formValues['feature.syncJobs.enabled'] === 'true'}
onCheckedChange={(checked) =>
handleSingleToggle('feature.syncJobs.enabled', checked)
}
disabled={updateSetting.isPending}
/>
</div>
{/* Swagger toggle */}
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
<div className="space-y-1">
<Label htmlFor="feature-swagger" className="text-sm font-medium">
{t('enableSwagger')}
</Label>
<p className="text-sm text-muted-foreground">{t('enableSwaggerDesc')}</p>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-3 w-3" />
<span>{t('requiresRestart')}</span>
</div>
</div>
<Switch
id="feature-swagger"
checked={formValues['feature.swagger.enabled'] === 'true'}
onCheckedChange={(checked) =>
handleSingleToggle('feature.swagger.enabled', checked)
}
disabled={updateSetting.isPending}
/>
</div>
</CardContent>
</Card>
</motion.div>
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -80,13 +80,29 @@ export function IntegrationDetailContent({
const dateLocale = locale === 'de' ? de : enUS;
const meta = integrationMeta[integrationType];
const Icon = meta?.icon ?? Building2;
const ContentComponent = integrationContentMap[integrationType];
const handleSync = () => {
triggerSync.mutate(integrationType);
};
// Integration type not supported
if (!meta) {
return (
<div className="container mx-auto space-y-6 py-6">
<Button variant="ghost" size="sm" asChild>
<Link href={`/${locale}/integrations`}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('overview')}
</Link>
</Button>
<p className="text-muted-foreground">{t('notFound')}</p>
</div>
);
}
const Icon = meta.icon;
if (isLoading) {
return (
<div className="container mx-auto space-y-6 py-6">
@@ -105,13 +121,19 @@ export function IntegrationDetailContent({
);
}
if (!config) {
return (
<div className="container mx-auto py-6">
<p className="text-muted-foreground">Integration nicht gefunden.</p>
</div>
);
}
// Use API data or fallback to disconnected state
const displayConfig = config ?? {
id: integrationType,
type: integrationType,
name: t(meta.nameKey as never),
enabled: false,
status: 'disconnected' as const,
lastSync: undefined,
lastError: undefined,
syncInterval: 15,
createdAt: new Date(),
updatedAt: new Date(),
};
return (
<div className="container mx-auto space-y-6 py-6">
@@ -136,7 +158,7 @@ export function IntegrationDetailContent({
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-lg',
config.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
displayConfig.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
<Icon className="h-6 w-6" />
@@ -144,7 +166,7 @@ export function IntegrationDetailContent({
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl">{t(meta.nameKey as never)}</CardTitle>
<IntegrationStatusBadge status={config.status} />
<IntegrationStatusBadge status={displayConfig.status} />
</div>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
@@ -156,7 +178,7 @@ export function IntegrationDetailContent({
variant="outline"
size="sm"
onClick={handleSync}
disabled={triggerSync.isPending || config.status !== 'connected'}
disabled={triggerSync.isPending || displayConfig.status !== 'connected'}
>
<RefreshCw
className={cn('mr-2 h-4 w-4', triggerSync.isPending && 'animate-spin')}
@@ -169,9 +191,9 @@ export function IntegrationDetailContent({
<CardContent>
<SyncStatus
lastSync={config.lastSync}
lastSync={displayConfig.lastSync}
locale={locale}
error={config.lastError}
error={displayConfig.lastError}
/>
</CardContent>
</Card>
@@ -227,15 +249,15 @@ export function IntegrationDetailContent({
<div className="flex items-center justify-between border-b py-2">
<span className="text-muted-foreground">{t('syncSuccessful')}</span>
<span>
{config.lastSync
? formatDistanceToNow(config.lastSync, { addSuffix: true, locale: dateLocale })
{displayConfig.lastSync
? formatDistanceToNow(displayConfig.lastSync, { addSuffix: true, locale: dateLocale })
: '-'}
</span>
</div>
{config.lastError && (
{displayConfig.lastError && (
<div className="rounded-lg bg-destructive/10 p-3 text-destructive">
<p className="font-medium">{t('lastError')}</p>
<p>{config.lastError}</p>
<p>{displayConfig.lastError}</p>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState, useEffect } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
@@ -125,6 +126,11 @@ export function ActivityWidget({
}: ActivityWidgetProps) {
const t = useTranslations('widgets.activity');
const dateLocale = locale === 'de' ? de : enUS;
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const displayedActivities = activities.slice(0, maxItems);
@@ -196,10 +202,12 @@ export function ActivityWidget({
<p className="text-sm text-muted-foreground">{activity.description}</p>
)}
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(activity.timestamp, {
addSuffix: true,
locale: dateLocale,
})}
{mounted
? formatDistanceToNow(activity.timestamp, {
addSuffix: true,
locale: dateLocale,
})
: '\u00A0'}
</p>
</div>
</motion.div>

View File

@@ -44,6 +44,7 @@ interface NavItem {
icon: LucideIcon;
requiredRoles?: UserRole[];
children?: NavItem[];
exactMatch?: boolean;
}
/** Main navigation structure with role requirements */
@@ -115,6 +116,7 @@ const mainNavItems: NavItem[] = [
key: 'overview',
href: '/integrations',
icon: Plug,
exactMatch: true,
},
{
key: 'plentyOne',
@@ -152,6 +154,11 @@ const bottomNavItems: NavItem[] = [
href: '/admin/integrations',
icon: Plug,
},
{
key: 'systemSettings',
href: '/admin/settings',
icon: Settings,
},
],
},
{
@@ -209,14 +216,15 @@ export function Sidebar({ locale }: SidebarProps) {
const filteredMainNav = filterNavItems(mainNavItems, userRoles);
const filteredBottomNav = filterNavItems(bottomNavItems, userRoles);
const isActive = (href: string) => {
const isActive = (href: string, exactMatch?: boolean) => {
const localePath = `/${locale}${href}`;
if (exactMatch) return pathname === localePath;
return pathname === localePath || pathname.startsWith(`${localePath}/`);
};
const isParentActive = (item: NavItem) => {
if (isActive(item.href)) return true;
return item.children?.some((child) => isActive(child.href)) || false;
return item.children?.some((child) => isActive(child.href, child.exactMatch)) || false;
};
return (
@@ -307,7 +315,7 @@ interface NavItemComponentProps {
item: NavItem;
locale: string;
isExpanded: boolean;
isActive: (href: string) => boolean;
isActive: (href: string, exactMatch?: boolean) => boolean;
isParentActive: (item: NavItem) => boolean;
t: (key: string) => string;
}
@@ -321,7 +329,7 @@ function NavItemComponent({
t,
}: NavItemComponentProps) {
const Icon = item.icon;
const active = isActive(item.href);
const active = isActive(item.href, item.exactMatch);
const parentActive = isParentActive(item);
const hasChildren = item.children && item.children.length > 0;
@@ -383,7 +391,7 @@ function NavItemComponent({
href={`/${locale}${child.href}`}
className={cn(
'rounded px-2 py-1 text-sm transition-colors hover:bg-accent',
isActive(child.href) && 'bg-accent font-medium'
isActive(child.href, child.exactMatch) && 'bg-accent font-medium'
)}
>
{t(child.key)}
@@ -426,7 +434,7 @@ function NavItemComponent({
>
{item.children?.map((child) => {
const ChildIcon = child.icon;
const childActive = isActive(child.href);
const childActive = isActive(child.href, child.exactMatch);
return (
<Link

View File

@@ -1,4 +1,22 @@
// Integration hooks barrel export
export {
useCredentials,
useCredentialDetail,
useCreateCredential,
useUpdateCredential,
useDeleteCredential,
useTestCredentialConnection,
credentialKeys,
} from './use-credentials';
export type {
CredentialListItem,
CredentialDetail,
PaginatedCredentials,
CreateCredentialPayload,
UpdateCredentialPayload,
TestConnectionResult,
} from './use-credentials';
export {
useIntegrationStatus,
useAllIntegrationStatuses,

View File

@@ -0,0 +1,147 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
// ============================================================
// Types matching backend DTOs and service response shapes
// ============================================================
export interface CredentialListItem {
id: string;
type: string;
name: string;
isActive: boolean;
lastUsed: string | null;
lastSync: string | null;
syncStatus: string;
syncError: string | null;
createdAt: string;
updatedAt: string;
}
export interface CredentialDetail extends CredentialListItem {
credentials: Record<string, string>;
}
export interface PaginatedCredentials {
data: CredentialListItem[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface CreateCredentialPayload {
type: string;
name: string;
credentials: Record<string, string>;
}
export interface UpdateCredentialPayload {
name?: string;
credentials?: Record<string, string>;
isActive?: boolean;
}
export interface TestConnectionResult {
success: boolean;
message: string;
latency?: number;
}
// ============================================================
// Query key factory
// ============================================================
export const credentialKeys = {
all: ['credentials'] as const,
list: (type?: string) => [...credentialKeys.all, 'list', type] as const,
detail: (id: string) => [...credentialKeys.all, 'detail', id] as const,
};
// ============================================================
// Hooks
// ============================================================
/**
* Fetch all credentials, optionally filtered by integration type.
* Does NOT include decrypted credential secrets.
*/
export function useCredentials(type?: string) {
return useQuery({
queryKey: credentialKeys.list(type),
queryFn: () =>
api.get<PaginatedCredentials>('/integrations/credentials', {
params: type
? { type: type.toUpperCase(), limit: 100 }
: { limit: 100 },
}),
staleTime: 30_000,
});
}
/**
* Fetch a single credential by ID with decrypted secrets.
* staleTime is 0 so the decrypted values are always fresh.
*/
export function useCredentialDetail(id: string | null) {
return useQuery({
queryKey: credentialKeys.detail(id!),
queryFn: () =>
api.get<CredentialDetail>(`/integrations/credentials/${id}`),
enabled: !!id,
staleTime: 0,
});
}
/** Create a new integration credential. */
export function useCreateCredential() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCredentialPayload) =>
api.post<CredentialListItem>('/integrations/credentials', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: credentialKeys.all });
},
});
}
/** Update name, credentials payload, or isActive flag. */
export function useUpdateCredential() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: UpdateCredentialPayload & { id: string }) =>
api.put<CredentialListItem>(`/integrations/credentials/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: credentialKeys.all });
},
});
}
/** Soft-delete (deactivate) a credential. */
export function useDeleteCredential() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
api.delete<CredentialListItem>(`/integrations/credentials/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: credentialKeys.all });
},
});
}
/** Test the connection for a saved credential by its ID. */
export function useTestCredentialConnection() {
return useMutation({
mutationFn: (id: string) =>
api.post<TestConnectionResult>(
`/integrations/credentials/${id}/test`,
),
});
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SettingValueType = 'string' | 'number' | 'boolean' | 'json';
export interface SystemSetting {
key: string;
value: string;
category: string;
description: string;
valueType: SettingValueType;
isSecret: boolean;
}
export interface SystemSettingsResponse {
settings: SystemSetting[];
categories: string[];
}
export interface UpdateSettingPayload {
value: string;
}
export interface BulkUpdatePayload {
settings: { key: string; value: string }[];
}
// ---------------------------------------------------------------------------
// Query key factory
// ---------------------------------------------------------------------------
export const systemSettingsKeys = {
all: ['system-settings'] as const,
list: (category?: string) =>
[...systemSettingsKeys.all, 'list', category] as const,
detail: (key: string) => [...systemSettingsKeys.all, 'detail', key] as const,
};
// ---------------------------------------------------------------------------
// Fetch functions
// ---------------------------------------------------------------------------
async function fetchSystemSettings(category?: string): Promise<SystemSettingsResponse> {
const params: Record<string, string | undefined> = {};
if (category) params.category = category;
return api.get<SystemSettingsResponse>('/system-settings', { params });
}
async function updateSetting(key: string, value: string): Promise<SystemSetting> {
return api.put<SystemSetting>(`/system-settings/${encodeURIComponent(key)}`, { value });
}
async function bulkUpdateSettings(payload: BulkUpdatePayload): Promise<SystemSetting[]> {
return api.put<SystemSetting[]>('/system-settings/bulk', payload);
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
/**
* Fetch all system settings, optionally filtered by category.
*/
export function useSystemSettings(category?: string) {
return useQuery({
queryKey: systemSettingsKeys.list(category),
queryFn: () => fetchSystemSettings(category),
staleTime: 60_000,
});
}
/**
* Mutation: update a single setting by key.
*/
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
updateSetting(key, value),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: systemSettingsKeys.all });
},
});
}
/**
* Mutation: update multiple settings at once.
*/
export function useBulkUpdateSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: BulkUpdatePayload) => bulkUpdateSettings(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: systemSettingsKeys.all });
},
});
}

View File

@@ -59,7 +59,12 @@ async function request<T>(endpoint: string, config: RequestConfig = {}): Promise
// Handle empty responses
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json();
const json = await response.json();
// Unwrap backend's {success, data, timestamp} envelope
if (json && typeof json === 'object' && 'success' in json && 'data' in json) {
return json.data as T;
}
return json as T;
}
return {} as T;