Skip to content

Commit 3f9dde5

Browse files
committed
emerg: fix the profile creation flow
1 parent 28702b0 commit 3f9dde5

File tree

9 files changed

+129
-18
lines changed

9 files changed

+129
-18
lines changed

src/api/functions/entraId.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
EntraFetchError,
1010
EntraGroupError,
1111
EntraInvitationError,
12+
EntraPatchError,
1213
InternalServerError,
1314
} from "../../common/errors/index.js";
1415
import { getSecretValue } from "../plugins/auth.js";
@@ -17,6 +18,7 @@ import { getItemFromCache, insertItemIntoCache } from "./cache.js";
1718
import {
1819
EntraGroupActions,
1920
EntraInvitationResponse,
21+
ProfilePatchRequest,
2022
} from "../../common/types/iam.js";
2123
import { UserProfileDataBase } from "common/types/msGraphApi.js";
2224
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
@@ -395,3 +397,49 @@ export async function getUserProfile(
395397
});
396398
}
397399
}
400+
401+
/**
402+
* Patches the profile of a user from Entra ID.
403+
* @param token - Entra ID token authorized to perform this action.
404+
* @param userId - The user ID to patch the profile for.
405+
* @throws {EntraUserError} If setting the user profile fails.
406+
* @returns {Promise<void>} nothing
407+
*/
408+
export async function patchUserProfile(
409+
token: string,
410+
email: string,
411+
userId: string,
412+
data: ProfilePatchRequest,
413+
): Promise<void> {
414+
try {
415+
const url = `https://graph.microsoft.com/v1.0/users/${userId}`;
416+
const response = await fetch(url, {
417+
method: "PATCH",
418+
headers: {
419+
Authorization: `Bearer ${token}`,
420+
"Content-Type": "application/json",
421+
},
422+
body: JSON.stringify(data),
423+
});
424+
425+
if (!response.ok) {
426+
const errorData = (await response.json()) as {
427+
error?: { message?: string };
428+
};
429+
throw new EntraPatchError({
430+
message: errorData?.error?.message ?? response.statusText,
431+
email,
432+
});
433+
}
434+
return;
435+
} catch (error) {
436+
if (error instanceof EntraPatchError) {
437+
throw error;
438+
}
439+
440+
throw new EntraPatchError({
441+
message: error instanceof Error ? error.message : String(error),
442+
email,
443+
});
444+
}
445+
}

