From 699f8f02d78c55214387d465245ffb0dd83dcea7 Mon Sep 17 00:00:00 2001 From: Mosh Feu Date: Sun, 7 Dec 2025 22:50:18 +0200 Subject: [PATCH 1/4] feat(edit-profile): allow custom avatar --- .../functions/modules/users/current.ts | 7 +- src/Me/Routes/Home/Avatar/Avatar.tsx | 145 ++++++++++++++-- src/Me/Routes/Home/Avatar/AvatarEditModal.tsx | 164 ++++++++++++++++++ src/components/MemberArea/EditProfile.js | 18 +- src/components/MemberArea/MemberArea.js | 8 +- src/components/MemberArea/model.js | 10 +- src/types/models.d.ts | 1 + 7 files changed, 327 insertions(+), 26 deletions(-) create mode 100644 src/Me/Routes/Home/Avatar/AvatarEditModal.tsx diff --git a/netlify/functions-src/functions/modules/users/current.ts b/netlify/functions-src/functions/modules/users/current.ts index c93faf293..aaee3cd3f 100644 --- a/netlify/functions-src/functions/modules/users/current.ts +++ b/netlify/functions-src/functions/modules/users/current.ts @@ -57,12 +57,13 @@ const getCurrentUserHandler: ApiHandler = async (_event: HandlerEvent, context: if (!auth0Id) { return error('Unauthorized: user not found', 401) } + const currentUser = await getCurrentUser(auth0Id); const applicationUser = { - ...await getCurrentUser(auth0Id), + ...currentUser, email_verified: context.user?.email_verified, - avatar: context.user?.picture, + avatar: currentUser.avatar || context.user?.picture, // Use custom avatar if set, otherwise Auth0 picture + auth0Picture: context.user?.picture, // Temporary field for client-side fallback (not in DB) }; - // TODO: remove avatar from the database return success({ data: applicationUser }) } diff --git a/src/Me/Routes/Home/Avatar/Avatar.tsx b/src/Me/Routes/Home/Avatar/Avatar.tsx index ba6225e1c..6eb091fce 100644 --- a/src/Me/Routes/Home/Avatar/Avatar.tsx +++ b/src/Me/Routes/Home/Avatar/Avatar.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import styled from 'styled-components/macro'; import { useUser } from '../../../../context/userContext/UserContext'; import Camera from '../../../../assets/me/camera.svg'; @@ -8,7 +8,9 @@ import { IconButton } from '../../../components/Button/IconButton'; import { Tooltip } from 'react-tippy'; import { toast } from 'react-toastify'; import { report } from '../../../../ga'; -import { RedirectToGravatar } from '../../../Modals/RedirectToGravatar'; +import { useApi } from '../../../../context/apiContext/ApiContext'; +import messages from '../../../../messages'; +import AvatarEditModal from './AvatarEditModal'; const ShareProfile = ({ url }: { url: string }) => { const [showInput, setShowInput] = React.useState(false); @@ -51,41 +53,145 @@ const ShareProfile = ({ url }: { url: string }) => { }; const Avatar: FC = () => { - const { currentUser } = useUser(); + const { currentUser, updateCurrentUser } = useUser(); + const api = useApi(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [primaryAvatarFailed, setPrimaryAvatarFailed] = useState(false); if (!currentUser) { return null; } + // Access auth0Picture dynamically - it's returned from API but not in schema + const auth0Picture = (currentUser as any).auth0Picture; + + // Reset failed state when avatar data changes + useEffect(() => { + setPrimaryAvatarFailed(false); + }, [currentUser.avatar, auth0Picture]); + + // Determine which avatar to display + // If primary avatar exists and hasn't failed, use it. Otherwise use auth0Picture + const displayAvatar = (!primaryAvatarFailed && currentUser.avatar) + ? currentUser.avatar + : auth0Picture; + + const isUsingCustomAvatar = currentUser.avatar && currentUser.avatar !== auth0Picture; + + const handleImageError = () => { + // Only mark as failed if this is the primary avatar, not the fallback + if (!primaryAvatarFailed) { + setPrimaryAvatarFailed(true); + } + }; + + const handleSaveAvatar = async (avatarUrl: string) => { + setIsSaving(true); + + try { + const updateMentorResult = await api.updateMentor({ + ...currentUser, + avatar: avatarUrl || null, // null to clear and use Auth0 default + }); + + if (updateMentorResult) { + api.clearCurrentUser(); + const updatedUser = await api.getCurrentUser(); + if (updatedUser) { + updateCurrentUser(updatedUser); + setPrimaryAvatarFailed(false); // Reset error state + toast.success('Avatar updated successfully'); + setIsModalOpen(false); + } + } else { + toast.error(messages.GENERIC_ERROR); + } + } catch (error) { + toast.error(messages.GENERIC_ERROR); + } finally { + setIsSaving(false); + } + }; + return ( -
- {currentUser && currentUser.avatar ? ( - - ) : ( - - )} - - Change your avatar on - -
+ + setIsModalOpen(true)}> + {displayAvatar ? ( + + ) : ( + + )} + + {isUsingCustomAvatar ? '📷' : '🔐'} + + + Click to change avatar +

{currentUser ? currentUser.name : ''}

{currentUser ? currentUser.title : ''}

+ + setIsModalOpen(false)} + onSave={handleSaveAvatar} + isSaving={isSaving} + />
); }; -// Styled components for the updated UI elements -const ChangeAvatarSection = styled.div` - margin: auto auto 10px; +// Styled components +const AvatarContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +`; + +const AvatarWrapper = styled.div` + position: relative; + cursor: pointer; + display: inline-block; + + &:hover img { + opacity: 0.9; + } +`; + +const AvatarSourceBadge = styled.div` + position: absolute; + top: 0; + right: 0; + width: 28px; + height: 28px; + background-color: white; + border: 2px solid #f0f0f0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +`; + +const AvatarHint = styled.p` + font-size: 12px; + color: #999; + margin: 0; + font-style: italic; `; const AvatarPlaceHolder = styled.img` @@ -101,6 +207,7 @@ const UserImage = styled.img` height: 100px; object-fit: cover; border-radius: 8px; + transition: opacity 0.2s ease; `; const Container = styled.div` diff --git a/src/Me/Routes/Home/Avatar/AvatarEditModal.tsx b/src/Me/Routes/Home/Avatar/AvatarEditModal.tsx new file mode 100644 index 000000000..b3f222e3b --- /dev/null +++ b/src/Me/Routes/Home/Avatar/AvatarEditModal.tsx @@ -0,0 +1,164 @@ +import React, { FC, useState } from 'react'; +import styled from 'styled-components/macro'; +import { Modal } from '../../../Modals/Modal'; +import { toast } from 'react-toastify'; +import { report } from '../../../../ga'; +import messages from '../../../../messages'; +import { getAvatarProviderInfo } from '../../../../helpers/authProvider'; + +type AvatarEditModalProps = { + isOpen: boolean; + currentAvatar?: string; + auth0Id?: string; + onClose: () => void; + onSave: (avatarUrl: string) => Promise; + isSaving?: boolean; +}; + +const AvatarEditModal: FC = ({ + isOpen, + currentAvatar = '', + auth0Id, + onClose, + onSave, + isSaving = false, +}) => { + const [avatarUrl, setAvatarUrl] = useState(currentAvatar); + const providerInfo = getAvatarProviderInfo(auth0Id); + + if (!isOpen) { + return null; + } + + const handleSave = async () => { + report('Avatar', 'save custom avatar url'); + try { + await onSave(avatarUrl); + } catch (error) { + toast.error(messages.GENERIC_ERROR); + } + }; + + const handleClear = () => { + setAvatarUrl(''); + }; + + const handleClose = () => { + setAvatarUrl(currentAvatar); + onClose(); + }; + + return ( + + + + setAvatarUrl(e.target.value)} + autoFocus + /> + {avatarUrl && ( + + × + + )} + + + + Enter any public image URL (Gravatar, Imgur, etc.) or leave empty to use your default profile picture + + + + Change your avatar on{' '} + + {providerInfo.label} + + + + + ); +}; + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 0; +`; + +const InputWrapper = styled.div` + position: relative; + display: flex; + align-items: center; +`; + +const AvatarInput = styled.input` + width: 100%; + padding: 10px 36px 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + + &:focus { + outline: none; + border-color: #179a6f; + box-shadow: 0 0 0 2px rgba(23, 154, 111, 0.1); + } +`; + +const ClearButton = styled.button` + position: absolute; + right: 10px; + background: none; + border: none; + font-size: 24px; + color: #999; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + + &:hover:not(:disabled) { + color: #666; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const HelpText = styled.div` + font-size: 13px; + color: #666; + line-height: 1.5; +`; + +const LinkSection = styled.div` + font-size: 13px; + color: #666; +`; + +const ProviderLink = styled.a` + color: #179a6f; + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } +`; + +export default AvatarEditModal; diff --git a/src/components/MemberArea/EditProfile.js b/src/components/MemberArea/EditProfile.js index 639ff8a06..f434c2599 100644 --- a/src/components/MemberArea/EditProfile.js +++ b/src/components/MemberArea/EditProfile.js @@ -164,8 +164,24 @@ export default class EditProfile extends Component { className="form-field-preview" src={getAvatarUrl(user[fieldName])} alt="avatar" + onError={(e) => { + if (user.auth0Picture && e.target.src !== user.auth0Picture) { + e.target.src = user.auth0Picture; + } + }} /> -
Change your avatar on
+ this.handleInputChangeEvent(e)} + className="input" + style={{ marginTop: '8px' }} + /> +
+ Use any public image URL (Gravatar, Imgur, etc.) or +
diff --git a/src/components/MemberArea/MemberArea.js b/src/components/MemberArea/MemberArea.js index d414dc79b..7ef918335 100644 --- a/src/components/MemberArea/MemberArea.js +++ b/src/components/MemberArea/MemberArea.js @@ -16,6 +16,7 @@ import { useDeviceType } from '../../hooks/useDeviceType'; function MemberArea({ onOpenModal }) { const { isDesktop } = useDeviceType(); const [isMemberMenuOpen, setIsMemberMenuOpen] = useState(false); + const [avatarError, setAvatarError] = useState(false); const { currentUser, isMentor, isAdmin, isAuthenticated, logout, isNotYetVerified } = useUser(); const api = useApi(); const auth = useAuth(); @@ -44,6 +45,10 @@ function MemberArea({ onOpenModal }) { setIsMemberMenuOpen(false); }; + const avatarUrl = avatarError && currentUser?.auth0Picture + ? currentUser.auth0Picture + : currentUser?.avatar || currentUser?.auth0Picture; + return (
{isAuthenticated ? ( @@ -60,7 +65,8 @@ function MemberArea({ onOpenModal }) { {currentUser ? ( setAvatarError(true)} /> ) : ( diff --git a/src/components/MemberArea/model.js b/src/components/MemberArea/model.js index f6818d73d..39d24302d 100644 --- a/src/components/MemberArea/model.js +++ b/src/components/MemberArea/model.js @@ -40,10 +40,16 @@ export default { label: 'Avatar', type: 'gravatar', defaultValue: '', - helpText: 'Please use your real image', + helpText: 'Enter any public image URL or leave empty to use OAuth default', previewImage: true, validate: (value) => { - return ['gravatar', 'googleusercontent'].some((provider) => value.includes(provider)) + if (!value) return true; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } }, }, title: { diff --git a/src/types/models.d.ts b/src/types/models.d.ts index 121bc719f..53f530cc5 100644 --- a/src/types/models.d.ts +++ b/src/types/models.d.ts @@ -20,6 +20,7 @@ export type User = BaseDBObject & { email_verified: boolean; tags: string[]; avatar?: string; + auth0Id?: string; country: Country; roles: UserRole[]; available: boolean; From 457c2c2ec4194e2f0ef318b4dd4cf94e7fc9b585 Mon Sep 17 00:00:00 2001 From: Mosh Feu Date: Wed, 10 Dec 2025 01:28:34 +0200 Subject: [PATCH 2/4] Gravatar switch (#996) --- .../functions/modules/users/current.ts | 7 +- .../functions/modules/users/toggleAvatar.ts | 52 ++++++ .../functions/modules/users/userInfo.ts | 2 +- netlify/functions-src/functions/users.ts | 2 + src/Me/MentorshipRequests/UsersList.tsx | 3 +- src/Me/Routes/Home/Avatar/Avatar.tsx | 140 +++++++-------- src/Me/Routes/Home/Avatar/AvatarEditModal.tsx | 164 ------------------ src/api/index.ts | 12 ++ src/components/Card/Card.tsx | 7 +- src/components/MemberArea/AvatarField.tsx | 136 +++++++++++++++ src/components/MemberArea/EditProfile.js | 74 ++++---- src/components/MemberArea/MemberArea.js | 3 +- .../MemberArea/PendingApplications.js | 3 +- src/components/MemberArea/model.js | 25 +-- src/helpers/authProvider.ts | 30 ++++ src/helpers/avatar.js | 6 - src/types/models.d.ts | 1 + 17 files changed, 348 insertions(+), 319 deletions(-) create mode 100644 netlify/functions-src/functions/modules/users/toggleAvatar.ts delete mode 100644 src/Me/Routes/Home/Avatar/AvatarEditModal.tsx create mode 100644 src/components/MemberArea/AvatarField.tsx create mode 100644 src/helpers/authProvider.ts delete mode 100644 src/helpers/avatar.js diff --git a/netlify/functions-src/functions/modules/users/current.ts b/netlify/functions-src/functions/modules/users/current.ts index aaee3cd3f..4e625b694 100644 --- a/netlify/functions-src/functions/modules/users/current.ts +++ b/netlify/functions-src/functions/modules/users/current.ts @@ -58,11 +58,14 @@ const getCurrentUserHandler: ApiHandler = async (_event: HandlerEvent, context: return error('Unauthorized: user not found', 401) } const currentUser = await getCurrentUser(auth0Id); + + // Use stored avatar if exists, otherwise fallback to Auth0 picture + const avatarUrl = currentUser.avatar || context.user?.picture; + const applicationUser = { ...currentUser, email_verified: context.user?.email_verified, - avatar: currentUser.avatar || context.user?.picture, // Use custom avatar if set, otherwise Auth0 picture - auth0Picture: context.user?.picture, // Temporary field for client-side fallback (not in DB) + avatar: avatarUrl, }; return success({ data: applicationUser }) } diff --git a/netlify/functions-src/functions/modules/users/toggleAvatar.ts b/netlify/functions-src/functions/modules/users/toggleAvatar.ts new file mode 100644 index 000000000..64fa0c02c --- /dev/null +++ b/netlify/functions-src/functions/modules/users/toggleAvatar.ts @@ -0,0 +1,52 @@ +import type { ApiHandler } from '../../types'; +import { error, success } from '../../utils/response'; +import { getUserBy, upsertUser } from '../../data/users'; +import { UserDto } from '../../common/dto/user.dto'; +import crypto from 'crypto'; + +function getGravatarUrl(email: string): string { + const hash = crypto + .createHash('md5') + .update(email.trim().toLowerCase()) + .digest('hex'); + return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon`; +} + +type ToggleAvatarRequest = { + useGravatar: boolean; +}; + +export const toggleAvatarHandler: ApiHandler = async (event, context) => { + try { + const { useGravatar } = event.parsedBody || {}; + + if (typeof useGravatar !== 'boolean') { + return error('Invalid request body: useGravatar must be a boolean', 400); + } + + const auth0Id = context.user?.auth0Id; + if (!auth0Id) { + return error('Unauthorized', 401); + } + + const currentUser = await getUserBy('auth0Id', auth0Id); + if (!currentUser) { + return error('User not found', 404); + } + + const avatarUrl = useGravatar ? getGravatarUrl(currentUser.email) : context.user?.picture; + + const userDto: UserDto = new UserDto({ + _id: currentUser._id, + avatar: avatarUrl, + }); + + const updatedUser = await upsertUser(userDto); + + return success({ + data: updatedUser, + }); + } catch (e) { + return error((e as Error).message, 500); + } +}; diff --git a/netlify/functions-src/functions/modules/users/userInfo.ts b/netlify/functions-src/functions/modules/users/userInfo.ts index 4a9dd892b..5ad6cd9fd 100644 --- a/netlify/functions-src/functions/modules/users/userInfo.ts +++ b/netlify/functions-src/functions/modules/users/userInfo.ts @@ -41,6 +41,6 @@ export const updateUserInfoHandler: ApiHandler = async (event, context) => data: upsertedUser, }); } catch (e) { - return error(e.message, 400); + return error((e as Error).message, 400); } } diff --git a/netlify/functions-src/functions/users.ts b/netlify/functions-src/functions/users.ts index f8520682f..49134cfa1 100644 --- a/netlify/functions-src/functions/users.ts +++ b/netlify/functions-src/functions/users.ts @@ -3,6 +3,7 @@ import { handler as usersCurrentHandler } from './modules/users/current' import { handler as getUserInfoHandler, updateUserInfoHandler } from './modules/users/userInfo' import { handler as deleteUser } from './modules/users/delete' import { handler as verifyUserHandler } from './modules/users/verify' +import { toggleAvatarHandler } from './modules/users/toggleAvatar' import { withRouter } from './hof/withRouter'; import { withDB } from './hof/withDB'; import { withAuth } from './utils/auth'; @@ -14,6 +15,7 @@ export const handler: ApiHandler = withDB( includeFullUser: true, })], ['/current', 'GET', usersCurrentHandler], + ['/current/avatar', 'POST', withAuth(toggleAvatarHandler)], ['/verify', 'POST', withAuth(verifyUserHandler, { emailVerificationRequired: false, includeFullUser: true, diff --git a/src/Me/MentorshipRequests/UsersList.tsx b/src/Me/MentorshipRequests/UsersList.tsx index ed84a2e99..ccc4daa73 100644 --- a/src/Me/MentorshipRequests/UsersList.tsx +++ b/src/Me/MentorshipRequests/UsersList.tsx @@ -1,4 +1,3 @@ -import { getAvatarUrl } from '../../helpers/avatar'; import ReqContent from './ReqContent'; import { Status } from '../../helpers/mentorship'; import { formatTimeAgo } from '../../helpers/time'; @@ -96,7 +95,7 @@ const renderList = ({ { diff --git a/src/Me/Routes/Home/Avatar/Avatar.tsx b/src/Me/Routes/Home/Avatar/Avatar.tsx index 6eb091fce..6bd2f46de 100644 --- a/src/Me/Routes/Home/Avatar/Avatar.tsx +++ b/src/Me/Routes/Home/Avatar/Avatar.tsx @@ -1,16 +1,17 @@ import React, { FC, useState, useEffect } from 'react'; import styled from 'styled-components/macro'; import { useUser } from '../../../../context/userContext/UserContext'; +import type { User } from '../../../../types/models'; import Camera from '../../../../assets/me/camera.svg'; import CardContainer from '../../../components/Card/index'; -import { getAvatarUrl } from '../../../../helpers/avatar'; +import { isGoogleOAuthUser } from '../../../../helpers/authProvider'; import { IconButton } from '../../../components/Button/IconButton'; import { Tooltip } from 'react-tippy'; import { toast } from 'react-toastify'; import { report } from '../../../../ga'; import { useApi } from '../../../../context/apiContext/ApiContext'; import messages from '../../../../messages'; -import AvatarEditModal from './AvatarEditModal'; +import Switch from '../../../../components/Switch/Switch'; const ShareProfile = ({ url }: { url: string }) => { const [showInput, setShowInput] = React.useState(false); @@ -55,55 +56,23 @@ const ShareProfile = ({ url }: { url: string }) => { const Avatar: FC = () => { const { currentUser, updateCurrentUser } = useUser(); const api = useApi(); - const [isModalOpen, setIsModalOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [primaryAvatarFailed, setPrimaryAvatarFailed] = useState(false); if (!currentUser) { return null; } - // Access auth0Picture dynamically - it's returned from API but not in schema - const auth0Picture = (currentUser as any).auth0Picture; + const isUsingGravatar = currentUser.avatar?.includes('gravatar.com') || false; - // Reset failed state when avatar data changes - useEffect(() => { - setPrimaryAvatarFailed(false); - }, [currentUser.avatar, auth0Picture]); - - // Determine which avatar to display - // If primary avatar exists and hasn't failed, use it. Otherwise use auth0Picture - const displayAvatar = (!primaryAvatarFailed && currentUser.avatar) - ? currentUser.avatar - : auth0Picture; - - const isUsingCustomAvatar = currentUser.avatar && currentUser.avatar !== auth0Picture; - - const handleImageError = () => { - // Only mark as failed if this is the primary avatar, not the fallback - if (!primaryAvatarFailed) { - setPrimaryAvatarFailed(true); - } - }; - - const handleSaveAvatar = async (avatarUrl: string) => { + const handleToggleGravatar = async (newValue: boolean) => { setIsSaving(true); - try { - const updateMentorResult = await api.updateMentor({ - ...currentUser, - avatar: avatarUrl || null, // null to clear and use Auth0 default - }); - - if (updateMentorResult) { + report('Avatar', newValue ? 'use gravatar' : 'use google profile picture'); + const updatedUser = await api.toggleAvatar(newValue); + if (updatedUser) { api.clearCurrentUser(); - const updatedUser = await api.getCurrentUser(); - if (updatedUser) { - updateCurrentUser(updatedUser); - setPrimaryAvatarFailed(false); // Reset error state - toast.success('Avatar updated successfully'); - setIsModalOpen(false); - } + updateCurrentUser(updatedUser); + toast.success('Avatar updated successfully', { toastId: 'avatar-updated' }); } else { toast.error(messages.GENERIC_ERROR); } @@ -114,6 +83,8 @@ const Avatar: FC = () => { } }; + const isGoogleUser = isGoogleOAuthUser(currentUser.auth0Id); + return ( @@ -121,33 +92,47 @@ const Avatar: FC = () => { url={`${process.env.NEXT_PUBLIC_AUTH_CALLBACK}/u/${currentUser._id}`} /> - setIsModalOpen(true)}> - {displayAvatar ? ( + + {currentUser.avatar ? ( ) : ( )} - - {isUsingCustomAvatar ? '📷' : '🔐'} - - Click to change avatar + + {isGoogleUser && ( + <> + + + {" "} + + + + + Update your avatar picture at{" "} + {isUsingGravatar + ? Gravatar + : Google Profile + } + + + )}

{currentUser ? currentUser.name : ''}

{currentUser ? currentUser.title : ''}

- - setIsModalOpen(false)} - onSave={handleSaveAvatar} - isSaving={isSaving} - />
); @@ -163,7 +148,6 @@ const AvatarContainer = styled.div` const AvatarWrapper = styled.div` position: relative; - cursor: pointer; display: inline-block; &:hover img { @@ -171,29 +155,6 @@ const AvatarWrapper = styled.div` } `; -const AvatarSourceBadge = styled.div` - position: absolute; - top: 0; - right: 0; - width: 28px; - height: 28px; - background-color: white; - border: 2px solid #f0f0f0; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -`; - -const AvatarHint = styled.p` - font-size: 12px; - color: #999; - margin: 0; - font-style: italic; -`; - const AvatarPlaceHolder = styled.img` width: 100px; height: 100px; @@ -205,11 +166,26 @@ const AvatarPlaceHolder = styled.img` const UserImage = styled.img` width: 100px; height: 100px; + display: block; object-fit: cover; border-radius: 8px; + border: 2px solid #e0e0e0; transition: opacity 0.2s ease; `; +const ToggleLabel = styled.div` + display: inline-flex; + align-items: center; + gap: 12px; +`; + +const ToggleDescription = styled.div` + font-size: 13px; + color: #666; + margin: 0 0 12px 0; + line-height: 1.5; +`; + const Container = styled.div` position: relative; text-align: center; diff --git a/src/Me/Routes/Home/Avatar/AvatarEditModal.tsx b/src/Me/Routes/Home/Avatar/AvatarEditModal.tsx deleted file mode 100644 index b3f222e3b..000000000 --- a/src/Me/Routes/Home/Avatar/AvatarEditModal.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React, { FC, useState } from 'react'; -import styled from 'styled-components/macro'; -import { Modal } from '../../../Modals/Modal'; -import { toast } from 'react-toastify'; -import { report } from '../../../../ga'; -import messages from '../../../../messages'; -import { getAvatarProviderInfo } from '../../../../helpers/authProvider'; - -type AvatarEditModalProps = { - isOpen: boolean; - currentAvatar?: string; - auth0Id?: string; - onClose: () => void; - onSave: (avatarUrl: string) => Promise; - isSaving?: boolean; -}; - -const AvatarEditModal: FC = ({ - isOpen, - currentAvatar = '', - auth0Id, - onClose, - onSave, - isSaving = false, -}) => { - const [avatarUrl, setAvatarUrl] = useState(currentAvatar); - const providerInfo = getAvatarProviderInfo(auth0Id); - - if (!isOpen) { - return null; - } - - const handleSave = async () => { - report('Avatar', 'save custom avatar url'); - try { - await onSave(avatarUrl); - } catch (error) { - toast.error(messages.GENERIC_ERROR); - } - }; - - const handleClear = () => { - setAvatarUrl(''); - }; - - const handleClose = () => { - setAvatarUrl(currentAvatar); - onClose(); - }; - - return ( - - - - setAvatarUrl(e.target.value)} - autoFocus - /> - {avatarUrl && ( - - × - - )} - - - - Enter any public image URL (Gravatar, Imgur, etc.) or leave empty to use your default profile picture - - - - Change your avatar on{' '} - - {providerInfo.label} - - - - - ); -}; - -const ModalContent = styled.div` - display: flex; - flex-direction: column; - gap: 16px; - padding: 0; -`; - -const InputWrapper = styled.div` - position: relative; - display: flex; - align-items: center; -`; - -const AvatarInput = styled.input` - width: 100%; - padding: 10px 36px 10px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - - &:focus { - outline: none; - border-color: #179a6f; - box-shadow: 0 0 0 2px rgba(23, 154, 111, 0.1); - } -`; - -const ClearButton = styled.button` - position: absolute; - right: 10px; - background: none; - border: none; - font-size: 24px; - color: #999; - cursor: pointer; - padding: 0; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - - &:hover:not(:disabled) { - color: #666; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`; - -const HelpText = styled.div` - font-size: 13px; - color: #666; - line-height: 1.5; -`; - -const LinkSection = styled.div` - font-size: 13px; - color: #666; -`; - -const ProviderLink = styled.a` - color: #179a6f; - text-decoration: none; - font-weight: 500; - - &:hover { - text-decoration: underline; - } -`; - -export default AvatarEditModal; diff --git a/src/api/index.ts b/src/api/index.ts index 86260661b..eb941b083 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -206,6 +206,18 @@ export default class ApiService { return !!response?.success; } + toggleAvatar = async (useGravatar: boolean) => { + const response = await this.makeApiCall( + `${paths.USERS}/current/avatar`, + { useGravatar }, + 'POST' + ); + if (response?.success) { + return response.data; + } + return null; + } + // no need. we're using gravatar now // updateMentorAvatar = async (mentor: Mentor, value: FormData) => { // const response = await this.makeApiCall( diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 44e09885f..1ae81ba35 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -12,7 +12,6 @@ import { useDeviceType } from '../../hooks/useDeviceType'; import { report } from '../../ga'; import messages from '../../messages'; import { UnstyledList } from '../common'; -import { getAvatarUrl } from '../../helpers/avatar'; import { languageName } from '../../helpers/languages'; import { getChannelInfo } from '../../channelProvider'; import { CardProps, CTAButtonProps } from './Card.types'; @@ -114,7 +113,7 @@ const Avatar = ({ mentor, id }: { mentor: Mentor; id: string }) => { {`${mentor.name}`} e.currentTarget.classList.add('broken')} @@ -336,14 +335,14 @@ const Card = ({ mentor, onFavMentor, isFav, appearance }: CardProps) => { data-testid="mentor-card" appearance={appearance} > - - ; + isUsingGravatar: boolean; + onToggleGravatar: (value: boolean) => void; +} + +const AvatarField: FC = ({ + user, + isUsingGravatar, + onToggleGravatar, +}) => { + const isGoogleUser = isGoogleOAuthUser(user.auth0Id); + const displayAvatar = user.avatar || user.auth0Picture; + + return ( + + + {displayAvatar ? ( + + ) : ( + + )} + + + {isGoogleUser ? ( + <> + + + + + Update your avatar picture at{' '} + {isUsingGravatar ? ( + + Gravatar + + ) : ( + + Google Profile + + )} + + + ) : ( + <> + + Your avatar is managed by Gravatar using your email address. + + + Update at Gravatar → + + + )} + + + ); +}; + +const AvatarContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const AvatarPreview = styled.div` + display: flex; + width: 100px; + height: 100px; + border-radius: 8px; + overflow: hidden; + background-color: #f5f5f5; + border: 2px solid #e0e0e0; +`; + +const AvatarImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; + +const AvatarPlaceholder = styled.i` + font-size: 80px; + color: #ccc; +`; + +const AvatarControls = styled.div` + display: flex; + flex-direction: column; + gap: 5px; +`; + +const SwitchWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const HelpText = styled.div` + font-size: 12px; + color: #888; + line-height: 1.4; + + a { + color: #4a90e2; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +`; + +export default AvatarField; diff --git a/src/components/MemberArea/EditProfile.js b/src/components/MemberArea/EditProfile.js index f434c2599..9d5f0e987 100644 --- a/src/components/MemberArea/EditProfile.js +++ b/src/components/MemberArea/EditProfile.js @@ -6,13 +6,12 @@ import model from './model'; import Select from 'react-select'; import { isMentor, fromMtoVM, fromVMtoM } from '../../helpers/user'; import Switch from '../Switch/Switch'; -import { getAvatarUrl } from '../../helpers/avatar'; import { providers } from '../../channelProvider'; import messages from '../../messages'; import { report, reportError } from '../../ga'; import UserContext from '../../context/userContext/UserContext'; import { links } from '../../config/constants'; -import { RedirectToGravatar } from '../../Me/Modals/RedirectToGravatar'; +import AvatarField from './AvatarField'; export default class EditProfile extends Component { static contextType = UserContext; @@ -20,6 +19,7 @@ export default class EditProfile extends Component { user: fromMtoVM(this.context.currentUser), errors: [], agree: false, + isUsingGravatar: this.context.currentUser?.avatar?.includes('gravatar.com') || false, }; validate() { @@ -97,6 +97,32 @@ export default class EditProfile extends Component { } }; + handleToggleGravatar = async (newValue) => { + const { updateCurrentUser } = this.context; + const { api } = this.props; + + this.setState({ disabled: true }); + try { + report('Avatar', newValue ? 'use gravatar' : 'use google profile picture'); + const updatedUser = await api.toggleAvatar(newValue); + if (updatedUser) { + api.clearCurrentUser(); + updateCurrentUser(updatedUser); + this.setState({ + user: fromMtoVM(updatedUser), + isUsingGravatar: newValue, + }); + toast.success('Avatar updated successfully', { toastId: 'avatar-updated' }); + } else { + toast.error(messages.GENERIC_ERROR); + } + } catch (error) { + toast.error(messages.GENERIC_ERROR); + } finally { + this.setState({ disabled: false }); + } + }; + formField = (fieldName, config) => { const { user } = this.state; switch (config.type) { @@ -121,7 +147,7 @@ export default class EditProfile extends Component { (user[fieldName] ? ( avatar ) : ( @@ -147,43 +173,15 @@ export default class EditProfile extends Component {
); case 'gravatar': + const { isUsingGravatar } = this.state; + return (
- +
); case 'tags': diff --git a/src/components/MemberArea/MemberArea.js b/src/components/MemberArea/MemberArea.js index 7ef918335..6cfc48766 100644 --- a/src/components/MemberArea/MemberArea.js +++ b/src/components/MemberArea/MemberArea.js @@ -3,7 +3,6 @@ import onClickOutside from 'react-onclickoutside'; import styled from 'styled-components'; import Link from '../Link/Link'; -import { getAvatarUrl } from '../../helpers/avatar'; import { useUser } from '../../context/userContext/UserContext'; import { useAuth } from '../../context/authContext/AuthContext'; import { useApi } from '../../context/apiContext/ApiContext'; @@ -65,7 +64,7 @@ function MemberArea({ onOpenModal }) { {currentUser ? ( setAvatarError(true)} /> ) : ( diff --git a/src/components/MemberArea/PendingApplications.js b/src/components/MemberArea/PendingApplications.js index e174870e5..47eb1de2c 100644 --- a/src/components/MemberArea/PendingApplications.js +++ b/src/components/MemberArea/PendingApplications.js @@ -3,7 +3,6 @@ import styled, { css } from 'styled-components'; import { Loader } from '../Loader'; import { getChannelInfo } from '../../channelProvider'; -import { getAvatarUrl } from '../../helpers/avatar'; export default class PendingApplications extends Component { state = { @@ -99,7 +98,7 @@ export default class PendingApplications extends Component { {user.name} diff --git a/src/components/MemberArea/model.js b/src/components/MemberArea/model.js index 39d24302d..2d70988a1 100644 --- a/src/components/MemberArea/model.js +++ b/src/components/MemberArea/model.js @@ -21,6 +21,12 @@ const nameValidation = (value) => value.length > 3 && value.length <= 50 && /^\S+(\s\S+)+$/.test(value); export default { + avatar: { + type: 'gravatar', + style: { + width: '100%', + }, + }, email: { label: 'Email', type: 'text', @@ -36,22 +42,6 @@ export default { helpText: 'Please use your real name', validate: (value) => !!value && nameValidation(value), }, - avatar: { - label: 'Avatar', - type: 'gravatar', - defaultValue: '', - helpText: 'Enter any public image URL or leave empty to use OAuth default', - previewImage: true, - validate: (value) => { - if (!value) return true; - try { - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } - }, - }, title: { label: 'Title', type: 'text', @@ -59,6 +49,9 @@ export default { defaultValue: '', helpText: 'e.g. Software Developer. Min 3 characters', validate: (value) => !!value && value.length > 2 && value.length <= 50, + style: { + width: '100%', + }, }, description: { label: 'Description', diff --git a/src/helpers/authProvider.ts b/src/helpers/authProvider.ts new file mode 100644 index 000000000..4a9714c61 --- /dev/null +++ b/src/helpers/authProvider.ts @@ -0,0 +1,30 @@ +/** + * Detects the auth provider from auth0Id + * Auth0 ID format: "provider|unique-id" + * Examples: + * - "auth0|..." - Username/Password (uses Gravatar) + * - "google-oauth2|..." - Google OAuth (uses Google profile picture) + */ +export enum AuthProvider { + USERNAME = 'auth0', + GOOGLE = 'google-oauth2', + UNKNOWN = 'unknown', +} + +export function getAuthProvider(auth0Id?: string): AuthProvider { + if (!auth0Id) return AuthProvider.UNKNOWN; + + if (auth0Id.startsWith('google-oauth2|')) { + return AuthProvider.GOOGLE; + } + + if (auth0Id.startsWith('auth0|')) { + return AuthProvider.USERNAME; + } + + return AuthProvider.UNKNOWN; +} + +export function isGoogleOAuthUser(auth0Id?: string): boolean { + return getAuthProvider(auth0Id) === AuthProvider.GOOGLE; +} diff --git a/src/helpers/avatar.js b/src/helpers/avatar.js deleted file mode 100644 index 1ec01c498..000000000 --- a/src/helpers/avatar.js +++ /dev/null @@ -1,6 +0,0 @@ -export const getAvatarUrl = (avatar) => { - if (avatar?.startsWith('/avatars/')) { - return `${process.env.NEXT_PUBLIC_API_ENDPOINT}${avatar}`; - } - return avatar; -}; diff --git a/src/types/models.d.ts b/src/types/models.d.ts index 53f530cc5..a71112c68 100644 --- a/src/types/models.d.ts +++ b/src/types/models.d.ts @@ -20,6 +20,7 @@ export type User = BaseDBObject & { email_verified: boolean; tags: string[]; avatar?: string; + auth0Picture?: string; auth0Id?: string; country: Country; roles: UserRole[]; From 3f852e0faa8bd1fb33c951ecce19fac190dfa42b Mon Sep 17 00:00:00 2001 From: Mosh Feu Date: Wed, 10 Dec 2025 01:53:43 +0200 Subject: [PATCH 3/4] Update src/Me/Routes/Home/Avatar/Avatar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Me/Routes/Home/Avatar/Avatar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Me/Routes/Home/Avatar/Avatar.tsx b/src/Me/Routes/Home/Avatar/Avatar.tsx index 6bd2f46de..460f85e42 100644 --- a/src/Me/Routes/Home/Avatar/Avatar.tsx +++ b/src/Me/Routes/Home/Avatar/Avatar.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState } from 'react'; import styled from 'styled-components/macro'; import { useUser } from '../../../../context/userContext/UserContext'; import type { User } from '../../../../types/models'; From c34cd5e560c4085ccd49f785137456f9195c81e7 Mon Sep 17 00:00:00 2001 From: Mosh Feu Date: Wed, 10 Dec 2025 01:54:55 +0200 Subject: [PATCH 4/4] Update src/Me/Routes/Home/Avatar/Avatar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Me/Routes/Home/Avatar/Avatar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Me/Routes/Home/Avatar/Avatar.tsx b/src/Me/Routes/Home/Avatar/Avatar.tsx index 460f85e42..213f2db03 100644 --- a/src/Me/Routes/Home/Avatar/Avatar.tsx +++ b/src/Me/Routes/Home/Avatar/Avatar.tsx @@ -1,7 +1,6 @@ import React, { FC, useState } from 'react'; import styled from 'styled-components/macro'; import { useUser } from '../../../../context/userContext/UserContext'; -import type { User } from '../../../../types/models'; import Camera from '../../../../assets/me/camera.svg'; import CardContainer from '../../../components/Card/index'; import { isGoogleOAuthUser } from '../../../../helpers/authProvider';