Feat/selection title reports access (#21)
Tests / frontend-test (push) Successful in 1m19s

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:
2026-09-25 04:43:37 +00:00
parent ab21683377
commit ec94de3c1b
10 changed files with 144 additions and 8372 deletions
@@ -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;
}