From 0fba45d2c2c4774108128b2a60aff3f9fba3257b Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 30 Jun 2026 11:57:33 +0200 Subject: [PATCH 1/3] feat(quick-260630-gbh-01): avatar storage endpoints + enriched /auth/me - Add avatarPath String? column to User model (migration: add_user_avatar) - POST /users/me/avatar: 2MB limit, image/png/jpeg/webp allowlist, writes to user-files/avatars/{userId}.{ext} - GET /users/me/avatar: streams avatar with Cache-Control: no-store - AuthService.getMe(): returns isLocalUser + hasAvatar without leaking passwordHash/ldapDn - AuthController GET /auth/me: now returns enriched profile via getMe() --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 1 + apps/api/src/auth/auth.controller.ts | 7 +- apps/api/src/auth/auth.service.ts | 34 +++++ apps/api/src/user/user.controller.ts | 118 ++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql diff --git a/apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql b/apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql new file mode 100644 index 0000000..f9c3b3a --- /dev/null +++ b/apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatarPath" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 902280a..05f9dd4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastLoginAt DateTime? + avatarPath String? passwordResetTokens PasswordResetToken[] @@index([tenantId]) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index a2c8820..7b815e7 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -53,11 +53,12 @@ export class AuthController { /** * GET /auth/me - * Returns the current user from JWT (session check). + * Returns enriched user profile: public fields + isLocalUser + hasAvatar. + * T-gbh-03: passwordHash and ldapDn are never serialised in the response. */ @Get('me') - me(@CurrentUser() user: any) { - return user; + async me(@CurrentUser() user: any) { + return this.authService.getMe(user.id); } /** diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index f815260..6e53649 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -182,6 +182,40 @@ export class AuthService { this.logger.log(`Password reset completed for user ${resetToken.userId}`); } + /** + * Return enriched profile for the currently authenticated user. + * T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never + * passwordHash or ldapDn. + */ + async getMe(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + displayName: true, + role: true, + tenantId: true, + mustChangePassword: true, + passwordHash: true, + ldapDn: true, + avatarPath: true, + }, + }); + + if (!user) { + return null; + } + + const { passwordHash, ldapDn, avatarPath, ...publicFields } = user; + + return { + ...publicFields, + isLocalUser: !!passwordHash && !ldapDn, + hasAvatar: !!avatarPath, + }; + } + /** * Change password for the currently logged-in user. * Verifies current password before allowing change. diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 4c0d0e3..28f53ea 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -8,9 +9,16 @@ import { Param, Patch, Post, + Res, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { Role } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Response } from 'express'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -19,6 +27,19 @@ import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserService } from './user.service'; +/** Map accepted MIME types to file extensions (T-gbh-01). */ +const AVATAR_MIME_TO_EXT: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', +}; + +/** Resolve the avatars storage directory relative to the monorepo root. + * At runtime __dirname = apps/api/dist/user/ → go up 4 levels. */ +function resolveAvatarsDir(): string { + return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'avatars'); +} + @Controller('users') @UseGuards(RolesGuard) export class UserController { @@ -194,4 +215,101 @@ export class UserController { await this.userService.delete(id); return { message: 'User deleted' }; } + + // ─── Self-service avatar endpoints (all authenticated roles) ─────────────── + // No @Roles() → RolesGuard.canActivate() returns true when requiredRoles is + // empty (see guards/roles.guard.ts). Global JwtAuthGuard still enforces auth. + + /** + * POST /users/me/avatar + * Upload or replace the current user's profile picture. + * T-gbh-01: 2 MB size limit + image-only MIME allowlist. + * T-gbh-02: userId derived from @CurrentUser() only — never from request body. + * T-gbh-04: filename = {userId}.{ext} derived from MIME — no path traversal. + */ + @Post('me/avatar') + @UseInterceptors( + FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }), + ) + async uploadAvatar( + @UploadedFile() file: any, + @CurrentUser() currentUser: any, + ) { + if (!file || !file.buffer) { + throw new BadRequestException('No file provided'); + } + + const ext = AVATAR_MIME_TO_EXT[file.mimetype as string]; + if (!ext) { + throw new BadRequestException( + 'Invalid file type. Allowed: image/png, image/jpeg, image/webp', + ); + } + + const avatarsDir = resolveAvatarsDir(); + fs.mkdirSync(avatarsDir, { recursive: true }); + + const filename = `${currentUser.id}.${ext}`; + const filePath = path.join(avatarsDir, filename); + + // Remove any previous avatar files for this user (different extension) + for (const existingExt of Object.values(AVATAR_MIME_TO_EXT)) { + const candidate = path.join(avatarsDir, `${currentUser.id}.${existingExt}`); + if (candidate !== filePath && fs.existsSync(candidate)) { + fs.unlinkSync(candidate); + } + } + + fs.writeFileSync(filePath, file.buffer as Buffer); + + // Persist relative path (relative to monorepo root) + const relativePath = path.join('user-files', 'avatars', filename); + await this.prisma.user.update({ + where: { id: currentUser.id }, + data: { avatarPath: relativePath }, + }); + + return { success: true }; + } + + /** + * GET /users/me/avatar + * Stream the current user's avatar image. + * T-gbh-05: Only the authenticated user's own file is served here. + */ + @Get('me/avatar') + async getAvatar( + @CurrentUser() currentUser: any, + @Res() res: Response, + ) { + const user = await this.prisma.user.findUnique({ + where: { id: currentUser.id }, + select: { avatarPath: true }, + }); + + if (!user?.avatarPath) { + throw new NotFoundException('No avatar set'); + } + + // Resolve from monorepo root (same upward-walk pattern as DkvService) + const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..'); + const absolutePath = path.join(monorepoRoot, user.avatarPath); + + if (!fs.existsSync(absolutePath)) { + throw new NotFoundException('Avatar file not found'); + } + + const ext = path.extname(absolutePath).slice(1).toLowerCase(); + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + webp: 'image/webp', + }; + const contentType = mimeTypes[ext] ?? 'application/octet-stream'; + + const buffer = fs.readFileSync(absolutePath); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-store'); + res.send(buffer); + } } From 4482a8ce783deb7b480be688577fcd3f17321a6c Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 30 Jun 2026 12:00:40 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(quick-260630-gbh-01):=20account=20sett?= =?UTF-8?q?ings=20page=20=E2=80=94=20avatar=20upload=20+=20conditional=20p?= =?UTF-8?q?assword=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthUser interface: add isLocalUser? + hasAvatar? fields - uploadAvatarAction: server action forwarding file to POST /users/me/avatar - AccountSettingsForm: avatar preview with initial fallback + upload; password form only for local users; LDAP notice - /settings/general/account page mirroring smtp page structure - SettingsSidebar: Konto link above SMTP link - de.json + en.json: categoryAccount + account.* keys --- .../settings/general/account/page.tsx | 21 ++ .../settings/account-settings-form.tsx | 239 ++++++++++++++++++ .../components/settings/settings-sidebar.tsx | 13 + apps/web/src/lib/auth-actions.ts | 43 ++++ apps/web/src/messages/de.json | 11 + apps/web/src/messages/en.json | 11 + 6 files changed, 338 insertions(+) create mode 100644 apps/web/src/app/(portal)/settings/general/account/page.tsx create mode 100644 apps/web/src/components/settings/account-settings-form.tsx diff --git a/apps/web/src/app/(portal)/settings/general/account/page.tsx b/apps/web/src/app/(portal)/settings/general/account/page.tsx new file mode 100644 index 0000000..bd41d0e --- /dev/null +++ b/apps/web/src/app/(portal)/settings/general/account/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { AccountSettingsForm } from '@/components/settings/account-settings-form'; + +/** + * Account settings page — /settings/general/account. + * Shows avatar upload and (for local users only) password change form. + */ +export default function AccountSettingsPage() { + const t = useTranslations('settings'); + + return ( +
+

