Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Versioning policy: see [CONTRIBUTING.md](CONTRIBUTING.md#versioning).

## [Unreleased]

## [2.4.2] — 2026-06-30

### Fixed
- **Редактор снова может работать со справочниками и реестром.** У пользователя с ролью **Редактор** в меню был только «Профиль», а страница управления **Компаниями** была закрыта (admin-only гард), хотя бэкенд с 2.3.0 разрешает редактору создавать компании. Теперь:
- в меню редактора (и наблюдателя) есть пункт **«Реестр»**, а у редактора — ещё и **«Компании»**;
- страница **«Компании»** доступна редактору: он может **создавать и просматривать** компании; **редактирование и архивирование — только администратор** (колонка действий скрыта для не-админов — зеркалит RBAC бэкенда: `POST /assets` = редактор+админ, `PATCH`/архив = админ);
- **Типы документов** остаются только у администратора (тип задаёт глобальное расписание уведомлений) — редактор по-прежнему выбирает из готовых типов при создании документа.

> Только фронтенд: бэкенд, схема БД и публичный контракт не менялись (бэкенд уже разрешал редактору создавать компании). PATCH согласно [политике версионирования](CONTRIBUTING.md#versioning) — устранение дефекта доступа.

## [2.4.1] — 2026-06-30

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lotsman/web",
"version": "2.4.1",
"version": "2.4.2",
"license": "BUSL-1.1",
"private": true,
"type": "module",
Expand Down
6 changes: 4 additions & 2 deletions web/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ function GuardedAdminUsers() {
function GuardedAdminAssets() {
return (
<AuthGuard>
{/* biome-ignore lint/a11y/useValidAriaRole: role is a RoleGuard custom prop, not an HTML attribute */}
<RoleGuard role="admin" fallback={adminFallback}>
{/* Companies are managed by editors too (create + view); the page itself
gates edit/archive to admins. biome-ignore lint/a11y/useValidAriaRole:
role is a RoleGuard custom prop, not an HTML attribute */}
<RoleGuard role={["admin", "editor"]} fallback={adminFallback}>
<AssetsPage />
</RoleGuard>
</AuthGuard>
Expand Down
11 changes: 6 additions & 5 deletions web/src/features/auth/RoleGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@
* navigating to /admin/users gets a 403 from the API, not a confusing redirect.
*
* Usage:
* <RoleGuard role="admin">
* <AdminUsersPage />
* </RoleGuard>
* <RoleGuard role="admin">…</RoleGuard>
* <RoleGuard role={["admin", "editor"]}>…</RoleGuard>
*/

import type * as React from "react";
import { useAuth } from "./AuthProvider";
import type { UserRole } from "./types";

interface RoleGuardProps {
role: UserRole;
/** A single allowed role, or any-of a list of allowed roles. */
role: UserRole | UserRole[];
children: React.ReactNode;
/** Optional fallback rendered when role does not match */
fallback?: React.ReactNode;
Expand All @@ -29,7 +29,8 @@ export function RoleGuard({ role, children, fallback = null }: RoleGuardProps) {
const { claims } = useAuth();

if (!claims) return null;
if (claims.role !== role) return <>{fallback}</>;
const allowed = Array.isArray(role) ? role : [role];
if (!allowed.includes(claims.role)) return <>{fallback}</>;

return <>{children}</>;
}
66 changes: 39 additions & 27 deletions web/src/pages/admin/assets/AssetsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// Copyright (c) 2026 Maximilian Kaufmann. See LICENSE (Business Source License 1.1).

/**
* AssetsPage — admin-only (RoleGuard) management of partner companies.
* US-12..US-15: list, create, edit, archive assets.
* AssetsPage — management of partner companies (Компании).
* Editors and admins can list + create; editing and archiving are admin-only
* (the «Действия» column is hidden for non-admins, mirroring the backend RBAC:
* POST /assets = editor+admin, PATCH/archive = admin). US-12..US-15.
*/

import {
Expand All @@ -16,6 +18,7 @@ import { format, parseISO } from "date-fns";
import { ru } from "date-fns/locale";
import { Archive, Pencil, Plus, RefreshCw } from "lucide-react";
import * as React from "react";
import { useAuth } from "@/features/auth/AuthProvider";
import {
useArchiveAsset,
useAssets,
Expand Down Expand Up @@ -45,6 +48,9 @@ export function AssetsPage() {
};
}, [q]);

const { claims } = useAuth();
const isAdmin = claims?.role === "admin";

const { data, isLoading, isError, refetch } = useAssets(debouncedQ ? { q: debouncedQ } : {});
const createMutation = useCreateAsset();
const patchMutation = usePatchAsset();
Expand Down Expand Up @@ -84,32 +90,38 @@ export function AssetsPage() {
</span>
),
}),
columnHelper.display({
id: "actions",
header: "Действия",
cell: ({ row }) => (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setEditAsset(row.original)}
aria-label={`Редактировать ${row.original.name}`}
className="rounded p-1.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil className="size-4" aria-hidden />
</button>
<button
type="button"
onClick={() => setArchiveAsset(row.original)}
aria-label={`Архивировать ${row.original.name}`}
className="rounded p-1.5 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Archive className="size-4" aria-hidden />
</button>
</div>
),
}),
// Edit + archive are admin-only — hide the «Действия» column for editors,
// who can create + view companies but not mutate existing ones.
...(isAdmin
? [
columnHelper.display({
id: "actions",
header: "Действия",
cell: ({ row }) => (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setEditAsset(row.original)}
aria-label={`Редактировать ${row.original.name}`}
className="rounded p-1.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil className="size-4" aria-hidden />
</button>
<button
type="button"
onClick={() => setArchiveAsset(row.original)}
aria-label={`Архивировать ${row.original.name}`}
className="rounded p-1.5 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Archive className="size-4" aria-hidden />
</button>
</div>
),
}),
]
: []),
],
[],
[isAdmin],
);

