mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat: add profile picture upload, sidebar hint, and fix 2FA bugs
- Bug fix: include twoFactorEnabled in login response so ProfilePage shows correct 2FA status after login (not always "Aktivieren") - Bug fix: restructure 2FA enable/disable handlers to separate API call from state updates, preventing false error messages on success - New: avatar field in User model (Base64 data-URL in PostgreSQL TEXT) - New: UserAvatar component with initials fallback - New: client-side image resize to 200x200px before upload - New: avatar upload/remove on ProfilePage with preview - New: avatar display + "Zum Profil" hint in sidebar - Increase JSON body size limit to 1mb for avatar uploads Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ffb618ee65
commit
6fa86714db
14 changed files with 397 additions and 17 deletions
|
|
@ -23,6 +23,7 @@ model User {
|
||||||
email String @unique @db.VarChar(255)
|
email String @unique @db.VarChar(255)
|
||||||
firstName String @map("first_name") @db.VarChar(100)
|
firstName String @map("first_name") @db.VarChar(100)
|
||||||
lastName String @map("last_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
|
role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "avatar" TEXT;
|
||||||
|
|
@ -113,6 +113,7 @@ export class AuthService {
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
twoFactorEnabled: user.twoFactorEnabled,
|
||||||
},
|
},
|
||||||
requiresTwoFactor: true,
|
requiresTwoFactor: true,
|
||||||
};
|
};
|
||||||
|
|
@ -159,6 +160,7 @@ export class AuthService {
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
twoFactorEnabled: user.twoFactorEnabled,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
|
|
@ -18,4 +25,19 @@ export class UpdateUserDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ export class UsersService {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
avatar: user.avatar,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
twoFactorEnabled: user.twoFactorEnabled,
|
twoFactorEnabled: user.twoFactorEnabled,
|
||||||
|
|
@ -124,6 +125,7 @@ export class UsersService {
|
||||||
firstName: dto.firstName,
|
firstName: dto.firstName,
|
||||||
lastName: dto.lastName,
|
lastName: dto.lastName,
|
||||||
isActive: dto.isActive,
|
isActive: dto.isActive,
|
||||||
|
...(dto.avatar !== undefined && { avatar: dto.avatar }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -132,6 +134,7 @@ export class UsersService {
|
||||||
email: updated.email,
|
email: updated.email,
|
||||||
firstName: updated.firstName,
|
firstName: updated.firstName,
|
||||||
lastName: updated.lastName,
|
lastName: updated.lastName,
|
||||||
|
avatar: updated.avatar,
|
||||||
role: updated.role,
|
role: updated.role,
|
||||||
isActive: updated.isActive,
|
isActive: updated.isActive,
|
||||||
};
|
};
|
||||||
|
|
@ -151,6 +154,7 @@ export class UsersService {
|
||||||
data: {
|
data: {
|
||||||
...(dto.firstName !== undefined && { firstName: dto.firstName }),
|
...(dto.firstName !== undefined && { firstName: dto.firstName }),
|
||||||
...(dto.lastName !== undefined && { lastName: dto.lastName }),
|
...(dto.lastName !== undefined && { lastName: dto.lastName }),
|
||||||
|
...(dto.avatar !== undefined && { avatar: dto.avatar }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,6 +163,7 @@ export class UsersService {
|
||||||
email: updated.email,
|
email: updated.email,
|
||||||
firstName: updated.firstName,
|
firstName: updated.firstName,
|
||||||
lastName: updated.lastName,
|
lastName: updated.lastName,
|
||||||
|
avatar: updated.avatar,
|
||||||
role: updated.role,
|
role: updated.role,
|
||||||
isActive: updated.isActive,
|
isActive: updated.isActive,
|
||||||
twoFactorEnabled: updated.twoFactorEnabled,
|
twoFactorEnabled: updated.twoFactorEnabled,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ async function bootstrap(): Promise<void> {
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Body size limit erhoehen fuer Base64 Avatar-Uploads (Standard ~100KB)
|
||||||
|
app.useBodyParser('json', { limit: '1mb' });
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [
|
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [
|
||||||
'http://172.20.10.59',
|
'http://172.20.10.59',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface User {
|
||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
avatar?: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
packages/frontend/src/components/UserAvatar.module.css
Normal file
15
packages/frontend/src/components/UserAvatar.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
40
packages/frontend/src/components/UserAvatar.tsx
Normal file
40
packages/frontend/src/components/UserAvatar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt={`${firstName} ${lastName}`}
|
||||||
|
className={`${styles.avatar} ${className ?? ''}`}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.avatar} ${styles.initials} ${className ?? ''}`}
|
||||||
|
style={{ width: size, height: size, fontSize: size * 0.4 }}
|
||||||
|
aria-label={`${firstName} ${lastName}`}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -204,3 +204,54 @@
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
border: 1px solid var(--color-border);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useAuth } from '../auth/AuthContext';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
import { resizeImageToBase64 } from '../utils/imageUtils';
|
||||||
import styles from './ProfilePage.module.css';
|
import styles from './ProfilePage.module.css';
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
|
|
@ -13,6 +15,13 @@ export function ProfilePage() {
|
||||||
const [profileError, setProfileError] = useState('');
|
const [profileError, setProfileError] = useState('');
|
||||||
const [profileLoading, setProfileLoading] = useState(false);
|
const [profileLoading, setProfileLoading] = useState(false);
|
||||||
|
|
||||||
|
// --- Profilbild ---
|
||||||
|
const [avatar, setAvatar] = useState<string | null>(user?.avatar ?? null);
|
||||||
|
const [avatarMsg, setAvatarMsg] = useState('');
|
||||||
|
const [avatarError, setAvatarError] = useState('');
|
||||||
|
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// --- Passwort aendern ---
|
// --- Passwort aendern ---
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
|
@ -36,6 +45,79 @@ export function ProfilePage() {
|
||||||
const [tfaError, setTfaError] = useState('');
|
const [tfaError, setTfaError] = useState('');
|
||||||
const [tfaLoading, setTfaLoading] = useState(false);
|
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<HTMLInputElement>) => {
|
||||||
|
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 ===
|
// === Handler: Profil aktualisieren ===
|
||||||
const handleProfileUpdate = async (e: FormEvent) => {
|
const handleProfileUpdate = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -124,19 +206,24 @@ export function ProfilePage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/2fa/enable', { totpCode });
|
await api.post('/auth/2fa/enable', { totpCode });
|
||||||
setTwoFactorEnabled(true);
|
|
||||||
setSetupData(null);
|
|
||||||
setTotpCode('');
|
|
||||||
await refreshUser();
|
|
||||||
setTfaMsg('2FA wurde erfolgreich aktiviert');
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { message?: string } } };
|
const error = err as { response?: { data?: { message?: string } } };
|
||||||
setTfaError(
|
setTfaError(
|
||||||
error.response?.data?.message ?? 'Ungueltiger Code',
|
error.response?.data?.message ?? 'Ungueltiger Code',
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
setTfaLoading(false);
|
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 ===
|
// === Handler: 2FA deaktivieren ===
|
||||||
|
|
@ -148,19 +235,24 @@ export function ProfilePage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/2fa/disable', { password: disablePassword });
|
await api.post('/auth/2fa/disable', { password: disablePassword });
|
||||||
setTwoFactorEnabled(false);
|
|
||||||
setShowDisableConfirm(false);
|
|
||||||
setDisablePassword('');
|
|
||||||
await refreshUser();
|
|
||||||
setTfaMsg('2FA wurde erfolgreich deaktiviert');
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { message?: string } } };
|
const error = err as { response?: { data?: { message?: string } } };
|
||||||
setTfaError(
|
setTfaError(
|
||||||
error.response?.data?.message ?? 'Fehler beim Deaktivieren',
|
error.response?.data?.message ?? 'Fehler beim Deaktivieren',
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
setTfaLoading(false);
|
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 (
|
return (
|
||||||
|
|
@ -178,6 +270,62 @@ export function ProfilePage() {
|
||||||
{/* === Sektion 1: Persoenliche Informationen === */}
|
{/* === Sektion 1: Persoenliche Informationen === */}
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Persoenliche Informationen</h2>
|
<h2 className={styles.sectionTitle}>Persoenliche Informationen</h2>
|
||||||
|
|
||||||
|
{/* Profilbild */}
|
||||||
|
<div className={styles.avatarSection}>
|
||||||
|
<div
|
||||||
|
className={styles.avatarPreview}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
firstName={user?.firstName ?? ''}
|
||||||
|
lastName={user?.lastName ?? ''}
|
||||||
|
avatar={avatar}
|
||||||
|
size={96}
|
||||||
|
/>
|
||||||
|
<div className={styles.avatarOverlay}>
|
||||||
|
<span>Bild aendern</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
className={styles.hiddenInput}
|
||||||
|
/>
|
||||||
|
<div className={styles.avatarActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.buttonSecondary}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={avatarLoading}
|
||||||
|
>
|
||||||
|
{avatarLoading ? 'Laden...' : 'Profilbild hochladen'}
|
||||||
|
</button>
|
||||||
|
{avatar && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.buttonDanger}
|
||||||
|
onClick={handleAvatarRemove}
|
||||||
|
disabled={avatarLoading}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{avatarMsg && <div className={styles.success}>{avatarMsg}</div>}
|
||||||
|
{avatarError && <div className={styles.error}>{avatarError}</div>}
|
||||||
|
<small className={styles.avatarHint}>
|
||||||
|
JPEG, PNG, GIF oder WebP. Wird auf 200x200 Pixel skaliert.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleProfileUpdate} className={styles.form}>
|
<form onSubmit={handleProfileUpdate} className={styles.form}>
|
||||||
{profileMsg && <div className={styles.success}>{profileMsg}</div>}
|
{profileMsg && <div className={styles.success}>{profileMsg}</div>}
|
||||||
{profileError && <div className={styles.error}>{profileError}</div>}
|
{profileError && <div className={styles.error}>{profileError}</div>}
|
||||||
|
|
|
||||||
|
|
@ -82,15 +82,42 @@
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.userProfileInner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userDetails {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.userName {
|
.userName {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userEmail {
|
.userEmail {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
margin-top: 0.125rem;
|
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 {
|
.logoutBtn {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
import styles from './AppLayout.module.css';
|
import styles from './AppLayout.module.css';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
|
@ -64,11 +65,22 @@ export function AppLayout() {
|
||||||
if (e.key === 'Enter' || e.key === ' ') navigate('/profile');
|
if (e.key === 'Enter' || e.key === ' ') navigate('/profile');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className={styles.userProfileInner}>
|
||||||
|
<UserAvatar
|
||||||
|
firstName={user?.firstName ?? ''}
|
||||||
|
lastName={user?.lastName ?? ''}
|
||||||
|
avatar={user?.avatar}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<div className={styles.userDetails}>
|
||||||
<div className={styles.userName}>
|
<div className={styles.userName}>
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.userEmail}>{user?.email}</div>
|
<div className={styles.userEmail}>{user?.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.profileHint}>Zum Profil →</div>
|
||||||
|
</div>
|
||||||
<button className={styles.logoutBtn} onClick={handleLogout}>
|
<button className={styles.logoutBtn} onClick={handleLogout}>
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
51
packages/frontend/src/utils/imageUtils.ts
Normal file
51
packages/frontend/src/utils/imageUtils.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Skaliert ein Bild auf maximal maxWidth x maxHeight und gibt es als Base64 Data-URL zurueck.
|
||||||
|
* Das Seitenverhaeltnis bleibt erhalten.
|
||||||
|
* Ausgabeformat: JPEG mit Qualitaet 0.85 (guter Kompromiss aus Qualitaet und Groesse).
|
||||||
|
*/
|
||||||
|
export function resizeImageToBase64(
|
||||||
|
file: File,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number,
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
img.onload = () => {
|
||||||
|
let { width, height } = img;
|
||||||
|
|
||||||
|
// Skalierung berechnen (Seitenverhaeltnis beibehalten)
|
||||||
|
if (width > maxWidth || height > maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||||
|
width = Math.round(width * ratio);
|
||||||
|
height = Math.round(height * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas erstellen und Bild zeichnen
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Canvas-Kontext konnte nicht erstellt werden'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Als JPEG Base64 exportieren (kleinere Dateien als PNG)
|
||||||
|
const base64 = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => reject(new Error('Bild konnte nicht geladen werden'));
|
||||||
|
img.src = reader.result as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue