From 595ab02b167ce4b1c34e3c9b904b8f8c73dbc1e1 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 14 Aug 2026 12:47:52 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=B5=D1=80=D1=81=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=B9=D1=88=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requestPortal/RequestPortalWebPart.ts | 12 ++++++ .../components/RequestPortal.module.scss | 14 +++++++ .../components/RequestPortal.tsx | 15 +++++++ .../features/myRequests/myRequests.service.ts | 7 +++- .../RecruitingHire/RecruitingHireForm.tsx | 3 +- .../RecruitingSelectionForm.tsx | 3 +- .../forms/Ticket1C/Ticket1CForm.tsx | 3 +- .../forms/TmcTransfer/TmcTransferForm.tsx | 8 +++- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 3 +- src/webparts/requestPortal/loc/en-us.js | 2 + src/webparts/requestPortal/loc/mystrings.d.ts | 2 + .../shared/approval/approvalList.service.ts | 7 ++++ .../requestPortal/shared/impersonation.ts | 39 +++++++++++++++++++ 13 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 src/webparts/requestPortal/shared/impersonation.ts diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 3ee3ac1..34789f4 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -13,10 +13,13 @@ import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; import { installErrorLogging } from './shared/diagnostics'; +import { setImpersonatedEmail } from './shared/impersonation'; import { ErrorOverlay } from './shared/ui'; export interface IRequestPortalWebPartProps { description: string; + /** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */ + debugUserEmail: string; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -25,6 +28,10 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart/.module.scss. // Здесь — только оболочка (root + раскладка колонок), общая для всех фич. diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index f99288d..02eaa43 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -19,6 +19,7 @@ import { OwnTab, } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; +import { getImpersonatedEmail } from '../shared/impersonation'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; import { Drawer } from './Drawer/Drawer'; @@ -57,6 +58,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context .filter(Boolean) .join(' '); + // Читаем при рендере, а не через состояние: значение выставляет веб-часть + // (setImpersonatedEmail в render) до создания этого элемента, и меняется оно + // только вместе с новым render всей веб-части. + const impersonatedEmail = getImpersonatedEmail(); + const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); const [navCounts, setNavCounts] = React.useState(undefined); @@ -254,6 +260,15 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context
+ {/* Подмену личности легко забыть включённой — тогда портал молча показывал + бы чужие заявки и чужую очередь. Полосу поэтому не прячем и не даём + закрыть: она исчезает только когда свойство веб-части очищено. */} + {impersonatedEmail && ( +
+ Включена отладочная подмена: портал работает от имени {impersonatedEmail}. + Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи. +
+ )}
= ({ requestType, context, // редактирование, маршрут читается из уже записанных строк — планирование // затёрло бы правки, сделанные при первом сохранении. const approval = useMemo(() => hireApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 9f24af6..15f58e6 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -33,6 +33,7 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { saveSelectionRequest, selectionApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; @@ -86,7 +87,7 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con // У заявки на редактировании маршрут читается из уже записанных строк, чтобы не // затереть правки инициатора при первом сохранении. const approval = useMemo(() => selectionApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index a6a2693..2e369ac 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -33,6 +33,7 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { @@ -132,7 +133,7 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan }, [editId]); const approval = useMemo(() => ticket1cApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index e59102c..c11a273 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -34,6 +34,7 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { saveTmcTransferRequest, tmcTransferApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; @@ -63,7 +64,10 @@ const TmcTransferForm: React.FC = ({ requestType, context, on const today = new Date().toISOString().slice(0, 10); setData((prev) => ({ ...prev, - RequesterEmail: user.email || '', + // email — через effectiveUserEmail (учитывает отладочную подмену личности); + // displayName подменить нечем, справочник ФИО тут не читается — при + // подмене имя останется настоящим, для отладки это неважно. + RequesterEmail: effectiveUserEmail(context), AuthorName: user.displayName || '', RequestDate: prev.RequestDate || today })); @@ -95,7 +99,7 @@ const TmcTransferForm: React.FC = ({ requestType, context, on }, [editId]); const approval = useMemo(() => tmcTransferApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 7155f22..84cb067 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -34,6 +34,7 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { saveWorkplaceRequest, workplaceApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; @@ -108,7 +109,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, }, [sourceId]); const approval = useMemo(() => workplaceApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); diff --git a/src/webparts/requestPortal/loc/en-us.js b/src/webparts/requestPortal/loc/en-us.js index 3b25e74..6dbeb61 100644 --- a/src/webparts/requestPortal/loc/en-us.js +++ b/src/webparts/requestPortal/loc/en-us.js @@ -3,6 +3,8 @@ define([], function() { "PropertyPaneDescription": "Description", "BasicGroupName": "Group Name", "DescriptionFieldLabel": "Description Field", + "DebugUserEmailFieldLabel": "Отладка: работать от имени сотрудника", + "DebugUserEmailFieldDescription": "Только для отладки маршрутов. Портал будет считать текущим пользователем указанный e-mail: по нему планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.", "AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part", "AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app", "AppLocalEnvironmentOffice": "The app is running on your local environment in office.com", diff --git a/src/webparts/requestPortal/loc/mystrings.d.ts b/src/webparts/requestPortal/loc/mystrings.d.ts index 5cdfc88..8535e51 100644 --- a/src/webparts/requestPortal/loc/mystrings.d.ts +++ b/src/webparts/requestPortal/loc/mystrings.d.ts @@ -2,6 +2,8 @@ declare interface IRequestPortalWebPartStrings { PropertyPaneDescription: string; BasicGroupName: string; DescriptionFieldLabel: string; + DebugUserEmailFieldLabel: string; + DebugUserEmailFieldDescription: string; AppLocalEnvironmentSharePoint: string; AppLocalEnvironmentTeams: string; AppLocalEnvironmentOffice: string; diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index 5668738..d145a60 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -5,6 +5,7 @@ import { IList } from '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/profiles'; +import { getImpersonatedEmail } from '../impersonation'; import { IApprovalRow } from './models/IApprovalRow'; import { IMatrixRow } from './models/IMatrixRow'; import { ISiteUser } from './models/ISiteUser'; @@ -349,7 +350,13 @@ export class ApprovalListService { } // Id текущего пользователя на сайте (для сравнения с полем «Согласующий»). + // При отладочной подмене личности (см. shared/impersonation.ts) отдаём Id + // подменённого сотрудника: иначе «моя очередь согласования» и делегирование + // считались бы по реальной учётке, и подмена не позволяла бы проверить шаг + // чужого согласующего — ради чего она и заводилась. public async getCurrentUserId(): Promise { + const impersonated = getImpersonatedEmail(); + if (impersonated) return this.resolveUserId(impersonated); const user = await getSP().web.currentUser.select('Id')(); return user.Id; } diff --git a/src/webparts/requestPortal/shared/impersonation.ts b/src/webparts/requestPortal/shared/impersonation.ts new file mode 100644 index 0000000..ddc95fd --- /dev/null +++ b/src/webparts/requestPortal/shared/impersonation.ts @@ -0,0 +1,39 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; + +/** + * ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им + * пользуется указанный сотрудник. Нужна, чтобы проверять маршруты и права + * согласующего (например, «карту СКУД правит начальник КДП» в обходном листе), + * не выпрашивая учётку у каждого участника маршрута. + * + * Значение задаётся в свойствах веб-части (property pane, см. + * RequestPortalWebPart.getPropertyPaneConfiguration) и живёт на уровне модуля — + * иначе его пришлось бы протаскивать пропсами через всё дерево до каждой формы. + * + * ЧТО ЭТО МЕНЯЕТ: только то, ЧЕЙ email портал отправляет как инициатора при + * планировании маршрута и по какому пользователю фильтрует «свои» заявки и + * очередь согласования. + * + * ЧЕГО ЭТО НЕ МЕНЯЕТ: прав доступа. Запись в SharePoint по-прежнему идёт под + * реальной учёткой (Author/Editor в списках — настоящий пользователь), токен к + * approval-route API тоже настоящий. То есть подмена не даёт увидеть то, к чему + * нет доступа, но ПОЗВОЛЯЕТ принять решение за чужой шаг согласования — поэтому + * на бою поле должно быть пустым (см. предупреждающую полосу в RequestPortal). + */ + +let impersonatedEmail = ''; + +/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */ +export function setImpersonatedEmail(email: string | undefined): void { + impersonatedEmail = (email || '').trim(); +} + +/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */ +export function getImpersonatedEmail(): string { + return impersonatedEmail; +} + +/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */ +export function effectiveUserEmail(context: WebPartContext): string { + return impersonatedEmail || context.pageContext.user.email || ''; +}