fix: check report access with the user id of the lists site

The user id differs per site, so the id from the page context showed
"Нет доступа" in reports when the page was not on /sites/stroy.
The id and the photo folder path now come from settings.siteUrl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-19 16:17:56 +05:00
parent cb810972c4
commit f0272c2170
10 changed files with 97 additions and 19 deletions
+1 -1
View File
@@ -2,5 +2,5 @@
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json", "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
"port": 4321, "port": 4321,
"https": true, "https": true,
"initialPage": "https://tnsukz.sharepoint.com/_layouts/workbench.aspx" "initialPage": "https://tnsukz.sharepoint.com/sites/stroy/_layouts/workbench.aspx"
} }
@@ -1,4 +1,5 @@
import { IAppServices } from '../../services/AppServices'; import { IAppServices } from '../../services/AppServices';
import { siteServerRelativeUrl } from '../../services/siteUser';
import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository'; import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository';
import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository'; import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository';
@@ -9,13 +10,15 @@ export interface IReportServices {
siteUrl: string; siteUrl: string;
} }
// Всё — от сайта со списками (settings.siteUrl), а не от сайта страницы: в workbench корневого
// сайта id пользователя и путь к библиотеке DocLib там другие.
export function createReportServices(services: IAppServices): IReportServices { export function createReportServices(services: IAppServices): IReportServices {
return { return {
sp: createSharePointReportRepository({ sp: createSharePointReportRepository({
client: services.sharePoint, client: services.sharePoint,
siteUrl: services.settings.siteUrl, siteUrl: services.settings.siteUrl,
userId: services.page.userId, getCurrentUserId: services.getCurrentUserId,
webServerRelativeUrl: services.page.webServerRelativeUrl webServerRelativeUrl: siteServerRelativeUrl(services.settings.siteUrl)
}), }),
dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl), dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl),
siteUrl: services.settings.siteUrl siteUrl: services.settings.siteUrl
@@ -17,7 +17,7 @@ const repositoryWith = (routes: IFakeRoute[]): {
const repository = createSharePointReportRepository({ const repository = createSharePointReportRepository({
client, client,
siteUrl: SITE, siteUrl: SITE,
userId: 42, getCurrentUserId: async () => 42,
webServerRelativeUrl: '/sites/stroy/' webServerRelativeUrl: '/sites/stroy/'
}); });
return { client, repository }; return { client, repository };
@@ -111,9 +111,13 @@ export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fil
export interface ISharePointReportDeps { export interface ISharePointReportDeps {
client: IHttpClient; client: IHttpClient;
siteUrl: string; siteUrl: string;
/** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */ /**
userId: number | undefined; * Id текущего пользователя на сайте со списками — по нему ищется строка «Доступы».
/** Серверный относительный URL сайта — для пути к папке фото. */ * Старый код брал legacyPageContext.userId (id на сайте страницы); в workbench корневого сайта
* это другой id, и доступ не находился. Теперь — `_api/web/currentuser` сайта со списками.
*/
getCurrentUserId: () => Promise<number>;
/** Серверный относительный URL сайта со списками (`/sites/stroy`) — для пути к папке фото. */
webServerRelativeUrl: string; webServerRelativeUrl: string;
} }
@@ -224,8 +228,11 @@ export function createSharePointReportRepository(deps: ISharePointReportDeps): I
createListItem, createListItem,
checkAccess: async (projectId) => { checkAccess: async (projectId) => {
const rows = await getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }); const [rows, userId] = await Promise.all([
return pickAccess(rows, deps.userId); getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }),
deps.getCurrentUserId()
]);
return pickAccess(rows, userId);
}, },
uploadPhotos: async (projectId, reportNumber, photos) => { uploadPhotos: async (projectId, reportNumber, photos) => {
@@ -11,7 +11,8 @@ export interface IReportListDeps {
dataverse: IHttpClient; dataverse: IHttpClient;
siteUrl: string; siteUrl: string;
dataverseUrl: string; dataverseUrl: string;
userId: number | undefined; /** Id пользователя на сайте со списками (не на сайте страницы) — см. IAppServices.getCurrentUserId. */
getCurrentUserId: () => Promise<number>;
} }
/** Справочники для подписей карточек: компания по Id, путь объекта. */ /** Справочники для подписей карточек: компания по Id, путь объекта. */
@@ -49,7 +50,8 @@ export async function loadReportList(
projectId: number, projectId: number,
range: IDateRange range: IDateRange
): Promise<ReportListInitResult> { ): Promise<ReportListInitResult> {
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' }; if (!accessResult) return { kind: 'denied' };
const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access }; const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access };
@@ -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); const result = await loadReportList(deps, 5, WEEK);
@@ -174,7 +174,7 @@ describe('loadReportList', () => {
it('нет доступа — denied, справочники не грузятся', async () => { it('нет доступа — denied, справочники не грузятся', async () => {
const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]); const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]);
const dataverse = createFakeHttpClient([]); 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(result).toEqual({ kind: 'denied' });
expect(sharePoint.requests).toHaveLength(1); expect(sharePoint.requests).toHaveLength(1);
}); });
@@ -182,7 +182,7 @@ describe('loadReportList', () => {
it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => { it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => {
const sharePoint = createFakeHttpClient([access, companies, objects]); const sharePoint = createFakeHttpClient([access, companies, objects]);
const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]); 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({ expect(result).toMatchObject({
kind: 'ready', kind: 'ready',
reports: undefined, reports: undefined,
@@ -26,7 +26,7 @@ export interface IReportListState {
/** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */ /** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */
export function useReportList(projectId: number, initialRange: IDateRange): 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<ReportListPhase>('loading'); const [phase, setPhase] = React.useState<ReportListPhase>('loading');
const [projectName, setProjectName] = React.useState<string>(''); const [projectName, setProjectName] = React.useState<string>('');
const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS); const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS);
@@ -44,9 +44,9 @@ export function useReportList(projectId: number, initialRange: IDateRange): IRep
dataverse, dataverse,
siteUrl: settings.siteUrl, siteUrl: settings.siteUrl,
dataverseUrl: settings.dataverseUrl, dataverseUrl: settings.dataverseUrl,
userId: page.userId getCurrentUserId
}), }),
[sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, page.userId] [sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, getCurrentUserId]
); );
React.useEffect(() => { React.useEffect(() => {
+11 -2
View File
@@ -2,6 +2,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
import { IHttpClient } from './http/IHttpClient'; import { IHttpClient } from './http/IHttpClient';
import { createAadClient, createSharePointClient } from './http/spfxClients'; import { createAadClient, createSharePointClient } from './http/spfxClients';
import { DATAVERSE_URL, SITE_URL, TNSU_API_BASE_URL, TNSU_API_RESOURCE_ID } from '../config/constants'; import { DATAVERSE_URL, SITE_URL, TNSU_API_BASE_URL, TNSU_API_RESOURCE_ID } from '../config/constants';
import { createCurrentUserIdProvider } from './siteUser';
/** Настройки подключения из property pane (пустое значение — берётся значение по умолчанию). */ /** Настройки подключения из property pane (пустое значение — берётся значение по умолчанию). */
export interface IConnectionSettings { export interface IConnectionSettings {
@@ -34,6 +35,12 @@ export interface IAppServices {
tnsuApi: IHttpClient; tnsuApi: IHttpClient;
settings: IResolvedSettings; settings: IResolvedSettings;
page: IPageInfo; page: IPageInfo;
/**
* Id текущего пользователя на сайте со списками (settings.siteUrl). Id пользователя у каждого сайта
* свой: legacyPageContext.userId — это id на сайте страницы, и в workbench корневого сайта он не
* совпадает с id в «Доступах» сайта stroy. Запрашивается один раз.
*/
getCurrentUserId: () => Promise<number>;
} }
function withoutTrailingSlash(url: string): string { function withoutTrailingSlash(url: string): string {
@@ -60,11 +67,13 @@ function readPageInfo(context: WebPartContext): IPageInfo {
export function createAppServices(context: WebPartContext, settings: IConnectionSettings): IAppServices { export function createAppServices(context: WebPartContext, settings: IConnectionSettings): IAppServices {
const resolved = resolveSettings(settings); const resolved = resolveSettings(settings);
const sharePoint = createSharePointClient(context);
return { return {
sharePoint: createSharePointClient(context), sharePoint,
dataverse: createAadClient(context, resolved.dataverseUrl), dataverse: createAadClient(context, resolved.dataverseUrl),
tnsuApi: createAadClient(context, TNSU_API_RESOURCE_ID), tnsuApi: createAadClient(context, TNSU_API_RESOURCE_ID),
settings: resolved, settings: resolved,
page: readPageInfo(context) page: readPageInfo(context),
getCurrentUserId: createCurrentUserIdProvider(sharePoint, resolved.siteUrl)
}; };
} }
@@ -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);
});
});
@@ -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<number> {
let userIdPromise: Promise<number> | undefined;
return () => {
if (userIdPromise) return userIdPromise;
const request = fetchCurrentUserId(sharePoint, siteUrl).catch((error: unknown) => {
userIdPromise = undefined;
throw error;
});
userIdPromise = request;
return request;
};
}