[598-51][@ant] fix(desktop): убрать утечку прав видимости стола поставщика и показать ФИО в реестре — председатель без одобренного допуска видел рабочие страницы поставщика из-за общего гранта Offer:read вместо offerer-эксклюзивного, а реестр поставщиков показывал только username вместо ФИО/названия организации
Typecheck / desktop (pull_request) Failing after 10m6s
Typecheck / controller (pull_request) Failing after 9m42s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ant
2026-07-20 19:27:09 +00:00
parent fec6218eba
commit 0bf986e1a9
8 changed files with 107 additions and 32 deletions
+16 -10
View File
@@ -73,7 +73,13 @@ import { registerMarketplaceProcessInfoHandlers } from './app/extensions'
* на резолверах бэкенда; здесь — UI-видимость + навигационный гард.
*
* Соответствие столов правам (как воспроизведена прежняя видимость по ролям):
* - market / market-supplier → `Offer:read` (любой онбординутый пайщик)
* - market → `Order:create` (orderer-эксклюзивный грант,
* НЕ выдаётся admin/board — иначе председатель видел бы рабочие страницы
* заказчика до подписи своей персональной оферты, см. ниже)
* - market-supplier → `Offer:create:own` (offerer-эксклюзивный грант;
* НЕ `Offer:read` — это право есть и у orderer, и у admin (каталог/модерация),
* поэтому им нельзя гейтить рабочие страницы поставщика — председатель без
* одобренного допуска видел бы «Мои предложения» и т.д., см. ниже)
* - market-pvz → `Warehouse:read:own-KU` (оператор КУ + админ)
* - market-admin → `Order:read:all` (совет + председатель),
* кроме `Whitelist:manage` (только председатель) и `Extension:configure`
@@ -317,7 +323,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
// Онбординг поставщика: backend выдаёт `Onboarding:offerer`
// пайщику без одобренного допуска в реестре — тогда виден только
// этот экран (выбор модели + реквизиты договора + заявка). После
// одобрения offerer-роль даёт `Offer:read` и открывает стол.
// одобрения offerer-роль даёт `Offer:create:own` и открывает стол.
// Зеркало L3-гейта заказчика (marketplace-onboarding-member-cpp).
path: 'onboarding',
name: 'marketplace-onboarding-supplier',
@@ -342,7 +348,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Мои предложения',
icon: 'fa-solid fa-clipboard-list',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -358,7 +364,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Создать предложение',
icon: 'fa-solid fa-plus-circle',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
hidden: true,
@@ -374,7 +380,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Редактирование предложения',
icon: 'fa-solid fa-pen',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
hidden: true,
@@ -390,7 +396,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Входящие заказы',
icon: 'fa-solid fa-inbox',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -406,7 +412,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Подготовка отгрузки',
icon: 'fa-solid fa-truck-ramp-box',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -424,7 +430,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Отгрузить партию',
icon: 'fa-solid fa-qrcode',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -440,7 +446,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Подпись приёмки',
icon: 'fa-solid fa-file-signature',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -456,7 +462,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Выплаты',
icon: 'fa-solid fa-money-bill-transfer',
requires: 'Offer:read',
requires: 'Offer:create:own',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -126,9 +126,9 @@ q-page.mp-role-offerer.supplier-onboarding(role="region", aria-label="Подкл
.supplier-onboarding__status-text
.text-h6 Заявка на рассмотрении
.text-body2.text-grey-7
| Ваша заявка на допуск поставщика передана председателю кооператива.
| Как только её рассмотрят, стол поставщика откроется здесь появятся
| публикация предложений и приёмка партий.
| Заявку рассмотрит председатель кооператива. После одобрения
| откроется стол поставщика там можно будет публиковать
| предложения, принимать заказы и отгружать партии.
.supplier-onboarding__contract(v-if="contractLabel")
DataRow(
label="Договор",
@@ -5,6 +5,9 @@ import { useSessionStore } from 'src/entities/Session/model';
import { BaseBadge, BaseButton, BaseInput, BaseDialog, EmptyState } from 'src/shared/ui/base';
import { TableSkeleton } from 'src/shared/ui/base/TableSkeleton';
import type { TableSkeletonColumn } from 'src/shared/ui/base/TableSkeleton';
import { IdentityCell } from 'src/shared/ui/domain';
import { getName } from 'src/shared/lib/utils/account';
import { api as accountApi } from 'src/entities/Account/api';
import {
addSupplier,
approveSupplier,
@@ -32,6 +35,34 @@ const items = ref<MarketplaceSupplierView[]>([]);
const loading = ref(false);
const acting = ref<string | null>(null);
// Резолв ФИО/наименования организации по username (реестр несёт только
// member_account) — канон из NotificationJournalWidget. Кэш на компонент.
const supplierNames = ref<Record<string, string>>({});
function supplierName(username: string): string {
return supplierNames.value[username] ?? '';
}
async function resolveSupplierNames(): Promise<void> {
const usernames = [...new Set(items.value.map((i) => i.member_account).filter(Boolean))];
const missing = usernames.filter((u) => !(u in supplierNames.value));
if (!missing.length) return;
await Promise.all(
missing.map(async (username) => {
let name = '';
try {
const account = await accountApi.getAccount(username);
name = account
? (getName(account) ?? '').replace(/undefined/g, '').replace(/\s+/g, ' ').trim()
: '';
} catch {
name = '';
}
supplierNames.value = { ...supplierNames.value, [username]: name };
}),
);
}
const skeletonColumns: TableSkeletonColumn[] = [
{ label: 'Поставщик' },
{ label: 'Модель' },
@@ -53,6 +84,7 @@ async function load(): Promise<void> {
loading.value = true;
try {
items.value = await fetchSuppliers();
void resolveSupplierNames();
} catch (e) {
FailAlert(e);
} finally {
@@ -149,7 +181,11 @@ q-page.mp-role-admin.supplier-registry(role="region", aria-label="Реестр
th.col-action Действия
tbody
tr.data-row(v-for="row in items", :key="row.id")
td.cell-name {{ row.member_account }}
td
IdentityCell(
:account-name="row.member_account",
:full-name="supplierName(row.member_account)"
)
td {{ SUPPLIER_MODEL_LABEL[row.model] || row.model }}
td {{ contractLabel(row) }}
td
@@ -0,0 +1,5 @@
export interface IdentityCellProps {
accountName: string;
fullName?: string | null;
copyable?: boolean;
}
@@ -0,0 +1,31 @@
<template lang="pug">
.identity-cell
span.identity-cell__name(v-if='fullName') {{ fullName }}
AccountBadge(:account-name='accountName', size='sm', :copyable='copyable')
</template>
<script setup lang="ts">
import { AccountBadge } from 'src/shared/ui/domain/AccountBadge';
import type { IdentityCellProps } from './IdentityCell.types';
withDefaults(defineProps<IdentityCellProps>(), {
copyable: true,
});
</script>
<style scoped>
.identity-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--p-1, 4px);
min-width: 0;
}
.identity-cell__name {
color: var(--p-ink);
font-size: var(--p-fs-body-sm, 13px);
line-height: 1.3;
overflow-wrap: break-word;
}
</style>
@@ -0,0 +1,2 @@
export { default as IdentityCell } from './IdentityCell.vue';
export type { IdentityCellProps } from './IdentityCell.types';
@@ -14,6 +14,7 @@ export * from './ExpenseProposalDocuments';
export * from './ExpenseProposalList';
export * from './FileUploader';
export * from './FilterBar';
export * from './IdentityCell';
export * from './IdentityPanel';
export * from './NotificationCenter';
export * from './OtpInput';
@@ -33,9 +33,11 @@
tr(v-for='row in store.items', :key='row.id')
td.col-date {{ formatDateToHumanDateTime(row.createdAt) }}
td.cell-recipient
template(v-if='row.recipientUsername')
span.cell-recipient__name(v-if='recipientName(row.recipientUsername)') {{ recipientName(row.recipientUsername) }}
AccountBadge(:account-name='row.recipientUsername', size='sm')
IdentityCell(
v-if='row.recipientUsername',
:account-name='row.recipientUsername',
:full-name='recipientName(row.recipientUsername)'
)
span.cell-recipient__sub(v-else) {{ row.recipientSubscriberId }}
td {{ workflowLabel(row.workflowId) }}
td {{ channelLabels[row.channel] ?? row.channel }}
@@ -64,9 +66,12 @@
.nj-card(v-for='row in store.items', :key='row.id')
.nj-card__head
.nj-card__recipient
span.nj-card__name(v-if='row.recipientUsername && recipientName(row.recipientUsername)') {{ recipientName(row.recipientUsername) }}
AccountBadge(v-if='row.recipientUsername', :account-name='row.recipientUsername', size='sm')
span.nj-card__sub(v-else-if='!row.recipientUsername') {{ row.recipientSubscriberId }}
IdentityCell(
v-if='row.recipientUsername',
:account-name='row.recipientUsername',
:full-name='recipientName(row.recipientUsername)'
)
span.nj-card__sub(v-else) {{ row.recipientSubscriberId }}
BaseBadge(:variant='statusVariant(row.status)') {{ statusLabels[row.status] ?? row.status }}
.nj-card__grid
.nj-card__cell
@@ -121,7 +126,7 @@ import { BaseButton } from 'src/shared/ui/base/BaseButton';
import { EmptyState } from 'src/shared/ui/base/EmptyState';
import { TableSkeleton } from 'src/shared/ui/base/TableSkeleton';
import type { TableSkeletonColumn } from 'src/shared/ui/base/TableSkeleton';
import { AccountBadge } from 'src/shared/ui/domain/AccountBadge';
import { IdentityCell } from 'src/shared/ui/domain/IdentityCell';
import { FilterBar } from 'src/shared/ui/domain/FilterBar';
import type { FilterDefinition, FilterValues } from 'src/shared/ui/domain/FilterBar';
import { formatDateToHumanDateTime } from 'src/shared/lib/utils/dates/formatDateToHumanDateTime';
@@ -315,11 +320,6 @@ onMounted(() => {
align-items: flex-start;
gap: 4px;
}
.cell-recipient__name {
color: var(--p-ink);
font-size: var(--p-fs-body-sm, 13px);
line-height: 1.3;
}
.cell-recipient__sub {
font-family: var(--p-mono);
font-size: var(--p-fs-mono-sm, 12px);
@@ -372,12 +372,6 @@ onMounted(() => {
gap: 2px;
min-width: 0;
}
.nj-card__name {
font-weight: 500;
color: var(--p-ink);
font-size: var(--p-fs-body-sm, 13px);
overflow-wrap: break-word;
}
.nj-card__sub {
font-family: var(--p-mono);
font-size: var(--p-fs-mono-sm, 12px);