+ {t('account.title')} +

+ +
+ ); +} diff --git a/apps/web/src/components/settings/account-settings-form.tsx b/apps/web/src/components/settings/account-settings-form.tsx new file mode 100644 index 0000000..d038be5 --- /dev/null +++ b/apps/web/src/components/settings/account-settings-form.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { useState, useTransition, useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; +import { + fetchCurrentUser, + changePasswordAction, + uploadAvatarAction, +} from '@/lib/auth-actions'; + +export function AccountSettingsForm() { + const t = useTranslations('settings'); + const tAuth = useTranslations('auth'); + + const [isLocalUser, setIsLocalUser] = useState(null); + const [username, setUsername] = useState(''); + const [hasAvatar, setHasAvatar] = useState(false); + const [avatarSrc, setAvatarSrc] = useState('/api-proxy/users/me/avatar'); + const [avatarKey, setAvatarKey] = useState(0); + + // Password change state + const [pwError, setPwError] = useState(null); + const [pwSuccess, setPwSuccess] = useState(false); + const [pwMismatch, setPwMismatch] = useState(false); + const [isPwPending, startPwTransition] = useTransition(); + const pwFormRef = useRef(null); + + // Avatar upload state + const [avatarError, setAvatarError] = useState(null); + const [avatarSuccess, setAvatarSuccess] = useState(false); + const [isAvatarPending, startAvatarTransition] = useTransition(); + + useEffect(() => { + fetchCurrentUser().then((u) => { + if (u) { + setIsLocalUser(u.isLocalUser ?? false); + setUsername(u.displayName ?? u.username ?? ''); + setHasAvatar(!!u.hasAvatar); + } + }); + }, []); + + async function handlePasswordSubmit(e: React.FormEvent) { + e.preventDefault(); + setPwError(null); + setPwSuccess(false); + setPwMismatch(false); + + const formData = new FormData(e.currentTarget); + const currentPassword = formData.get('currentPassword') as string; + const newPassword = formData.get('newPassword') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + if (newPassword !== confirmPassword) { + setPwMismatch(true); + return; + } + + startPwTransition(async () => { + const result = await changePasswordAction(currentPassword, newPassword); + // changePasswordAction redirects to '/' on success, so we only reach here on error + if (result) { + setPwError(result.error); + } + }); + } + + async function handleAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setAvatarError(null); + setAvatarSuccess(false); + + const formData = new FormData(); + formData.append('file', file); + + startAvatarTransition(async () => { + const result = await uploadAvatarAction(formData); + if (result.success) { + setAvatarSuccess(true); + setHasAvatar(true); + // Cache-bust the avatar image to force reload + setAvatarKey((k) => k + 1); + } else { + setAvatarError(result.error ?? 'uploadError'); + } + }); + } + + const userInitial = username.charAt(0).toUpperCase() || '?'; + + return ( +
+ {/* ── Avatar section ─────────────────────────────────────── */} +
+

+ {t('account.avatarLabel')} +

+
+ {/* Avatar preview with initial fallback */} +
+ {hasAvatar ? ( + setHasAvatar(false)} + /> + ) : ( +
+ {userInitial} +
+ )} +
+
+ {t('account.avatarHelp')} +
+
+ + {avatarSuccess && ( +
+ {t('account.uploadSuccess')} +
+ )} + {avatarError && ( +
+ {t('account.uploadError')} +
+ )} + + +
+ + {/* ── Password section ────────────────────────────────────── */} +
+

+ {t('account.passwordSectionTitle')} +

+ + {isLocalUser === false && ( +
+ {t('account.ldapManagedNotice')} +
+ )} + + {isLocalUser === true && ( + <> + {pwSuccess && ( +
+ {tAuth('changePassword.success')} +
+ )} + {pwError && ( +
+ {tAuth(`changePassword.${pwError}`)} +
+ )} + {pwMismatch && ( +
+ {tAuth('changePassword.passwordMismatch')} +
+ )} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/settings-sidebar.tsx b/apps/web/src/components/settings/settings-sidebar.tsx index e6e4d0b..5ebb07e 100644 --- a/apps/web/src/components/settings/settings-sidebar.tsx +++ b/apps/web/src/components/settings/settings-sidebar.tsx @@ -45,6 +45,19 @@ export function SettingsSidebar() {