diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma index 6e67512..99a03bd 100644 --- a/packages/core-service/prisma/core.schema.prisma +++ b/packages/core-service/prisma/core.schema.prisma @@ -24,6 +24,16 @@ model User { 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 + + // Kontaktdaten + phone String? @map("phone") @db.VarChar(30) + mobile String? @map("mobile") @db.VarChar(30) + + // Adresse + street String? @map("street") @db.VarChar(200) + postalCode String? @map("postal_code") @db.VarChar(10) + city String? @map("city") @db.VarChar(100) + 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/20260309000000_add_user_contact_fields/migration.sql b/packages/core-service/prisma/migrations/20260309000000_add_user_contact_fields/migration.sql new file mode 100644 index 0000000..94d27a5 --- /dev/null +++ b/packages/core-service/prisma/migrations/20260309000000_add_user_contact_fields/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable: Kontakt- und Adressfelder für Benutzerprofil +ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(30); +ALTER TABLE "users" ADD COLUMN "mobile" VARCHAR(30); +ALTER TABLE "users" ADD COLUMN "street" VARCHAR(200); +ALTER TABLE "users" ADD COLUMN "postal_code" VARCHAR(10); +ALTER TABLE "users" ADD COLUMN "city" VARCHAR(100); 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 111397a..998818b 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 @@ -40,4 +40,41 @@ export class UpdateUserDto { message: 'Profilbild muss ein gültiges Base64-Bild sein (JPEG, PNG, GIF oder WebP)', }) avatar?: string | null; + + // --- Kontaktdaten --- + @ApiProperty({ example: '+49 123 456789', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.phone !== null) + @IsString() + @MaxLength(30) + phone?: string | null; + + @ApiProperty({ example: '+49 170 1234567', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.mobile !== null) + @IsString() + @MaxLength(30) + mobile?: string | null; + + // --- Adresse --- + @ApiProperty({ example: 'Musterstraße 42', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.street !== null) + @IsString() + @MaxLength(200) + street?: string | null; + + @ApiProperty({ example: '12345', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.postalCode !== null) + @IsString() + @MaxLength(10) + postalCode?: string | null; + + @ApiProperty({ example: 'Berlin', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.city !== null) + @IsString() + @MaxLength(100) + city?: 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 e9f38b2..f678807 100644 --- a/packages/core-service/src/core/users/users.service.ts +++ b/packages/core-service/src/core/users/users.service.ts @@ -96,6 +96,11 @@ export class UsersService { firstName: user.firstName, lastName: user.lastName, avatar: user.avatar, + phone: user.phone, + mobile: user.mobile, + street: user.street, + postalCode: user.postalCode, + city: user.city, role: user.role, isActive: user.isActive, twoFactorEnabled: user.twoFactorEnabled, @@ -126,6 +131,11 @@ export class UsersService { lastName: dto.lastName, isActive: dto.isActive, ...(dto.avatar !== undefined && { avatar: dto.avatar }), + ...(dto.phone !== undefined && { phone: dto.phone }), + ...(dto.mobile !== undefined && { mobile: dto.mobile }), + ...(dto.street !== undefined && { street: dto.street }), + ...(dto.postalCode !== undefined && { postalCode: dto.postalCode }), + ...(dto.city !== undefined && { city: dto.city }), }, }); @@ -135,6 +145,11 @@ export class UsersService { firstName: updated.firstName, lastName: updated.lastName, avatar: updated.avatar, + phone: updated.phone, + mobile: updated.mobile, + street: updated.street, + postalCode: updated.postalCode, + city: updated.city, role: updated.role, isActive: updated.isActive, }; @@ -155,6 +170,11 @@ export class UsersService { ...(dto.firstName !== undefined && { firstName: dto.firstName }), ...(dto.lastName !== undefined && { lastName: dto.lastName }), ...(dto.avatar !== undefined && { avatar: dto.avatar }), + ...(dto.phone !== undefined && { phone: dto.phone }), + ...(dto.mobile !== undefined && { mobile: dto.mobile }), + ...(dto.street !== undefined && { street: dto.street }), + ...(dto.postalCode !== undefined && { postalCode: dto.postalCode }), + ...(dto.city !== undefined && { city: dto.city }), }, }); @@ -164,6 +184,11 @@ export class UsersService { firstName: updated.firstName, lastName: updated.lastName, avatar: updated.avatar, + phone: updated.phone, + mobile: updated.mobile, + street: updated.street, + postalCode: updated.postalCode, + city: updated.city, role: updated.role, isActive: updated.isActive, twoFactorEnabled: updated.twoFactorEnabled, diff --git a/packages/frontend/src/auth/AuthContext.tsx b/packages/frontend/src/auth/AuthContext.tsx index ce5a802..8f88831 100644 --- a/packages/frontend/src/auth/AuthContext.tsx +++ b/packages/frontend/src/auth/AuthContext.tsx @@ -14,6 +14,11 @@ interface User { firstName: string; lastName: string; avatar?: string | null; + phone?: string | null; + mobile?: string | null; + street?: string | null; + postalCode?: string | null; + city?: string | null; role: string; twoFactorEnabled: boolean; } diff --git a/packages/frontend/src/profile/ProfilePage.module.css b/packages/frontend/src/profile/ProfilePage.module.css index a9cedc2..be3ae44 100644 --- a/packages/frontend/src/profile/ProfilePage.module.css +++ b/packages/frontend/src/profile/ProfilePage.module.css @@ -54,6 +54,29 @@ border-bottom: 1px solid var(--color-border); } +/* === Profil-Layout (Avatar links, Formular rechts) === */ +.profileLayout { + display: flex; + gap: 2rem; + align-items: flex-start; +} + +.avatarColumn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + min-width: 140px; + flex-shrink: 0; + padding-top: 0.25rem; +} + +.formColumn { + flex: 1; + min-width: 0; +} + +/* === Formular === */ .form { display: flex; flex-direction: column; @@ -64,6 +87,7 @@ display: flex; flex-direction: column; gap: 0.375rem; + flex: 1; } .field label { @@ -105,6 +129,31 @@ flex: 1; } +.fieldSmall { + flex: 0 0 120px !important; +} + +/* === Fieldset-Gruppen === */ +.fieldGroup { + border: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.fieldGroupLegend { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + padding: 0; + margin-bottom: 0.125rem; +} + +/* === Buttons === */ .button { padding: 0.625rem 1.25rem; background: var(--color-primary); @@ -128,12 +177,12 @@ } .buttonSecondary { - padding: 0.625rem 1.25rem; + padding: 0.5rem 1rem; background: transparent; color: var(--color-text-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-sm); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 500; cursor: pointer; transition: all 0.15s; @@ -144,12 +193,12 @@ } .buttonDanger { - padding: 0.625rem 1.25rem; + padding: 0.5rem 1rem; background: var(--color-error); color: white; border: none; border-radius: var(--radius-sm); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 600; cursor: pointer; transition: background 0.15s; @@ -171,6 +220,7 @@ align-items: center; } +/* === Meldungen === */ .success { background: #f0fdf4; color: var(--color-success); @@ -189,6 +239,7 @@ border: 1px solid #fecaca; } +/* === 2FA === */ .tfaStatus { display: flex; align-items: center; @@ -244,17 +295,34 @@ border: 1px solid var(--color-border); } -/* === Avatar === */ -.avatarSection { - display: flex; - flex-direction: column; +/* === 2FA Warn-Badge === */ +.tfaWarning { + display: inline-flex; align-items: center; - gap: 0.75rem; - padding-bottom: 1.25rem; - margin-bottom: 1.25rem; - border-bottom: 1px solid var(--color-border); + gap: 0.375rem; + padding: 0.25rem 0.75rem; + background: #fff7ed; + color: #c2410c; + border: 1px solid #fed7aa; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; } +.tfaWarning::before { + content: '\26A0'; + font-size: 0.875rem; +} + +.tfaWarning:hover { + background: #ffedd5; + border-color: #fdba74; +} + +/* === Avatar === */ .avatarPreview { position: relative; cursor: pointer; @@ -288,9 +356,45 @@ .avatarHint { color: var(--color-text-muted); - font-size: 0.75rem; + font-size: 0.6875rem; + text-align: center; } .hiddenInput { display: none; } + +/* === Responsive === */ +@media (max-width: 640px) { + .profileLayout { + flex-direction: column; + align-items: center; + } + + .avatarColumn { + min-width: unset; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--color-border); + width: 100%; + } + + .fieldRow { + flex-direction: column; + gap: 1rem; + } + + .fieldSmall { + flex: 1 !important; + } + + .tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .tab { + padding: 0.625rem 1rem; + font-size: 0.875rem; + white-space: nowrap; + } +} diff --git a/packages/frontend/src/profile/ProfilePage.tsx b/packages/frontend/src/profile/ProfilePage.tsx index 7b94f84..5c6d54c 100644 --- a/packages/frontend/src/profile/ProfilePage.tsx +++ b/packages/frontend/src/profile/ProfilePage.tsx @@ -13,6 +13,11 @@ export function ProfilePage() { // --- Persönliche Informationen --- const [firstName, setFirstName] = useState(user?.firstName ?? ''); const [lastName, setLastName] = useState(user?.lastName ?? ''); + const [phone, setPhone] = useState(user?.phone ?? ''); + const [mobile, setMobile] = useState(user?.mobile ?? ''); + const [street, setStreet] = useState(user?.street ?? ''); + const [postalCode, setPostalCode] = useState(user?.postalCode ?? ''); + const [city, setCity] = useState(user?.city ?? ''); const [profileMsg, setProfileMsg] = useState(''); const [profileError, setProfileError] = useState(''); const [profileLoading, setProfileLoading] = useState(false); @@ -47,7 +52,7 @@ 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) + // 2FA-Status mit Context-User synchronisieren useEffect(() => { if (user?.twoFactorEnabled !== undefined) { setTwoFactorEnabled(user.twoFactorEnabled); @@ -61,6 +66,17 @@ export function ProfilePage() { } }, [user?.avatar]); + // Kontaktdaten mit Context-User synchronisieren + useEffect(() => { + if (user) { + setPhone(user.phone ?? ''); + setMobile(user.mobile ?? ''); + setStreet(user.street ?? ''); + setPostalCode(user.postalCode ?? ''); + setCity(user.city ?? ''); + } + }, [user?.phone, user?.mobile, user?.street, user?.postalCode, user?.city]); + // === Handler: Profilbild hochladen === const handleAvatarChange = async (e: ChangeEvent) => { const file = e.target.files?.[0]; @@ -128,7 +144,15 @@ export function ProfilePage() { setProfileLoading(true); try { - await api.patch('/users/me', { firstName, lastName }); + await api.patch('/users/me', { + firstName, + lastName, + phone: phone || null, + mobile: mobile || null, + street: street || null, + postalCode: postalCode || null, + city: city || null, + }); await refreshUser(); setProfileMsg('Profil erfolgreich aktualisiert'); } catch (err: unknown) { @@ -267,9 +291,26 @@ export function ProfilePage() { fontSize: '1.5rem', fontWeight: 600, marginBottom: '1.5rem', + display: 'flex', + alignItems: 'center', + gap: '0.75rem', }} > Mein Profil + {!twoFactorEnabled && ( + setActiveTab('password')} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setActiveTab('password'); + }} + title="Klicken, um 2FA zu aktivieren" + > + 2FA nicht aktiv + + )} {/* === Tab-Leiste === */} @@ -279,7 +320,7 @@ export function ProfilePage() { className={`${styles.tab} ${activeTab === 'personal' ? styles.tabActive : ''}`} onClick={() => setActiveTab('personal')} > - Persönliche Informationen + Profil - {avatar && ( + +
+ Ändern +
+ + +
- )} + {avatar && ( + + )} +
+ {avatarMsg &&
{avatarMsg}
} + {avatarError &&
{avatarError}
} + + JPEG, PNG, GIF oder WebP. Max. 200x200px. + + + + {/* --- Rechte Spalte: Formular --- */} +
+
+ {profileMsg &&
{profileMsg}
} + {profileError &&
{profileError}
} + + {/* Name */} +
+ Name +
+
+ + setFirstName(e.target.value)} + required + maxLength={100} + /> +
+
+ + setLastName(e.target.value)} + required + maxLength={100} + /> +
+
+
+ + {/* E-Mail & Rolle */} +
+
+ + + E-Mail kann nicht geändert werden +
+
+ + +
+
+ + {/* Telefon */} +
+ Kontakt +
+
+ + setPhone(e.target.value)} + maxLength={30} + placeholder="+49 123 456789" + /> +
+
+ + setMobile(e.target.value)} + maxLength={30} + placeholder="+49 170 1234567" + /> +
+
+
+ + {/* Adresse */} +
+ Adresse +
+ + setStreet(e.target.value)} + maxLength={200} + placeholder="Musterstraße 42" + /> +
+
+
+ + setPostalCode(e.target.value)} + maxLength={10} + placeholder="12345" + /> +
+
+ + setCity(e.target.value)} + maxLength={100} + placeholder="Berlin" + /> +
+
+
+ + +
- {avatarMsg &&
{avatarMsg}
} - {avatarError &&
{avatarError}
} - - JPEG, PNG, GIF oder WebP. Wird auf 200x200 Pixel skaliert. - - -
- {profileMsg &&
{profileMsg}
} - {profileError &&
{profileError}
} - -
- - - E-Mail-Adresse kann nicht geändert werden -
- -
-
- - setFirstName(e.target.value)} - required - maxLength={100} - /> -
-
- - setLastName(e.target.value)} - required - maxLength={100} - /> -
-
- -
- - -
- - -
)} @@ -421,222 +542,224 @@ export function ProfilePage() { )} - {/* === Tab: Passwort ändern === */} + {/* === Tab: Passwort ändern + 2FA === */} {activeTab === 'password' && ( -
-

Passwort ändern

-
- {passwordMsg &&
{passwordMsg}
} - {passwordError && ( -
{passwordError}
- )} + <> +
+

Passwort ändern

+ + {passwordMsg &&
{passwordMsg}
} + {passwordError && ( +
{passwordError}
+ )} -
- - setCurrentPassword(e.target.value)} - required - minLength={8} - /> -
- -
- - setNewPassword(e.target.value)} - required - minLength={8} - /> -
- -
- - setConfirmPassword(e.target.value)} - required - minLength={8} - /> -
- - - -
- )} - - {/* === Sicherheit: Zwei-Faktor-Authentifizierung (immer sichtbar) === */} -
-

