Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/21
This commit was merged in pull request #21.
This commit is contained in:
@@ -16,6 +16,6 @@
|
||||
"title": { "default": "request-portal" },
|
||||
"description": { "default": "request-portal description" },
|
||||
"officeFabricIconFontName": "Page",
|
||||
"properties": {}
|
||||
"properties": { "notificationsEnabled": true }
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import { type IPropertyPaneConfiguration, PropertyPaneDropdown } from '@microsoft/sp-property-pane';
|
||||
import {
|
||||
type IPropertyPaneConfiguration,
|
||||
PropertyPaneDropdown,
|
||||
PropertyPaneTextField,
|
||||
PropertyPaneToggle
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||
|
||||
@@ -11,7 +16,12 @@ import { IRequestPortalProps } from './components/IRequestPortalProps';
|
||||
import { initSP } from './shared/pnp/pnpjs';
|
||||
import { installErrorLogging } from './shared/diagnostics';
|
||||
import { setImpersonatedIdentity } from './shared/impersonation';
|
||||
import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, setApprovalApiBaseUrl } from './shared/config';
|
||||
import {
|
||||
APPROVAL_API_BASE_URL_LOCAL,
|
||||
APPROVAL_API_BASE_URL_PROD,
|
||||
setApprovalApiBaseUrl,
|
||||
setNotificationSettings
|
||||
} from './shared/config';
|
||||
import { initListPartitions } from './shared/approval';
|
||||
import { ErrorOverlay } from './shared/ui';
|
||||
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
|
||||
@@ -28,6 +38,13 @@ export interface IRequestPortalWebPartProps {
|
||||
debugUserDisplayName: string;
|
||||
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
||||
approvalApiBaseUrl: string;
|
||||
/**
|
||||
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
|
||||
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
|
||||
*/
|
||||
notificationsEnabled?: boolean;
|
||||
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
||||
notifyOnlyTo?: string;
|
||||
}
|
||||
|
||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||
@@ -41,6 +58,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||
this._applyNotificationSettings();
|
||||
|
||||
const element: React.ReactElement = React.createElement(
|
||||
ErrorOverlay,
|
||||
@@ -71,6 +89,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
// панели без перезапуска), но render случается позже onInit.
|
||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||
this._applyNotificationSettings();
|
||||
|
||||
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
|
||||
// формам синхронно (см. shared/listTitles.ts), а формы — лениво подгружаемые
|
||||
@@ -92,7 +111,9 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private _applyNotificationSettings(): void {
|
||||
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
||||
}
|
||||
|
||||
private _getEnvironmentMessage(): Promise<string> {
|
||||
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
||||
@@ -196,6 +217,23 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
]
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
groupName: 'Уведомления',
|
||||
groupFields: [
|
||||
PropertyPaneToggle('notificationsEnabled', {
|
||||
label: 'Уведомления согласующим',
|
||||
onText: 'Включены',
|
||||
offText: 'Выключены',
|
||||
checked: this.properties.notificationsEnabled !== false
|
||||
}),
|
||||
PropertyPaneTextField('notifyOnlyTo', {
|
||||
label: 'Слать только на адрес',
|
||||
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
||||
placeholder: 'user@tnsu.kz',
|
||||
disabled: this.properties.notificationsEnabled === false
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -73,18 +73,31 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
|
||||
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
||||
|
||||
// Раздел «Отчёты» — точечный список почт, пока в SharePoint нет группы HR
|
||||
// (см. reportsAccess.ts). Пересчитывается на каждый рендер намеренно: сам
|
||||
// hasReportsAccess — синхронная проверка без сети, а input (effectiveUserEmail)
|
||||
// меняется только по подмене личности в property pane, что и так вызывает
|
||||
// ререндер всей веб-части.
|
||||
const canSeeReports = hasReportsAccess(context);
|
||||
// Раздел «Отчёты» — группа «Отчеты по заявкам» + администраторы (см.
|
||||
// reportsAccess.ts). undefined — ответ ещё не пришёл. Перезапрашиваем при
|
||||
// смене подменённой личности.
|
||||
const [canSeeReports, setCanSeeReports] = React.useState<boolean | undefined>(undefined);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCanSeeReports(undefined);
|
||||
hasReportsAccess(context)
|
||||
.then((v) => {
|
||||
if (!cancelled) setCanSeeReports(v);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCanSeeReports(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context, impersonatedEmail]);
|
||||
|
||||
// Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же
|
||||
// раздел, что скрыт из навигации, должен быть недоступен и так: без этого
|
||||
// редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку.
|
||||
// Пока ответ не пришёл — не редиректим, иначе участника группы выкинет.
|
||||
React.useEffect(() => {
|
||||
if (route.nav === 'reports' && !canSeeReports) {
|
||||
if (route.nav === 'reports' && canSeeReports === false) {
|
||||
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
|
||||
}
|
||||
}, [route.nav, canSeeReports]);
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import { AadHttpClient } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL } from '../config';
|
||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
/**
|
||||
* ВРЕМЕННАЯ ЗАГЛУШКА на период обкатки рассылки: уведомление отправляется,
|
||||
* только если активный шаг назначен на этот адрес. Для всех остальных
|
||||
* согласующих поток не вызывается вообще — письма не будет.
|
||||
*
|
||||
* Ограничение стоит ЗДЕСЬ, на стороне портала, а не в потоке Power Automate,
|
||||
* потому что адресата поток определяет САМ по RowID — тело запроса на выбор
|
||||
* получателя не влияет. Значит, единственный способ не отправить письмо, не
|
||||
* трогая поток, — не дёргать его. Переадресовать письмо на тестовый ящик
|
||||
* отсюда нельзя по той же причине: поток такого параметра не принимает.
|
||||
*
|
||||
* Пустая строка — заглушка снята, уведомления идут всем как обычно.
|
||||
*/
|
||||
const NOTIFY_ONLY_TO = 'gorbenko.pavel@tnsu.kz';
|
||||
|
||||
// Данные формы для тела запроса к потоку (постоянны для формы).
|
||||
export interface INotifyTarget {
|
||||
requestTypeId: number; // RequestTypeId (id типа заявки во внешнем API маршрутов)
|
||||
@@ -42,20 +27,29 @@ async function postNotify(context: WebPartContext, body: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешена ли отправка на согласующего активной строки (см. NOTIFY_ONLY_TO).
|
||||
// Разрешена ли отправка на согласующего активной строки — по свойствам веб-части
|
||||
// notificationsEnabled / notifyOnlyTo (см. setNotificationSettings в config.ts).
|
||||
//
|
||||
// Неизвестный адрес (пользователь не найден, поле пустое, запрос упал) —
|
||||
// НЕ отправляем: пока заглушка включена, сомнение трактуется в пользу
|
||||
// «письмо живому человеку лучше не слать», а не наоборот.
|
||||
// Ограничение стоит ЗДЕСЬ, на стороне портала, а не в потоке Power Automate,
|
||||
// потому что адресата поток определяет САМ по RowID — тело запроса на выбор
|
||||
// получателя не влияет. Значит, единственный способ не отправить письмо, не
|
||||
// трогая поток, — не дёргать его.
|
||||
//
|
||||
// При заданном notifyOnlyTo неизвестный адрес (пользователь не найден, поле
|
||||
// пустое, запрос упал) — НЕ отправляем: сомнение трактуется в пользу «письмо
|
||||
// живому человеку лучше не слать».
|
||||
async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise<boolean> {
|
||||
if (!NOTIFY_ONLY_TO) return true;
|
||||
if (!getNotificationsEnabled()) return false;
|
||||
|
||||
const onlyTo = getNotifyOnlyTo();
|
||||
if (!onlyTo) return true;
|
||||
|
||||
const email = await approval.resolveUserEmail(approverId);
|
||||
if (email.toLowerCase() === NOTIFY_ONLY_TO.toLowerCase()) return true;
|
||||
if (email.toLowerCase() === onlyTo.toLowerCase()) return true;
|
||||
|
||||
console.warn(
|
||||
`[Оповещение] Включена заглушка рассылки: письмо для «${email || 'адрес неизвестен'}» не отправлено ` +
|
||||
`(разрешён только ${NOTIFY_ONLY_TO})`
|
||||
`[Оповещение] Рассылка ограничена одним адресом: письмо для «${email || 'адрес неизвестен'}» не отправлено ` +
|
||||
`(разрешён только ${onlyTo})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,28 @@ export function getApprovalApiBaseUrl(): string {
|
||||
return _approvalApiBaseUrl;
|
||||
}
|
||||
|
||||
// Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts).
|
||||
// Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот
|
||||
// же модульный синглтон, что и у approvalApiBaseUrl выше.
|
||||
// enabled — false выключает рассылку целиком;
|
||||
// onlyTo — непустой адрес: письма уходят ТОЛЬКО если активный шаг назначен
|
||||
// на него (обкатка рассылки); пусто — всем.
|
||||
let _notificationsEnabled = true;
|
||||
let _notifyOnlyTo = '';
|
||||
|
||||
export function setNotificationSettings(enabled: boolean, onlyTo: string): void {
|
||||
_notificationsEnabled = enabled;
|
||||
_notifyOnlyTo = (onlyTo || '').trim();
|
||||
}
|
||||
|
||||
export function getNotificationsEnabled(): boolean {
|
||||
return _notificationsEnabled;
|
||||
}
|
||||
|
||||
export function getNotifyOnlyTo(): string {
|
||||
return _notifyOnlyTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app
|
||||
* (APPROVAL_API_CLIENT_ID), но другой путь/сервис. Используется, например, для
|
||||
|
||||
@@ -1,34 +1,64 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { effectiveUserEmail } from '../impersonation';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import '@pnp/sp/site-groups/web';
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import { effectiveUserEmail, getImpersonatedEmail } from '../impersonation';
|
||||
|
||||
/**
|
||||
* Кто видит раздел «Отчёты» в боковом меню. Пока это фиксированный список
|
||||
* почт — группы HR в SharePoint ещё нет (её заведут отдельно); появится —
|
||||
* список ниже заменится на реальную проверку членства (см. TODO).
|
||||
* SharePoint-группа «Отчеты по заявкам» на сайте данных (/sites/requests) —
|
||||
* её участники видят раздел «Отчёты». Id, а не Title: Title можно
|
||||
* переименовать в SharePoint без последствий для кода.
|
||||
*/
|
||||
const REPORTS_GROUP_ID = 199;
|
||||
|
||||
/**
|
||||
* Администраторы портала — видят раздел независимо от состава группы.
|
||||
*
|
||||
* Email — из справочника сотрудников (api.tnsu.kz/Employee/All), а не из
|
||||
* Title сайта: у части учёток встречаются устаревшие site user записи со
|
||||
* старым email (пример — Тришина: m.trishina@tnsu.kz как устаревший алиас
|
||||
* рядом с текущим trishina.mariya@tnsu.kz).
|
||||
*/
|
||||
const ALLOWED_EMAILS: ReadonlySet<string> = new Set([
|
||||
const ALWAYS_ALLOWED_EMAILS: ReadonlySet<string> = new Set([
|
||||
'trishina.mariya@tnsu.kz', // Мария Тришина
|
||||
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
|
||||
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
|
||||
'sarniyazova.assilya@tnsu.kz', // Асилья Сарниязова
|
||||
]);
|
||||
|
||||
// TODO: заменить точечный список на членство в SharePoint-группе HR, когда
|
||||
// её заведут — тогда список выше станет фолбэком для тех троих, кто должен
|
||||
// видеть раздел независимо от состава группы (администраторы портала).
|
||||
const cache = new Map<string, Promise<boolean>>();
|
||||
|
||||
async function fetchIsGroupMember(email: string): Promise<boolean> {
|
||||
// Под подменой личности спрашиваем состав группы, а не currentUser: иначе
|
||||
// проверялся бы реальный пользователь. Состав группы виден только её
|
||||
// участникам и владельцам сайта — остальным запрос вернёт ошибку.
|
||||
if (getImpersonatedEmail()) {
|
||||
const users = await getSP().web.siteGroups.getById(REPORTS_GROUP_ID).users.select('Email')();
|
||||
return users.some((u) => (u.Email || '').trim().toLowerCase() === email);
|
||||
}
|
||||
const groups = await getSP().web.currentUser.groups.select('Id')();
|
||||
return groups.some((g) => g.Id === REPORTS_GROUP_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли у текущего пользователя доступ к разделу «Отчёты». Использует
|
||||
* effectiveUserEmail — то есть учитывает отладочную подмену личности (см.
|
||||
* impersonation.ts): под подменённым пользователем раздел виден/не виден
|
||||
* так же, как было бы у него самого.
|
||||
* impersonation.ts). Ответ кешируется на сеанс по email; ошибка запроса = нет
|
||||
* доступа и не кешируется, чтобы временный сбой не «залип».
|
||||
*/
|
||||
export function hasReportsAccess(context: WebPartContext): boolean {
|
||||
export function hasReportsAccess(context: WebPartContext): Promise<boolean> {
|
||||
const email = effectiveUserEmail(context).trim().toLowerCase();
|
||||
return email.length > 0 && ALLOWED_EMAILS.has(email);
|
||||
if (!email) return Promise.resolve(false);
|
||||
if (ALWAYS_ALLOWED_EMAILS.has(email)) return Promise.resolve(true);
|
||||
|
||||
let pending = cache.get(email);
|
||||
if (!pending) {
|
||||
pending = fetchIsGroupMember(email).catch(() => {
|
||||
cache.delete(email);
|
||||
return false;
|
||||
});
|
||||
cache.set(email, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user