Обходной лист #7

Merged
gorbenko.pavel merged 12 commits from сlearance-form into main 2026-08-17 05:43:39 +00:00
13 changed files with 111 additions and 7 deletions
Showing only changes of commit 595ab02b16 - Show all commits
@@ -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<IRequestPortalWebPartProps> {
@@ -25,6 +28,10 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
private _environmentMessage: string = '';
public render(): void {
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
// веб-части, и render вызывается на каждое такое изменение.
setImpersonatedEmail(this.properties.debugUserEmail);
const element: React.ReactElement = React.createElement(
ErrorOverlay,
undefined,
@@ -117,6 +124,11 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
groupFields: [
PropertyPaneTextField('description', {
label: strings.DescriptionFieldLabel
}),
PropertyPaneTextField('debugUserEmail', {
label: strings.DebugUserEmailFieldLabel,
description: strings.DebugUserEmailFieldDescription,
placeholder: 'employee@tnsu.kz'
})
]
}
@@ -93,5 +93,19 @@
}
}
// Полоса отладочной подмены личности (см. shared/impersonation.ts). Красная и
// во всю ширину намеренно: это не подсказка, а предупреждение о том, что портал
// показывает не ваши данные — незаметная плашка тут хуже, чем никакой.
.impersonationBar {
padding: $sp-3 $sp-4;
margin-bottom: $sp-4;
border: 1px solid $red-500;
border-radius: $r-md;
background: rgba($red-500, .08);
color: $navy-900;
font-size: $fs-sm;
line-height: 1.45;
}
// Стили конкретных фич живут в features/<name>/<Name>.module.scss.
// Здесь — только оболочка (root + раскладка колонок), общая для всех фич.
@@ -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<IRequestPortalProps> = ({ hasTeamsContext, context
.filter(Boolean)
.join(' ');
// Читаем при рендере, а не через состояние: значение выставляет веб-часть
// (setImpersonatedEmail в render) до создания этого элемента, и меняется оно
// только вместе с новым render всей веб-части.
const impersonatedEmail = getImpersonatedEmail();
const route = useRoute();
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
@@ -254,6 +260,15 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
<section className={rootClasses}>
<IconSprite />
<ConfirmHost />
{/* Подмену личности легко забыть включённой — тогда портал молча показывал
бы чужие заявки и чужую очередь. Полосу поэтому не прячем и не даём
закрыть: она исчезает только когда свойство веб-части очищено. */}
{impersonatedEmail && (
<div className={styles.impersonationBar}>
Включена отладочная подмена: портал работает от имени <strong>{impersonatedEmail}</strong>.
Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
</div>
)}
<div className={styles.layout}>
<div className={styles.side}>
<SideNav
@@ -7,6 +7,7 @@ import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
@@ -53,8 +54,12 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER';
// AuthorOWSUSER, статус ни при чём.
const MP_STATUS = 'ApprovalStatusOWSCHCS';
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
// маршрут в форме, но собственные списки заявок.
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
return effectiveUserEmail(context).toLowerCase();
}
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
@@ -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 { saveHireRequest, hireApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit';
@@ -89,7 +90,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ 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);
@@ -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<IRequestFormProps> = ({ 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);
@@ -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<IRequestFormProps> = ({ 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);
@@ -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<IRequestFormProps> = ({ 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<IRequestFormProps> = ({ 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);
@@ -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<IRequestFormProps> = ({ 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);
+2
View File
@@ -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",
+2
View File
@@ -2,6 +2,8 @@ declare interface IRequestPortalWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
DebugUserEmailFieldLabel: string;
DebugUserEmailFieldDescription: string;
AppLocalEnvironmentSharePoint: string;
AppLocalEnvironmentTeams: string;
AppLocalEnvironmentOffice: string;
@@ -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<number> {
const impersonated = getImpersonatedEmail();
if (impersonated) return this.resolveUserId(impersonated);
const user = await getSP().web.currentUser.select('Id')();
return user.Id;
}
@@ -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 || '';
}