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
10 changes: 7 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,16 @@ 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,
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The auth0Picture and auth0Id fields are not being populated in the response. The frontend components like AvatarField.tsx and Avatar.tsx depend on these fields to determine if the user is a Google user and to provide fallback avatar URLs.

You should add these fields to the response:

const applicationUser = {
  ...currentUser,
  email_verified: context.user?.email_verified,
  avatar: avatarUrl,
  auth0Picture: context.user?.picture,
  auth0Id: currentUser.auth0Id,
};
Suggested change
avatar: avatarUrl,
avatar: avatarUrl,
auth0Picture: context.user?.picture,
auth0Id: currentUser.auth0Id,

Copilot uses AI. Check for mistakes.
};
// TODO: remove avatar from the database
return success({ data: applicationUser })
}

Expand Down
52 changes: 52 additions & 0 deletions netlify/functions-src/functions/modules/users/toggleAvatar.ts
Original file line number Diff line number Diff line change
@@ -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<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;

const userDto: UserDto = new UserDto({
_id: currentUser._id,
avatar: avatarUrl,
});

const updatedUser = await upsertUser(userDto);

return success({
data: updatedUser,
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The toggleAvatarHandler should return the complete user object with auth0Picture and auth0Id fields to match what the frontend expects. Currently, upsertUser returns the database user object which may not include these fields populated from the Auth0 context.

Consider returning a complete application user similar to the current handler:

const updatedUser = await upsertUser(userDto);

return success({
  data: {
    ...updatedUser,
    auth0Picture: context.user?.picture,
    auth0Id: updatedUser.auth0Id,
  },
});
Suggested change
data: updatedUser,
data: {
...updatedUser,
auth0Picture: context.user?.picture,
auth0Id: updatedUser.auth0Id,
},

Copilot uses AI. Check for mistakes.
});
} 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
122 changes: 102 additions & 20 deletions src/Me/Routes/Home/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React, { FC } from 'react';
import React, { FC, useState } from 'react';
import styled from 'styled-components/macro';
import { useUser } from '../../../../context/userContext/UserContext';
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 +53,105 @@ 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);
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The isSaving state is declared but never used to disable the UI or show a loading state during the avatar toggle operation. Consider using this state to provide visual feedback:

<Switch
  label="Use Gravatar"
  isChecked={isUsingGravatar}
  onToggle={handleToggleGravatar}
  size="small"
  disabled={isSaving}  // Add this
/>

Copilot uses AI. Check for mistakes.

if (!currentUser) {
return null;
}

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

const handleToggleGravatar = async (newValue: boolean) => {
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 && (
<>
<Tooltip
title="Use Gravatar for a different avatar from your Google photo"
size="regular"
arrow={true}
position="bottom"
>
<i className="fa fa-info-circle"></i>
</Tooltip>{" "}
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

[nitpick] There's an inconsistent space character {" "} on line 116. Consider placing the content inline or formatting it consistently with line 126:

</Tooltip>
<ToggleLabel>

or remove the space entirely as it's not needed between these elements.

Suggested change
</Tooltip>{" "}
</Tooltip>

Copilot uses AI. Check for mistakes.
<ToggleLabel>
<Switch
label="Use Gravatar"
isChecked={isUsingGravatar}
onToggle={handleToggleGravatar}
size="small"
/>
</ToggleLabel>
<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>
</>
)}
<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 AvatarWrapper = styled.div`
position: relative;
display: inline-block;

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

const AvatarPlaceHolder = styled.img`
Expand All @@ -99,8 +165,24 @@ 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`
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