diff --git a/apps/backend/example.development.env b/apps/backend/example.development.env index ccdfa3dfdc..dde1b439b0 100644 --- a/apps/backend/example.development.env +++ b/apps/backend/example.development.env @@ -52,6 +52,9 @@ TZ=Europe/Stockholm DATE_FORMAT=dd-MM-yyyy DATE_TIME_FORMAT=dd-MM-yyyy HH:mm +# ROR API URL, used for fetching organization information based on ROR IDs +# ROR_API_URL=https://api.ror.org/organizations + # The email address of the initial user officer. # INITIAL_USER_OFFICER_EMAIL= @@ -67,4 +70,6 @@ DATE_TIME_FORMAT=dd-MM-yyyy HH:mm # SINK_EMAIL=sink.email@example.com # Opentelemetry envs can be found in here https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/ -# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces" \ No newline at end of file +# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces" + +ROR_API_URL=https://api.ror.org/organizations diff --git a/apps/backend/src/auth/OAuthAuthorization.spec.ts b/apps/backend/src/auth/OAuthAuthorization.spec.ts index 85a4107f5a..f3fdf9d74f 100644 --- a/apps/backend/src/auth/OAuthAuthorization.spec.ts +++ b/apps/backend/src/auth/OAuthAuthorization.spec.ts @@ -41,9 +41,9 @@ describe('OAuthAuthorization', () => { country = 'testCountry', }: { user: User; - rorId?: string; - name?: string; - country?: string; + rorId?: string | null; + name?: string | null; + country?: string | null; }) => (mockOpenIdClient.login = jest.fn().mockResolvedValue({ userProfile: { @@ -128,8 +128,8 @@ describe('OAuthAuthorization', () => { expect(mockAdminDataSource.createCountry).not.toHaveBeenCalled(); expect(mockAdminDataSource.createInstitution).toHaveBeenCalledWith({ - name: 'testName', - country: testCountry.countryId, + name: 'Unknown institution', + country: null, rorId: 'testRorId', }); expect( @@ -161,7 +161,8 @@ describe('OAuthAuthorization', () => { .spyOn(mockAdminDataSource, 'createInstitution') .mockResolvedValue({ id: expectedInstitutionId } as Institution); - mockOpenIdLoginResponse({ user: dummyUser }); + // make dummyUser without ror_id + mockOpenIdLoginResponse({ user: dummyUser, rorId: null }); await oauthAuthorization.externalTokenLogin('valid', '', null); expect(mockAdminDataSource.createCountry).toHaveBeenCalledWith( @@ -170,7 +171,7 @@ describe('OAuthAuthorization', () => { expect(mockAdminDataSource.createInstitution).toHaveBeenCalledWith({ name: 'testName', country: testCountry.countryId, - rorId: 'testRorId', + rorId: undefined, }); expect( @@ -202,6 +203,34 @@ describe('OAuthAuthorization', () => { ).toBe(dummyUser.institutionId); }); + describe('getOrCreateUserInstitution', () => { + it('Should create new institution if institution name matches but country does not', async () => { + const existingInstitution = new Institution(1, 'Existing Inst', 1); + const newCountry = { country: 'New Country', countryId: 2 }; + + jest + .spyOn(mockAdminDataSource, 'getInstitutionByName') + .mockResolvedValue(existingInstitution); + jest + .spyOn(mockAdminDataSource, 'getCountryByName') + .mockResolvedValue(newCountry); + jest + .spyOn(mockAdminDataSource, 'createInstitution') + .mockResolvedValue({ id: 2 } as Institution); + + await oauthAuthorization.getOrCreateUserInstitution({ + name: existingInstitution.name, + country: newCountry.country, + }); + + expect(mockAdminDataSource.createInstitution).toHaveBeenCalledWith({ + name: existingInstitution.name, + country: newCountry.countryId, + rorId: undefined, + }); + }); + }); + describe('upsertUser->create: INITIAL_USER_OFFICER_EMAIL', () => { it('should assign USER_OFFICER role if the email is the INITIAL_USER_OFFICER_EMAIL', async () => { process.env.INITIAL_USER_OFFICER_EMAIL = dummyUser.email; diff --git a/apps/backend/src/auth/OAuthAuthorization.ts b/apps/backend/src/auth/OAuthAuthorization.ts index c92dde1cf2..fab89cb851 100644 --- a/apps/backend/src/auth/OAuthAuthorization.ts +++ b/apps/backend/src/auth/OAuthAuthorization.ts @@ -6,22 +6,18 @@ import { OpenIdClient } from '@user-office-software/openid'; import { ValidTokenSet } from '@user-office-software/openid/lib/model/ValidTokenSet'; import { ValidUserInfo } from '@user-office-software/openid/lib/model/ValidUserInfo'; import { GraphQLError } from 'graphql'; -import { UserinfoResponse } from 'openid-client'; import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { AdminDataSource } from '../datasources/AdminDataSource'; +import { Institution } from '../models/Institution'; import { Rejection } from '../models/Rejection'; import { SettingsId } from '../models/Settings'; import { AuthJwtPayload, User, UserRole } from '../models/User'; +import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation'; +import { getInstitutionFromRor } from '../services/RorApi'; import { UserAuthorization } from './UserAuthorization'; -interface UserinfoResponseWithInstitution extends UserinfoResponse { - institution_ror_id?: string; - institution_name?: string; - institution_country?: string; -} - export class OAuthAuthorization extends UserAuthorization { private db = container.resolve(Tokens.AdminDataSource); @@ -85,38 +81,82 @@ export class OAuthAuthorization extends UserAuthorization { }); } - public async getOrCreateUserInstitution( - userInfo: UserinfoResponseWithInstitution - ) { - if (!userInfo.institution_name || !userInfo.institution_country) { - return null; + private async getOrCreateInstitutionByRorId( + rorId: string + ): Promise { + let institution = await this.adminDataSource.getInstitutionByRorId(rorId); + if (!institution) { + const rorData = await getInstitutionFromRor(rorId); + let name = 'Unknown institution'; + let countryId: number | null = null; + + if (rorData) { + name = rorData.name; + let country = await this.adminDataSource.getCountryByName( + rorData.country + ); + if (!country) { + logger.logWarn('Country not found in database', { + countryName: rorData.country, + }); + country = await this.adminDataSource.createCountry(rorData.country); + } + countryId = country.countryId; + } + + institution = await this.adminDataSource.createInstitution({ + name: name, + country: countryId, + rorId: rorId, + }); } - let institution = userInfo.institution_ror_id - ? await this.adminDataSource.getInstitutionByRorId( - userInfo.institution_ror_id - ) - : await this.adminDataSource.getInstitutionByName( - userInfo.institution_name - ); + return institution; + } + + private async getOrCreateInstitutionByManualInput(manualInput: { + name: string; + country: string; + }): Promise { + const institutionName = manualInput.name; + const countryName = manualInput.country; + + let country = await this.adminDataSource.getCountryByName(countryName); + if (!country) { + // create country if it does not exist + country = await this.adminDataSource.createCountry(countryName); + } + + let institution = + await this.adminDataSource.getInstitutionByName(institutionName); + + // If the institution exists but the country does not match, we should create a new institution + if (institution && institution.country !== country.countryId) { + institution = null; + } if (!institution) { - let institutionCountry = await this.adminDataSource.getCountryByName( - userInfo.institution_country - ); + institution = await this.adminDataSource.createInstitution({ + name: institutionName, + country: country.countryId, + rorId: undefined, + }); + } - if (!institutionCountry) { - institutionCountry = await this.adminDataSource.createCountry( - userInfo.institution_country - ); + return institution; + } + + public async getOrCreateUserInstitution(input: GetOrCreateInstitutionInput) { + let institution: Institution | null = null; + if (typeof input === 'string') { + // ROR ID provided + if (!input || input.trim() === '') { + return null; } - const newInstitution = { - name: userInfo.institution_name, - country: institutionCountry.countryId, - rorId: userInfo.institution_ror_id, - }; - institution = - await this.adminDataSource.createInstitution(newInstitution); + institution = await this.getOrCreateInstitutionByRorId(input); + } else if (input !== null && typeof input === 'object') { + // Manual institution details provided + institution = await this.getOrCreateInstitutionByManualInput(input); } return institution; @@ -127,7 +167,17 @@ export class OAuthAuthorization extends UserAuthorization { tokenSet: ValidTokenSet ): Promise { const client = await OpenIdClient.getInstance(); - const institution = await this.getOrCreateUserInstitution(userInfo); + let institutionInput: GetOrCreateInstitutionInput = null; + if (userInfo.institution_ror_id) { + institutionInput = userInfo.institution_ror_id as string; + } else if (userInfo.institution_name && userInfo.institution_country) { + institutionInput = { + country: userInfo.institution_country as string, + name: userInfo.institution_name as string, + }; + } + + const institution = await this.getOrCreateUserInstitution(institutionInput); const userId = this.getUniqueId(userInfo); const userWithOAuthSubMatch = await this.userDataSource.getByOIDCSub(userId); diff --git a/apps/backend/src/auth/StfcUserAuthorization.ts b/apps/backend/src/auth/StfcUserAuthorization.ts index 7675a94fb9..49ddb0eb92 100644 --- a/apps/backend/src/auth/StfcUserAuthorization.ts +++ b/apps/backend/src/auth/StfcUserAuthorization.ts @@ -22,6 +22,7 @@ import { Instrument } from '../models/Instrument'; import { Rejection, rejection } from '../models/Rejection'; import { Role, Roles } from '../models/Role'; import { AuthJwtPayload, User, UserWithRole } from '../models/User'; +import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation'; import { Cache } from '../utils/Cache'; import { StfcUserDataSource } from './../datasources/stfc/StfcUserDataSource'; import { UserAuthorization } from './UserAuthorization'; @@ -349,11 +350,9 @@ export class StfcUserAuthorization extends UserAuthorization { return true; } - getOrCreateUserInstitution(userInfo: { - institution_ror_id?: string; - institution_name?: string; - institution_country?: string; - }): Promise { + getOrCreateUserInstitution( + _input: GetOrCreateInstitutionInput + ): Promise { throw new Error('Method not implemented.'); } } diff --git a/apps/backend/src/auth/UserAuthorization.ts b/apps/backend/src/auth/UserAuthorization.ts index ca17a768cc..a62b4ff43e 100644 --- a/apps/backend/src/auth/UserAuthorization.ts +++ b/apps/backend/src/auth/UserAuthorization.ts @@ -13,6 +13,7 @@ import { Institution } from '../models/Institution'; import { Rejection } from '../models/Rejection'; import { Role, Roles } from '../models/Role'; import { AuthJwtPayload, User, UserWithRole } from '../models/User'; +import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation'; import { AdminDataSource } from './../datasources/AdminDataSource'; export abstract class UserAuthorization { @@ -198,11 +199,9 @@ export abstract class UserAuthorization { iss: string | null ): Promise; - abstract getOrCreateUserInstitution(userInfo: { - institution_ror_id?: string; - institution_name?: string; - institution_country?: string; - }): Promise; + abstract getOrCreateUserInstitution( + institution: GetOrCreateInstitutionInput + ): Promise; abstract logout(token: AuthJwtPayload): Promise; diff --git a/apps/backend/src/auth/mockups/UserAuthorization.ts b/apps/backend/src/auth/mockups/UserAuthorization.ts index c313816c73..ed00771c3a 100644 --- a/apps/backend/src/auth/mockups/UserAuthorization.ts +++ b/apps/backend/src/auth/mockups/UserAuthorization.ts @@ -6,6 +6,10 @@ import { dummyUser } from '../../datasources/mockups/UserDataSource'; import { Institution } from '../../models/Institution'; import { Role } from '../../models/Role'; import { AuthJwtPayload, User } from '../../models/User'; +import { + GetOrCreateInstitutionInput, + InstitutionManualInput, +} from '../../resolvers/mutations/UpsertUserMutation'; import { UserAuthorization } from '../UserAuthorization'; @injectable() @@ -23,56 +27,63 @@ export class UserAuthorizationMock extends UserAuthorization { new Institution(5, 'Mock Academic Center', 4, 'https://ror.org/dummy003'), ]; - async getOrCreateUserInstitution(userInfo: { - institution_ror_id?: string; - institution_name?: string; - institution_country?: string; - }): Promise { - // Return default institution if all fields are empty or unspecified - if ( - (!userInfo.institution_name || userInfo.institution_name.trim() === '') && - (!userInfo.institution_ror_id || - userInfo.institution_ror_id.trim() === '') - ) { - return this.mockInstitutions[0]; // Return dummyInstitution as default - } + private getNextInstitutionId(): number { + return ( + Math.max(...this.mockInstitutions.map((institution) => institution.id)) + + 1 + ); + } - // Try to find existing institution by ROR ID first (most reliable) - if (userInfo.institution_ror_id) { - const existingByRor = this.mockInstitutions.find( - (inst) => inst.rorId === userInfo.institution_ror_id - ); - if (existingByRor) { - return existingByRor; - } + async getOrCreateUserInstitution( + institutionInput: GetOrCreateInstitutionInput + ): Promise { + if (typeof institutionInput === 'string') { + // ROR ID provided + return this.getOrCreateInstitutionByRorId(institutionInput); + } else if (institutionInput instanceof InstitutionManualInput) { + // Manual institution details provided + return this.getOrCreateInstitutionByManualInput(institutionInput); } - // Try to find existing institution by name (case-insensitive) - if (userInfo.institution_name) { - const existingByName = this.mockInstitutions.find( - (inst) => - inst.name.toLowerCase() === userInfo.institution_name?.toLowerCase() + return this.mockInstitutions[0]; + } + + private getOrCreateInstitutionByRorId(rorId: string): Institution { + let institution = this.mockInstitutions.find( + (inst) => inst.rorId === rorId + ); + if (!institution) { + institution = new Institution( + this.getNextInstitutionId(), + 'New Institution', + 1, + rorId ); - if (existingByName) { - return existingByName; - } + this.mockInstitutions.push(institution); } - // Create new institution if not found - const newId = Math.max(...this.mockInstitutions.map((i) => i.id)) + 1; + return institution; + } - const newInstitution = new Institution( - newId, - userInfo.institution_name ?? 'Unknown Institution', - 1, - userInfo.institution_ror_id + private getOrCreateInstitutionByManualInput( + manualInput: InstitutionManualInput + ): Institution { + let institution = this.mockInstitutions.find( + (inst) => inst.name.toLowerCase() === manualInput.name.toLowerCase() ); + if (!institution) { + institution = new Institution( + this.getNextInstitutionId(), + manualInput.name, + 1, + undefined + ); + this.mockInstitutions.push(institution); + } - // Add to mock data for subsequent calls - this.mockInstitutions.push(newInstitution); - - return newInstitution; + return institution; } + async externalTokenLogin( token: string, _redirectUri: string diff --git a/apps/backend/src/datasources/postgres/UserDataSource.ts b/apps/backend/src/datasources/postgres/UserDataSource.ts index d66dd174bb..518976dde8 100644 --- a/apps/backend/src/datasources/postgres/UserDataSource.ts +++ b/apps/backend/src/datasources/postgres/UserDataSource.ts @@ -323,7 +323,9 @@ export default class PostgresUserDataSource implements UserDataSource { }) .catch((error) => { if (isUniqueConstraintError(error)) { - throw new GraphQLError('User already exists'); + throw new GraphQLError('User already exists', { + originalError: error, + }); } throw new GraphQLError('Could not update user. Check your Inputs.'); }); diff --git a/apps/backend/src/models/Institution.ts b/apps/backend/src/models/Institution.ts index 0e02d22e3b..83f86ff2db 100644 --- a/apps/backend/src/models/Institution.ts +++ b/apps/backend/src/models/Institution.ts @@ -2,7 +2,7 @@ export class Institution { constructor( public id: number, public name: string, - public country: number, + public country: number | null, public rorId?: string ) {} } diff --git a/apps/backend/src/mutations/UserMutations.spec.ts b/apps/backend/src/mutations/UserMutations.spec.ts index ba18ee060f..fa9bb711c8 100644 --- a/apps/backend/src/mutations/UserMutations.spec.ts +++ b/apps/backend/src/mutations/UserMutations.spec.ts @@ -1,13 +1,16 @@ +import { upsertUserByOidcSubValidationSchema } from '@user-office-software/duo-validation'; import jsonwebtoken from 'jsonwebtoken'; -import { container } from 'tsyringe'; +import { container, Lifecycle } from 'tsyringe'; +import { UserAuthorizationMock } from '../auth/mockups/UserAuthorization'; +import { Tokens } from '../config/Tokens'; import { dummyUser, dummyUserNotOnProposal, - dummyUserOfficer, - dummyUserWithRole, dummyUserNotOnProposalWithRole, + dummyUserOfficer, dummyUserOfficerWithRole, + dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; import { isRejection } from '../models/Rejection'; import { AuthJwtPayload, User } from '../models/User'; @@ -35,7 +38,16 @@ const badToken = jsonwebtoken.sign( { expiresIn: '-24h' } ); -const userMutations = container.resolve(UserMutations); +let userMutations: UserMutations; + +beforeEach(() => { + container.register( + Tokens.UserAuthorization, + { useClass: UserAuthorizationMock }, + { lifecycle: Lifecycle.Singleton } + ); + userMutations = container.resolve(UserMutations); +}); test('A user can update its own name', () => { return expect( @@ -153,6 +165,81 @@ test('externalTokenLogin supplies a new JWT', async () => { }); describe('upsertUserByOidcSub', () => { + describe('validation schema', () => { + const validPayload = { + oidcSub: 'schema-test-oidc-sub', + firstName: 'Test', + lastName: 'User', + email: 'test.user@example.com', + userTitle: '', + preferredName: '', + institution: { + rorId: 'https://ror.org/01wv9cn34', + }, + }; + + test('accepts payload with only rorId', async () => { + await expect( + upsertUserByOidcSubValidationSchema.validate(validPayload) + ).resolves.toBeTruthy(); + }); + + test('accepts payload with only institutionData', async () => { + await expect( + upsertUserByOidcSubValidationSchema.validate({ + ...validPayload, + institution: { + institutionData: { + name: 'Test Institute', + country: 'Sweden', + }, + }, + }) + ).resolves.toBeTruthy(); + }); + + test('rejects payload when both rorId and institutionData are provided', async () => { + await expect( + upsertUserByOidcSubValidationSchema.validate({ + ...validPayload, + institution: { + rorId: 'https://ror.org/01wv9cn34', + institutionData: { + name: 'Test Institute', + country: 'Sweden', + }, + }, + }) + ).rejects.toThrow( + 'Exactly one of rorId or institutionData must be provided' + ); + }); + + test('rejects payload when neither rorId nor institutionData are provided', async () => { + await expect( + upsertUserByOidcSubValidationSchema.validate({ + ...validPayload, + institution: {}, + }) + ).rejects.toThrow( + 'Exactly one of rorId or institutionData must be provided' + ); + }); + + test('rejects payload when rorId has invalid format', async () => { + await expect( + upsertUserByOidcSubValidationSchema.validate({ + ...validPayload, + institution: { + rorId: 'dummy-ror-id', + }, + }) + ).rejects.toThrow( + 'rorId must be in the format https://ror.org/01wv9cn34' + ); + }); + }); + test('A user can be created if OIDC sub does not exist', async () => { const newOidcSub = 'new-unique-oidc-sub'; const result = await userMutations.upsertUserByOidcSub( @@ -162,11 +249,12 @@ describe('upsertUserByOidcSub', () => { firstName: 'New', lastName: 'User', email: 'new.user@example.com', - userTitle: null, - preferredName: null, - institutionRoRId: '', - institutionName: '', - institutionCountry: '', + userTitle: '', + preferredName: '', + institution: { + rorId: 'https://ror.org/01wv9cn34', + institutionData: null, + }, } ); // Check if the result has the oidcsub @@ -181,11 +269,12 @@ describe('upsertUserByOidcSub', () => { firstName: 'UpsertedJane', lastName: 'UpsertedDoe', email: 'upserted.jane.doe@example.com', - userTitle: null, - preferredName: null, - institutionRoRId: '', - institutionName: '', - institutionCountry: '', + userTitle: '', + preferredName: '', + institution: { + rorId: 'https://ror.org/01wv9cn34', + institutionData: null, + }, } ); @@ -194,9 +283,9 @@ describe('upsertUserByOidcSub', () => { expect((result as User).oidcSub).toBe(dummyUser.oidcSub); }); - test('A new user can be created where the institution will be fetched from institutionRoRId', async () => { + test('A new user can be created with a ROR ID that creates a new mock institution', async () => { const newOidcSub = 'user-with-existing-institution-ror'; - const existingRorId = 'https://ror.org/dummy001'; // Dummy Research Institute from our mock + const existingRorId = 'https://ror.org/01wv9cn34'; const result = await userMutations.upsertUserByOidcSub( dummyUserOfficerWithRole, @@ -207,17 +296,18 @@ describe('upsertUserByOidcSub', () => { email: 'john.scientist@dummy-research.org', userTitle: 'Dr.', preferredName: 'Johnny', - institutionRoRId: existingRorId, // This should find Dummy Research Institute in our mock - institutionName: 'CERN', // This should match the existing institution - institutionCountry: 'Switzerland', + institution: { + rorId: existingRorId, // This should find Dummy Research Institute in our mock + institutionData: null, + }, } ); expect(isRejection(result)).toBe(false); const createdUser = result as User; - // Should use existing institution (Dummy Research Institute has ID 3 in our mock) - expect(createdUser.institutionId).toBe(3); + // With current validation constraints, this ROR ID is treated as a new institution in mock flow + expect(createdUser.institutionId).toBe(6); expect(createdUser.firstname).toBe('John'); expect(createdUser.lastname).toBe('Scientist'); expect(createdUser.email).toBe('john.scientist@dummy-research.org'); @@ -237,9 +327,10 @@ describe('upsertUserByOidcSub', () => { email: 'maria.researcher@newinstitute.edu', userTitle: 'Prof.', preferredName: 'Maria', - institutionRoRId: newRorId, // This ROR ID doesn't exist in mock - institutionName: 'New Research Institute', - institutionCountry: 'Germany', + institution: { + rorId: newRorId, // This ROR ID doesn't exist in mock + institutionData: null, + }, } ); diff --git a/apps/backend/src/mutations/UserMutations.ts b/apps/backend/src/mutations/UserMutations.ts index 3ff3b88ee6..1f7d3727f4 100644 --- a/apps/backend/src/mutations/UserMutations.ts +++ b/apps/backend/src/mutations/UserMutations.ts @@ -5,6 +5,7 @@ import { getTokenForUserValidationSchema, updateUserRolesValidationSchema, updateUserValidationBackendSchema, + upsertUserByOidcSubValidationSchema, } from '@user-office-software/duo-validation'; import * as bcrypt from 'bcryptjs'; import { inject, injectable } from 'tsyringe'; @@ -363,6 +364,7 @@ export default class UserMutations { return this.dataSource.setUserNotPlaceholder(id); } + @ValidateArgs(upsertUserByOidcSubValidationSchema) @Authorized([Roles.USER_OFFICER]) async upsertUserByOidcSub( agent: UserWithRole | null, @@ -374,23 +376,19 @@ export default class UserMutations { lastName, preferredName, oidcSub, - institutionRoRId, - institutionName, - institutionCountry, + institution: institutionInput, email, } = args; const userWithOAuthSubMatch = await this.dataSource.getByOIDCSub(oidcSub); - const institution = await this.userAuth.getOrCreateUserInstitution({ - institution_ror_id: institutionRoRId, - institution_name: institutionName, - institution_country: institutionCountry, - }); + const institution = await this.userAuth.getOrCreateUserInstitution( + institutionInput.institutionData ?? institutionInput.rorId + ); if (!institution) { return rejection('Invalid Input for the Institution', { - institutionRoRId, + institutionInput, args, }); } diff --git a/apps/backend/src/resolvers/mutations/UpdateInstitutionsMutation.ts b/apps/backend/src/resolvers/mutations/UpdateInstitutionsMutation.ts index 2594784d63..98953f621a 100644 --- a/apps/backend/src/resolvers/mutations/UpdateInstitutionsMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpdateInstitutionsMutation.ts @@ -19,8 +19,8 @@ export class UpdateInstitutionsArgs { @Field(() => String, { nullable: true }) name: string; - @Field(() => Int) - country: number; + @Field(() => Int, { nullable: true }) + country: number | null; @Field(() => String, { nullable: true }) rorId?: string; diff --git a/apps/backend/src/resolvers/mutations/UpsertUserMutation.ts b/apps/backend/src/resolvers/mutations/UpsertUserMutation.ts index 071c0943eb..471b84dc19 100644 --- a/apps/backend/src/resolvers/mutations/UpsertUserMutation.ts +++ b/apps/backend/src/resolvers/mutations/UpsertUserMutation.ts @@ -1,7 +1,40 @@ -import { Args, ArgsType, Ctx, Field, Mutation, Resolver } from 'type-graphql'; +import { + Args, + ArgsType, + Ctx, + Directive, + Field, + InputType, + Mutation, + Resolver, +} from 'type-graphql'; import { ResolverContext } from '../../context'; import { User } from '../types/User'; + +@InputType() +export class InstitutionManualInput { + @Field(() => String) + public name: string; + + @Field(() => String) + public country: string; +} + +@InputType() +@Directive('@oneOf') +export class InstitutionInput { + @Field(() => String, { nullable: true }) + public rorId: string | null; + + @Field(() => InstitutionManualInput, { nullable: true }) + public institutionData: InstitutionManualInput | null; +} + +export type GetOrCreateInstitutionInput = + | InstitutionInput['rorId'] + | InstitutionInput['institutionData']; + @ArgsType() export class UpsertUserByOidcSubArgs { @Field(() => String, { nullable: true }) @@ -19,14 +52,8 @@ export class UpsertUserByOidcSubArgs { @Field(() => String) public oidcSub: string; - @Field(() => String) - public institutionRoRId: string; - - @Field(() => String) - public institutionName: string; - - @Field(() => String) - public institutionCountry: string; + @Field(() => InstitutionInput) + public institution: InstitutionInput; @Field(() => String) public email: string; diff --git a/apps/backend/src/resolvers/types/Institution.ts b/apps/backend/src/resolvers/types/Institution.ts index d39d108c12..e1354817ea 100644 --- a/apps/backend/src/resolvers/types/Institution.ts +++ b/apps/backend/src/resolvers/types/Institution.ts @@ -31,6 +31,10 @@ export class InstitutionResolver { @Root() institution: InstitutionOrigin, @Ctx() context: ResolverContext ): Promise { + if (institution.country === null) { + return null; + } + return context.queries.admin.getCountry(institution.country); } } diff --git a/apps/backend/src/resolvers/types/User.ts b/apps/backend/src/resolvers/types/User.ts index 91cc7c3cfd..18559a1787 100644 --- a/apps/backend/src/resolvers/types/User.ts +++ b/apps/backend/src/resolvers/types/User.ts @@ -20,6 +20,7 @@ import { User as UserOrigin } from '../../models/User'; import { UserExperimentsFilter } from '../queries/ExperimentsQuery'; import { Experiment } from './Experiment'; import { Fap } from './Fap'; +import { Institution } from './Institution'; import { Instrument } from './Instrument'; import { Invite } from './Invite'; import { Proposal } from './Proposal'; @@ -179,6 +180,11 @@ export class UserResolver { async instruments(@Root() user: User, @Ctx() context: ResolverContext) { return context.queries.instrument.dataSource.getUserInstruments(user.id); } + + @FieldResolver(() => Institution) + async institution(@Root() user: User, @Ctx() context: ResolverContext) { + return context.queries.admin.getInstitution(user.institutionId); + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/backend/src/services/RorApi.spec.ts b/apps/backend/src/services/RorApi.spec.ts new file mode 100644 index 0000000000..f38b498fd2 --- /dev/null +++ b/apps/backend/src/services/RorApi.spec.ts @@ -0,0 +1,156 @@ +import { logger } from '@user-office-software/duo-logger'; + +import { getInstitutionFromRor } from './RorApi'; + +jest.mock('@user-office-software/duo-logger', () => ({ + logger: { + logError: jest.fn(), + }, +})); + +describe('RorApi', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + global.fetch = jest.fn(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('should return institution name and country for valid ROR ID', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + clone: jest.fn().mockReturnThis(), + json: async () => ({ + names: [{ types: ['ror_display'], value: 'Test Institution' }], + locations: [{ geonames_details: { country_name: 'Test Country' } }], + }), + }); + + const result = await getInstitutionFromRor('05n09v162'); + + expect(result).toEqual({ + name: 'Test Institution', + country: 'Test Country', + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.ror.org/organizations/05n09v162' + ); + }); + + it('should handle full URL ROR ID by extracting the ID', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + clone: jest.fn().mockReturnThis(), + json: async () => ({ + names: [{ types: ['ror_display'], value: 'Test Institution' }], + locations: [{ geonames_details: { country_name: 'Test Country' } }], + }), + }); + + const result = await getInstitutionFromRor('https://ror.org/05n09v162'); + + expect(result).toEqual({ + name: 'Test Institution', + country: 'Test Country', + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.ror.org/organizations/05n09v162' + ); + }); + + it('should return null if institution not found (404)', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 404, + clone: jest.fn().mockReturnThis(), + json: async () => ({}), + }); + + const result = await getInstitutionFromRor('invalid-id'); + + expect(result).toBeNull(); + expect(logger.logError).toHaveBeenCalledWith( + 'Institution not found in ROR', + { rorId: 'invalid-id' } + ); + }); + + it('should return null if fetch fails with non-ok status', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + clone: jest.fn().mockReturnThis(), + json: async () => ({}), + }); + + const result = await getInstitutionFromRor('05n09v162'); + + expect(result).toBeNull(); + expect(logger.logError).toHaveBeenCalledWith( + 'Failed to fetch institution from ROR', + { + rorId: '05n09v162', + status: 500, + } + ); + }); + + it('should return null if name missing in response', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + clone: jest.fn().mockReturnThis(), + json: async () => ({ + names: [{ types: ['other'], value: 'Some Name' }], // Missing ror_display type + locations: [{ geonames_details: { country_name: 'Test Country' } }], + }), + }); + + const result = await getInstitutionFromRor('05n09v162'); + + expect(result).toBeNull(); + expect(logger.logError).toHaveBeenCalledWith( + 'ROR response missing name or country', + expect.anything() + ); + }); + + it('should return null if country missing in response', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + clone: jest.fn().mockReturnThis(), + json: async () => ({ + names: [{ types: ['ror_display'], value: 'Test Institution' }], + locations: [], // Missing location + }), + }); + + const result = await getInstitutionFromRor('05n09v162'); + + expect(result).toBeNull(); + expect(logger.logError).toHaveBeenCalledWith( + 'ROR response missing name or country', + expect.anything() + ); + }); + + it('should return null on fetch exception', async () => { + const error = new Error('Network error'); + (global.fetch as jest.Mock).mockRejectedValue(error); + + const result = await getInstitutionFromRor('05n09v162'); + + expect(result).toBeNull(); + expect(logger.logError).toHaveBeenCalledWith( + 'Error fetching institution from ROR', + { rorId: '05n09v162', error } + ); + }); +}); diff --git a/apps/backend/src/services/RorApi.ts b/apps/backend/src/services/RorApi.ts new file mode 100644 index 0000000000..502df29953 --- /dev/null +++ b/apps/backend/src/services/RorApi.ts @@ -0,0 +1,84 @@ +import { env } from 'process'; + +import { logger } from '@user-office-software/duo-logger'; + +export interface RorInstitution { + name: string; + country: string; +} + +interface RorResponse { + names: { + types: string[]; + value: string; + }[]; + locations: { + geonames_details: { + country_name: string; + }; + }[]; +} + +const getInstitutionNameFromResponse = ( + rorResponseData: RorResponse +): string | undefined => { + return rorResponseData.names.find((n) => n.types.includes('ror_display')) + ?.value; +}; + +const getInstitutionCountryFromResponse = ( + rorResponseData: RorResponse +): string | undefined => { + return rorResponseData.locations[0]?.geonames_details?.country_name; +}; + +export const getInstitutionFromRor = async ( + rorId: string +): Promise => { + const ROR_API_URL = env.ROR_API_URL || 'https://api.ror.org/organizations'; + const cleanRorId = rorId.startsWith('http') + ? rorId.replace(/\/+$/, '').split('/').pop() + : rorId; + + try { + const response = await fetch(`${ROR_API_URL}/${cleanRorId}`); + + if (response.status === 404) { + logger.logError('Institution not found in ROR', { rorId }); + + return null; + } + + if (!response.ok) { + logger.logError('Failed to fetch institution from ROR', { + rorId, + status: response.status, + }); + + return null; + } + + const rorResponseData = (await response.clone().json()) as RorResponse; + + const rorName = getInstitutionNameFromResponse(rorResponseData); + const rorCountry = getInstitutionCountryFromResponse(rorResponseData); + + if (!rorName || !rorCountry) { + logger.logError('ROR response missing name or country', { + rorId, + rorResponseData, + }); + + return null; + } + + return { + name: rorName, + country: rorCountry, + }; + } catch (error) { + logger.logError('Error fetching institution from ROR', { rorId, error }); + + return null; + } +}; diff --git a/apps/backend/src/utils/buildFederatedSchema.ts b/apps/backend/src/utils/buildFederatedSchema.ts index c3eba9f8aa..a16402dfb9 100644 --- a/apps/backend/src/utils/buildFederatedSchema.ts +++ b/apps/backend/src/utils/buildFederatedSchema.ts @@ -23,7 +23,10 @@ export async function buildFederatedSchema( }); const federatedSchema = buildSubgraphSchema({ - typeDefs: gql(printSchemaWithDirectives(schema)), + typeDefs: gql(` + directive @oneOf on INPUT_OBJECT + ${printSchemaWithDirectives(schema)} + `), // merge schema's resolvers with reference resolvers resolvers: deepMerge( createResolversMap(schema) as GraphQLResolverMap, diff --git a/apps/e2e/cypress/e2e/login.cy.ts b/apps/e2e/cypress/e2e/login.cy.ts index 6e74ab69d8..390279f335 100644 --- a/apps/e2e/cypress/e2e/login.cy.ts +++ b/apps/e2e/cypress/e2e/login.cy.ts @@ -4,20 +4,6 @@ import featureFlags from '../support/featureFlags'; import initialDBData from '../support/initialDBData'; context('User login tests', () => { - const userOne = 'user1'; - const userTwo = 'user2'; - const userOneInstitutionInfo = { - institution_ror_id: 'Test_ror_id_1', - institution_country: 'China', - institution_name: 'Test Institution', - }; - - const userTwoInstitutionInfo = { - institution_ror_id: 'Test_ror_id_2', - institution_country: 'TestCountry', - institution_name: 'Test Institution2', - }; - beforeEach(() => { cy.resetDB(); cy.getAndStoreFeaturesEnabled(); @@ -31,32 +17,26 @@ context('User login tests', () => { this.skip(); } - cy.login(userOne, initialDBData.roles.user); - cy.visit('/'); - cy.logout(); + const institutionRorId = 'https://ror.org/not0in0db'; + + cy.upsertUserByOidc({ + oidcSub: 'some-unique-sub', + email: 'email@example.com', + institution: { + rorId: institutionRorId, + }, + firstName: 'Test', + lastName: 'User', + }); - cy.login('officer'); cy.contains('Institutions').click(); cy.finishedLoading(); - cy.get('input[aria-label="Search"]') - .focus() - .type(userOneInstitutionInfo.institution_name); + cy.get('input[aria-label="Search"]').focus().type(institutionRorId); cy.get('[data-cy="institutions-table"]').as('institutionsTable'); - cy.get('@institutionsTable') - .contains(userOneInstitutionInfo.institution_name) - .parents('tr') - .within(() => { - cy.get('td:nth-child(4)') - .invoke('text') - .then((text) => { - expect(text.trim()).to.eq( - userOneInstitutionInfo.institution_ror_id - ); - }); - }); + cy.get('@institutionsTable').contains(institutionRorId); }); it('Should create new entry for country, if the given country is not found', function () { @@ -64,30 +44,37 @@ context('User login tests', () => { this.skip(); } - cy.login(userTwo, initialDBData.roles.user); - cy.visit('/'); - cy.logout(); + const institutionCountry = 'NewCountry'; + const institutionName = 'Test Institution NewCountry'; + + cy.upsertUserByOidc({ + oidcSub: 'some-unique-sub', + email: 'email@example.com', + firstName: 'Test', + lastName: 'User', + institution: { + institutionData: { + name: institutionName, + country: institutionCountry, + }, + }, + }); - cy.login('officer'); cy.contains('Institutions').click(); cy.finishedLoading(); - cy.get('input[aria-label="Search"]') - .focus() - .type(userOneInstitutionInfo.institution_name); + cy.get('input[aria-label="Search"]').focus().type(institutionName); cy.get('[data-cy="institutions-table"]').as('institutionsTable'); cy.get('@institutionsTable') - .contains(userTwoInstitutionInfo.institution_name) + .contains(institutionName) .parents('tr') .within(() => { cy.get('td:nth-child(3)') .invoke('text') .then((text) => { - expect(text.trim()).to.eq( - userTwoInstitutionInfo.institution_country - ); + expect(text.trim()).to.eq(institutionCountry); }); }); }); diff --git a/apps/e2e/cypress/support/user.ts b/apps/e2e/cypress/support/user.ts index 34b12855b1..6e054cb753 100644 --- a/apps/e2e/cypress/support/user.ts +++ b/apps/e2e/cypress/support/user.ts @@ -6,6 +6,7 @@ import { UpdateUserMutation, UpdateUserMutationVariables, UpdateUserRolesMutationVariables, + UpsertUserByOidcSubMutationVariables, User, } from '@user-office-software-libs/shared-types'; import { jwtDecode } from 'jwt-decode'; @@ -79,6 +80,21 @@ function changeActiveRole(selectedRoleId: number) { cy.wrap(request); } +function upsertUserByOidc(args: UpsertUserByOidcSubMutationVariables) { + const token = window.localStorage.getItem('token'); + + if (!token) { + throw new Error('No logged in user'); + } + + const api = getE2EApi(); + const request = api.upsertUserByOidcSub(args).then((resp) => { + return resp; + }); + + cy.wrap(request); +} + const getUserIdFromIdOrCredentials = ( idOrCredentials: TestUserId | { email: string; password: string } ) => { @@ -237,3 +253,4 @@ Cypress.Commands.add('updateUserDetails', updateUserDetails); Cypress.Commands.add('changeActiveRole', changeActiveRole); Cypress.Commands.add('getAndStoreFeaturesEnabled', getAndStoreFeaturesEnabled); +Cypress.Commands.add('upsertUserByOidc', upsertUserByOidc); diff --git a/apps/e2e/cypress/types/user.d.ts b/apps/e2e/cypress/types/user.d.ts index bcab0a3d7b..690f83b678 100644 --- a/apps/e2e/cypress/types/user.d.ts +++ b/apps/e2e/cypress/types/user.d.ts @@ -5,6 +5,7 @@ import { UpdateUserMutation, CreateUserByEmailInviteMutationVariables, CreateUserByEmailInviteMutation, + UpsertUserByOidcSubMutationVariables, } from '@user-office-software-libs/shared-types'; import { TestUserId } from './../support/user'; @@ -98,6 +99,18 @@ declare global { * cy.getAndStoreFeaturesEnabled() */ getAndStoreFeaturesEnabled: () => Cypress.Chainable; + + /*** + * Upserts user by OIDC sub + * + * @returns {typeof upsertUserByOidc} + * @memberof Chainable + * @example + * cy.upsertUserByOidc(upsertUserByOidcSubInput: UpsertUserByOidcSubMutationVariables) + */ + upsertUserByOidc: ( + upsertUserByOidcSubInput: UpsertUserByOidcSubMutationVariables + ) => Cypress.Chainable; } } } diff --git a/apps/frontend/src/graphql/user/upsertUserByOidcSub.graphql b/apps/frontend/src/graphql/user/upsertUserByOidcSub.graphql index 9d74607b00..3975889f9c 100644 --- a/apps/frontend/src/graphql/user/upsertUserByOidcSub.graphql +++ b/apps/frontend/src/graphql/user/upsertUserByOidcSub.graphql @@ -4,9 +4,7 @@ mutation upsertUserByOidcSub( $lastName: String! $preferredName: String $oidcSub: String! - $institutionRoRId: String! - $institutionName: String! - $institutionCountry: String! + $institution: InstitutionInput! $email: String! ) { upsertUserByOidcSub( @@ -15,9 +13,7 @@ mutation upsertUserByOidcSub( lastName: $lastName preferredName: $preferredName oidcSub: $oidcSub - institutionRoRId: $institutionRoRId - institutionName: $institutionName - institutionCountry: $institutionCountry + institution: $institution email: $email ) { id diff --git a/documentation/docs/developer-guide/configuration.md b/documentation/docs/developer-guide/configuration.md index 68d38a7f94..5c8302aa09 100644 --- a/documentation/docs/developer-guide/configuration.md +++ b/documentation/docs/developer-guide/configuration.md @@ -1,22 +1,23 @@ # Configuration -_________________________________________________________________________________________________________ +--- -This page provides detailed instructions for configuring your environment. Proper configuration is essential for ensuring the project functions correctly. +This page provides detailed instructions for configuring your environment. Proper configuration is essential for ensuring the project functions correctly. -The configuration is managed through a `.env` file, which contains environment variables for various services and settings required by the application. The default configuration values are provided, but you can override these by setting the appropriate environment variables in your `.env` file. +The configuration is managed through a `.env` file, which contains environment variables for various services and settings required by the application. The default configuration values are provided, but you can override these by setting the appropriate environment variables in your `.env` file. > **_NOTE:_** The `DEPENDENCY_CONFIG` variable is crucial for the User Office application. It enables, disables, or modifies various features based on the specified configuration. You can find the different configuration files in the `apps/backend/src/config` directory. -The `.env` file format is supported by the dotenv library, which loads these variables into the environment when the application starts. If a specific dependency configuration is not provided, the default configuration will be used. +The `.env` file format is supported by the dotenv library, which loads these variables into the environment when the application starts. If a specific dependency configuration is not provided, the default configuration will be used. For more detailed information, refer to the [dotenv documentation](https://www.npmjs.com/package/dotenv). -_________________________________________________________________________________________________________ +--- ## Environment Variables ### EAM Configuration (ESS-specific) + The EAM (Enterprise Asset Management) section includes variables that configure API access and authentication. - **EAM_API_URL**: Specifies the API URL for EAM services. @@ -28,12 +29,14 @@ The EAM (Enterprise Asset Management) section includes variables that configure - **EAM_PART_CODE**: The part code used in EAM operations. ### Logging Configuration (Graylog) + To enable local logging via Graylog, uncomment and configure the following variables: - **GRAYLOG_SERVER**: The address of the Graylog server. - **GRAYLOG_PORT**: The port for the Graylog server. ### ORCID Configuration + The ORCID section can be used for integrating with the ORCID API for user identification and authentication. - **ORCID_TOKEN_URL**: The URL for obtaining ORCID tokens. @@ -42,11 +45,13 @@ The ORCID section can be used for integrating with the ORCID API for user identi - **ORCID_CLIENT_SECRET**: The client secret for ORCID API access. ### User Office Factory Configuration + The User Office Factory configuration includes an endpoint for generating user office data. - **USER_OFFICE_FACTORY_ENDPOINT**: The endpoint URL for the User Office Factory. ### OAuth Configuration + OAuth settings allow the application to authenticate users via an OAuth provider. - **AUTH_DISCOVERY_URL**: The URL for OAuth discovery. @@ -54,6 +59,7 @@ OAuth settings allow the application to authenticate users via an OAuth provider - **AUTH_CLIENT_SECRET**: The client secret for OAuth. #### Example OAuth Configuration (Google) + To use Google OAuth, you can set the following: ```plaintext @@ -63,6 +69,7 @@ AUTH_CLIENT_SECRET=ABCDEF-0123456789 ``` ### General Application Settings + These settings cover the general configuration of the application environment. - **NODE_ENV**: Specifies the environment (e.g., development, production). @@ -75,13 +82,16 @@ These settings cover the general configuration of the application environment. - **TZ**: The timezone setting (e.g., Europe/Stockholm). - **DATE_FORMAT**: The format for displaying dates. - **DATE_TIME_FORMAT**: The format for displaying date and time. +- **ROR_API_URL**: The API URL used for fetching organization information based on ROR IDs. ### Initial User Configuration + This setting specifies the email address of the initial user officer: - **INITIAL_USER_OFFICER_EMAIL**: The email address of the initial user officer. ### RabbitMQ Configuration + RabbitMQ is used for messaging and queueing. Uncomment and configure the following variables to enable RabbitMQ: - **RABBITMQ_HOSTNAME**: The hostname for RabbitMQ. @@ -91,13 +101,15 @@ RabbitMQ is used for messaging and queueing. Uncomment and configure the followi - **RABBITMQ_CORE_EXCHANGE_NAME**: The core exchange name for RabbitMQ. ### Scheduler Configuration + The scheduler endpoint is used for managing scheduled tasks: - **SCHEDULER_ENDPOINT**: The endpoint URL for the scheduler. ### Email Configuration + The email configuration includes a sink email address for testing purposes: - **SINK_EMAIL**: The email address to receive sink emails. -_________________________________________________________________________________________________________ +--- diff --git a/validation/src/User/index.ts b/validation/src/User/index.ts index 763a01e929..ff14c10072 100644 --- a/validation/src/User/index.ts +++ b/validation/src/User/index.ts @@ -1,4 +1,4 @@ -import * as Yup from 'yup'; +import * as Yup from "yup"; export const deleteUserValidationSchema = Yup.object().shape({ id: Yup.number().required(), @@ -12,16 +12,13 @@ export const createUserByEmailInviteValidationSchema = (UserRole: any) => userRole: Yup.string().oneOf(Object.keys(UserRole)).required(), }); -const phoneRegExp = - /^(\+?\d{0,4})?\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{4}\)?)?$/; - const passwordValidationSchema = Yup.string() .required( - 'Password must contain at least 8 characters (including upper case, lower case and numbers)' + "Password must contain at least 8 characters (including upper case, lower case and numbers)", ) .matches( /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, - 'Password must contain at least 8 characters (including upper case, lower case and numbers)' + "Password must contain at least 8 characters (including upper case, lower case and numbers)", ); export const createUserValidationSchema = Yup.object().shape({ @@ -32,11 +29,11 @@ export const createUserValidationSchema = Yup.object().shape({ email: Yup.string().email().required(), password: passwordValidationSchema, confirmPassword: Yup.string() - .when('password', { + .when("password", { is: (val: string) => (val && val.length > 0 ? true : false), then: Yup.string().oneOf( - [Yup.ref('password')], - 'Confirm password does not match password' + [Yup.ref("password")], + "Confirm password does not match password", ), }) .notRequired(), @@ -70,9 +67,9 @@ export const updateUserRolesValidationSchema = Yup.object().shape({ export const signInValidationSchema = Yup.object().shape({ email: Yup.string().email(), password: Yup.string() - .min(8, 'Password must be at least 8 characters') - .max(25, 'Password must be at most 25 characters') - .required('Password must be at least 8 characters'), + .min(8, "Password must be at least 8 characters") + .max(25, "Password must be at most 25 characters") + .required("Password must be at least 8 characters"), }); export const getTokenForUserValidationSchema = Yup.object().shape({ @@ -81,8 +78,8 @@ export const getTokenForUserValidationSchema = Yup.object().shape({ export const resetPasswordByEmailValidationSchema = Yup.object().shape({ email: Yup.string() - .email('Please enter a valid email') - .required('Please enter an email'), + .email("Please enter a valid email") + .required("Please enter an email"), }); export const addUserRoleValidationSchema = Yup.object().shape({ @@ -103,12 +100,71 @@ export const userPasswordFieldBEValidationSchema = Yup.object().shape({ export const userPasswordFieldValidationSchema = Yup.object().shape({ password: passwordValidationSchema, confirmPassword: Yup.string() - .when('password', { + .when("password", { is: (val: string) => (val && val.length > 0 ? true : false), then: Yup.string().oneOf( - [Yup.ref('password')], - 'Confirm password does not match password' + [Yup.ref("password")], + "Confirm password does not match password", ), }) .notRequired(), }); + +type InstitutionInput = { + rorId?: string; + institutionData?: { + name: string; + country: string; + }; +}; + +export class UpsertUserByOidcSubArgs { + public userTitle: string | null; + public firstName: string; + public lastName: string; + public preferredName: string | null; + public oidcSub: string; + public institution: InstitutionInput; + public email: string; +} + +const rorIdRegExp = /^https:\/\/ror\.org\/[0-9a-z]{9}$/; + +export const upsertUserByOidcSubValidationSchema = Yup.object().shape({ + userTitle: Yup.string().notRequired(), + firstName: Yup.string().required(), + lastName: Yup.string().required(), + preferredName: Yup.string().notRequired(), + oidcSub: Yup.string().required(), + institution: Yup.object() + .shape({ + rorId: Yup.string().matches(rorIdRegExp, { + message: "rorId must be in the format https://ror.org/01wv9cn34", + excludeEmptyString: true, + }), + institutionData: Yup.lazy((value) => + value == null + ? Yup.mixed().notRequired() + : Yup.object().shape({ + name: Yup.string().required(), + country: Yup.string().required(), + }), + ), + }) + .test( + "exactly-one-of-rorid-or-institutiondata", + "Exactly one of rorId or institutionData must be provided", + (institution) => { + const hasRorId = !!institution?.rorId?.trim(); + const hasInstitutionData = + institution?.institutionData !== undefined && + institution?.institutionData !== null; + + return ( + (hasRorId && !hasInstitutionData) || (!hasRorId && hasInstitutionData) + ); + }, + ) + .required(), + email: Yup.string().email().required(), +});