Merged PR 145: typist名をAzure AD B2Cから取得し表示できるようにする

## 概要
[Task1950: typist名をAzure AD B2Cから取得し表示できるようにする](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1950)

- チェックアウト権限にあるTypistのユーザー名をB2Cから取得する
  - チェックアウト権限に含まれているuser.externalIdを列挙
  - ExternalIdでフィルターをかけてユーザー情報を取得
  - B2Cへのリクエスト上限超過時のエラーを制御するために専用エラーを定義
- import文が常に絶対パスで指定されていて、それでテストがこけるので相対パスでインポートするようにvscodeを設定

## レビューポイント
- convert.tsの修正は問題ないか

## UIの変更
- Before/Afterのスクショなど
- スクショ置き場

## 動作確認状況
- ローカルで確認

## 補足
- 取得方法についてはMSに問い合わせ中
This commit is contained in:
saito.k 2023-06-19 00:48:32 +00:00
parent bb926f9feb
commit d75c003b09
10 changed files with 257 additions and 26 deletions

View File

@ -18,5 +18,7 @@
"editor.renderWhitespace": "all",
"editor.insertSpaces": false,
"editor.renderLineHighlight": "all",
"prettier.prettierPath": "./node_modules/prettier"
"prettier.prettierPath": "./node_modules/prettier",
"typescript.preferences.importModuleSpecifier": "relative"
}

View File

