From 2a4ba14d381a459fbbb0de307a3504335b66592f Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 05:39:37 +0500 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=D1=8D=D1=84=D1=84=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=D0=B2=20/api/me=20(=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RolePermissions.Effective(role, isSuperAdmin, isAdmin, advancedMode) — флаги с учётом роли и простого/расширенного режима; AdvancedOnly — единый список advanced-only прав (зеркалит [RequiresAdvancedMode]). - /api/me теперь отдаёт permissions (имя→bool). Мобильное и веб скрывают одно и то же. - Веб: хук useCan() читает me.permissions (раньше гейтил только по именам ролей). Co-Authored-By: Claude Opus 5.5 --- src/food-market.api/Program.cs | 19 +++++++++- .../Organizations/RolePermissions.cs | 37 +++++++++++++++++++ src/food-market.web/src/lib/useMe.ts | 15 ++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 296acaf..b88e3bc 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -718,6 +718,11 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // false по умолчанию → UI показывает только «простое» меню. // Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode. bool advancedMode = false; + // Sprint (mobile-api): эффективные права текущего пользователя — + // единый источник для мобильного и веба (скрывают одно и то же). + foodmarket.Domain.Organizations.RolePermissions? rolePerms = null; + var isSuperAdmin = roles.Contains(HttpContextTenantContext.SuperAdminRole); + var isAdmin = roles.Contains("Admin"); if (Guid.TryParse(orgIdRaw, out var orgId)) { var org = await db.Organizations.IgnoreQueryFilters() @@ -731,11 +736,18 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme } if (Guid.TryParse(subRaw, out var sub)) { - hasActiveEmployee = await db.Employees.IgnoreQueryFilters() - .AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive); + var emp = await db.Employees.IgnoreQueryFilters() + .Where(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive && !e.IsDeleted) + .Select(e => new { e.Role }) + .FirstOrDefaultAsync(); + hasActiveEmployee = emp is not null; + rolePerms = emp?.Role?.Permissions; } } + var permissions = foodmarket.Domain.Organizations.RolePermissions.Effective( + rolePerms, isSuperAdmin, isAdmin, advancedMode); + return Results.Ok(new { sub = subRaw, @@ -746,6 +758,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme hasLiveOrg, hasActiveEmployee, advancedMode, + // Эффективные права (имя→bool) с учётом роли и простого/расширенного + // режима. Клиенты (моб./веб) гейтят UI по этим флагам. + permissions, }); }).RequireAuthorization(); diff --git a/src/food-market.domain/Organizations/RolePermissions.cs b/src/food-market.domain/Organizations/RolePermissions.cs index 53a4acc..b53c394 100644 --- a/src/food-market.domain/Organizations/RolePermissions.cs +++ b/src/food-market.domain/Organizations/RolePermissions.cs @@ -68,6 +68,43 @@ public class RolePermissions public bool MoneyEdit { get; set; } public bool CashShiftOperate { get; set; } + /// Права, доступные только в расширенном режиме организации + /// (в простом режиме скрыты и на сервере, и в UI). Единый источник правды, + /// зеркалит контроллеры с [RequiresAdvancedMode]: Оптовые отгрузки, + /// Перемещения, Лояльность, Акции, Типы цен. + public static readonly IReadOnlySet AdvancedOnly = new HashSet(StringComparer.Ordinal) + { + nameof(DemandsView), nameof(DemandsEdit), nameof(DemandsPost), + nameof(TransferEdit), nameof(LoyaltyManage), nameof(PromotionsManage), + nameof(PriceTypesManage), + }; + + /// Все bool-флаги прав в виде словаря имя→значение (через рефлексию). + public Dictionary ToDictionary() + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var p in typeof(RolePermissions).GetProperties()) + if (p.PropertyType == typeof(bool) && p.CanRead) + dict[p.Name] = (bool)p.GetValue(this)!; + return dict; + } + + /// Эффективные права текущего пользователя для клиента (моб./веб): + /// SuperAdmin — всё без ограничений; Admin — всё, но в простом режиме + /// advanced-only скрыты; обычная роль — её флаги, в простом режиме с + /// обнулением advanced-only. Единый источник, чтобы клиенты скрывали одно и то же. + public static Dictionary Effective(RolePermissions? role, bool isSuperAdmin, bool isAdmin, bool advancedMode) + { + var basePerms = (isSuperAdmin || isAdmin) ? All() : (role ?? new RolePermissions()); + var dict = basePerms.ToDictionary(); + // SuperAdmin обходит гейт расширенного режима; остальным в простом режиме + // advanced-only права выключаем. + if (!isSuperAdmin && !advancedMode) + foreach (var name in AdvancedOnly) + dict[name] = false; + return dict; + } + /// Полный набор всех true — для системной роли «Администратор». public static RolePermissions All() => new() { diff --git a/src/food-market.web/src/lib/useMe.ts b/src/food-market.web/src/lib/useMe.ts index 64a63e4..d2d91f6 100644 --- a/src/food-market.web/src/lib/useMe.ts +++ b/src/food-market.web/src/lib/useMe.ts @@ -17,6 +17,10 @@ export interface MeResponse { * модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит, * пресеты, Telegram-бот, SSO, несколько типов цен. */ advancedMode: boolean + /** Эффективные права текущего пользователя (имя флага RolePermissions → + * bool) с учётом роли и простого/расширенного режима. Единый источник + * для веба и мобильного — скрываем одно и то же. Советуй `useCan()`. */ + permissions?: Record } /** Текущий залогиненный юзер с ролями. Используется для гейтов @@ -33,3 +37,14 @@ export function useIsSuperAdmin(): boolean { const me = useMe() return !!me.data?.roles?.includes('SuperAdmin') } + +/** Проверка эффективного права текущего пользователя по серверному источнику + * (`/api/me` → permissions). Тот же набор, что у мобильного приложения, — + * веб и моб. скрывают одно и то же. `can('ProductsEdit')` и т.п. + * SuperAdmin и Admin получают все флаги (advanced-only — только в расш. режиме), + * это уже учтено сервером. */ +export function useCan(): (permission: string) => boolean { + const me = useMe() + const perms = me.data?.permissions + return (permission: string) => !!perms && perms[permission] === true +}