feat: restructure admin area with separate layout, external links management

- Admin section moved to dedicated area with horizontal tab navigation
- Sidebar now shows gear icon link to Administration (PLATFORM_ADMIN only)
- External links management page for configuring sidebar shortcuts
- External links displayed in sidebar for all authenticated users
- Backend: Redis-based CRUD endpoints for external link configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 11:04:43 +01:00
parent 79635c31d2
commit 1a87356048
9 changed files with 920 additions and 31 deletions

View file

@ -10,6 +10,7 @@ import { AuthModule } from './core/auth/auth.module';
import { UsersModule } from './core/users/users.module'; import { UsersModule } from './core/users/users.module';
import { TenantsModule } from './core/tenants/tenants.module'; import { TenantsModule } from './core/tenants/tenants.module';
import { ExpertProfileModule } from './core/expert-profile/expert-profile.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { validateConfig } from './config/env.validation'; import { validateConfig } from './config/env.validation';
@ -42,6 +43,7 @@ import { validateConfig } from './config/env.validation';
UsersModule, UsersModule,
TenantsModule, TenantsModule,
ExpertProfileModule, ExpertProfileModule,
SettingsModule,
], ],
providers: [ providers: [
// Global Guards: Alle Routen sind standardmaessig geschuetzt // Global Guards: Alle Routen sind standardmaessig geschuetzt

View file

@ -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<ExternalLink[]> {
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 };
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
@Module({
controllers: [SettingsController],
})
export class SettingsModule {}

View file

@ -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<HTMLInputElement>(null);
const handleIconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div
style={{
display: 'grid',
gridTemplateColumns: '64px 1fr 1fr 80px auto',
gap: '0.75rem',
alignItems: 'end',
padding: '1rem 0',
borderBottom: '1px solid var(--color-border)',
}}
>
{/* Icon */}
<div>
<label style={labelStyle}>Icon</label>
<div
onClick={() => 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 ? (
<img
src={link.icon}
alt=""
style={{ width: 24, height: 24, objectFit: 'contain' }}
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="var(--color-text-muted)"
strokeWidth="1.5"
>
<path d="M8 3v10M3 8h10" />
</svg>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/svg+xml,image/jpeg,image/webp"
style={{ display: 'none' }}
onChange={handleIconUpload}
/>
</div>
{/* Label */}
<div>
<label style={labelStyle}>Bezeichnung *</label>
<input
type="text"
value={link.label}
onChange={(e) => onChange({ ...link, label: e.target.value })}
placeholder="z.B. Jira, Confluence"
style={inputStyle}
/>
</div>
{/* URL */}
<div>
<label style={labelStyle}>URL *</label>
<input
type="text"
value={link.url}
onChange={(e) => onChange({ ...link, url: e.target.value })}
placeholder="https://..."
style={inputStyle}
/>
</div>
{/* Reihenfolge */}
<div
style={{
display: 'flex',
gap: '0.25rem',
paddingBottom: '0.25rem',
}}
>
<button
onClick={onMoveUp}
disabled={isFirst}
style={{
...btnSecondary,
padding: '0.25rem 0.5rem',
opacity: isFirst ? 0.3 : 1,
}}
title="Nach oben"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M7 11V3M3 7l4-4 4 4" />
</svg>
</button>
<button
onClick={onMoveDown}
disabled={isLast}
style={{
...btnSecondary,
padding: '0.25rem 0.5rem',
opacity: isLast ? 0.3 : 1,
}}
title="Nach unten"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M7 3v8M3 7l4 4 4-4" />
</svg>
</button>
</div>
{/* Entfernen */}
<button
onClick={onRemove}
style={{
...btnSecondary,
color: 'var(--color-error)',
borderColor: 'transparent',
padding: '0.25rem 0.5rem',
}}
title="Entfernen"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
);
}
export function AdminExternalLinksPage() {
const queryClient = useQueryClient();
const [links, setLinks] = useState<ExternalLink[]>([]);
const [hasChanges, setHasChanges] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const { data, isLoading } = useQuery<ExternalLink[]>({
queryKey: ['settings', 'external-links'],
queryFn: async () => {
const res = await api.get<ExternalLink[]>('/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 <p style={{ color: 'var(--color-text-secondary)' }}>Laden...</p>;
}
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>
Externe Links
</h1>
<p
style={{
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
marginTop: '0.25rem',
}}
>
Links zu externen Anwendungen, die in der Sidebar fuer alle Benutzer
angezeigt werden.
</p>
</div>
</div>
<div style={cardStyle}>
{links.length === 0 ? (
<div
style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--color-text-muted)',
}}
>
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
style={{ margin: '0 auto 1rem' }}
>
<rect x="8" y="8" width="32" height="32" rx="4" />
<path d="M20 20l8 8M28 20l-8 8" />
</svg>
<p style={{ fontSize: '0.875rem' }}>
Noch keine externen Links konfiguriert.
</p>
</div>
) : (
<div>
{links.map((link, index) => (
<LinkRow
key={link.id}
link={link}
onChange={(updated) => updateLink(index, updated)}
onRemove={() => removeLink(index)}
onMoveUp={() => moveLink(index, -1)}
onMoveDown={() => moveLink(index, 1)}
isFirst={index === 0}
isLast={index === links.length - 1}
/>
))}
</div>
)}
{/* Aktionen */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '1.25rem',
paddingTop: links.length > 0 ? '0.5rem' : 0,
}}
>
<button
onClick={addLink}
style={{
...btnSecondary,
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
}}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M7 2v10M2 7h10" />
</svg>
Link hinzufuegen
</button>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
}}
>
{saveSuccess && (
<span
style={{
fontSize: '0.8125rem',
color: '#166534',
fontWeight: 500,
}}
>
Gespeichert!
</span>
)}
{saveMutation.isError && (
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
}}
>
Fehler beim Speichern
</span>
)}
<button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
style={{
...btnPrimary,
opacity: hasChanges ? 1 : 0.5,
cursor: hasChanges ? 'pointer' : 'not-allowed',
}}
>
{saveMutation.isPending ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/* Hinweis */}
<div
style={{
padding: '1rem 1.25rem',
background: '#eff6ff',
border: '1px solid #bfdbfe',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
color: '#1e40af',
}}
>
<strong>Hinweis:</strong> 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).
</div>
</div>
);
}

