diff --git a/packages/core-service/src/app.module.ts b/packages/core-service/src/app.module.ts index 09d531b..2a5b84c 100644 --- a/packages/core-service/src/app.module.ts +++ b/packages/core-service/src/app.module.ts @@ -10,6 +10,7 @@ import { AuthModule } from './core/auth/auth.module'; import { UsersModule } from './core/users/users.module'; import { TenantsModule } from './core/tenants/tenants.module'; import { ExpertProfileModule } from './core/expert-profile/expert-profile.module'; +import { SettingsModule } from './core/settings/settings.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { validateConfig } from './config/env.validation'; @@ -42,6 +43,7 @@ import { validateConfig } from './config/env.validation'; UsersModule, TenantsModule, ExpertProfileModule, + SettingsModule, ], providers: [ // Global Guards: Alle Routen sind standardmaessig geschuetzt diff --git a/packages/core-service/src/core/settings/settings.controller.ts b/packages/core-service/src/core/settings/settings.controller.ts new file mode 100644 index 0000000..2b7243f --- /dev/null +++ b/packages/core-service/src/core/settings/settings.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Get, + Post, + Body, + Logger, + UseGuards, + BadRequestException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { RedisService } from '../../redis/redis.service'; + +/** + * Ein externer Link fuer die Sidebar-Navigation. + */ +interface ExternalLink { + id: string; + label: string; + url: string; + /** Base64-encodiertes Icon (data URI), z.B. "data:image/png;base64,..." */ + icon?: string; + sortOrder: number; +} + +const EXTERNAL_LINKS_KEY = 'platform_external_links'; +const MAX_ICON_SIZE = 100_000; // ~100KB Base64 + +@ApiTags('Settings') +@Controller('settings') +export class SettingsController { + private readonly logger = new Logger(SettingsController.name); + + constructor(private readonly redis: RedisService) {} + + /** + * GET /api/v1/settings/external-links + * Externe Links fuer die Sidebar lesen (jeder authentifizierte User). + */ + @Get('external-links') + @ApiOperation({ summary: 'Externe Links lesen' }) + async getExternalLinks(): Promise { + const raw = await this.redis.get(EXTERNAL_LINKS_KEY); + if (!raw) return []; + + try { + return JSON.parse(raw) as ExternalLink[]; + } catch { + return []; + } + } + + /** + * POST /api/v1/settings/external-links + * Externe Links speichern (nur PLATFORM_ADMIN). + * Erwartet ein Array von Links. + */ + @Post('external-links') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + @ApiOperation({ summary: 'Externe Links speichern (Admin)' }) + async saveExternalLinks( + @Body() body: { links: ExternalLink[] }, + ): Promise<{ success: boolean; count: number }> { + if (!Array.isArray(body.links)) { + throw new BadRequestException('links muss ein Array sein'); + } + + // Validierung + for (const link of body.links) { + if (!link.label?.trim()) { + throw new BadRequestException('Jeder Link braucht ein Label'); + } + if (!link.url?.trim()) { + throw new BadRequestException('Jeder Link braucht eine URL'); + } + if (link.icon && link.icon.length > MAX_ICON_SIZE) { + throw new BadRequestException( + `Icon fuer "${link.label}" ist zu gross (max ~75KB)`, + ); + } + } + + // Sortierung sicherstellen + const sorted = body.links.map((link, index) => ({ + id: link.id || crypto.randomUUID(), + label: link.label.trim(), + url: link.url.trim(), + icon: link.icon || undefined, + sortOrder: link.sortOrder ?? index, + })); + + sorted.sort((a, b) => a.sortOrder - b.sortOrder); + + await this.redis.set(EXTERNAL_LINKS_KEY, JSON.stringify(sorted)); + + this.logger.log(`${sorted.length} externe Links gespeichert`); + + return { success: true, count: sorted.length }; + } +} diff --git a/packages/core-service/src/core/settings/settings.module.ts b/packages/core-service/src/core/settings/settings.module.ts new file mode 100644 index 0000000..7ba2fa0 --- /dev/null +++ b/packages/core-service/src/core/settings/settings.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { SettingsController } from './settings.controller'; + +@Module({ + controllers: [SettingsController], +}) +export class SettingsModule {} diff --git a/packages/frontend/src/admin/AdminExternalLinksPage.tsx b/packages/frontend/src/admin/AdminExternalLinksPage.tsx new file mode 100644 index 0000000..afcc8f8 --- /dev/null +++ b/packages/frontend/src/admin/AdminExternalLinksPage.tsx @@ -0,0 +1,513 @@ +import { useState, useEffect, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; + +interface ExternalLink { + id: string; + label: string; + url: string; + icon?: string; + sortOrder: number; +} + +const cardStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-sm)', + border: '1px solid var(--color-border)', + padding: '1.5rem', + marginBottom: '1.5rem', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '0.5rem 0.75rem', + fontSize: '0.875rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + background: 'var(--color-bg-card)', + color: 'var(--color-text)', + outline: 'none', + boxSizing: 'border-box' as const, +}; + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '0.75rem', + fontWeight: 600, + color: 'var(--color-text-secondary)', + marginBottom: '0.25rem', + textTransform: 'uppercase' as const, + letterSpacing: '0.5px', +}; + +const btnPrimary: React.CSSProperties = { + padding: '0.5rem 1.25rem', + background: 'var(--color-primary)', + color: 'white', + border: 'none', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer', +}; + +const btnSecondary: React.CSSProperties = { + padding: '0.375rem 0.75rem', + background: 'none', + color: 'var(--color-text-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.8125rem', + cursor: 'pointer', +}; + +function LinkRow({ + link, + onChange, + onRemove, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}: { + link: ExternalLink; + onChange: (updated: ExternalLink) => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +}) { + const fileInputRef = useRef(null); + + const handleIconUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Max 75KB + if (file.size > 75_000) { + alert('Icon darf max. 75KB gross sein'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + onChange({ ...link, icon: reader.result as string }); + }; + reader.readAsDataURL(file); + }; + + return ( +
+ {/* Icon */} +
+ +
fileInputRef.current?.click()} + style={{ + width: 40, + height: 40, + borderRadius: 'var(--radius-sm)', + border: '1px dashed var(--color-border)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + overflow: 'hidden', + background: 'var(--color-bg)', + }} + title="Icon hochladen (PNG, SVG, max 75KB)" + > + {link.icon ? ( + + ) : ( + + + + )} +
+ +
+ + {/* Label */} +
+ + onChange({ ...link, label: e.target.value })} + placeholder="z.B. Jira, Confluence" + style={inputStyle} + /> +
+ + {/* URL */} +
+ + onChange({ ...link, url: e.target.value })} + placeholder="https://..." + style={inputStyle} + /> +
+ + {/* Reihenfolge */} +
+ + +
+ + {/* Entfernen */} + +
+ ); +} + +export function AdminExternalLinksPage() { + const queryClient = useQueryClient(); + const [links, setLinks] = useState([]); + const [hasChanges, setHasChanges] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ['settings', 'external-links'], + queryFn: async () => { + const res = await api.get('/settings/external-links'); + return res.data; + }, + }); + + useEffect(() => { + if (data) { + setLinks(data); + setHasChanges(false); + } + }, [data]); + + const saveMutation = useMutation({ + mutationFn: async (linksToSave: ExternalLink[]) => { + const res = await api.post('/settings/external-links', { + links: linksToSave, + }); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['settings', 'external-links'], + }); + setHasChanges(false); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 3000); + }, + }); + + const addLink = () => { + setLinks((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + label: '', + url: '', + icon: undefined, + sortOrder: prev.length, + }, + ]); + setHasChanges(true); + }; + + const updateLink = (index: number, updated: ExternalLink) => { + setLinks((prev) => prev.map((l, i) => (i === index ? updated : l))); + setHasChanges(true); + }; + + const removeLink = (index: number) => { + setLinks((prev) => prev.filter((_, i) => i !== index)); + setHasChanges(true); + }; + + const moveLink = (index: number, direction: -1 | 1) => { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= links.length) return; + + setLinks((prev) => { + const updated = [...prev]; + const temp = updated[index]; + updated[index] = updated[newIndex]; + updated[newIndex] = temp; + return updated.map((l, i) => ({ ...l, sortOrder: i })); + }); + setHasChanges(true); + }; + + const handleSave = () => { + const valid = links.every((l) => l.label.trim() && l.url.trim()); + if (!valid) { + alert('Bitte Bezeichnung und URL fuer alle Links ausfuellen'); + return; + } + saveMutation.mutate(links); + }; + + if (isLoading) { + return

Laden...

; + } + + return ( +
+
+
+

+ Externe Links +

+

+ Links zu externen Anwendungen, die in der Sidebar fuer alle Benutzer + angezeigt werden. +

+
+
+ +
+ {links.length === 0 ? ( +
+ + + + +

+ Noch keine externen Links konfiguriert. +

+
+ ) : ( +
+ {links.map((link, index) => ( + updateLink(index, updated)} + onRemove={() => removeLink(index)} + onMoveUp={() => moveLink(index, -1)} + onMoveDown={() => moveLink(index, 1)} + isFirst={index === 0} + isLast={index === links.length - 1} + /> + ))} +
+ )} + + {/* Aktionen */} +
0 ? '0.5rem' : 0, + }} + > + + +
+ {saveSuccess && ( + + Gespeichert! + + )} + + {saveMutation.isError && ( + + Fehler beim Speichern + + )} + + +
+
+
+ + {/* Hinweis */} +
+ Hinweis: Externe Links werden in der Sidebar fuer alle + angemeldeten Benutzer angezeigt. Icons sollten quadratisch sein (z.B. + 32x32px) und als PNG, SVG oder JPEG hochgeladen werden (max. 75KB). +
+
+ ); +} diff --git a/packages/frontend/src/admin/AdminLayout.module.css b/packages/frontend/src/admin/AdminLayout.module.css new file mode 100644 index 0000000..2ccda7b --- /dev/null +++ b/packages/frontend/src/admin/AdminLayout.module.css @@ -0,0 +1,69 @@ +.header { + margin: -2rem -2rem 2rem -2rem; + background: var(--color-bg-card); + border-bottom: 1px solid var(--color-border); +} + +.headerTop { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem 2rem 0; +} + +.backButton { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.15s; +} + +.backButton:hover { + color: var(--color-text); + background: var(--color-bg); + border-color: var(--color-text-muted); +} + +.title { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text); +} + +.tabs { + display: flex; + gap: 0; + padding: 0 2rem; + margin-top: 1rem; +} + +.tab { + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); + text-decoration: none; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} + +.tab:hover { + color: var(--color-text); + text-decoration: none; +} + +.tabActive { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +.content { + /* Content-Bereich unter den Tabs */ +} diff --git a/packages/frontend/src/admin/AdminLayout.tsx b/packages/frontend/src/admin/AdminLayout.tsx new file mode 100644 index 0000000..cfdd76e --- /dev/null +++ b/packages/frontend/src/admin/AdminLayout.tsx @@ -0,0 +1,61 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom'; +import styles from './AdminLayout.module.css'; + +const tabs = [ + { to: '/admin/users', label: 'Benutzer' }, + { to: '/admin/tenants', label: 'Mandanten' }, + { to: '/admin/sso', label: 'SSO-Konfiguration' }, + { to: '/admin/external-links', label: 'Externe Links' }, +]; + +export function AdminLayout() { + const navigate = useNavigate(); + + return ( +
+ {/* Header mit Zurück-Button und Tabs */} +
+
+ +

Administration

+
+ + +
+ + {/* Content */} +
+ +
+
+ ); +} diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 32468f8..1636c1f 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -4,9 +4,11 @@ import { LoginPage } from '../auth/LoginPage'; import { SsoCallbackPage } from '../auth/SsoCallbackPage'; import { AppLayout } from './AppLayout'; import { DashboardPage } from './DashboardPage'; +import { AdminLayout } from '../admin/AdminLayout'; import { AdminUsersPage } from '../admin/AdminUsersPage'; import { AdminTenantsPage } from '../admin/AdminTenantsPage'; import { AdminSsoPage } from '../admin/AdminSsoPage'; +import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage'; import { ProfilePage } from '../profile/ProfilePage'; function PrivateRoute({ children }: { children: React.ReactNode }) { @@ -45,9 +47,14 @@ export function App() { > } /> } /> - } /> - } /> - } /> + {/* Admin-Bereich mit eigenem Layout (Top-Tabs) */} + }> + } /> + } /> + } /> + } /> + } /> + {/* Fallback */} diff --git a/packages/frontend/src/shell/AppLayout.module.css b/packages/frontend/src/shell/AppLayout.module.css index b46860b..8fd5eb6 100644 --- a/packages/frontend/src/shell/AppLayout.module.css +++ b/packages/frontend/src/shell/AppLayout.module.css @@ -44,7 +44,9 @@ } .navLink { - display: block; + display: flex; + align-items: center; + gap: 0.625rem; padding: 0.625rem 1.5rem; color: rgba(255, 255, 255, 0.7); text-decoration: none; @@ -65,6 +67,36 @@ border-left-color: #60a5fa; } +/* Admin-Bereich ueber dem Profil */ +.adminSection { + padding: 0.5rem 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.adminLink { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.75rem; + color: rgba(255, 255, 255, 0.6); + text-decoration: none; + font-size: 0.8125rem; + font-weight: 500; + border-radius: var(--radius-sm); + transition: all 0.15s; +} + +.adminLink:hover { + color: white; + background: rgba(255, 255, 255, 0.08); + text-decoration: none; +} + +.adminLinkActive { + color: white; + background: rgba(96, 165, 250, 0.15); +} + .userInfo { padding: 1rem 1.5rem; border-top: 1px solid rgba(255, 255, 255, 0.1); diff --git a/packages/frontend/src/shell/AppLayout.tsx b/packages/frontend/src/shell/AppLayout.tsx index 711d0d1..4682a97 100644 --- a/packages/frontend/src/shell/AppLayout.tsx +++ b/packages/frontend/src/shell/AppLayout.tsx @@ -1,12 +1,31 @@ import { Outlet, NavLink, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../auth/AuthContext'; import { UserAvatar } from '../components/UserAvatar'; +import api from '../api/client'; import styles from './AppLayout.module.css'; +interface ExternalLink { + id: string; + label: string; + url: string; + icon?: string; + sortOrder: number; +} + export function AppLayout() { const { user, logout } = useAuth(); const navigate = useNavigate(); + const { data: externalLinks } = useQuery({ + queryKey: ['settings', 'external-links'], + queryFn: async () => { + const res = await api.get('/settings/external-links'); + return res.data; + }, + staleTime: 5 * 60 * 1000, + }); + const handleLogout = async () => { await logout(); navigate('/login'); @@ -28,41 +47,118 @@ export function AppLayout() { `${styles.navLink} ${isActive ? styles.active : ''}` } > + + + + Dashboard - {/* Admin-Bereich (nur fuer PLATFORM_ADMIN) */} - {user?.role === 'PLATFORM_ADMIN' && ( + {/* Externe Links */} + {externalLinks && externalLinks.length > 0 && ( <> -
Administration
- - `${styles.navLink} ${isActive ? styles.active : ''}` - } - > - Benutzer - - - `${styles.navLink} ${isActive ? styles.active : ''}` - } - > - Mandanten - - - `${styles.navLink} ${isActive ? styles.active : ''}` - } - > - SSO-Konfiguration - +
Anwendungen
+ {externalLinks.map((link) => ( + + {link.icon ? ( + + ) : ( + + + + + + )} + {link.label} + + + + + + ))} )} + {/* Admin-Link (nur PLATFORM_ADMIN) */} + {user?.role === 'PLATFORM_ADMIN' && ( +
+ + `${styles.adminLink} ${isActive ? styles.adminLinkActive : ''}` + } + // Alle /admin/* Pfade sollen aktiv sein + style={({ isActive }) => { + const isAdminPath = + window.location.pathname.startsWith('/admin'); + return isAdminPath && !isActive + ? { + color: 'white', + background: 'rgba(96, 165, 250, 0.15)', + } + : {}; + }} + > + + + + + Administration + +
+ )} +