@ -22,6 +22,7 @@ export const ErrorCodes = [
'E000106', // トークンアルゴリズムエラー
'E000107', // トークン不足エラー
'E000108', // トークン権限エラー
'E000301', // ADB2Cへのリクエスト上限超過エラー
'E010001', // パラメータ形式不正エラー
'E010201', // 未認証ユーザエラー
'E010202', // 認証済ユーザエラー

View File

@ -11,6 +11,7 @@ export const errors: Errors = {
E000106: 'Token invalid algorithm Error.',
E000107: 'Token is not exist Error.',
E000108: 'Token authority failed Error.',
E000301: 'ADB2C request limit exceeded Error',
E010001: 'Param invalid format Error.',
E010201: 'Email not verified user Error.',
E010202: 'Email already verified user Error.',

View File

@ -3,9 +3,10 @@ import { TasksService } from './tasks.service';
import { TasksController } from './tasks.controller';
import { UsersRepositoryModule } from '../../repositories/users/users.repository.module';
import { TasksRepositoryModule } from '../../repositories/tasks/tasks.repository.module';
import { AdB2cModule } from '../../gateways/adb2c/adb2c.module';
@Module({
imports: [UsersRepositoryModule, TasksRepositoryModule],
imports: [UsersRepositoryModule, TasksRepositoryModule, AdB2cModule],
providers: [TasksService],
controllers: [TasksController],
})

View File

@ -1,18 +1,22 @@
import {
makeDefaultAdb2cServiceMockValue,
makeDefaultTasksRepositoryMockValue,
makeDefaultUsersRepositoryMockValue,
makeTasksServiceMock,
} from './test/tasks.service.mock';
import { HttpException, HttpStatus } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { Adb2cTooManyRequestsError } from '../../gateways/adb2c/adb2c.service';
describe('TasksService', () => {
it('タスク一覧を取得できるadmin', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'admin', tier: 5 };
@ -33,7 +37,7 @@ describe('TasksService', () => {
).toEqual({
tasks: [
{
assignees: [{ typistName: 'USER_userId', typistUserId: 1 }],
assignees: [{ typistName: 'XXXX XXX', typistUserId: 1 }],
audioCreatedDate: '2023-01-01T01:01:01.000Z',
audioDuration: '123000',
audioFileId: 1,
@ -74,10 +78,12 @@ describe('TasksService', () => {
it('アカウント情報の取得に失敗した場合、エラーを返却する', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
usersRepositoryMockValue.findUserByExternalId = new Error('DB failed');
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'admin', tier: 5 };
@ -97,7 +103,7 @@ describe('TasksService', () => {
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E000101'),
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
@ -106,10 +112,12 @@ describe('TasksService', () => {
it('タスク一覧の取得に失敗した場合、エラーを返却するadmin', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
tasksRepositoryMockValue.getTasksFromAccountId = new Error('DB failed');
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'admin', tier: 5 };
@ -129,7 +137,7 @@ describe('TasksService', () => {
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E000101'),
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
@ -173,11 +181,12 @@ describe('TasksService', () => {
count: 1,
};
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'admin', tier: 5 };
const offset = 0;
const limit = 20;
@ -195,7 +204,7 @@ describe('TasksService', () => {
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E000101'),
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
@ -204,14 +213,15 @@ describe('TasksService', () => {
it('タスク一覧を取得できるauthor', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
if (usersRepositoryMockValue.findUserByExternalId instanceof Error) {
return;
}
usersRepositoryMockValue.findUserByExternalId.role = 'author';
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'author', tier: 5 };
@ -231,7 +241,7 @@ describe('TasksService', () => {
expect(result).toEqual({
tasks: [
{
assignees: [{ typistName: 'USER_userId', typistUserId: 1 }],
assignees: [{ typistName: 'XXXX XXX', typistUserId: 1 }],
audioCreatedDate: '2023-01-01T01:01:01.000Z',
audioDuration: '123000',
audioFileId: 1,
@ -278,12 +288,14 @@ describe('TasksService', () => {
it('タスク一覧の取得に失敗した場合、エラーを返却するauthor', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
tasksRepositoryMockValue.getTasksFromAuthorIdAndAccountId = new Error(
'DB failed',
);
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'author', tier: 5 };
@ -303,7 +315,7 @@ describe('TasksService', () => {
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E000101'),
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
@ -312,6 +324,7 @@ describe('TasksService', () => {
it('タスク一覧を取得できるtypist', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
if (usersRepositoryMockValue.findUserByExternalId instanceof Error) {
return;
}
@ -320,6 +333,7 @@ describe('TasksService', () => {
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'typist', tier: 5 };
@ -339,7 +353,7 @@ describe('TasksService', () => {
expect(result).toEqual({
tasks: [
{
assignees: [{ typistName: 'USER_userId', typistUserId: 1 }],
assignees: [{ typistName: 'XXXX XXX', typistUserId: 1 }],
audioCreatedDate: '2023-01-01T01:01:01.000Z',
audioDuration: '123000',
audioFileId: 1,
@ -386,12 +400,14 @@ describe('TasksService', () => {
it('タスク一覧の取得に失敗した場合、エラーを返却するtypist', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
tasksRepositoryMockValue.getTasksFromTypistRelations = new Error(
'DB failed',
);
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'typist', tier: 5 };
@ -411,7 +427,7 @@ describe('TasksService', () => {
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E000101'),
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
@ -420,9 +436,11 @@ describe('TasksService', () => {
it('想定外のRoleの場合、エラーを返却する', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'XXX', tier: 5 };
@ -442,7 +460,41 @@ describe('TasksService', () => {
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E000101'),
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
});
it('AdB2Cのリクエスト上限超過時、専用のエラーを返却する', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
adb2cServiceMockValue.getUsers = new Adb2cTooManyRequestsError();
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
const accessToken = { userId: 'userId', role: 'admin', tier: 5 };
const offset = 0;
const limit = 20;
const status = ['Uploaded,Backup'];
const paramName = 'JOB_NUMBER';
const direction = 'ASC';
await expect(
service.tasksService.getTasks(
accessToken,
offset,
limit,
status,
paramName,
direction,
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E000301'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);

View File

@ -2,6 +2,7 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service';
import { AccessToken } from '../../common/token';
import { Task } from './types/types';
import { Task as TaskEntity } from '../../repositories/tasks/entity/task.entity';
import { createTasks } from './types/convert';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
@ -10,6 +11,12 @@ import {
TaskListSortableAttribute,
} from '../../common/types/sort';
import { ADMIN_ROLES, USER_ROLES } from '../../constants';
import {
AdB2cService,
Adb2cTooManyRequestsError,
} from '../../gateways/adb2c/adb2c.service';
import { AdB2cUser } from '../../gateways/adb2c/types/types';
import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity';
@Injectable()
export class TasksService {
@ -17,6 +24,7 @@ export class TasksService {
constructor(
private readonly taskRepository: TasksRepositoryService,
private readonly usersRepository: UsersRepositoryService,
private readonly adB2cService: AdB2cService,
) {}
// TODO: 引数にAccessTokenがあるのは不適切なのでController側で分解したい
@ -49,7 +57,13 @@ export class TasksService {
status,
);
const tasks = createTasks(result.tasks, result.permissions);
// B2Cからユーザー名を取得する
const b2cUsers = await this.getB2cUsers(
result.tasks,
result.permissions,
);
const tasks = createTasks(result.tasks, result.permissions, b2cUsers);
return { tasks: tasks, total: result.count };
}
@ -64,7 +78,14 @@ export class TasksService {
direction ?? defaultDirection,
status,
);
const tasks = createTasks(result.tasks, result.permissions);
// B2Cからユーザー名を取得する
const b2cUsers = await this.getB2cUsers(
result.tasks,
result.permissions,
);
const tasks = createTasks(result.tasks, result.permissions, b2cUsers);
return { tasks: tasks, total: result.count };
}
@ -77,8 +98,13 @@ export class TasksService {
direction ?? defaultDirection,
status,
);
// B2Cからユーザー名を取得する
const b2cUsers = await this.getB2cUsers(
result.tasks,
result.permissions,
);
const tasks = createTasks(result.tasks, result.permissions);
const tasks = createTasks(result.tasks, result.permissions, b2cUsers);
return { tasks: tasks, total: result.count };
}
@ -86,10 +112,49 @@ export class TasksService {
throw new Error(`invalid roles: ${roles.join(',')}`);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
if (e.constructor === Adb2cTooManyRequestsError) {
throw new HttpException(
makeErrorResponse('E000301'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
throw new HttpException(
makeErrorResponse('E000101'),
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async getB2cUsers(
tasks: TaskEntity[],
permissions: CheckoutPermission[],
): Promise<AdB2cUser[]> {
// 割り当て候補の外部IDを列挙
const assigneesExternalIds = permissions.map((x) => {
if (x.user) {
return x.user.external_id;
}
});
// 割り当てられているタイピストの外部IDを列挙
const typistExternalIds = tasks.flatMap((x) => {
if (x.typist_user) {
return x.typist_user.external_id;
}
});
console.log(assigneesExternalIds.concat(undefined));
//重複をなくす
const distinctedExternalIds = [
...new Set(assigneesExternalIds.concat(typistExternalIds)),
];
// undefinedがあった場合、取り除く
const filteredExternalIds: string[] = distinctedExternalIds.filter(
(x): x is string => x !== undefined,
);
// B2Cからユーザー名を取得する
return await this.adB2cService.getUsers(filteredExternalIds);
}
}

View File

@ -9,6 +9,8 @@ import {
SortDirection,
TaskListSortableAttribute,
} from '../../../common/types/sort';
import { AdB2cService } from '../../../gateways/adb2c/adb2c.service';
import { AdB2cUser } from '../../../gateways/adb2c/types/types';
export type TasksRepositoryMockValue = {
getTasksFromAccountId:
@ -34,6 +36,10 @@ export type TasksRepositoryMockValue = {
| Error;
};
export type AdB2CServiceMockValue = {
getUsers: AdB2cUser[] | Error;
};
export type UsersRepositoryMockValue = {
findUserByExternalId: User | Error;
};
@ -41,6 +47,7 @@ export type UsersRepositoryMockValue = {
export const makeTasksServiceMock = async (
tasksRepositoryMockValue: TasksRepositoryMockValue,
usersRepositoryMockValue: UsersRepositoryMockValue,
adB2CServiceMockValue: AdB2CServiceMockValue,
): Promise<{
tasksService: TasksService;
taskRepoService: TasksRepositoryService;
@ -54,6 +61,8 @@ export const makeTasksServiceMock = async (
return makeTasksRepositoryMock(tasksRepositoryMockValue);
case UsersRepositoryService:
return makeUsersRepositoryMock(usersRepositoryMockValue);
case AdB2cService:
return makeAdb2cServiceMock(adB2CServiceMockValue);
}
})
.compile();
@ -159,6 +168,23 @@ export const makeDefaultTasksRepositoryMockValue =
};
};
export const makeAdb2cServiceMock = (value: AdB2CServiceMockValue) => {
const { getUsers } = value;
return {
getUsers:
getUsers instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(getUsers)
: jest.fn<Promise<AdB2cUser[]>, []>().mockResolvedValue(getUsers),
};
};
export const makeDefaultAdb2cServiceMockValue = (): AdB2CServiceMockValue => {
return {
getUsers: [{ id: 'userId', displayName: 'XXXX XXX' }],
};
};
export const makeDefaultUsersRepositoryMockValue =
(): UsersRepositoryMockValue => {
const user1 = new User();

View File

@ -6,18 +6,20 @@ import { AudioOptionItem as AudioOptionItemEntity } from '../../../repositories/
import { Task, Assignee } from './types';
import { AudioOptionItem } from '../../files/types/types';
import { Typist } from '../../../features/accounts/types/types';
import { AdB2cUser } from '../../../gateways/adb2c/types/types';
// Repository側のDTOからTaskオブジェクトの一覧を構築する
export const createTasks = (
tasks: TaskEntity[],
permissions: CheckoutPermissionEntity[],
b2cUsers: AdB2cUser[],
): Task[] => {
// Taskオブジェクトを構築
const convertedTasks = tasks.map((task) => {
const targets = permissions.filter(
(permission) => permission.task_id === task.id,
);
return createTask(task, targets);
return createTask(task, targets, b2cUsers);
});
return convertedTasks;
};
@ -26,6 +28,7 @@ export const createTasks = (
const createTask = (
task: TaskEntity,
permissions: CheckoutPermissionEntity[],
b2cUserInfo: AdB2cUser[],
): Task => {
const { file, option_items, typist_user } = task;
if (!file) {
@ -39,11 +42,13 @@ const createTask = (
const optionItems = createAudioOptionItems(option_items);
// RepositoryDTO => ControllerDTOに変換
const assignees = createAssignees(permissions);
const assignees = createAssignees(permissions, b2cUserInfo);
// RepositoryDTO => ControllerDTOに変換
const typist: Typist =
typist_user != null ? convertUserToTypist(typist_user) : undefined;
typist_user != null
? convertUserToTypist(typist_user, b2cUserInfo)
: undefined;
return {
audioFileId: task.audio_file_id,
@ -85,10 +90,11 @@ const createAudioOptionItems = (
// Repository側のDTOからAssigneeオブジェクトを構築する
const createAssignees = (
permissions: CheckoutPermissionEntity[],
b2cUserInfo: AdB2cUser[],
): Assignee[] => {
return permissions.flatMap((x): Assignee[] => {
if (x.user != null) {
return [convertUserToAssignee(x.user)];
return [convertUserToAssignee(x.user, b2cUserInfo)];
}
if (x.user_group != null) {
@ -100,11 +106,17 @@ const createAssignees = (
});
};
// RepositoryDTOのUserからAssigneeオブジェクトを生成します
const convertUserToAssignee = (user: UserEntity): Assignee => {
// RepositoryDTOのUserからTypistオブジェクトを生成します
const convertUserToAssignee = (
user: UserEntity,
b2cUserInfo: AdB2cUser[],
): Assignee => {
const typistName = b2cUserInfo.find(
(x) => x.id === user.external_id,
).displayName;
return {
typistUserId: user.id,
typistName: `USER_${user?.external_id}`, // XXX Azure AD B2Cから取得した名前を入れる
typistName,
};
};
@ -117,9 +129,15 @@ const convertUserGroupToAssignee = (userGroup: UserGroupEntity): Assignee => {
};
// RepositoryDTOのUserからTypistオブジェクトを生成します
const convertUserToTypist = (user: UserEntity): Typist => {
const convertUserToTypist = (
user: UserEntity,
b2cUserInfo: AdB2cUser[],
): Typist => {
const typistName = b2cUserInfo.find(
(x) => x.id === user.external_id,
).displayName;
return {
id: user.id,
name: `USER_${user?.external_id}`, // XXX Azure AD B2Cから取得した名前を入れる
name: typistName,
};
};

View File

@ -5,12 +5,15 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { Aadb2cUser, B2cMetadata, JwkSignKey } from '../../common/token';
import { AdB2cResponse, AdB2cUser } from './types/types';
export type ConflictError = {
reason: 'email';
message: string;
};
export class Adb2cTooManyRequestsError extends Error {}
export const isConflictError = (arg: unknown): arg is ConflictError => {
const value = arg as ConflictError;
if (value.message === undefined) {
@ -179,4 +182,61 @@ export class AdB2cService {
this.logger.log(`[OUT] ${this.getUser.name}`);
}
}
/**
* Gets users
* @param externalIds
* @returns users
*/
async getUsers(externalIds: string[]): Promise<AdB2cUser[]> {
this.logger.log(
`[IN] ${this.getUsers.name}; externalIds:[${externalIds.join(',')}]`,
);
/*
TODO 15
2002: B2Cからの名前取得をより低コストで行えるように修正する
*/
const chunkExternalIds = splitArrayInChunksOfFifteen(externalIds);
try {
const resArr: AdB2cUser[] = [];
for (let index = 0; index < chunkExternalIds.length; index++) {
const element = chunkExternalIds[index];
const res: AdB2cResponse = await this.graphClient
.api(`users/`)
.select(['id', 'displayName'])
.filter(`id in (${element.map((y) => `'${y}'`).join(',')})`)
.get();
resArr.push(...res.value);
}
const data: AdB2cResponse = await this.graphClient
.api(`users/`)
.select(['id', 'displayName'])
.filter(`id in (${externalIds.map((x) => `'${x}'`).join(',')})`)
.get();
return data.value;
} catch (e) {
this.logger.error(e);
const { statusCode } = e;
if (statusCode === 429) {
throw new Adb2cTooManyRequestsError();
}
throw e;
} finally {
this.logger.log(`[OUT] ${this.getUsers.name}`);
}
}
}
// TODO 文字列の配列を15要素ずつ区切る(この処理も別タスクで削除予定)
const splitArrayInChunksOfFifteen = (arr: string[]): string[][] => {
const result: string[][] = [];
const chunkSize = 15; // SDKの制限数
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize));
}
return result;
};

View File

@ -0,0 +1,5 @@
export type AdB2cResponse = {
'@odata.context': string;
value: AdB2cUser[];
};
export type AdB2cUser = { id: string; displayName: string };