View file

@ -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 */
}

View file

@ -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 (
<div>
{/* Header mit Zurück-Button und Tabs */}
<div className={styles.header}>
<div className={styles.headerTop}>
<button
className={styles.backButton}
onClick={() => navigate('/')}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 12L6 8l4-4" />
</svg>
Zurück zum Dashboard
</button>
<h1 className={styles.title}>Administration</h1>
</div>
<nav className={styles.tabs}>
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
`${styles.tab} ${isActive ? styles.tabActive : ''}`
}
>
{tab.label}
</NavLink>
))}
</nav>
</div>
{/* Content */}
<div className={styles.content}>
<Outlet />
</div>
</div>
);
}

View file

@ -4,9 +4,11 @@ import { LoginPage } from '../auth/LoginPage';
import { SsoCallbackPage } from '../auth/SsoCallbackPage'; import { SsoCallbackPage } from '../auth/SsoCallbackPage';
import { AppLayout } from './AppLayout'; import { AppLayout } from './AppLayout';
import { DashboardPage } from './DashboardPage'; import { DashboardPage } from './DashboardPage';
import { AdminLayout } from '../admin/AdminLayout';
import { AdminUsersPage } from '../admin/AdminUsersPage'; import { AdminUsersPage } from '../admin/AdminUsersPage';
import { AdminTenantsPage } from '../admin/AdminTenantsPage'; import { AdminTenantsPage } from '../admin/AdminTenantsPage';
import { AdminSsoPage } from '../admin/AdminSsoPage'; import { AdminSsoPage } from '../admin/AdminSsoPage';
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
import { ProfilePage } from '../profile/ProfilePage'; import { ProfilePage } from '../profile/ProfilePage';
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
@ -45,9 +47,14 @@ export function App() {
> >
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="admin/users" element={<AdminUsersPage />} /> {/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}
<Route path="admin/tenants" element={<AdminTenantsPage />} /> <Route path="admin" element={<AdminLayout />}>
<Route path="admin/sso" element={<AdminSsoPage />} /> <Route index element={<Navigate to="users" replace />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="tenants" element={<AdminTenantsPage />} />
<Route path="sso" element={<AdminSsoPage />} />
<Route path="external-links" element={<AdminExternalLinksPage />} />
</Route>
</Route> </Route>
{/* Fallback */} {/* Fallback */}

View file

@ -44,7 +44,9 @@
} }
.navLink { .navLink {
display: block; display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 1.5rem; padding: 0.625rem 1.5rem;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
text-decoration: none; text-decoration: none;
@ -65,6 +67,36 @@
border-left-color: #60a5fa; 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 { .userInfo {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);

View file

@ -1,12 +1,31 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { UserAvatar } from '../components/UserAvatar'; import { UserAvatar } from '../components/UserAvatar';
import api from '../api/client';
import styles from './AppLayout.module.css'; import styles from './AppLayout.module.css';
interface ExternalLink {
id: string;
label: string;
url: string;
icon?: string;
sortOrder: number;
}
export function AppLayout() { export function AppLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: externalLinks } = useQuery<ExternalLink[]>({
queryKey: ['settings', 'external-links'],
queryFn: async () => {
const res = await api.get<ExternalLink[]>('/settings/external-links');
return res.data;
},
staleTime: 5 * 60 * 1000,
});
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
@ -28,41 +47,118 @@ export function AppLayout() {
`${styles.navLink} ${isActive ? styles.active : ''}` `${styles.navLink} ${isActive ? styles.active : ''}`
} }
> >
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 6l6-4 6 4v7a1 1 0 01-1 1H3a1 1 0 01-1-1V6z" />
<path d="M6 14V8h4v6" />
</svg>
Dashboard Dashboard
</NavLink> </NavLink>
{/* Admin-Bereich (nur fuer PLATFORM_ADMIN) */} {/* Externe Links */}
{user?.role === 'PLATFORM_ADMIN' && ( {externalLinks && externalLinks.length > 0 && (
<> <>
<div className={styles.navSection}>Administration</div> <div className={styles.navSection}>Anwendungen</div>
<NavLink {externalLinks.map((link) => (
to="/admin/users" <a
className={({ isActive }) => key={link.id}
`${styles.navLink} ${isActive ? styles.active : ''}` href={link.url}
} target="_blank"
rel="noopener noreferrer"
className={styles.navLink}
> >
Benutzer {link.icon ? (
</NavLink> <img
<NavLink src={link.icon}
to="/admin/tenants" alt=""
className={({ isActive }) => style={{
`${styles.navLink} ${isActive ? styles.active : ''}` width: 16,
} height: 16,
objectFit: 'contain',
borderRadius: 2,
}}
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
> >
Mandanten <path d="M10 2h4v4" />
</NavLink> <path d="M6 10L14 2" />
<NavLink <path d="M14 9v4a1 1 0 01-1 1H3a1 1 0 01-1-1V3a1 1 0 011-1h4" />
to="/admin/sso" </svg>
className={({ isActive }) => )}
`${styles.navLink} ${isActive ? styles.active : ''}` {link.label}
} <svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
style={{ marginLeft: 'auto', opacity: 0.4 }}
> >
SSO-Konfiguration <path d="M6 1h3v3" />
</NavLink> <path d="M4 6l5-5" />
</svg>
</a>
))}
</> </>
)} )}
</nav> </nav>
{/* Admin-Link (nur PLATFORM_ADMIN) */}
{user?.role === 'PLATFORM_ADMIN' && (
<div className={styles.adminSection}>
<NavLink
to="/admin/users"
className={({ isActive }) =>
`${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)',
}
: {};
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="8" r="2.5" />
<path d="M8 1.5v1.25M8 13.25v1.25M14.5 8h-1.25M2.75 8H1.5M12.6 3.4l-.88.88M4.28 11.72l-.88.88M12.6 12.6l-.88-.88M4.28 4.28l-.88-.88" />
</svg>
Administration
</NavLink>
</div>
)}
<div className={styles.userInfo}> <div className={styles.userInfo}>
<div <div
className={styles.userProfile} className={styles.userProfile}