merge(quick-260630-gbh): User Settings — Passwort ändern (non-LDAP) + Profilbild
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "avatarPath" TEXT;
|
||||||
@@ -39,6 +39,7 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
|
avatarPath String?
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ export class AuthController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /auth/me
|
* 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')
|
@Get('me')
|
||||||
me(@CurrentUser() user: any) {
|
async me(@CurrentUser() user: any) {
|
||||||
return user;
|
return this.authService.getMe(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -182,6 +182,40 @@ export class AuthService {
|
|||||||
this.logger.log(`Password reset completed for user ${resetToken.userId}`);
|
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.
|
* Change password for the currently logged-in user.
|
||||||
* Verifies current password before allowing change.
|
* Verifies current password before allowing change.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -8,9 +9,16 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Role } from '@prisma/client';
|
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 { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
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 { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
|
/** Map accepted MIME types to file extensions (T-gbh-01). */
|
||||||
|
const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
||||||
|
'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')
|
@Controller('users')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
export class UserController {
|
export class UserController {
|
||||||
@@ -194,4 +215,101 @@ export class UserController {
|
|||||||
await this.userService.delete(id);
|
await this.userService.delete(id);
|
||||||
return { message: 'User deleted' };
|
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<string, string> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||||
|
{t('account.title')}
|
||||||
|
</h1>
|
||||||
|
<AccountSettingsForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export function Header() {
|
|||||||
displayName: u.displayName,
|
displayName: u.displayName,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
tenantId: u.tenantId,
|
tenantId: u.tenantId,
|
||||||
|
hasAvatar: u.hasAvatar,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -53,6 +54,8 @@ export function Header() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [dropdownOpen]);
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
|
||||||
const userInitial = user
|
const userInitial = user
|
||||||
? (user.displayName ?? user.username).charAt(0).toUpperCase()
|
? (user.displayName ?? user.username).charAt(0).toUpperCase()
|
||||||
: '?';
|
: '?';
|
||||||
@@ -104,10 +107,19 @@ export function Header() {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
className="h-8 w-8 rounded-full overflow-hidden bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||||
aria-label={tHeader('userMenu')}
|
aria-label={tHeader('userMenu')}
|
||||||
>
|
>
|
||||||
{userInitial}
|
{user?.hasAvatar && !avatarError ? (
|
||||||
|
<img
|
||||||
|
src="/api-proxy/users/me/avatar"
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{userInitial}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown menu */}
|
{/* Dropdown menu */}
|
||||||
|
|||||||
@@ -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<boolean | null>(null);
|
||||||
|
const [username, setUsername] = useState<string>('');
|
||||||
|
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<string | null>(null);
|
||||||
|
const [pwSuccess, setPwSuccess] = useState(false);
|
||||||
|
const [pwMismatch, setPwMismatch] = useState(false);
|
||||||
|
const [isPwPending, startPwTransition] = useTransition();
|
||||||
|
const pwFormRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
// Avatar upload state
|
||||||
|
const [avatarError, setAvatarError] = useState<string | null>(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<HTMLFormElement>) {
|
||||||
|
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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8 max-w-lg">
|
||||||
|
{/* ── Avatar section ─────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
{t('account.avatarLabel')}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
{/* Avatar preview with initial fallback */}
|
||||||
|
<div className="relative h-16 w-16 shrink-0">
|
||||||
|
{hasAvatar ? (
|
||||||
|
<img
|
||||||
|
key={avatarKey}
|
||||||
|
src={`/api-proxy/users/me/avatar?t=${avatarKey}`}
|
||||||
|
alt=""
|
||||||
|
className="h-16 w-16 rounded-full object-cover"
|
||||||
|
onError={() => setHasAvatar(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center text-xl font-medium text-primary-foreground">
|
||||||
|
{userInitial}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t('account.avatarHelp')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{avatarSuccess && (
|
||||||
|
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
|
||||||
|
{t('account.uploadSuccess')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{avatarError && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-3">
|
||||||
|
{t('account.uploadError')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={isAvatarPending}
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
/>
|
||||||
|
<span className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||||
|
{isAvatarPending ? '...' : t('account.uploadCta')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Password section ────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
{t('account.passwordSectionTitle')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isLocalUser === false && (
|
||||||
|
<div className="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{t('account.ldapManagedNotice')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLocalUser === true && (
|
||||||
|
<>
|
||||||
|
{pwSuccess && (
|
||||||
|
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-4">
|
||||||
|
{tAuth('changePassword.success')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pwError && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-4">
|
||||||
|
{tAuth(`changePassword.${pwError}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pwMismatch && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-4">
|
||||||
|
{tAuth('changePassword.passwordMismatch')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form ref={pwFormRef} onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">
|
||||||
|
{tAuth('changePassword.currentPassword')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="currentPassword"
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="newPassword" className="text-sm font-medium text-foreground">
|
||||||
|
{tAuth('changePassword.newPassword')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="confirmPassword" className="text-sm font-medium text-foreground">
|
||||||
|
{tAuth('changePassword.confirmPassword')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPwPending}
|
||||||
|
className="flex items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPwPending ? (
|
||||||
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
tAuth('changePassword.submit')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,19 @@ export function SettingsSidebar() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mb-2 flex flex-col gap-1 px-3">
|
<nav className="mb-2 flex flex-col gap-1 px-3">
|
||||||
|
<Link
|
||||||
|
href="/settings/general/account"
|
||||||
|
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
|
||||||
|
isActive('/settings/general/account')
|
||||||
|
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||||
|
: 'text-sidebar-foreground hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
aria-current={
|
||||||
|
isActive('/settings/general/account') ? 'page' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('categoryAccount')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/settings/general/smtp"
|
href="/settings/general/smtp"
|
||||||
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
|
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface AuthUser {
|
|||||||
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
|
isLocalUser?: boolean;
|
||||||
|
hasAvatar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
@@ -157,6 +159,47 @@ export async function changePasswordAction(
|
|||||||
redirect('/');
|
redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadAvatarResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a profile picture for the currently authenticated user.
|
||||||
|
* Forwards the multipart file to POST /users/me/avatar using the session cookie.
|
||||||
|
* Returns a discriminated result — does NOT redirect.
|
||||||
|
*/
|
||||||
|
export async function uploadAvatarAction(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<UploadAvatarResult> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get('session')?.value;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'notAuthenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/users/me/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Cookie: `session=${session}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return { success: false, error: data?.message ?? 'uploadError' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[uploadAvatarAction] fetch threw:', err);
|
||||||
|
return { success: false, error: 'networkError' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the current authenticated user from the API.
|
* Fetch the current authenticated user from the API.
|
||||||
* Uses the session cookie for authentication.
|
* Uses the session cookie for authentication.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface AuthUser {
|
|||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
hasAvatar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
@@ -114,7 +114,18 @@
|
|||||||
"categoryWidgets": "Widgets",
|
"categoryWidgets": "Widgets",
|
||||||
"categoryCalendar": "Kalender",
|
"categoryCalendar": "Kalender",
|
||||||
"categoryGeneral": "Allgemein",
|
"categoryGeneral": "Allgemein",
|
||||||
|
"categoryAccount": "Konto",
|
||||||
"categorySmtp": "SMTP",
|
"categorySmtp": "SMTP",
|
||||||
|
"account": {
|
||||||
|
"title": "Konto",
|
||||||
|
"avatarLabel": "Profilbild",
|
||||||
|
"avatarHelp": "Erlaubte Formate: PNG, JPEG, WebP. Maximale Groesse: 2 MB.",
|
||||||
|
"uploadCta": "Bild hochladen",
|
||||||
|
"uploadSuccess": "Profilbild wurde erfolgreich aktualisiert.",
|
||||||
|
"uploadError": "Hochladen fehlgeschlagen. Bitte pruefen Sie das Dateiformat und die Dateigröße.",
|
||||||
|
"ldapManagedNotice": "Ihr Passwort wird ueber das Verzeichnis (LDAP) verwaltet. Eine Aenderung ist hier nicht moeglich.",
|
||||||
|
"passwordSectionTitle": "Passwort aendern"
|
||||||
|
},
|
||||||
"sourceDeleteConfirm": "Moechten Sie diese Kalenderquelle wirklich loeschen?",
|
"sourceDeleteConfirm": "Moechten Sie diese Kalenderquelle wirklich loeschen?",
|
||||||
"sourceDeleteCta": "Quelle loeschen",
|
"sourceDeleteCta": "Quelle loeschen",
|
||||||
"providerDeleteConfirm": "Moechten Sie diesen Suchanbieter wirklich loeschen?",
|
"providerDeleteConfirm": "Moechten Sie diesen Suchanbieter wirklich loeschen?",
|
||||||
|
|||||||
@@ -114,7 +114,18 @@
|
|||||||
"categoryWidgets": "Widgets",
|
"categoryWidgets": "Widgets",
|
||||||
"categoryCalendar": "Calendar",
|
"categoryCalendar": "Calendar",
|
||||||
"categoryGeneral": "General",
|
"categoryGeneral": "General",
|
||||||
|
"categoryAccount": "Account",
|
||||||
"categorySmtp": "SMTP",
|
"categorySmtp": "SMTP",
|
||||||
|
"account": {
|
||||||
|
"title": "Account",
|
||||||
|
"avatarLabel": "Profile Picture",
|
||||||
|
"avatarHelp": "Allowed formats: PNG, JPEG, WebP. Maximum size: 2 MB.",
|
||||||
|
"uploadCta": "Upload picture",
|
||||||
|
"uploadSuccess": "Profile picture updated successfully.",
|
||||||
|
"uploadError": "Upload failed. Please check the file format and size.",
|
||||||
|
"ldapManagedNotice": "Your password is managed via the directory (LDAP) and cannot be changed here.",
|
||||||
|
"passwordSectionTitle": "Change Password"
|
||||||
|
},
|
||||||
"sourceDeleteConfirm": "Are you sure you want to delete this calendar source?",
|
"sourceDeleteConfirm": "Are you sure you want to delete this calendar source?",
|
||||||
"sourceDeleteCta": "Delete source",
|
"sourceDeleteCta": "Delete source",
|
||||||
"providerDeleteConfirm": "Are you sure you want to delete this search provider?",
|
"providerDeleteConfirm": "Are you sure you want to delete this search provider?",
|
||||||
|
|||||||
Reference in New Issue
Block a user