mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
79635c31d2
commit
1a87356048
9 changed files with 920 additions and 31 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
102
packages/core-service/src/core/settings/settings.controller.ts
Normal file
102
packages/core-service/src/core/settings/settings.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SettingsController],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
513
packages/frontend/src/admin/AdminExternalLinksPage.tsx
Normal file
513
packages/frontend/src/admin/AdminExternalLinksPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
packages/frontend/src/admin/AdminLayout.module.css
Normal file
69
packages/frontend/src/admin/AdminLayout.module.css
Normal 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 */
|
||||||
|
}
|
||||||
61
packages/frontend/src/admin/AdminLayout.tsx
Normal file
61
packages/frontend/src/admin/AdminLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue