diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma index 94ff324..6e67512 100644 --- a/packages/core-service/prisma/core.schema.prisma +++ b/packages/core-service/prisma/core.schema.prisma @@ -23,6 +23,7 @@ model User { email String @unique @db.VarChar(255) firstName String @map("first_name") @db.VarChar(100) lastName String @map("last_name") @db.VarChar(100) + avatar String? @db.Text // Profilbild als Base64 Data-URL role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER isActive Boolean @default(true) @map("is_active") diff --git a/packages/core-service/prisma/migrations/20260308200000_add_user_avatar/migration.sql b/packages/core-service/prisma/migrations/20260308200000_add_user_avatar/migration.sql new file mode 100644 index 0000000..2d75ad1 --- /dev/null +++ b/packages/core-service/prisma/migrations/20260308200000_add_user_avatar/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "avatar" TEXT; diff --git a/packages/core-service/src/core/auth/auth.service.ts b/packages/core-service/src/core/auth/auth.service.ts index cc5f937..8d0ccc5 100644 --- a/packages/core-service/src/core/auth/auth.service.ts +++ b/packages/core-service/src/core/auth/auth.service.ts @@ -113,6 +113,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, role: user.role, + twoFactorEnabled: user.twoFactorEnabled, }, requiresTwoFactor: true, }; @@ -159,6 +160,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, role: user.role, + twoFactorEnabled: user.twoFactorEnabled, }, }; } diff --git a/packages/core-service/src/core/users/dto/update-user.dto.ts b/packages/core-service/src/core/users/dto/update-user.dto.ts index d73f6b6..0c2ff34 100644 --- a/packages/core-service/src/core/users/dto/update-user.dto.ts +++ b/packages/core-service/src/core/users/dto/update-user.dto.ts @@ -1,4 +1,11 @@ -import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + IsBoolean, + IsOptional, + IsString, + MaxLength, + Matches, + ValidateIf, +} from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class UpdateUserDto { @@ -18,4 +25,19 @@ export class UpdateUserDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @ApiProperty({ + description: 'Profilbild als Base64 Data-URL (max. 400KB), null zum Entfernen', + example: 'data:image/png;base64,iVBORw0KGgo...', + required: false, + nullable: true, + }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.avatar !== null) + @IsString() + @MaxLength(400000, { message: 'Profilbild darf maximal 400KB gross sein' }) + @Matches(/^data:image\/(jpeg|png|gif|webp);base64,[A-Za-z0-9+/=]+$/, { + message: 'Profilbild muss ein gueltiges Base64-Bild sein (JPEG, PNG, GIF oder WebP)', + }) + avatar?: string | null; } diff --git a/packages/core-service/src/core/users/users.service.ts b/packages/core-service/src/core/users/users.service.ts index 9b00363..18d785a 100644 --- a/packages/core-service/src/core/users/users.service.ts +++ b/packages/core-service/src/core/users/users.service.ts @@ -95,6 +95,7 @@ export class UsersService { email: user.email, firstName: user.firstName, lastName: user.lastName, + avatar: user.avatar, role: user.role, isActive: user.isActive, twoFactorEnabled: user.twoFactorEnabled, @@ -124,6 +125,7 @@ export class UsersService { firstName: dto.firstName, lastName: dto.lastName, isActive: dto.isActive, + ...(dto.avatar !== undefined && { avatar: dto.avatar }), }, }); @@ -132,6 +134,7 @@ export class UsersService { email: updated.email, firstName: updated.firstName, lastName: updated.lastName, + avatar: updated.avatar, role: updated.role, isActive: updated.isActive, }; @@ -151,6 +154,7 @@ export class UsersService { data: { ...(dto.firstName !== undefined && { firstName: dto.firstName }), ...(dto.lastName !== undefined && { lastName: dto.lastName }), + ...(dto.avatar !== undefined && { avatar: dto.avatar }), }, }); @@ -159,6 +163,7 @@ export class UsersService { email: updated.email, firstName: updated.firstName, lastName: updated.lastName, + avatar: updated.avatar, role: updated.role, isActive: updated.isActive, twoFactorEnabled: updated.twoFactorEnabled, diff --git a/packages/core-service/src/main.ts b/packages/core-service/src/main.ts index ae0050f..64173cd 100644 --- a/packages/core-service/src/main.ts +++ b/packages/core-service/src/main.ts @@ -15,6 +15,9 @@ async function bootstrap(): Promise { app.use(helmet()); app.use(cookieParser()); + // Body size limit erhoehen fuer Base64 Avatar-Uploads (Standard ~100KB) + app.useBodyParser('json', { limit: '1mb' }); + // CORS const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [ 'http://172.20.10.59', diff --git a/packages/frontend/src/auth/AuthContext.tsx b/packages/frontend/src/auth/AuthContext.tsx index 8b0fde2..96fc15b 100644 --- a/packages/frontend/src/auth/AuthContext.tsx +++ b/packages/frontend/src/auth/AuthContext.tsx @@ -13,6 +13,7 @@ interface User { email: string; firstName: string; lastName: string; + avatar?: string | null; role: string; twoFactorEnabled: boolean; } diff --git a/packages/frontend/src/components/UserAvatar.module.css b/packages/frontend/src/components/UserAvatar.module.css new file mode 100644 index 0000000..4b067b3 --- /dev/null +++ b/packages/frontend/src/components/UserAvatar.module.css @@ -0,0 +1,15 @@ +.avatar { + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.initials { + display: flex; + align-items: center; + justify-content: center; + background: #3b82f6; + color: white; + font-weight: 600; + user-select: none; +} diff --git a/packages/frontend/src/components/UserAvatar.tsx b/packages/frontend/src/components/UserAvatar.tsx new file mode 100644 index 0000000..7dff6ae --- /dev/null +++ b/packages/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,40 @@ +import styles from './UserAvatar.module.css'; + +interface UserAvatarProps { + firstName: string; + lastName: string; + avatar?: string | null; + size?: number; + className?: string; +} + +export function UserAvatar({ + firstName, + lastName, + avatar, + size = 36, + className, +}: UserAvatarProps) { + const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + + if (avatar) { + return ( + {`${firstName} + ); + } + + return ( +
+ {initials} +
+ ); +} diff --git a/packages/frontend/src/profile/ProfilePage.module.css b/packages/frontend/src/profile/ProfilePage.module.css index f861869..31c1727 100644 --- a/packages/frontend/src/profile/ProfilePage.module.css +++ b/packages/frontend/src/profile/ProfilePage.module.css @@ -204,3 +204,54 @@ word-break: break-all; border: 1px solid var(--color-border); } + +/* === Avatar === */ +.avatarSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding-bottom: 1.25rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid var(--color-border); +} + +.avatarPreview { + position: relative; + cursor: pointer; + border-radius: 50%; + overflow: hidden; +} + +.avatarOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + color: white; + font-size: 0.75rem; + font-weight: 500; + opacity: 0; + transition: opacity 0.2s; + border-radius: 50%; +} + +.avatarPreview:hover .avatarOverlay { + opacity: 1; +} + +.avatarActions { + display: flex; + gap: 0.5rem; +} + +.avatarHint { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.hiddenInput { + display: none; +} diff --git a/packages/frontend/src/profile/ProfilePage.tsx b/packages/frontend/src/profile/ProfilePage.tsx index fe9c039..8207a47 100644 --- a/packages/frontend/src/profile/ProfilePage.tsx +++ b/packages/frontend/src/profile/ProfilePage.tsx @@ -1,6 +1,8 @@ -import { useState, type FormEvent } from 'react'; +import { useState, useEffect, useRef, type FormEvent, type ChangeEvent } from 'react'; import { useAuth } from '../auth/AuthContext'; import api from '../api/client'; +import { UserAvatar } from '../components/UserAvatar'; +import { resizeImageToBase64 } from '../utils/imageUtils'; import styles from './ProfilePage.module.css'; export function ProfilePage() { @@ -13,6 +15,13 @@ export function ProfilePage() { const [profileError, setProfileError] = useState(''); const [profileLoading, setProfileLoading] = useState(false); + // --- Profilbild --- + const [avatar, setAvatar] = useState(user?.avatar ?? null); + const [avatarMsg, setAvatarMsg] = useState(''); + const [avatarError, setAvatarError] = useState(''); + const [avatarLoading, setAvatarLoading] = useState(false); + const fileInputRef = useRef(null); + // --- Passwort aendern --- const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); @@ -36,6 +45,79 @@ export function ProfilePage() { const [tfaError, setTfaError] = useState(''); const [tfaLoading, setTfaLoading] = useState(false); + // 2FA-Status mit Context-User synchronisieren (Bug-Fix: Login-Response hat jetzt twoFactorEnabled) + useEffect(() => { + if (user?.twoFactorEnabled !== undefined) { + setTwoFactorEnabled(user.twoFactorEnabled); + } + }, [user?.twoFactorEnabled]); + + // Avatar mit Context-User synchronisieren + useEffect(() => { + if (user?.avatar !== undefined) { + setAvatar(user.avatar ?? null); + } + }, [user?.avatar]); + + // === Handler: Profilbild hochladen === + const handleAvatarChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + setAvatarError('Bitte waehlen Sie eine Bilddatei aus'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + setAvatarError('Bild darf maximal 5MB gross sein'); + return; + } + + setAvatarMsg(''); + setAvatarError(''); + setAvatarLoading(true); + + try { + const base64 = await resizeImageToBase64(file, 200, 200); + await api.patch('/users/me', { avatar: base64 }); + setAvatar(base64); + await refreshUser(); + setAvatarMsg('Profilbild erfolgreich aktualisiert'); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setAvatarError( + error.response?.data?.message ?? 'Fehler beim Hochladen des Profilbilds', + ); + } finally { + setAvatarLoading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + // === Handler: Profilbild entfernen === + const handleAvatarRemove = async () => { + setAvatarMsg(''); + setAvatarError(''); + setAvatarLoading(true); + + try { + await api.patch('/users/me', { avatar: null }); + setAvatar(null); + await refreshUser(); + setAvatarMsg('Profilbild entfernt'); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setAvatarError( + error.response?.data?.message ?? 'Fehler beim Entfernen des Profilbilds', + ); + } finally { + setAvatarLoading(false); + } + }; + // === Handler: Profil aktualisieren === const handleProfileUpdate = async (e: FormEvent) => { e.preventDefault(); @@ -124,19 +206,24 @@ export function ProfilePage() { try { await api.post('/auth/2fa/enable', { totpCode }); - setTwoFactorEnabled(true); - setSetupData(null); - setTotpCode(''); - await refreshUser(); - setTfaMsg('2FA wurde erfolgreich aktiviert'); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; setTfaError( error.response?.data?.message ?? 'Ungueltiger Code', ); - } finally { setTfaLoading(false); + return; } + + // Erfolg — State aktualisieren + setTwoFactorEnabled(true); + setSetupData(null); + setTotpCode(''); + setTfaMsg('2FA wurde erfolgreich aktiviert'); + setTfaLoading(false); + + // User-Context im Hintergrund aktualisieren + refreshUser().catch(() => {}); }; // === Handler: 2FA deaktivieren === @@ -148,19 +235,24 @@ export function ProfilePage() { try { await api.post('/auth/2fa/disable', { password: disablePassword }); - setTwoFactorEnabled(false); - setShowDisableConfirm(false); - setDisablePassword(''); - await refreshUser(); - setTfaMsg('2FA wurde erfolgreich deaktiviert'); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; setTfaError( error.response?.data?.message ?? 'Fehler beim Deaktivieren', ); - } finally { setTfaLoading(false); + return; } + + // Erfolg — State aktualisieren + setTwoFactorEnabled(false); + setShowDisableConfirm(false); + setDisablePassword(''); + setTfaMsg('2FA wurde erfolgreich deaktiviert'); + setTfaLoading(false); + + // User-Context im Hintergrund aktualisieren + refreshUser().catch(() => {}); }; return ( @@ -178,6 +270,62 @@ export function ProfilePage() { {/* === Sektion 1: Persoenliche Informationen === */}

Persoenliche Informationen

+ + {/* Profilbild */} +
+
fileInputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); + }} + > + +
+ Bild aendern +
+
+ +
+ + {avatar && ( + + )} +
+ {avatarMsg &&
{avatarMsg}
} + {avatarError &&
{avatarError}
} + + JPEG, PNG, GIF oder WebP. Wird auf 200x200 Pixel skaliert. + +
+
{profileMsg &&
{profileMsg}
} {profileError &&
{profileError}
} diff --git a/packages/frontend/src/shell/AppLayout.module.css b/packages/frontend/src/shell/AppLayout.module.css index 744bc53..b46860b 100644 --- a/packages/frontend/src/shell/AppLayout.module.css +++ b/packages/frontend/src/shell/AppLayout.module.css @@ -82,15 +82,42 @@ background: rgba(255, 255, 255, 0.1); } +.userProfileInner { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.userDetails { + min-width: 0; +} + .userName { font-size: 0.875rem; font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .userEmail { font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); margin-top: 0.125rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profileHint { + font-size: 0.6875rem; + color: rgba(255, 255, 255, 0.35); + margin-top: 0.375rem; + transition: color 0.15s; +} + +.userProfile:hover .profileHint { + color: rgba(255, 255, 255, 0.7); } .logoutBtn { diff --git a/packages/frontend/src/shell/AppLayout.tsx b/packages/frontend/src/shell/AppLayout.tsx index 0963feb..810a548 100644 --- a/packages/frontend/src/shell/AppLayout.tsx +++ b/packages/frontend/src/shell/AppLayout.tsx @@ -1,5 +1,6 @@ import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/AuthContext'; +import { UserAvatar } from '../components/UserAvatar'; import styles from './AppLayout.module.css'; export function AppLayout() { @@ -64,10 +65,21 @@ export function AppLayout() { if (e.key === 'Enter' || e.key === ' ') navigate('/profile'); }} > -
- {user?.firstName} {user?.lastName} +
+ +
+
+ {user?.firstName} {user?.lastName} +
+
{user?.email}
+
-
{user?.email}
+
Zum Profil →