const table = useReactTable({
Expand Down
56 changes: 44 additions & 12 deletions web/src/shared/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

import { Link } from "@tanstack/react-router";
import {
Building2,
Calendar,
ChevronDown,
ClipboardList,
Compass,
LogOut,
Radio,
Expand Down Expand Up @@ -150,6 +152,32 @@ function UserMenu() {
</span>
</div>

{/* Registry — primary workspace, available to all roles */}
{!isSuperAdmin && (
<Link
to="/registry"
search={{
q: undefined,
type_code: undefined,
status: undefined,
asset_id: undefined,
show_archived: undefined,
sort: undefined,
dir: undefined,
page: undefined,
}}
role="menuitem"
onClick={() => setOpen(false)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-sm",
"hover:bg-accent focus-visible:outline-none focus-visible:bg-accent",
)}
>
<ClipboardList className="h-4 w-4" aria-hidden />
{t("nav.registry")}
</Link>
)}

{/* Profile link */}
<Link
to="/profile"
Expand All @@ -164,6 +192,22 @@ function UserMenu() {
{t("nav.profile")}
</Link>

{/* Companies — editors and admins manage companies (create + view) */}
{(claims.role === "admin" || claims.role === "editor") && (
<Link
to="/admin/assets"
role="menuitem"
onClick={() => setOpen(false)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-sm",
"hover:bg-accent focus-visible:outline-none focus-visible:bg-accent",
)}
>
<Building2 className="h-4 w-4" aria-hidden />
{t("nav.admin_assets")}
</Link>
)}

{/* Admin links — admin only (NOT super_admin) */}
{claims.role === "admin" && (
<>
Expand All @@ -179,18 +223,6 @@ function UserMenu() {
<Shield className="h-4 w-4" aria-hidden />
{t("nav.admin_users")}
</Link>
<Link
to="/admin/assets"
role="menuitem"
onClick={() => setOpen(false)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-sm",
"hover:bg-accent focus-visible:outline-none focus-visible:bg-accent",
)}
>
<Shield className="h-4 w-4" aria-hidden />
{t("nav.admin_assets")}
</Link>
<Link
to="/admin/document-types"
role="menuitem"
Expand Down
Loading