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,