diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 063ede7d..c536aded 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -451,6 +451,8 @@ "fieldRole": "Role", "fieldProgram": "Program", "fieldRegion": "Region", + "fieldProfilePicture": "Profile Picture", + "fieldProfilePictureUpdated": "Updated", "saveSuccess": "User saved successfully!", "emailExistsTitle": "Email already exists", "emailExistsDesc": "This email is already registered. Please use a different email address.", diff --git a/client/public/locales/es/translation.json b/client/public/locales/es/translation.json index d060f49e..488e3cf7 100644 --- a/client/public/locales/es/translation.json +++ b/client/public/locales/es/translation.json @@ -451,6 +451,8 @@ "fieldRole": "Rol", "fieldProgram": "Programa", "fieldRegion": "Región", + "fieldProfilePicture": "Foto de perfil", + "fieldProfilePictureUpdated": "Actualizada", "saveSuccess": "¡Usuario guardado exitosamente!", "emailExistsTitle": "El correo electrónico ya existe", "emailExistsDesc": "Este correo electrónico ya está registrado. Utilice una dirección de correo electrónico diferente.", diff --git a/client/public/locales/fr/translation.json b/client/public/locales/fr/translation.json index 54f2054d..dd1132c3 100644 --- a/client/public/locales/fr/translation.json +++ b/client/public/locales/fr/translation.json @@ -451,6 +451,8 @@ "fieldRole": "Rôle", "fieldProgram": "Programme", "fieldRegion": "Région", + "fieldProfilePicture": "Photo de profil", + "fieldProfilePictureUpdated": "Mise à jour", "saveSuccess": "L'utilisateur a été enregistré avec succès !", "emailExistsTitle": "L'e-mail existe déjà", "emailExistsDesc": "Cet e-mail est déjà enregistré. Veuillez utiliser une autre adresse e-mail.", diff --git a/client/public/locales/zh/translation.json b/client/public/locales/zh/translation.json index 0e357f0c..19c17697 100644 --- a/client/public/locales/zh/translation.json +++ b/client/public/locales/zh/translation.json @@ -447,6 +447,8 @@ "fieldRole": "角色", "fieldProgram": "程序", "fieldRegion": "地区", + "fieldProfilePicture": "头像", + "fieldProfilePictureUpdated": "已更新", "saveSuccess": "用户保存成功!", "emailExistsTitle": "电子邮件已存在", "emailExistsDesc": "此邮箱号已被注册。请使用不同的电子邮件地址。", diff --git a/client/src/components/accounts/AccountForm/AccountFormDrawer.jsx b/client/src/components/accounts/AccountForm/AccountFormDrawer.jsx index a4aa092f..201972ba 100644 --- a/client/src/components/accounts/AccountForm/AccountFormDrawer.jsx +++ b/client/src/components/accounts/AccountForm/AccountFormDrawer.jsx @@ -544,6 +544,7 @@ export const AccountFormDrawer = ({ diff --git a/client/src/components/accounts/AccountForm/changedFields.js b/client/src/components/accounts/AccountForm/changedFields.js index d72bef40..0824bd41 100644 --- a/client/src/components/accounts/AccountForm/changedFields.js +++ b/client/src/components/accounts/AccountForm/changedFields.js @@ -1,6 +1,18 @@ -export function computeChangedFields(formData, initialFormData, t) { +export function computeChangedFields( + formData, + initialFormData, + t, + pictureChanged = false +) { const mask = t('accountForm.passwordMaskStars'); const changes = []; + if (pictureChanged) { + changes.push({ + label: t('accountForm.fieldProfilePicture'), + old: '', + new: t('accountForm.fieldProfilePictureUpdated'), + }); + } if (formData.first_name !== initialFormData.first_name) { changes.push({ label: t('accountForm.fieldFirstName'), diff --git a/client/src/components/accounts/AccountForm/constants.js b/client/src/components/accounts/AccountForm/constants.js index 8899fcee..6ddd3c99 100644 --- a/client/src/components/accounts/AccountForm/constants.js +++ b/client/src/components/accounts/AccountForm/constants.js @@ -36,6 +36,7 @@ export const formStateToAuditSnapshot = (fd, meta = {}) => ({ ...(fd.role === 'Program Director' ? { bio: String(fd.bio ?? '').trim() || null } : {}), + ...(meta.picture !== undefined ? { picture: meta.picture } : {}), ...(meta.currentUserId !== undefined && meta.currentUserId !== null ? { currentUserId: meta.currentUserId } : {}), diff --git a/client/src/components/accounts/AccountForm/index.jsx b/client/src/components/accounts/AccountForm/index.jsx index 31c38499..be5d1e59 100644 --- a/client/src/components/accounts/AccountForm/index.jsx +++ b/client/src/components/accounts/AccountForm/index.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useDisclosure, useToast } from '@chakra-ui/react'; @@ -34,6 +34,8 @@ export const AccountForm = ({ targetUser, isOpen, onClose, onSave }) => { const [currentRegions, setCurrentRegions] = useState(null); const [profilePictureUrl, setProfilePictureUrl] = useState(''); const [profilePictureKey, setProfilePictureKey] = useState(''); + const [initialProfilePictureKey, setInitialProfilePictureKey] = useState(''); + const pictureRequestIdRef = useRef(0); const exitModal = useDisclosure(); const deleteModal = useDisclosure(); @@ -46,23 +48,34 @@ export const AccountForm = ({ targetUser, isOpen, onClose, onSave }) => { }); const isDirty = useMemo(() => { - return JSON.stringify(formData) !== JSON.stringify(initialFormData); - }, [formData, initialFormData]); + return ( + JSON.stringify(formData) !== JSON.stringify(initialFormData) || + profilePictureKey !== initialProfilePictureKey + ); + }, [formData, initialFormData, profilePictureKey, initialProfilePictureKey]); const changedFields = useMemo( - () => computeChangedFields(formData, initialFormData, t), - [formData, initialFormData, t] + () => + computeChangedFields( + formData, + initialFormData, + t, + profilePictureKey !== initialProfilePictureKey + ), + [formData, initialFormData, t, profilePictureKey, initialProfilePictureKey] ); // Reset form when drawer opens useEffect(() => { if (!isOpen) return; + pictureRequestIdRef.current += 1; setValidationErrors({}); setShowPassword(false); setIsFullScreen(false); setProfilePictureUrl(''); setProfilePictureKey(''); + setInitialProfilePictureKey(''); if (!targetUser) { const newState = { @@ -103,13 +116,17 @@ export const AccountForm = ({ targetUser, isOpen, onClose, onSave }) => { } if (targetUser.picture) { + setProfilePictureKey(targetUser.picture); + setInitialProfilePictureKey(targetUser.picture); + + const requestId = ++pictureRequestIdRef.current; const fetchPictureUrl = async () => { try { const urlResponse = await backend.get( `/images/url/${encodeURIComponent(targetUser.picture)}` ); + if (pictureRequestIdRef.current !== requestId) return; setProfilePictureUrl(urlResponse.data.url || ''); - setProfilePictureKey(targetUser.picture); } catch { // picture unavailable — leave blank } @@ -319,47 +336,31 @@ export const AccountForm = ({ targetUser, isOpen, onClose, onSave }) => { if (!uploadedFiles?.length) return; const key = uploadedFiles[0].s3_key; + const previousStagedKey = + profilePictureKey && profilePictureKey !== initialProfilePictureKey + ? profilePictureKey + : null; + + setProfilePictureKey(key); + + if (previousStagedKey && previousStagedKey !== key) { + backend + .delete(`/images/${encodeURIComponent(previousStagedKey)}`) + .catch((err) => { + console.error('Error deleting replaced profile picture upload:', err); + }); + } + const requestId = ++pictureRequestIdRef.current; try { const urlResponse = await backend.get( `/images/url/${encodeURIComponent(key)}` ); - const nextUrl = urlResponse.data.url || ''; - const prevKey = profilePictureKey || null; - if (targetUserId) { - await backend.post('/images/profile-picture', { - key, - userId: targetUserId, - }); - - await logAccountChange({ - user_id: String(targetUserId), - author_id: String(userId), - change_type: 'Update', - old_values: { - first_name: formData.first_name, - last_name: formData.last_name, - email: formData.email, - picture: prevKey, - bio: '', - }, - new_values: { - first_name: formData.first_name, - last_name: formData.last_name, - email: formData.email, - picture: key, - bio: '', - }, - resolved: false, - last_modified: new Date().toISOString(), - }); - } - - setProfilePictureKey(key); - setProfilePictureUrl(nextUrl); + if (pictureRequestIdRef.current !== requestId) return; + setProfilePictureUrl(urlResponse.data.url || ''); } catch (err) { - console.error('Error saving profile picture:', err); + console.error('Error loading profile picture preview:', err); } }; @@ -437,6 +438,15 @@ export const AccountForm = ({ targetUser, isOpen, onClose, onSave }) => { if (formData.password && formData.password.trim().length > 0) { userData.password = formData.password; } + const pictureChanged = profilePictureKey !== initialProfilePictureKey; + if (pictureChanged) { + await backend.post('/images/profile-picture', { + key: profilePictureKey || null, + userId: targetUserId, + }); + setInitialProfilePictureKey(profilePictureKey); + } + await backend.put('/gcf-users/admin/update-user', userData); if (userId) { @@ -447,10 +457,14 @@ export const AccountForm = ({ targetUser, isOpen, onClose, onSave }) => { old_values: formStateToAuditSnapshot(initialFormData, { currentUserId: userId, targetId: targetUserId, + ...(pictureChanged + ? { picture: initialProfilePictureKey || null } + : {}), }), new_values: formStateToAuditSnapshot(formData, { currentUserId: userId, targetId: targetUserId, + ...(pictureChanged ? { picture: profilePictureKey || null } : {}), }), resolved: true, last_modified: new Date().toISOString(), @@ -580,6 +594,19 @@ export const AccountForm = ({ targetUser, isOpen, onClose, onSave }) => { isOpen={exitModal.isOpen} onClose={exitModal.onClose} onExitWithoutSaving={() => { + if ( + profilePictureKey && + profilePictureKey !== initialProfilePictureKey + ) { + backend + .delete(`/images/${encodeURIComponent(profilePictureKey)}`) + .catch((err) => { + console.error( + 'Error deleting unsaved profile picture upload:', + err + ); + }); + } exitModal.onClose(); onClose(); }} diff --git a/client/src/components/profile/ProfileView.jsx b/client/src/components/profile/ProfileView.jsx index ad938170..cec3b2c9 100644 --- a/client/src/components/profile/ProfileView.jsx +++ b/client/src/components/profile/ProfileView.jsx @@ -55,6 +55,7 @@ export const ProfileView = (props) => { role, roleSpecificData, isEditing, + isSaving, formData, showPassword, setShowPassword, @@ -178,6 +179,7 @@ export const ProfileView = (props) => { bottom={2} right={2} onClick={onOpen} + isDisabled={isSaving} _hover={{ bg: 'gray.100' }} /> )} @@ -476,6 +478,7 @@ export const ProfileView = (props) => { leftIcon={} variant="outline" onClick={handleCancel} + isDisabled={isSaving} > {t('common.cancel')} @@ -485,6 +488,7 @@ export const ProfileView = (props) => { color="white" _hover={{ bg: 'teal.600' }} onClick={handleSave} + isLoading={isSaving} > {t('common.save')} diff --git a/client/src/components/profile/useProfile.js b/client/src/components/profile/useProfile.js index 48560877..ca576a24 100644 --- a/client/src/components/profile/useProfile.js +++ b/client/src/components/profile/useProfile.js @@ -29,11 +29,14 @@ export const useProfile = () => { const [roleSpecificData, setRoleSpecificData] = useState(null); const [loading, setLoading] = useState(true); const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [showPassword, setShowPassword] = useState(false); const [passwordEdited, setPasswordEdited] = useState(false); const [newPassword, setNewPassword] = useState(''); const profileEditBaselineRef = useRef(null); + const editSessionRef = useRef(0); + const isSavingRef = useRef(false); const [pendingPictureKey, setPendingPictureKey] = useState(null); const [pendingPicturePreviewUrl, setPendingPicturePreviewUrl] = useState(null); @@ -187,64 +190,54 @@ export const useProfile = () => { const key = uploadedFiles[0].s3_key; + if (isSavingRef.current) { + backend.delete(`/images/${encodeURIComponent(key)}`).catch((err) => { + console.error( + 'Error deleting profile picture uploaded during save:', + err + ); + }); + return; + } + + const sessionId = editSessionRef.current; + const previousStagedKey = + pendingPictureKey && pendingPictureKey !== (gcfUser?.pictureKey || null) + ? pendingPictureKey + : null; + try { const urlResponse = await backend.get( `/images/url/${encodeURIComponent(key)}` ); - if (role === 'Program Director') { - setPendingPictureKey(key); - setPendingPicturePreviewUrl(urlResponse.data.url); + if (sessionId !== editSessionRef.current) { + backend.delete(`/images/${encodeURIComponent(key)}`).catch((err) => { + console.error('Error deleting stale profile picture upload:', err); + }); return; } - await backend.post('/images/profile-picture', { - key: key, - userId: currentUser.uid, - }); - - const prevPictureKey = gcfUser?.pictureKey || null; - const nextPictureKey = key; - - if (prevPictureKey !== nextPictureKey) { - try { - await backend.post('/accountChange', { - user_id: currentUser.uid, - author_id: currentUser.uid, - change_type: 'Update', - old_values: { - first_name: gcfUser?.firstName || '', - last_name: gcfUser?.lastName || '', - email: currentUser?.email || '', - picture: prevPictureKey, - bio: '', - }, - new_values: { - first_name: gcfUser?.firstName || '', - last_name: gcfUser?.lastName || '', - email: currentUser?.email || '', - picture: nextPictureKey, - bio: '', - }, - resolved: false, - last_modified: new Date().toISOString(), + setPendingPictureKey(key); + setPendingPicturePreviewUrl(urlResponse.data.url); + + if (previousStagedKey && previousStagedKey !== key) { + backend + .delete(`/images/${encodeURIComponent(previousStagedKey)}`) + .catch((err) => { + console.error( + 'Error deleting replaced profile picture upload:', + err + ); }); - } catch (changeErr) { - console.error('Error logging account change:', changeErr); - } } - - setGcfUser((prev) => ({ - ...prev, - pictureKey: nextPictureKey, - picture: urlResponse.data.url, - })); } catch (err) { console.error('Error saving profile picture:', err); } }; const handleEdit = () => { + editSessionRef.current += 1; const prefLang = gcfUser.preferredLanguage && isAppLocale(String(gcfUser.preferredLanguage)) @@ -270,6 +263,20 @@ export const useProfile = () => { }; const handleCancel = () => { + editSessionRef.current += 1; + if ( + pendingPictureKey && + pendingPictureKey !== (gcfUser?.pictureKey || null) + ) { + backend + .delete(`/images/${encodeURIComponent(pendingPictureKey)}`) + .catch((err) => { + console.error( + 'Error deleting discarded profile picture upload:', + err + ); + }); + } setIsEditing(false); setShowPassword(false); setNewPassword(''); @@ -280,6 +287,9 @@ export const useProfile = () => { }; const saveProfileEdits = async () => { + editSessionRef.current += 1; + isSavingRef.current = true; + setIsSaving(true); try { if (role === 'Program Director') { const prefLang = @@ -408,18 +418,22 @@ export const useProfile = () => { return; } + const prevPictureKey = gcfUser?.pictureKey || null; + const nextPictureKey = pendingPictureKey ?? prevPictureKey; + const pictureChanged = nextPictureKey !== prevPictureKey; + const oldValues = { first_name: gcfUser?.firstName || '', last_name: gcfUser?.lastName || '', email: currentUser?.email || '', - picture: gcfUser?.pictureKey || null, + picture: prevPictureKey, bio: '', }; const newValues = { first_name: formData.firstName, last_name: formData.lastName, email: currentUser?.email || '', - picture: gcfUser?.pictureKey || null, + picture: nextPictureKey, bio: '', }; @@ -428,6 +442,13 @@ export const useProfile = () => { last_name: formData.lastName, }); + if (pictureChanged) { + await backend.post('/images/profile-picture', { + key: nextPictureKey, + userId: currentUser.uid, + }); + } + if (JSON.stringify(oldValues) !== JSON.stringify(newValues)) { try { await backend.post('/accountChange', { @@ -454,7 +475,12 @@ export const useProfile = () => { preferredLanguage: formData.language, firstName: formData.firstName, lastName: formData.lastName, + ...(pictureChanged + ? { pictureKey: nextPictureKey, picture: pendingPicturePreviewUrl } + : {}), })); + setPendingPictureKey(null); + setPendingPicturePreviewUrl(null); window.dispatchEvent(new Event('profile-updated')); const now = new Date(); @@ -489,6 +515,9 @@ export const useProfile = () => { variant: 'subtle', position: 'bottom-right', }); + } finally { + isSavingRef.current = false; + setIsSaving(false); } }; @@ -532,12 +561,11 @@ export const useProfile = () => { setFormData((prev) => ({ ...prev, [field]: e.target.value })); }; - const profilePicture = - pendingPicturePreviewUrl && role === 'Program Director' - ? pendingPicturePreviewUrl - : gcfUser?.picture && gcfUser.picture.trim() !== '' - ? gcfUser.picture - : DEFAULT_PROFILE_IMAGE; + const profilePicture = pendingPicturePreviewUrl + ? pendingPicturePreviewUrl + : gcfUser?.picture && gcfUser.picture.trim() !== '' + ? gcfUser.picture + : DEFAULT_PROFILE_IMAGE; const pdPending = role === 'Program Director' && !!pendingAccountChange; const ov = pendingAccountChange?.oldValues || {}; @@ -573,6 +601,7 @@ export const useProfile = () => { role, roleSpecificData, isEditing, + isSaving, formData, showPassword, setShowPassword,