Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions netlify/functions-src/functions/modules/users/current.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,18 @@ const getCurrentUserHandler: ApiHandler = async (_event: HandlerEvent, context:
if (!auth0Id) {
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 = {
...await getCurrentUser(auth0Id),
...currentUser,
email_verified: context.user?.email_verified,
avatar: context.user?.picture,
avatar: avatarUrl,
auth0Picture: context.user?.picture,
auth0Id: currentUser.auth0Id,
};
// TODO: remove avatar from the database
return success({ data: applicationUser })
}

Expand Down
56 changes: 56 additions & 0 deletions netlify/functions-src/functions/modules/users/toggleAvatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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<ToggleAvatarRequest> = 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;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When useGravatar is false, the avatar URL is set to context.user?.picture, which could potentially be undefined. This could result in setting the avatar to undefined in the database. Consider adding validation to ensure context.user?.picture exists before allowing the switch, or falling back to generating a Gravatar URL if it's missing.

Suggested change
const avatarUrl = useGravatar ? getGravatarUrl(currentUser.email) : context.user?.picture;
const avatarUrl =
useGravatar || !context.user?.picture
? getGravatarUrl(currentUser.email)
: context.user.picture;

Copilot uses AI. Check for mistakes.

Comment on lines +37 to +38
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend handler should validate that the user is a Google OAuth user before allowing them to switch to their Google profile picture. Currently, any user can call this endpoint with useGravatar=false, which would set their avatar to context.user?.picture even if they're not a Google OAuth user. Consider adding validation similar to the frontend's isGoogleOAuthUser check to ensure only Google OAuth users can switch to their Google profile picture.

Suggested change
const avatarUrl = useGravatar ? getGravatarUrl(currentUser.email) : context.user?.picture;
const isGoogleOAuthUser = auth0Id.startsWith('google-oauth2|');
if (!useGravatar) {
if (!isGoogleOAuthUser || !context.user?.picture) {
return error(
'Switching to Google profile picture is only allowed for Google OAuth users.',
400
);
}
}
const avatarUrl = useGravatar ? getGravatarUrl(currentUser.email) : context.user.picture;

Copilot uses AI. Check for mistakes.
const userDto: UserDto = new UserDto({
_id: currentUser._id,
avatar: avatarUrl,
});

const updatedUser = await upsertUser(userDto);

return success({
data: {
...updatedUser,
auth0Picture: context.user?.picture,
auth0Id: updatedUser.auth0Id,
},
});
} catch (e) {
return error((e as Error).message, 500);
}
};
2 changes: 1 addition & 1 deletion netlify/functions-src/functions/modules/users/userInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ export const updateUserInfoHandler: ApiHandler<User> = async (event, context) =>
data: upsertedUser,
});
} catch (e) {
return error(e.message, 400);
return error((e as Error).message, 400);
}
}
2 changes: 2 additions & 0 deletions netlify/functions-src/functions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions src/Me/MentorshipRequests/UsersList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getAvatarUrl } from '../../helpers/avatar';
import ReqContent from './ReqContent';
import { Status } from '../../helpers/mentorship';
import { formatTimeAgo } from '../../helpers/time';
Expand Down Expand Up @@ -96,7 +95,7 @@ const renderList = ({
<RichItem
id={_id}
userId={user._id}
avatar={getAvatarUrl(user.avatar)}
avatar={user.avatar}
title={user.name}
subtitle={user.title}
onClick={() => {
Expand Down
136 changes: 116 additions & 20 deletions src/Me/Routes/Home/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import React, { FC } from 'react';
import React, { FC, useState, useEffect } from 'react';
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect import is unused in this file. It should be removed from the imports to keep the code clean.

Suggested change
import React, { FC, useState, useEffect } from 'react';
import React, { FC, useState } from 'react';

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import useEffect.

Suggested change
import React, { FC, useState, useEffect } from 'react';
import React, { FC } from 'react';

Copilot uses AI. Check for mistakes.
import styled from 'styled-components/macro';
import { useUser } from '../../../../context/userContext/UserContext';
import type { User } from '../../../../types/models';
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The User type import is unused in this file. It should be removed from the imports to keep the code clean.

Suggested change
import type { User } from '../../../../types/models';

Copilot uses AI. Check for mistakes.
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 { RedirectToGravatar } from '../../../Modals/RedirectToGravatar';
import { useApi } from '../../../../context/apiContext/ApiContext';
import messages from '../../../../messages';
import Switch from '../../../../components/Switch/Switch';

const ShareProfile = ({ url }: { url: string }) => {
const [showInput, setShowInput] = React.useState(false);
Expand Down Expand Up @@ -51,41 +54,114 @@ const ShareProfile = ({ url }: { url: string }) => {
};

const Avatar: FC = () => {
const { currentUser } = useUser<true>();
const { currentUser, updateCurrentUser } = useUser<true>();
const api = useApi();
const [isSaving, setIsSaving] = useState(false);

if (!currentUser) {
return null;
}

const isUsingGravatar = currentUser.avatar?.includes('gravatar.com') || false;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for determining whether a user is using Gravatar relies on checking if the avatar URL contains 'gravatar.com'. However, this approach may not work correctly if the avatar URL is undefined or if a user switches from Google to Gravatar and the URL hasn't been updated yet. Consider checking the avatar URL against both the auth0Picture to determine which one is currently active, or storing the avatar preference explicitly in the database.

Suggested change
const isUsingGravatar = currentUser.avatar?.includes('gravatar.com') || false;
const isUsingGravatar =
!!currentUser.avatar &&
!!(currentUser as User).auth0Picture &&
currentUser.avatar !== (currentUser as User).auth0Picture;

Copilot uses AI. Check for mistakes.

const handleToggleGravatar = async (newValue: boolean) => {
if (isSaving) {
return;
}

setIsSaving(true);
try {
report('Avatar', newValue ? 'use gravatar' : 'use google profile picture');
const updatedUser = await api.toggleAvatar(newValue);
if (updatedUser) {
api.clearCurrentUser();
updateCurrentUser(updatedUser);
toast.success('Avatar updated successfully', { toastId: 'avatar-updated' });
} else {
toast.error(messages.GENERIC_ERROR);
}
} catch (error) {
toast.error(messages.GENERIC_ERROR);
} finally {
setIsSaving(false);
}
};

const isGoogleUser = isGoogleOAuthUser(currentUser.auth0Id);

return (
<CardContainer>
<Container>
<ShareProfile
url={`${process.env.NEXT_PUBLIC_AUTH_CALLBACK}/u/${currentUser._id}`}
/>
<div>
{currentUser && currentUser.avatar ? (
<UserImage
alt={currentUser.email}
src={getAvatarUrl(currentUser.avatar)}
/>
) : (
<AvatarPlaceHolder alt="No profile picture" src={Camera} />
)}
<ChangeAvatarSection>
Change your avatar on <RedirectToGravatar />
</ChangeAvatarSection>
</div>
<AvatarContainer>
<AvatarWrapper>
{currentUser.avatar ? (
<UserImage
alt={currentUser.email}
src={currentUser.avatar}
/>
) : (
<AvatarPlaceHolder alt="No profile picture" src={Camera} />
)}
</AvatarWrapper>
</AvatarContainer>

{isGoogleUser && (
<GravatarToggleContainer>
<ToggleLabel>
<Switch
label={`Switch to ${isUsingGravatar ? 'Google' : 'Gravatar'} Avatar`}
isChecked={isUsingGravatar}
onToggle={handleToggleGravatar}
size="small"
/>
</ToggleLabel>
<Tooltip
title="Toggle between your Google profile picture and Gravatar avatar"
size="regular"
arrow={true}
position="bottom"
>
<i className="fa fa-info-circle"></i>
</Tooltip>
<ToggleDescription>
Update your avatar picture at{" "}
{isUsingGravatar
? <a href="https://gravatar.com/profile/avatars" target="_blank" rel="noopener noreferrer">Gravatar</a>
: <a href="https://myaccount.google.com/profile" target="_blank" rel="noopener noreferrer">Google Profile</a>
}
</ToggleDescription>
</GravatarToggleContainer>
)}
<h1>{currentUser ? currentUser.name : ''}</h1>
<p>{currentUser ? currentUser.title : ''}</p>
</Container>
</CardContainer>
);
};

// 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 GravatarToggleContainer = styled.div`
margin-top: 8px;
text-align: center;
`;

const AvatarWrapper = styled.div`
position: relative;
display: inline-block;

&:hover img {
opacity: 0.9;
}
`;

const AvatarPlaceHolder = styled.img`
Expand All @@ -99,8 +175,28 @@ 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;
margin-inline-end: 5px;

label {
cursor: pointer;
}
`;

const ToggleDescription = styled.div`
font-size: 13px;
color: #666;
margin: 0 0 12px 0;
line-height: 1.5;
`;

const Container = styled.div`
Expand Down
12 changes: 12 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@ export default class ApiService {
return !!response?.success;
}

toggleAvatar = async (useGravatar: boolean) => {
const response = await this.makeApiCall<User>(
`${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(
Expand Down
7 changes: 3 additions & 4 deletions src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,7 +113,7 @@ const Avatar = ({ mentor, id }: { mentor: Mentor; id: string }) => {
<Link href={urls.user.get(mentor)} className="avatar">
<i className="fa fa-user-circle" />
<img
src={getAvatarUrl(mentor.avatar)}
src={mentor.avatar}
aria-labelledby={`${id}`}
alt={`${mentor.name}`}
onError={(e) => e.currentTarget.classList.add('broken')}
Expand Down Expand Up @@ -336,14 +335,14 @@ const Card = ({ mentor, onFavMentor, isFav, appearance }: CardProps) => {
data-testid="mentor-card"
appearance={appearance}
>
<CardHeader
<CardHeader
mentor={mentor}
onFavMentor={onFavMentor}
isFav={isFav}
/>
<MentorInfo />
<SkillsTags />
<CardFooter
<CardFooter
mentor={mentor}
availability={availability}
appearance={appearance}
Expand Down
Loading
Loading