- Zwei-Faktor-Authentifizierung (2FA) -

- - {tfaMsg &&
{tfaMsg}
} - {tfaError &&
{tfaError}
} - -
- - - {twoFactorEnabled - ? '2FA ist aktiviert' - : '2FA ist nicht aktiviert'} - -
- - {/* 2FA NICHT aktiviert: Setup starten */} - {!twoFactorEnabled && !setupData && ( - - )} - - {/* QR-Code anzeigen + Verifizierung */} - {!twoFactorEnabled && setupData && ( -
-

- Scannen Sie den QR-Code mit Ihrer Authenticator-App (z.B. Google - Authenticator, Authy): -

- -
- QR-Code für 2FA -
- -
- - {setupData.secret} -
- -
- + setTotpCode(e.target.value)} - placeholder="6-stelliger Code" - maxLength={6} - pattern="[0-9]{6}" + id="currentPassword" + type="password" + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} required - autoFocus + minLength={8} /> - - Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein -
-
- - +
+ + setNewPassword(e.target.value)} + required + minLength={8} + />
- -
- )} - {/* 2FA aktiviert: Deaktivieren-Option */} - {twoFactorEnabled && !showDisableConfirm && ( - - )} +
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
- {/* Deaktivierung mit Passwort bestätigen */} - {twoFactorEnabled && showDisableConfirm && ( -
-

- Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren: -

-
- - setDisablePassword(e.target.value)} - required - minLength={8} - autoFocus - /> -
-
+ +
+ + {/* === 2FA Sektion (innerhalb Passwort-Tab) === */} +
+

+ Zwei-Faktor-Authentifizierung (2FA) +

+ + {tfaMsg &&
{tfaMsg}
} + {tfaError &&
{tfaError}
} + +
+ + + {twoFactorEnabled + ? '2FA ist aktiviert' + : '2FA ist nicht aktiviert'} + +
+ + {/* 2FA NICHT aktiviert: Setup starten */} + {!twoFactorEnabled && !setupData && ( -
- - )} -
+ )} + + {/* QR-Code anzeigen + Verifizierung */} + {!twoFactorEnabled && setupData && ( +
+

+ Scannen Sie den QR-Code mit Ihrer Authenticator-App (z.B. Google + Authenticator, Authy): +

+ +
+ QR-Code für 2FA +
+ +
+ + {setupData.secret} +
+ +
+
+ + setTotpCode(e.target.value)} + placeholder="6-stelliger Code" + maxLength={6} + pattern="[0-9]{6}" + required + autoFocus + /> + + Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein + +
+ +
+ + +
+
+
+ )} + + {/* 2FA aktiviert: Deaktivieren-Option */} + {twoFactorEnabled && !showDisableConfirm && ( + + )} + + {/* Deaktivierung mit Passwort bestätigen */} + {twoFactorEnabled && showDisableConfirm && ( +
+

+ Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren: +

+
+ + setDisablePassword(e.target.value)} + required + minLength={8} + autoFocus + /> +
+
+ + +
+
+ )} +
+ + )}
); }