diff --git a/config/serve.json b/config/serve.json index 115bd26..08b113e 100644 --- a/config/serve.json +++ b/config/serve.json @@ -2,5 +2,5 @@ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json", "port": 4321, "https": true, - "initialPage": "https://tnsukz.sharepoint.com/_layouts/workbench.aspx" + "initialPage": "https://tnsukz.sharepoint.com/sites/stroy/_layouts/workbench.aspx" } diff --git a/src/webparts/stroyBot/pages/report/reportServices.ts b/src/webparts/stroyBot/pages/report/reportServices.ts index e36b6db..e2ae771 100644 --- a/src/webparts/stroyBot/pages/report/reportServices.ts +++ b/src/webparts/stroyBot/pages/report/reportServices.ts @@ -1,4 +1,5 @@ import { IAppServices } from '../../services/AppServices'; +import { siteServerRelativeUrl } from '../../services/siteUser'; import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository'; import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository'; @@ -9,13 +10,15 @@ export interface IReportServices { siteUrl: string; } +// Всё — от сайта со списками (settings.siteUrl), а не от сайта страницы: в workbench корневого +// сайта id пользователя и путь к библиотеке DocLib там другие. export function createReportServices(services: IAppServices): IReportServices { return { sp: createSharePointReportRepository({ client: services.sharePoint, siteUrl: services.settings.siteUrl, - userId: services.page.userId, - webServerRelativeUrl: services.page.webServerRelativeUrl + getCurrentUserId: services.getCurrentUserId, + webServerRelativeUrl: siteServerRelativeUrl(services.settings.siteUrl) }), dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl), siteUrl: services.settings.siteUrl diff --git a/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts b/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts index cbab647..4353bd2 100644 --- a/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts +++ b/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts @@ -17,7 +17,7 @@ const repositoryWith = (routes: IFakeRoute[]): { const repository = createSharePointReportRepository({ client, siteUrl: SITE, - userId: 42, + getCurrentUserId: async () => 42, webServerRelativeUrl: '/sites/stroy/' }); return { client, repository }; diff --git a/src/webparts/stroyBot/pages/report/sharePointReportRepository.ts b/src/webparts/stroyBot/pages/report/sharePointReportRepository.ts index 71a95f9..1ae5962 100644 --- a/src/webparts/stroyBot/pages/report/sharePointReportRepository.ts +++ b/src/webparts/stroyBot/pages/report/sharePointReportRepository.ts @@ -111,9 +111,13 @@ export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fil export interface ISharePointReportDeps { client: IHttpClient; siteUrl: string; - /** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */ - userId: number | undefined; - /** Серверный относительный URL сайта — для пути к папке фото. */ + /** + * Id текущего пользователя на сайте со списками — по нему ищется строка «Доступы». + * Старый код брал legacyPageContext.userId (id на сайте страницы); в workbench корневого сайта + * это другой id, и доступ не находился. Теперь — `_api/web/currentuser` сайта со списками. + */ + getCurrentUserId: () => Promise; + /** Серверный относительный URL сайта со списками (`/sites/stroy`) — для пути к папке фото. */ webServerRelativeUrl: string; } @@ -224,8 +228,11 @@ export function createSharePointReportRepository(deps: ISharePointReportDeps): I createListItem, checkAccess: async (projectId) => { - const rows = await getItems(LISTS.access, { filter: `projectId eq ${projectId}` }); - return pickAccess(rows, deps.userId); + const [rows, userId] = await Promise.all([ + getItems(LISTS.access, { filter: `projectId eq ${projectId}` }), + deps.getCurrentUserId() + ]); + return pickAccess(rows, userId); }, uploadPhotos: async (projectId, reportNumber, photos) => { diff --git a/src/webparts/stroyBot/pages/reports/reportListLoader.ts b/src/webparts/stroyBot/pages/reports/reportListLoader.ts index 7b563cd..f1da344 100644 --- a/src/webparts/stroyBot/pages/reports/reportListLoader.ts +++ b/src/webparts/stroyBot/pages/reports/reportListLoader.ts @@ -11,7 +11,8 @@ export interface IReportListDeps { dataverse: IHttpClient; siteUrl: string; dataverseUrl: string; - userId: number | undefined; + /** Id пользователя на сайте со списками (не на сайте страницы) — см. IAppServices.getCurrentUserId. */ + getCurrentUserId: () => Promise; } /** Справочники для подписей карточек: компания по Id, путь объекта. */ @@ -49,7 +50,8 @@ export async function loadReportList( projectId: number, range: IDateRange ): Promise { - const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, deps.userId, projectId); + const userId = await deps.getCurrentUserId(); + const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, userId, projectId); if (!accessResult) return { kind: 'denied' }; const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access }; diff --git a/src/webparts/stroyBot/pages/reports/reportsRepository.test.ts b/src/webparts/stroyBot/pages/reports/reportsRepository.test.ts index 114173d..e3e8c98 100644 --- a/src/webparts/stroyBot/pages/reports/reportsRepository.test.ts +++ b/src/webparts/stroyBot/pages/reports/reportsRepository.test.ts @@ -149,7 +149,7 @@ describe('loadReportList', () => { } } ]); - const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }; + const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }; const result = await loadReportList(deps, 5, WEEK); @@ -174,7 +174,7 @@ describe('loadReportList', () => { it('нет доступа — denied, справочники не грузятся', async () => { const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]); const dataverse = createFakeHttpClient([]); - const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); + const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK); expect(result).toEqual({ kind: 'denied' }); expect(sharePoint.requests).toHaveLength(1); }); @@ -182,7 +182,7 @@ describe('loadReportList', () => { it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => { const sharePoint = createFakeHttpClient([access, companies, objects]); const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]); - const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); + const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK); expect(result).toMatchObject({ kind: 'ready', reports: undefined, diff --git a/src/webparts/stroyBot/pages/reports/useReportList.ts b/src/webparts/stroyBot/pages/reports/useReportList.ts index f10a8cc..581c1e1 100644 --- a/src/webparts/stroyBot/pages/reports/useReportList.ts +++ b/src/webparts/stroyBot/pages/reports/useReportList.ts @@ -26,7 +26,7 @@ export interface IReportListState { /** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */ export function useReportList(projectId: number, initialRange: IDateRange): IReportListState { - const { sharePoint, dataverse, settings, page } = useAppServices(); + const { sharePoint, dataverse, settings, getCurrentUserId } = useAppServices(); const [phase, setPhase] = React.useState('loading'); const [projectName, setProjectName] = React.useState(''); const [access, setAccess] = React.useState(NO_ACCESS); @@ -44,9 +44,9 @@ export function useReportList(projectId: number, initialRange: IDateRange): IRep dataverse, siteUrl: settings.siteUrl, dataverseUrl: settings.dataverseUrl, - userId: page.userId + getCurrentUserId }), - [sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, page.userId] + [sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, getCurrentUserId] ); React.useEffect(() => { diff --git a/src/webparts/stroyBot/services/AppServices.ts b/src/webparts/stroyBot/services/AppServices.ts index b67176c..9049475 100644 --- a/src/webparts/stroyBot/services/AppServices.ts +++ b/src/webparts/stroyBot/services/AppServices.ts @@ -2,6 +2,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; import { IHttpClient } from './http/IHttpClient'; import { createAadClient, createSharePointClient } from './http/spfxClients'; import { DATAVERSE_URL, SITE_URL, TNSU_API_BASE_URL, TNSU_API_RESOURCE_ID } from '../config/constants'; +import { createCurrentUserIdProvider } from './siteUser'; /** Настройки подключения из property pane (пустое значение — берётся значение по умолчанию). */ export interface IConnectionSettings { @@ -34,6 +35,12 @@ export interface IAppServices { tnsuApi: IHttpClient; settings: IResolvedSettings; page: IPageInfo; + /** + * Id текущего пользователя на сайте со списками (settings.siteUrl). Id пользователя у каждого сайта + * свой: legacyPageContext.userId — это id на сайте страницы, и в workbench корневого сайта он не + * совпадает с id в «Доступах» сайта stroy. Запрашивается один раз. + */ + getCurrentUserId: () => Promise; } function withoutTrailingSlash(url: string): string { @@ -60,11 +67,13 @@ function readPageInfo(context: WebPartContext): IPageInfo { export function createAppServices(context: WebPartContext, settings: IConnectionSettings): IAppServices { const resolved = resolveSettings(settings); + const sharePoint = createSharePointClient(context); return { - sharePoint: createSharePointClient(context), + sharePoint, dataverse: createAadClient(context, resolved.dataverseUrl), tnsuApi: createAadClient(context, TNSU_API_RESOURCE_ID), settings: resolved, - page: readPageInfo(context) + page: readPageInfo(context), + getCurrentUserId: createCurrentUserIdProvider(sharePoint, resolved.siteUrl) }; } diff --git a/src/webparts/stroyBot/services/siteUser.test.ts b/src/webparts/stroyBot/services/siteUser.test.ts new file mode 100644 index 0000000..b39d5f4 --- /dev/null +++ b/src/webparts/stroyBot/services/siteUser.test.ts @@ -0,0 +1,33 @@ +import { createCurrentUserIdProvider, siteServerRelativeUrl } from './siteUser'; +import { createFakeHttpClient } from '../shared/testing/fakeHttpClient'; + +const SITE = 'https://tnsukz.sharepoint.com/sites/stroy'; + +describe('siteServerRelativeUrl', () => { + it('путь сайта без домена', () => { + expect(siteServerRelativeUrl(SITE)).toEqual('/sites/stroy'); + expect(siteServerRelativeUrl('https://tnsukz.sharepoint.com')).toEqual('/'); + }); +}); + +describe('createCurrentUserIdProvider', () => { + it('спрашивает currentuser у сайта со списками (не у сайта страницы) и запоминает', async () => { + const sharePoint = createFakeHttpClient([{ match: 'currentuser', response: { body: { Id: 19 } } }]); + const getCurrentUserId = createCurrentUserIdProvider(sharePoint, SITE); + + expect(await getCurrentUserId()).toEqual(19); + expect(await getCurrentUserId()).toEqual(19); + expect(sharePoint.requests).toHaveLength(1); + expect(sharePoint.requests[0].url).toEqual(`${SITE}/_api/web/currentuser?$select=Id`); + }); + + it('неудачный запрос не запоминается — следующий вызов спрашивает снова', async () => { + const sharePoint = createFakeHttpClient([ + { match: 'currentuser', respond: (call) => (call === 1 ? { status: 503 } : { body: { Id: 19 } }) } + ]); + const getCurrentUserId = createCurrentUserIdProvider(sharePoint, SITE); + + await expect(getCurrentUserId()).rejects.toThrow('503'); + expect(await getCurrentUserId()).toEqual(19); + }); +}); diff --git a/src/webparts/stroyBot/services/siteUser.ts b/src/webparts/stroyBot/services/siteUser.ts new file mode 100644 index 0000000..e51612d --- /dev/null +++ b/src/webparts/stroyBot/services/siteUser.ts @@ -0,0 +1,24 @@ +import { IHttpClient } from './http/IHttpClient'; +import { fetchCurrentUserId } from '../shared/sharepoint/spRest'; + +// Пользователь и пути — всегда относительно сайта со списками (settings.siteUrl), а не сайта страницы: +// id пользователя у каждого сайта свой, и в workbench корневого сайта он не совпадает с id в «Доступах». + +/** Серверный относительный путь сайта (`/sites/stroy`) — для путей к папкам библиотек. */ +export function siteServerRelativeUrl(siteUrl: string): string { + return siteUrl.replace(/^https?:\/\/[^/]+/, '') || '/'; +} + +/** Запоминает id пользователя; неудачный запрос не запоминается — следующий вызов спросит снова. */ +export function createCurrentUserIdProvider(sharePoint: IHttpClient, siteUrl: string): () => Promise { + let userIdPromise: Promise | undefined; + return () => { + if (userIdPromise) return userIdPromise; + const request = fetchCurrentUserId(sharePoint, siteUrl).catch((error: unknown) => { + userIdPromise = undefined; + throw error; + }); + userIdPromise = request; + return request; + }; +}