diff --git a/client/components/mma/identity/idapi/user.ts b/client/components/mma/identity/idapi/user.ts index f8d020845..87adcf751 100644 --- a/client/components/mma/identity/idapi/user.ts +++ b/client/components/mma/identity/idapi/user.ts @@ -2,6 +2,7 @@ import { get } from 'lodash'; import { addCSRFToken, fetchWithDefaultParameters, + postRequest, putRequest, } from '@/client/utilities/fetch'; import type { User, UserError } from '../models'; @@ -194,3 +195,24 @@ export const read = async (): Promise => { ).then((response) => response.json()); return toUser(response); }; + +export const setUsername = async (user: Partial): Promise => { + const url = '/idapi/user/username'; + const body = { + publicFields: { + username: user.username, + }, + }; + try { + const response: UserAPIResponse = await fetchWithDefaultParameters( + url, + addCSRFToken(postRequest(body)), + ).then((response) => response.json()); + if (isErrorResponse(response)) { + throw toUserError(response); + } + return toUser(response); + } catch (e) { + throw isErrorResponse(e) ? toUserError(e) : e; + } +}; diff --git a/client/components/mma/identity/identity.ts b/client/components/mma/identity/identity.ts index 33925c806..c4f469c73 100644 --- a/client/components/mma/identity/identity.ts +++ b/client/components/mma/identity/identity.ts @@ -72,6 +72,9 @@ export const Users: UserCollection = { getChangedFields(original: User, changed: User): Partial { return diffWithCompositeFields(original, changed); }, + async setUsername(user: User): Promise { + return await UserAPI.setUsername(user); + }, }; export const ConsentOptions: ConsentOptionCollection = { diff --git a/client/components/mma/identity/models.ts b/client/components/mma/identity/models.ts index 452905869..32e5323cf 100644 --- a/client/components/mma/identity/models.ts +++ b/client/components/mma/identity/models.ts @@ -57,6 +57,7 @@ export interface UserCollection { save: (user: User) => Promise; saveChanges: (original: User, changed: User) => Promise; getChangedFields: (original: User, changed: User) => Partial; + setUsername: (user: User) => Promise; } export interface ConsentOption { diff --git a/client/components/mma/identity/publicProfile/PublicProfile.tsx b/client/components/mma/identity/publicProfile/PublicProfile.tsx index 3f93ee585..64c0869b5 100644 --- a/client/components/mma/identity/publicProfile/PublicProfile.tsx +++ b/client/components/mma/identity/publicProfile/PublicProfile.tsx @@ -43,10 +43,7 @@ export const PublicProfile = (_: { path?: string }) => { .catch(handleGeneralError); }, []); - const saveUser = async (originalUser: User, values: User) => { - const changedUser = { ...originalUser, ...values }; - return await Users.saveChanges(originalUser, changedUser); - }; + const setUsername = async (values: User) => await Users.setUsername(values); useEffect(() => { if (error && errorRef.current) { @@ -63,7 +60,9 @@ export const PublicProfile = (_: { path?: string }) => { const usernameDisplay = (u: User) => ( <> - {u.username} + + {u.username} + @@ -75,7 +74,7 @@ export const PublicProfile = (_: { path?: string }) => { <> saveUser(u, values)} + saveUser={(values) => setUsername(values)} onError={handleGeneralError} onSuccess={setUser} /> diff --git a/cypress/lib/signInOkta.ts b/cypress/lib/signInOkta.ts index 163dd9579..c45227857 100644 --- a/cypress/lib/signInOkta.ts +++ b/cypress/lib/signInOkta.ts @@ -16,6 +16,7 @@ export const signInOkta = () => { cy.visit('/'); cy.createTestUser({ isUserEmailValidated: true, + doNotSetUsername: true, })?.then(({ emailAddress, finalPassword }) => { cy.get('input[name=email]').type(emailAddress); cy.get('input[name=password]').type(finalPassword); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 45ed4da17..a613adc21 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -97,6 +97,7 @@ type IDAPITestUserOptions = { password?: string; deleteAfterMinute?: boolean; isGuestUser?: boolean; + doNotSetUsername?: boolean; }; type IDAPITestUserResponse = [ { @@ -128,6 +129,7 @@ export const createTestUser = ({ isUserEmailValidated = false, deleteAfterMinute = true, isGuestUser = false, + doNotSetUsername = false, }: IDAPITestUserOptions) => { // Generate a random email address if none is provided. const finalEmail = primaryEmailAddress || randomMailosaurEmail(); @@ -150,6 +152,7 @@ export const createTestUser = ({ password: finalPassword, deleteAfterMinute, isGuestUser, + doNotSetUsername, } as IDAPITestUserOptions, }) .then((res) => { diff --git a/cypress/tests/e2e/e2e.cy.ts b/cypress/tests/e2e/e2e.cy.ts index a35ea4a01..89b0df6d6 100644 --- a/cypress/tests/e2e/e2e.cy.ts +++ b/cypress/tests/e2e/e2e.cy.ts @@ -16,9 +16,18 @@ describe('E2E with Okta', () => { }); context('profile tab', () => { - it('should contain a username', () => { + it('should allow the user to set a username', () => { + const randomUsername = `testuser${Math.floor( + Math.random() * 100000, + )}`; cy.visit('/public-settings'); - cy.findByText('Username'); + cy.get('input[name="username"]').type(randomUsername); + cy.findByText('Save changes').click(); + cy.visit('/public-settings'); + cy.get('span[data-cy="username-display"]').should( + 'contain', + randomUsername, + ); }); }); diff --git a/server/routes/idapi.ts b/server/routes/idapi.ts index 7c1b850a9..70df15a9d 100644 --- a/server/routes/idapi.ts +++ b/server/routes/idapi.ts @@ -99,6 +99,15 @@ router.delete( }), ); +router.post( + '/user/username', + csrfValidateMiddleware, + idapiProxyHandler({ + url: '/user/me/username', + method: 'POST', + }), +); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- assume we don't know the range of possible types for the err argument? router.use((err: any, _: Request, res: Response, next: NextFunction) => { if (err.code && err.code === 'EBADCSRFTOKEN') {