feat(quick-260630-gbh-01): account settings page — avatar upload + conditional password form
- 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
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</div>
|
||||
<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
|
||||
href="/settings/general/smtp"
|
||||
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';
|
||||
tenantId: string;
|
||||
mustChangePassword: boolean;
|
||||
isLocalUser?: boolean;
|
||||
hasAvatar?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
@@ -157,6 +159,47 @@ export async function changePasswordAction(
|
||||
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.
|
||||
* Uses the session cookie for authentication.
|
||||
|
||||
@@ -114,7 +114,18 @@
|
||||
"categoryWidgets": "Widgets",
|
||||
"categoryCalendar": "Kalender",
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryAccount": "Konto",
|
||||
"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?",
|
||||
"sourceDeleteCta": "Quelle loeschen",
|
||||
"providerDeleteConfirm": "Moechten Sie diesen Suchanbieter wirklich loeschen?",
|
||||
|
||||
@@ -114,7 +114,18 @@
|
||||
"categoryWidgets": "Widgets",
|
||||
"categoryCalendar": "Calendar",
|
||||
"categoryGeneral": "General",
|
||||
"categoryAccount": "Account",
|
||||
"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?",
|
||||
"sourceDeleteCta": "Delete source",
|
||||
"providerDeleteConfirm": "Are you sure you want to delete this search provider?",
|
||||
|
||||
Reference in New Issue
Block a user