Обходной лист #7
@@ -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);
|
||||
|
||||
@@ -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,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 || '';
|
||||
}
|
||||
Reference in New Issue
Block a user