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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx
Normal file
20
apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
apps/web/src/hooks/integrations/use-credentials.ts
Normal file
147
apps/web/src/hooks/integrations/use-credentials.ts
Normal 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`,
|
||||
),
|
||||
});
|
||||
}
|
||||
105
apps/web/src/hooks/use-system-settings.ts
Normal file
105
apps/web/src/hooks/use-system-settings.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user