src/api/routes/iam.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { FastifyPluginAsync } from "fastify";
2-
import { AppRoles } from "../../common/roles.js";
2+
import { allAppRoles, AppRoles } from "../../common/roles.js";
33
import { zodToJsonSchema } from "zod-to-json-schema";
44
import {
55
addToTenant,
66
getEntraIdToken,
77
listGroupMembers,
88
modifyGroup,
9+
patchUserProfile,
910
} from "../functions/entraId.js";
1011
import {
1112
BaseError,
@@ -15,6 +16,7 @@ import {
1516
EntraInvitationError,
1617
InternalServerError,
1718
NotFoundError,
19+
UnauthorizedError,
1820
} from "../../common/errors/index.js";
1921
import { PutItemCommand } from "@aws-sdk/client-dynamodb";
2022
import { genericConfig } from "../../common/config.js";
@@ -29,13 +31,48 @@ import {
2931
GroupModificationPatchRequest,
3032
EntraGroupActions,
3133
entraGroupMembershipListResponse,
34+
ProfilePatchRequest,
35+
entraProfilePatchRequest,
3236
} from "../../common/types/iam.js";
3337
import {
3438
AUTH_DECISION_CACHE_SECONDS,
3539
getGroupRoles,
3640
} from "../functions/authorization.js";
3741

3842
const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
43+
fastify.patch<{ Body: ProfilePatchRequest }>(
44+
"/profile",
45+
{
46+
preValidation: async (request, reply) => {
47+
await fastify.zodValidateBody(request, reply, entraProfilePatchRequest);
48+
},
49+
onRequest: async (request, reply) => {
50+
await fastify.authorize(request, reply, allAppRoles);
51+
},
52+
},
53+
async (request, reply) => {
54+
if (!request.tokenPayload || !request.username) {
55+
throw new UnauthorizedError({
56+
message: "User does not have the privileges for this task.",
57+
});
58+
}
59+
const userOid = request.tokenPayload["oid"];
60+
const entraIdToken = await getEntraIdToken(
61+
{
62+
smClient: fastify.secretsManagerClient,
63+
dynamoClient: fastify.dynamoClient,
64+
},
65+
fastify.environmentConfig.AadValidClientId,
66+
);
67+
await patchUserProfile(
68+
entraIdToken,
69+
request.username,
70+
userOid,
71+
request.body,
72+
);
73+
reply.send(201);
74+
},
75+
);
3976
fastify.get<{
4077
Body: undefined;
4178
Querystring: { groupId: string };

src/common/errors/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,17 @@ export class EntraFetchError extends BaseError<"EntraFetchError"> {
214214
this.email = email;
215215
}
216216
}
217+
218+
219+
export class EntraPatchError extends BaseError<"EntraPatchError"> {
220+
email: string;
221+
constructor({ message, email }: { message?: string; email: string }) {
222+
super({
223+
name: "EntraPatchError",
224+
id: 510,
225+
message: message || "Could not set data at Entra ID.",
226+
httpStatusCode: 500,
227+
});
228+
this.email = email;
229+
}
230+
}

src/common/types/iam.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export type InviteUserPostRequest = z.infer<typeof invitePostRequestSchema>;
2323

2424
export const groupMappingCreatePostSchema = z.object({
2525
roles: z.union([
26-
z.array(z.nativeEnum(AppRoles))
26+
z
27+
.array(z.nativeEnum(AppRoles))
2728
.min(1)
2829
.refine((items) => new Set(items).size === items.length, {
2930
message: "All roles must be unique, no duplicate values allowed",
@@ -32,7 +33,6 @@ export const groupMappingCreatePostSchema = z.object({
3233
]),
3334
});
3435

35-
3636
export type GroupMappingCreatePostRequest = z.infer<
3737
typeof groupMappingCreatePostSchema
3838
>;
@@ -65,3 +65,13 @@ export const entraGroupMembershipListResponse = z.array(
6565
export type GroupMemberGetResponse = z.infer<
6666
typeof entraGroupMembershipListResponse
6767
>;
68+
69+
export const entraProfilePatchRequest = z.object({
70+
displayName: z.string().min(1),
71+
givenName: z.string().min(1),
72+
surname: z.string().min(1),
73+
mail: z.string().email(),
74+
otherMails: z.array(z.string()).min(1),
75+
});
76+
77+
export type ProfilePatchRequest = z.infer<typeof entraProfilePatchRequest>;

src/common/types/msGraphApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export interface UserProfileDataBase {
44
givenName?: string;
55
surname?: string;
66
mail?: string;
7-
otherMails?: string[]
7+
otherMails?: string[];
88
}
99

1010
export interface UserProfileData extends UserProfileDataBase {

src/ui/pages/Login.page.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ export function LoginPage() {
2323
givenName?: string;
2424
surname?: string;
2525
};
26-
// if (!me.givenName || !me.surname) {
27-
if (false) {
26+
if (!me.givenName || !me.surname) {
2827
setLoginStatus(null);
2928
navigate(`/profile?firstTime=true${returnTo ? `&returnTo=${returnTo}` : ''}`);
3029
} else {

src/ui/pages/events/ViewEvents.page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ export const ViewEventsPage: React.FC = () => {
5555
return (
5656
<Transition mounted={shouldShow} transition="fade" duration={400} timingFunction="ease">
5757
{(styles) => (
58-
<tr style={{ ...styles, display: shouldShow ? 'table-row' : 'none' }}>
58+
<tr
59+
style={{ ...styles, display: shouldShow ? 'table-row' : 'none' }}
60+
key={`${event.id}-tr`}
61+
>
5962
<Table.Td>{event.title}</Table.Td>
6063
<Table.Td>{dayjs(event.start).format('MMM D YYYY hh:mm')}</Table.Td>
6164
<Table.Td>{event.end ? dayjs(event.end).format('MMM D YYYY hh:mm') : 'N/A'}</Table.Td>

src/ui/pages/profile/ManageProfile.page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
88
import { useAuth } from '@ui/components/AuthContext';
99

1010
export const ManageProfilePage: React.FC = () => {
11-
const api = useApi('msGraphApi');
11+
const graphApi = useApi('msGraphApi');
12+
const api = useApi('core');
1213
const { setLoginStatus } = useAuth();
1314
const navigate = useNavigate();
1415
const [searchParams] = useSearchParams();
1516
const returnTo = searchParams.get('returnTo') || undefined;
1617
const firstTime = searchParams.get('firstTime') === 'true' || false;
1718
const getProfile = async () => {
1819
const raw = (
19-
await api.get(
20+
await graphApi.get(
2021
'/v1.0/me?$select=userPrincipalName,givenName,surname,displayName,otherMails,mail'
2122
)
2223
).data as UserProfileDataBase;
@@ -36,7 +37,7 @@ export const ManageProfilePage: React.FC = () => {
3637
}
3738
data.otherMails = newOtherEmails;
3839
delete data.discordUsername;
39-
const response = await api.patch('/v1.0/me', data);
40+
const response = await api.patch('/api/v1/iam/profile', data);
4041
if (response.status < 299 && firstTime) {
4142
setLoginStatus(true);
4243
}

src/ui/pages/profile/ManageProfileComponent.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ export const ManageProfileComponent: React.FC<ManageProfileComponentProps> = ({
4949
title: 'Profile updated successfully',
5050
message: 'Changes may take some time to reflect.',
5151
});
52-
await fetchProfile();
5352
} catch (e) {
5453
console.error(e);
5554
notifications.show({
@@ -62,15 +61,15 @@ export const ManageProfileComponent: React.FC<ManageProfileComponentProps> = ({
6261
};
6362

6463
if (userProfile === undefined) {
65-
return <LoadingOverlay visible={true} data-testId="profile-loading" />;
64+
return <LoadingOverlay visible={true} data-testid="profile-loading" />;
6665
}
6766

6867
return (
6968
<>
7069
{firstTime && (
7170
<Alert
7271
icon={<IconMoodSmileBeam />}
73-
title="Welcome to ACM @ UIUC Management Portal"
72+
title="Welcome to the ACM @ UIUC Management Portal"
7473
color="yellow"
7574
>
7675
Your profile is incomplete. Please provide us with the information below and click Save.
@@ -91,7 +90,7 @@ export const ManageProfileComponent: React.FC<ManageProfileComponentProps> = ({
9190
}
9291
placeholder={userProfile?.displayName}
9392
required
94-
data-testId="edit-displayName"
93+
data-testid="edit-displayName"
9594
/>
9695
<TextInput
9796
label="First Name"
@@ -101,15 +100,15 @@ export const ManageProfileComponent: React.FC<ManageProfileComponentProps> = ({
101100
}
102101
placeholder={userProfile?.givenName}
103102
required
104-
data-testId="edit-firstName"
103+
data-testid="edit-firstName"
105104
/>
106105
<TextInput
107106
label="Last Name"
108107
value={userProfile?.surname || ''}
109108
onChange={(e) => setUserProfile((prev) => prev && { ...prev, surname: e.target.value })}
110109
placeholder={userProfile?.surname}
111110
required
112-
data-testId="edit-lastName"
111+
data-testid="edit-lastName"
113112
/>
114113
<TextInput
115114
label="Email"
@@ -118,7 +117,7 @@ export const ManageProfileComponent: React.FC<ManageProfileComponentProps> = ({
118117
placeholder={userProfile?.mail}
119118
required
120119
disabled
121-
data-testId="edit-email"
120+
data-testid="edit-email"
122121
/>
123122

124123
<TextInput
@@ -127,7 +126,7 @@ export const ManageProfileComponent: React.FC<ManageProfileComponentProps> = ({
127126
onChange={(e) =>
128127
setUserProfile((prev) => prev && { ...prev, discordUsername: e.target.value })
129128
}
130-
data-testId="edit-discordUsername"
129+
data-testid="edit-discordUsername"
131130
/>
132131

133132
<Group mt="md">

0 commit comments

Comments
 (0)