feat(auth): эффективные права пользователя в /api/me (единый источник)

- RolePermissions.Effective(role, isSuperAdmin, isAdmin, advancedMode) — флаги с
  учётом роли и простого/расширенного режима; AdvancedOnly — единый список
  advanced-only прав (зеркалит [RequiresAdvancedMode]).
- /api/me теперь отдаёт permissions (имя→bool). Мобильное и веб скрывают одно и то же.
- Веб: хук useCan() читает me.permissions (раньше гейтил только по именам ролей).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 05:39:37 +05:00
parent 7231c83d23
commit 2a4ba14d38
3 changed files with 69 additions and 2 deletions

View file

@ -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();

View file

@ -68,6 +68,43 @@ public class RolePermissions
public bool MoneyEdit { get; set; }
public bool CashShiftOperate { get; set; }
/// <summary>Права, доступные только в расширенном режиме организации
/// (в простом режиме скрыты и на сервере, и в UI). Единый источник правды,
/// зеркалит контроллеры с <c>[RequiresAdvancedMode]</c>: Оптовые отгрузки,
/// Перемещения, Лояльность, Акции, Типы цен.</summary>
public static readonly IReadOnlySet<string> AdvancedOnly = new HashSet<string>(StringComparer.Ordinal)
{
nameof(DemandsView), nameof(DemandsEdit), nameof(DemandsPost),
nameof(TransferEdit), nameof(LoyaltyManage), nameof(PromotionsManage),
nameof(PriceTypesManage),
};
/// <summary>Все bool-флаги прав в виде словаря имя→значение (через рефлексию).</summary>
public Dictionary<string, bool> ToDictionary()
{
var dict = new Dictionary<string, bool>(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;
}
/// <summary>Эффективные права текущего пользователя для клиента (моб./веб):
/// SuperAdmin — всё без ограничений; Admin — всё, но в простом режиме
/// advanced-only скрыты; обычная роль — её флаги, в простом режиме с
/// обнулением advanced-only. Единый источник, чтобы клиенты скрывали одно и то же.</summary>
public static Dictionary<string, bool> 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;
}
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
public static RolePermissions All() => new()
{

View file

@ -17,6 +17,10 @@ export interface MeResponse {
* модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит,
* пресеты, Telegram-бот, SSO, несколько типов цен. */
advancedMode: boolean
/** Эффективные права текущего пользователя (имя флага RolePermissions →
* bool) с учётом роли и простого/расширенного режима. Единый источник
* для веба и мобильного — скрываем одно и то же. Советуй `useCan()`. */
permissions?: Record<string, boolean>
}
/** Текущий залогиненный юзер с ролями. Используется для гейтов
@ -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
}