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() {