dev режим убран

This commit is contained in:
gorbenko.pavel
2026-08-14 14:51:48 +05:00
parent f2c36506ed
commit 7d5d51982b
15 changed files with 118 additions and 162 deletions
@@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth;
public static class AuthSchemes
{
/// <summary>Локальная разработка и тесты — Entra не настроен (см. AuthSetup).</summary>
public const string Dev = "Dev";
/// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary>
public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
@@ -20,11 +20,11 @@ public static class AuthSetup
var tenantId = configuration["Entra:TenantId"];
var audience = configuration["Entra:Audience"];
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
AddEntraAuth(services, configuration, tenantId, audience);
else
AddDevAuth(services);
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
throw new InvalidOperationException(
"Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
AddEntraAuth(services, configuration, tenantId, audience);
return services;
}
@@ -125,26 +125,4 @@ public static class AuthSetup
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
});
}
private static void AddDevAuth(IServiceCollection services)
{
services.AddAuthentication(AuthSchemes.Dev)
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
// Те же имена политик, но поверх Dev-схемы: иначе контроллер с
// [Authorize(Policy = ...)] падал бы локально с «политика не найдена».
services.AddAuthorization(options =>
{
foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any })
{
options.AddPolicy(name, policy =>
{
policy.AuthenticationSchemes.Add(AuthSchemes.Dev);
policy.RequireAuthenticatedUser();
});
}
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
});
}
}
@@ -1,26 +0,0 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Api.Auth;
public sealed class DevAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
?? configuration["Dev:DefaultUserEmail"]
?? "dev@tnsu.kz";
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -1,8 +1,4 @@
{
"Entra": {
"TenantId": "",
"Audience": ""
},
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
}
@@ -13,9 +13,6 @@
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
"AllowedAppIds": []
},
"Dev": {
"DefaultUserEmail": "dev@tnsu.kz"
},
"Cors": {
"AllowedOrigins": [
"https://tnsukz.sharepoint.com",
@@ -1,11 +1,7 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneToggle
} from '@microsoft/sp-property-pane';
import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import ApprovalAdmin from './components/ApprovalAdmin';
@@ -13,8 +9,6 @@ import { IApprovalAdminProps } from './components/IApprovalAdminProps';
export interface IApprovalAdminWebPartProps {
apiBaseUrl: string;
useDevAuth: boolean;
devUserEmail: string;
aadResourceId: string;
portalBaseUrl: string;
}
@@ -25,8 +19,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
const element: React.ReactElement<IApprovalAdminProps> = React.createElement(ApprovalAdmin, {
context: this.context,
apiBaseUrl: this.properties.apiBaseUrl,
useDevAuth: this.properties.useDevAuth,
devUserEmail: this.properties.devUserEmail,
aadResourceId: this.properties.aadResourceId,
portalBaseUrl: this.properties.portalBaseUrl
});
@@ -39,11 +31,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
// Для локальной разработки переопределите значение в панели свойств веб-части
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route';
// Dev-режим ломает /resolve: наш бэкенд не получает Bearer-токен пользователя
// для пересылки в api.tnsu.kz и падает на собственном client_credentials
// (TnsuPortal:ClientSecret в appsettings.json пуст). По умолчанию — реальный AAD.
this.properties.useDevAuth = this.properties.useDevAuth ?? false;
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
return Promise.resolve();
@@ -72,15 +59,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
label: 'Api Base Url',
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.'
}),
PropertyPaneToggle('useDevAuth', {
label: 'Локальный Dev-режим (без Azure AD)',
onText: 'Вкл. (заголовок X-Dev-User-Email)',
offText: 'Выкл. (AAD-токен через SPFx)'
}),
PropertyPaneTextField('devUserEmail', {
label: 'Dev: email пользователя',
description: 'Используется только при включённом Dev-режиме.'
}),
PropertyPaneTextField('aadResourceId', {
label: 'AAD Resource Id (App ID URI)',
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
@@ -3,7 +3,7 @@ import styles from './ApprovalAdmin.module.scss';
import type { IApprovalAdminProps } from './IApprovalAdminProps';
import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
import { AadApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
import { SharePointListsClient } from '../services/sharePointLists';
import { RequestManageService } from '../services/requestManageService';
@@ -21,9 +21,6 @@ function ensureFontsLoaded(): void {
}
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> {
if (props.useDevAuth) {
return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz');
}
const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
return new AadApiHttpClient(aadClient, props.apiBaseUrl);
}
@@ -50,11 +47,8 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]);
}, [props.apiBaseUrl, props.aadResourceId]);
// Справочники всегда идут через AAD, независимо от Dev-режима нашего API:
// api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email
// ему ничего не говорит.
React.useEffect(() => {
let cancelled = false;
setDictionary(undefined);
@@ -3,8 +3,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface IApprovalAdminProps {
context: WebPartContext;
apiBaseUrl: string;
useDevAuth: boolean;
devUserEmail: string;
aadResourceId: string;
portalBaseUrl: string;
}
@@ -223,7 +223,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
// бэкенд ищет по компании инициатора). Найденные в других компаниях
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
const inRouteCompany = routeCompanyName
? positionMatches.exact.filter(e => e.company === routeCompanyName)
? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
: positionMatches.exact;
if (inRouteCompany.length > 0) {
@@ -235,12 +235,18 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
</span>
<ul className={styles.previewList}>
{inRouteCompany.map(e => (
{inRouteCompany.map(e => {
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
// маршрутом — она не обязана быть его company (первой из
// справочника), если он числится в нескольких (см. companies).
const shownCompany = routeCompanyName ?? e.company;
return (
<li key={e.email}>
<span className={styles.previewPerson}>{e.fio}</span>
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
{shownCompany && <span className={styles.previewCompany}> · {shownCompany}</span>}
</li>
))}
);
})}
</ul>
</div>
);
@@ -34,10 +34,17 @@ export interface Employee {
* отображения в списке (сам справочник должностей не показывает). */
position: string;
/**
* Компания сотрудника — '' если не пришла из справочника. Нужна подсказке
* «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ
* (должность, компания инициатора), поэтому одну и ту же должность в разных
* юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной.
* Первая компания сотрудника из справочника ('' если не пришла) — для
* отображения в пикере/списках, где на человека нужна ровно одна строка.
*/
company: string;
/**
* ВСЕ компании, где числится сотрудник — совместители и штат общих служб
* (бухгалтерия, HR) на несколько юрлиц не редкость: в api.tnsu.kz это
* отдельная строка на каждую компанию, а не одна с списком. Проверять
* принадлежность к конкретной компании (маршрут ограничен ею) нужно по
* этому полю, а не по company — иначе сотрудник, у которого в справочнике
* первой оказалась ДРУГАЯ его компания, ошибочно выглядел бы отсутствующим.
*/
companies: string[];
}
@@ -116,45 +116,3 @@ export class AadApiHttpClient implements IHttpClient {
.then((r: HttpClientResponse) => parseResponse<T>(r));
}
}
/**
* Локальная отладка: обычный fetch с заголовком X-Dev-User-Email.
* Работает только если бэкенд поднят с пустыми Entra:TenantId/Entra:Audience
* (см. backend/.../Auth/AuthSetup.cs — тогда включается DevAuthHandler).
*/
export class DevApiHttpClient implements IHttpClient {
constructor(private readonly baseUrl: string, private readonly devUserEmail: string) {}
public get<T>(path: string, query?: QueryParams): Promise<T> {
return this.send<T>('GET', path, undefined, query);
}
public post<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('POST', path, body);
}
public put<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PUT', path, body);
}
public patch<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PATCH', path, body);
}
public async delete(path: string): Promise<void> {
await this.send<void>('DELETE', path);
}
private async send<T>(method: string, path: string, body?: unknown, query?: QueryParams): Promise<T> {
const url = buildUrl(this.baseUrl, path, query);
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-Dev-User-Email': this.devUserEmail
},
body: body !== undefined ? JSON.stringify(body) : undefined
});
return parseResponse<T>(response);
}
}
@@ -0,0 +1,56 @@
import { AadHttpClient } from '@microsoft/sp-http';
import { PortalDictionaryClient } from './portalDictionary';
// Мок-клиент вместо настоящего AadHttpClient — employees() дёргает только .get().
function clientReturning(body: unknown): AadHttpClient {
return {
get: () =>
Promise.resolve({
ok: true,
text: () => Promise.resolve(JSON.stringify(body))
})
} as unknown as AadHttpClient;
}
describe('PortalDictionaryClient.employees', () => {
// Регрессия: api.tnsu.kz отдаёт ОТДЕЛЬНУЮ строку на каждую компанию, где
// числится сотрудник (совместители/бухгалтерия на несколько юрлиц — не
// редкость). Раньше company у дедуплицированной записи оставалась только
// первой попавшейся, а остальные компании терялись — сотрудник ошибочно
// выглядел отсутствующим в них (см. forms/Clearance-подсказку в RouteStepFormModal).
it('строки одного email по разным компаниям схлопываются в одного сотрудника, но все компании сохраняются', async () => {
const client = clientReturning([
{ fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО ГеоТрек' },
{ fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО AFL Engineering' },
{ fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО СТ Global' }
]);
const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
expect(employees).toHaveLength(1);
expect(employees[0].company).toBe('ТОО ГеоТрек'); // первая — для пикера/отображения
expect(employees[0].companies).toEqual(['ТОО ГеоТрек', 'ТОО AFL Engineering', 'ТОО СТ Global']);
});
it('разные email — разные сотрудники, дедупликация их не задевает', async () => {
const client = clientReturning([
{ fio: 'A', email: 'a@tnsu.kz', position: 'Инженер', company: 'Компания А' },
{ fio: 'B', email: 'b@tnsu.kz', position: 'Инженер', company: 'Компания Б' }
]);
const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
expect(employees.map(e => e.email)).toEqual(['a@tnsu.kz', 'b@tnsu.kz']);
expect(employees.map(e => e.companies)).toEqual([['Компания А'], ['Компания Б']]);
});
it('сотрудник без email в строке — пропускается (по нему нельзя резолвить исполнителя)', async () => {
const client = clientReturning([
{ fio: 'Без Email', email: null, position: 'Инженер', company: 'Компания А' }
]);
const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
expect(employees).toEqual([]);
});
});
@@ -117,13 +117,17 @@ export class PortalDictionaryClient {
fio: x.fio,
email: x.email,
position: x.position || '',
company: x.company || ''
company: x.company || '',
// Одна строка на каждую компанию, где числится сотрудник (совместители/
// бухгалтерия на несколько юрлиц — не редкость), companies собирает их
// все, а company остаётся первой встреченной (для пикера/отображения,
// где нужна ровно одна строка на человека).
companies: x.company ? [x.company] : []
});
} else {
// Один сотрудник — строка на каждый его отдел; должность и компания
// могли найтись не в первой из них.
if (!existing.position && x.position) existing.position = x.position;
if (!existing.company && x.company) existing.company = x.company;
if (x.company && existing.companies.indexOf(x.company) === -1) existing.companies.push(x.company);
}
});
return Array.from(byEmail.values());
@@ -1,9 +1,16 @@
import { Employee } from '../models';
import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee';
// Хелпер вместо повторения company/companies в каждом литерале — в тестах
// нет нужды в разных компаниях у одного сотрудника (это отдельно проверяется
// в matchEmployeesByPosition ниже).
function emp(fio: string, email: string, position: string, company = ''): Employee {
return { fio, email, position, company, companies: company ? [company] : [] };
}
const employees: Employee[] = [
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle', company: 'ТОО TANSU Construction' },
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела', company: 'ТОО TANSU Construction' }
emp('Горбенко Павел', 'gorbenko.pavel@tnsu.kz', 'Разработчик middle', 'ТОО TANSU Construction'),
emp('Соколов Давид', 'sokolov.david@tnsu.kz', 'Руководитель отдела', 'ТОО TANSU Construction')
];
describe('findEmployeeByEmail', () => {
@@ -37,7 +44,7 @@ describe('toEmployeeOption', () => {
// Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя.
it('без должности в подписи остаётся только email', () => {
expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '', company: '' })).toEqual({
expect(toEmployeeOption(emp('Без Должности', 'nobody@tnsu.kz', ''))).toEqual({
value: 'nobody@tnsu.kz',
label: 'Без Должности',
sublabel: 'nobody@tnsu.kz'
@@ -47,12 +54,12 @@ describe('toEmployeeOption', () => {
describe('matchEmployeesByPosition', () => {
const staff: Employee[] = [
{ fio: 'Плотникова Лариса', email: 'p@tnsu.kz', position: 'Генеральный директор', company: 'Компания А' },
{ fio: 'Самаева Асель', email: 's@tnsu.kz', position: 'Директор', company: 'Компания А' },
{ fio: 'Ким Николай', email: 'k@tnsu.kz', position: 'Управляющий директор', company: 'Компания Б' },
{ fio: 'Иванов Иван', email: 'i1@tnsu.kz', position: 'Инженер ПТО', company: 'Компания А' },
{ fio: 'Петров Пётр', email: 'i2@tnsu.kz', position: 'Инженер ПТО', company: 'Компания Б' },
{ fio: 'Без Должности', email: 'n@tnsu.kz', position: '', company: 'Компания А' }
emp('Плотникова Лариса', 'p@tnsu.kz', 'Генеральный директор', 'Компания А'),
emp('Самаева Асель', 's@tnsu.kz', 'Директор', 'Компания А'),
emp('Ким Николай', 'k@tnsu.kz', 'Управляющий директор', 'Компания Б'),
emp('Иванов Иван', 'i1@tnsu.kz', 'Инженер ПТО', 'Компания А'),
emp('Петров Пётр', 'i2@tnsu.kz', 'Инженер ПТО', 'Компания Б'),
emp('Без Должности', 'n@tnsu.kz', '', 'Компания А')
];
it('пустой ввод — ничего не подбирает', () => {
@@ -45,7 +45,13 @@ describe('detectExecutorKind', () => {
describe('describeStepExecutor', () => {
const employees: Employee[] = [
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик', company: 'ТОО TANSU Construction' }
{
fio: 'Горбенко Павел',
email: 'gorbenko.pavel@tnsu.kz',
position: 'Разработчик',
company: 'ТОО TANSU Construction',
companies: ['ТОО TANSU Construction']
}
];
it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
@@ -57,7 +63,7 @@ describe('describeStepExecutor', () => {
// начинаться с висящего разделителя.
it('для сотрудника без должности в подписи остаётся только email', () => {
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '' }
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '', companies: [] }
]);
expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
});