feat: доступ к «Отчётам» по группе SharePoint «Отчеты по заявкам»
Вместо точечного списка почт — членство в группе (Id 199); прежние четыре почты остаются как администраторы с доступом независимо от группы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,18 +73,31 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
|
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
|
||||||
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
||||||
|
|
||||||
// Раздел «Отчёты» — точечный список почт, пока в SharePoint нет группы HR
|
// Раздел «Отчёты» — группа «Отчеты по заявкам» + администраторы (см.
|
||||||
// (см. reportsAccess.ts). Пересчитывается на каждый рендер намеренно: сам
|
// reportsAccess.ts). undefined — ответ ещё не пришёл. Перезапрашиваем при
|
||||||
// hasReportsAccess — синхронная проверка без сети, а input (effectiveUserEmail)
|
// смене подменённой личности.
|
||||||
// меняется только по подмене личности в property pane, что и так вызывает
|
const [canSeeReports, setCanSeeReports] = React.useState<boolean | undefined>(undefined);
|
||||||
// ререндер всей веб-части.
|
React.useEffect(() => {
|
||||||
const canSeeReports = hasReportsAccess(context);
|
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) в обход бокового меню — тот же
|
// Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же
|
||||||
// раздел, что скрыт из навигации, должен быть недоступен и так: без этого
|
// раздел, что скрыт из навигации, должен быть недоступен и так: без этого
|
||||||
// редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку.
|
// редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку.
|
||||||
|
// Пока ответ не пришёл — не редиректим, иначе участника группы выкинет.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (route.nav === 'reports' && !canSeeReports) {
|
if (route.nav === 'reports' && canSeeReports === false) {
|
||||||
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
|
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
|
||||||
}
|
}
|
||||||
}, [route.nav, canSeeReports]);
|
}, [route.nav, canSeeReports]);
|
||||||
|
|||||||
@@ -1,34 +1,64 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Кто видит раздел «Отчёты» в боковом меню. Пока это фиксированный список
|
* SharePoint-группа «Отчеты по заявкам» на сайте данных (/sites/requests) —
|
||||||
* почт — группы HR в SharePoint ещё нет (её заведут отдельно); появится —
|
* её участники видят раздел «Отчёты». Id, а не Title: Title можно
|
||||||
* список ниже заменится на реальную проверку членства (см. TODO).
|
* переименовать в SharePoint без последствий для кода.
|
||||||
|
*/
|
||||||
|
const REPORTS_GROUP_ID = 199;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Администраторы портала — видят раздел независимо от состава группы.
|
||||||
*
|
*
|
||||||
* Email — из справочника сотрудников (api.tnsu.kz/Employee/All), а не из
|
* Email — из справочника сотрудников (api.tnsu.kz/Employee/All), а не из
|
||||||
* Title сайта: у части учёток встречаются устаревшие site user записи со
|
* Title сайта: у части учёток встречаются устаревшие site user записи со
|
||||||
* старым email (пример — Тришина: m.trishina@tnsu.kz как устаревший алиас
|
* старым email (пример — Тришина: m.trishina@tnsu.kz как устаревший алиас
|
||||||
* рядом с текущим trishina.mariya@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', // Мария Тришина
|
'trishina.mariya@tnsu.kz', // Мария Тришина
|
||||||
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
|
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
|
||||||
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
|
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
|
||||||
'sarniyazova.assilya@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 — то есть учитывает отладочную подмену личности (см.
|
* 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();
|
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