NODEDC_LAUNCHER/src/widgets/admin-overlay/AdminOverlay.tsx

4644 lines
182 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DraggableAttributes,
type DraggableSyntheticListeners,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Building2,
Check,
ClipboardList,
Copy,
DatabaseZap,
Edit3,
Globe2,
GripVertical,
HardDrive,
Image as ImageIcon,
KeyRound,
LayoutDashboard,
Link2,
ListChecks,
MailPlus,
Maximize2,
Minimize2,
Plus,
RefreshCw,
Save,
ShieldCheck,
SlidersHorizontal,
Trash2,
UsersRound,
Video,
X,
} from "lucide-react";
import type { AccessRequest, AccessRequestStatus } from "../../entities/access-request/types";
import type { ServiceAppRole } from "../../entities/access/types";
import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types";
import type { Invite, InviteStatus } from "../../entities/invite/types";
import { PUBLIC_POOL_CLIENT_ID, PUBLIC_POOL_CONTEXT_DESCRIPTION, PUBLIC_POOL_CONTEXT_LABEL, isPublicPoolClientId } from "../../entities/public-pool/constants";
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
import type { SyncState, SyncStatus } from "../../entities/sync/types";
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
import type {
ClientGroup,
ClientMembership,
ClientMembershipRole,
ClientMembershipStatus,
LauncherUser,
LauncherUserStatus,
} from "../../entities/user/types";
import {
buildAccessMatrix,
getClient,
getClientUsers,
getService,
getUser,
type AccessMatrixCell,
type LauncherData,
type LauncherSettings,
type MeResponse,
type TaskManagerWorkspaceCreationPolicy,
} from "../../shared/api/mockApi";
import type { TaskManagerProjectSummary, TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
import { uploadStorageFile } from "../../shared/api/storageApi";
import { cn } from "../../shared/lib/cn";
import { formatDate, formatDateTime } from "../../shared/lib/format";
import { NodeDcDateField, NodeDcDeleteModal, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
import { Button, IconButton } from "../../shared/ui/Button";
import { GlassSurface } from "../../shared/ui/Glass";
type AdminSection =
| "overview"
| "clients"
| "users"
| "groups"
| "services"
| "access"
| "invites"
| "sync"
| "audit"
| "misc"
| "company";
type AdminOverlayMode = "admin" | "platform";
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
type OperationalCoreRoleSelectValue = AccessAssignmentValue | "pending";
export interface SetUserServiceAccessCommand {
userId: string;
serviceId: string;
value: AccessAssignmentValue;
}
export interface CreateUserCommand {
clientId: string;
email: string;
name?: string;
role: ClientMembershipRole;
groupIds: string[];
provisionAuth: boolean;
generatePassword: boolean;
}
export interface EnsureTaskManagerWorkspaceMemberCommand {
clientId: string;
userId: string;
workspaceSlug?: string;
role: TaskManagerWorkspaceMemberRole;
}
export interface EnsureTaskManagerProjectMemberCommand {
clientId: string;
userId: string;
workspaceSlug: string;
projectId: string;
role: TaskManagerWorkspaceMemberRole;
}
const platformSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
{ id: "clients", label: "Компании", icon: <Building2 size={16} /> },
{ id: "users", label: "Пользователи", icon: <UsersRound size={16} /> },
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
{ id: "misc", label: "Разное", icon: <SlidersHorizontal size={16} /> },
];
const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
];
const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
{ id: "misc", label: "Разное", icon: <SlidersHorizontal size={16} /> },
];
export function AdminOverlay({
data,
me,
mode,
activeClientId,
onClose,
onSetUserServiceAccess,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
onUpdateAccessRequest,
onApproveAccessRequest,
onRejectAccessRequest,
onApproveTaskerInviteRequest,
onRejectTaskerInviteRequest,
onRetrySync,
onCreateClient,
onUpdateClient,
onDeleteClient,
onCreateUser,
onUpdateUser,
onDeleteUser,
onUpdateMembership,
onDeleteMembership,
pendingAccessAssignments,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
onUpdateService,
onReorderServices,
onCreateService,
onDeleteService,
onUpdateSettings,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
pendingTaskManagerMemberships,
pendingTaskManagerProjectMemberships,
onRefreshTaskManagerWorkspaces,
onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole,
}: {
data: LauncherData;
me: MeResponse;
mode: AdminOverlayMode;
activeClientId: string;
onClose: () => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
onUpdateAccessRequest: (
accessRequestId: string,
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
) => void;
onApproveAccessRequest: (
accessRequestId: string,
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
) => void;
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onRetrySync: (syncId: string) => void;
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
onCreateUser: (command: CreateUserCommand) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onDeleteUser: (userId: string) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
onDeleteService: (serviceId: string) => void;
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
pendingTaskManagerMemberships: Record<string, boolean>;
pendingTaskManagerProjectMemberships: Record<string, boolean>;
onRefreshTaskManagerWorkspaces: () => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
}) {
const isRoot = me.launcherRole === "root_admin";
const isPlatformMode = isRoot && mode === "platform";
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
const [selectedCompanyClientId, setSelectedCompanyClientId] = useState(
data.clients.some((client) => client.id === activeClientId) ? activeClientId : data.clients[0]?.id ?? activeClientId
);
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
const selectedCompanyClientExists = data.clients.some((client) => client.id === selectedCompanyClientId);
const scopedCompanyClientId = selectedCompanyClientExists ? selectedCompanyClientId : fallbackClientId;
const selectedContextIsPublicPool = isRoot && isPublicPoolClientId(selectedClientId);
const selectedClientExists = selectedContextIsPublicPool || data.clients.some((client) => client.id === selectedClientId);
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
const isPublicPoolContext = isRoot && isPublicPoolClientId(scopedClientId);
const currentClient = getClient(data, scopedClientId);
const selectedCompanyClient = getClient(data, scopedCompanyClientId);
const sections = isPlatformMode ? platformSections : isPublicPoolContext ? publicPoolSections : clientSections;
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
const selectedAccessCell =
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
accessMatrix.cells[0] ??
null;
useEffect(() => {
if (isRoot && !selectedClientExists && data.clients.length) {
setSelectedClientId(data.clients[0].id);
setSelectedCell(null);
}
}, [data.clients, isRoot, selectedClientExists]);
useEffect(() => {
if (isRoot && !selectedCompanyClientExists && data.clients.length) {
setSelectedCompanyClientId(data.clients[0].id);
}
}, [data.clients, isRoot, selectedCompanyClientExists]);
useEffect(() => {
if (activeSection && !sections.some((section) => section.id === activeSection)) {
setActiveSection(sections[0]?.id ?? null);
}
}, [activeSection, sections]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
useEffect(() => {
if (!activeSection) setIsContentFullscreen(false);
}, [activeSection]);
return (
<div
className={cn(
"admin-panel-layer",
activeSection && "admin-panel-layer--content-open",
isContentFullscreen && "admin-panel-layer--fullscreen"
)}
>
<aside className="admin-panel-nav">
<div className="admin-panel-nav__head">
<div>
<p className="eyebrow">NODE.DC</p>
<h2>{isPlatformMode ? "Платформа" : "Администрирование"}</h2>
</div>
<IconButton label={isPlatformMode ? "Закрыть платформу" : "Закрыть администрирование"} className="admin-panel-close" onClick={onClose}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
{isPlatformMode ? (
<div className="admin-panel-client-select admin-panel-client-select--static">
<span className="admin-panel-client-select__icon">
<ShieldCheck size={16} />
</span>
<span className="admin-panel-client-select__name">Root Admin</span>
</div>
) : isRoot ? (
<div className="admin-panel-context-switcher">
<button
className={cn("admin-panel-client-select", selectedContextIsPublicPool && "admin-panel-client-select--active")}
type="button"
aria-pressed={selectedContextIsPublicPool}
onClick={() => {
setSelectedClientId(PUBLIC_POOL_CLIENT_ID);
setSelectedCell(null);
}}
>
<span className="admin-panel-client-select__icon">
<Globe2 size={16} />
</span>
<span className="admin-panel-client-select__body">
<span className="admin-panel-client-select__name">{PUBLIC_POOL_CONTEXT_LABEL}</span>
<span className="admin-panel-client-select__description">{PUBLIC_POOL_CONTEXT_DESCRIPTION}</span>
</span>
</button>
<div className="admin-panel-context-group">
<span className="admin-panel-context-group__label">Компании</span>
<NodeDcSelect
value={scopedCompanyClientId}
options={data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))}
label="Выбрать компанию"
searchable
minMenuWidth={292}
onChange={(clientId) => {
setSelectedCompanyClientId(clientId);
setSelectedClientId(clientId);
setSelectedCell(null);
}}
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
<div
ref={setTriggerRef}
className={cn(
"admin-panel-client-select",
"admin-panel-client-select--company",
!selectedContextIsPublicPool && "admin-panel-client-select--active"
)}
aria-expanded={open}
>
<button
className="admin-panel-client-select__main"
type="button"
aria-pressed={!selectedContextIsPublicPool}
onClick={() => {
setSelectedClientId(scopedCompanyClientId);
setSelectedCell(null);
}}
>
<span className="admin-panel-client-select__icon">
<Building2 size={16} />
</span>
<span className="admin-panel-client-select__body">
<span className="admin-panel-client-select__name">{selectedOption?.label ?? selectedCompanyClient.name}</span>
{selectedOption?.description ? (
<span className="admin-panel-client-select__description">{selectedOption.description}</span>
) : null}
</span>
</button>
<button
className="admin-panel-client-select__toggle"
type="button"
aria-label="Выбрать другую компанию"
aria-expanded={open}
onClick={(event) => {
event.stopPropagation();
toggle();
}}
>
<span className="admin-panel-client-select__chevron" aria-hidden="true" />
</button>
</div>
)}
/>
</div>
</div>
) : (
<div className="admin-panel-client-select">
<span className="admin-panel-client-select__icon">
<Building2 size={16} />
</span>
<span className="admin-panel-client-select__name">{currentClient.name}</span>
</div>
)}
<nav className="admin-panel-nav-list">
{sections.map((section) => (
<button
key={section.id}
className={cn("admin-panel-nav-item", activeSection === section.id && "admin-panel-nav-item--active")}
type="button"
onClick={() => setActiveSection((current) => (current === section.id ? null : section.id))}
>
<span className="admin-panel-nav-item__icon">{section.icon}</span>
<span>{section.label}</span>
</button>
))}
</nav>
<div className="admin-panel-role">
<span className="admin-panel-nav-item__icon">
<ShieldCheck size={16} />
</span>
<span>{roleLabel(me.launcherRole)}</span>
</div>
</aside>
{activeSection ? (
<section className="admin-panel-content">
<AdminHeader
isFullscreen={isContentFullscreen}
onToggleFullscreen={() => setIsContentFullscreen((current) => !current)}
onCloseContent={() => setActiveSection(null)}
/>
<div className="admin-panel-content__body">
{activeSection === "overview" ? (
<OverviewSection
data={data}
clientId={scopedClientId}
isPlatformMode={isPlatformMode}
isPublicPoolContext={isPublicPoolContext}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onUpdateClient={onUpdateClient}
/>
) : null}
{activeSection === "clients" && isPlatformMode ? (
<ClientsSection
data={data}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onCreateClient={onCreateClient}
onUpdateClient={onUpdateClient}
onDeleteClient={onDeleteClient}
/>
) : null}
{activeSection === "users" ? (
isPlatformMode ? (
<PlatformUsersSection data={data} onUpdateUser={onUpdateUser} onDeleteUser={onDeleteUser} />
) : (
<UsersSection
data={data}
clientId={scopedClientId}
onCreateUser={onCreateUser}
onUpdateUser={onUpdateUser}
/>
)
) : null}
{activeSection === "groups" ? (
<GroupsSection
data={data}
clientId={scopedClientId}
onCreateGroup={onCreateGroup}
onUpdateGroup={onUpdateGroup}
onDeleteGroup={onDeleteGroup}
/>
) : null}
{activeSection === "services" && isRoot ? (
<ServicesSection
data={data}
isPublicPoolContext={isPublicPoolContext}
onUpdateService={onUpdateService}
onReorderServices={onReorderServices}
onCreateService={onCreateService}
onDeleteService={onDeleteService}
/>
) : null}
{activeSection === "access" ? (
<AccessSection
data={data}
matrix={accessMatrix}
selectedCell={selectedAccessCell}
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
onSetUserServiceAccess={onSetUserServiceAccess}
pendingAccessAssignments={pendingAccessAssignments}
onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
taskManagerWorkspaceCatalog={taskManagerWorkspaces}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
/>
) : null}
{activeSection === "invites" ? (
<InvitesSection
data={data}
clientId={scopedClientId}
actorUserId={me.user.id}
isPublicPoolContext={isPublicPoolContext}
onCreateInvite={onCreateInvite}
onUpdateInvite={onUpdateInvite}
onDeleteInvite={onDeleteInvite}
onUpdateAccessRequest={onUpdateAccessRequest}
onApproveAccessRequest={onApproveAccessRequest}
onRejectAccessRequest={onRejectAccessRequest}
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
/>
) : null}
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
</div>
</section>
) : null}
</div>
);
}
function AdminHeader({
isFullscreen,
onToggleFullscreen,
onCloseContent,
}: {
isFullscreen: boolean;
onToggleFullscreen: () => void;
onCloseContent: () => void;
}) {
return (
<div className="admin-header">
<div className="admin-header__actions">
<IconButton
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
className={cn("admin-circle-action", isFullscreen && "admin-circle-action--active")}
type="button"
onClick={onToggleFullscreen}
aria-pressed={isFullscreen}
>
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</IconButton>
<IconButton label="Закрыть панель раздела" className="admin-circle-action admin-content-close" type="button" onClick={onCloseContent}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
</div>
);
}
function OverviewSection({
data,
clientId,
isPlatformMode,
isPublicPoolContext,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
onRefreshTaskManagerWorkspaces,
onUpdateClient,
}: {
data: LauncherData;
clientId: string;
isPlatformMode: boolean;
isPublicPoolContext: boolean;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
onRefreshTaskManagerWorkspaces: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
}) {
const client = getClient(data, clientId);
const clientUsers = getClientUsers(data, clientId);
const clientServiceCount = data.grants.filter((grant) => grant.targetType === "client" && grant.targetId === clientId).length;
const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isPlatformMode || sync.objectId === clientId)).length;
const newAccessRequests = data.accessRequests.filter((request) => request.status === "new").length;
const approvedAccessRequests = data.accessRequests.filter((request) => request.status === "approved").length;
const publicInvites = data.invites.filter((invite) => invite.clientId === PUBLIC_POOL_CLIENT_ID).length;
if (isPublicPoolContext) {
return (
<section className="admin-section-grid">
<MetricCard label="Входящих заявок" value={newAccessRequests} hint="Ожидают approve" danger={newAccessRequests > 0} />
<MetricCard label="Public участников" value={clientUsers.length} hint="Открытый контур" />
<MetricCard label="Public инвайтов" value={publicInvites} hint="Исходящие ссылки" />
<MetricCard label="Подтверждено" value={approvedAccessRequests} hint="Заявки public pool" />
<GlassSurface className="admin-wide-card">
<h3>Public access pool</h3>
<p className="admin-helper-note">
Это системный контур для свободных запросов доступа. Заявки можно оставить в открытом контуре или вручную назначить
в enterprise-клиента перед выпуском инвайта.
</p>
<div className="activity-list">
{data.auditEvents.slice(0, 5).map((event) => (
<div key={event.id} className="activity-row">
<span>{formatDateTime(event.at)}</span>
<strong>{event.action}</strong>
<em>{event.objectName}</em>
</div>
))}
</div>
</GlassSurface>
</section>
);
}
if (isPlatformMode) {
return (
<section className="admin-section-grid">
<MetricCard label="Компаний" value={data.clients.length} hint="Контуры платформы" />
<MetricCard label="Активных сервисов" value={data.services.filter((service) => service.status === "active").length} hint="В каталоге" />
<MetricCard label="Клиентских грантов" value={data.grants.filter((grant) => grant.targetType === "client").length} hint="Назначения контуров" />
<MetricCard label="Ошибок sync" value={syncErrors} hint="Требуют внимания" danger={syncErrors > 0} />
<GlassSurface className="admin-wide-card">
<h3>Последние действия платформы</h3>
<div className="activity-list">
{data.auditEvents.slice(0, 5).map((event) => (
<div key={event.id} className="activity-row">
<span>{formatDateTime(event.at)}</span>
<strong>{event.action}</strong>
<em>{event.objectName}</em>
</div>
))}
</div>
</GlassSurface>
</section>
);
}
return (
<section className="admin-section-grid">
<MetricCard label="Участников" value={clientUsers.length} hint="Текущий контур" />
<MetricCard label="Активных сервисов" value={data.services.filter((service) => service.status === "active").length} hint="В каталоге" />
<MetricCard label="Подключений клиента" value={clientServiceCount} hint="Гранты клиента" />
<MetricCard label="Ошибок sync" value={syncErrors} hint="Требуют внимания" danger={syncErrors > 0} />
<GlassSurface className="admin-wide-card client-profile-card">
<div className="table-toolbar client-profile-card__head">
<div>
<p className="eyebrow">Клиент</p>
<h3>Профиль контура</h3>
</div>
<IconButton
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
className="admin-circle-action admin-circle-action--solid"
type="button"
disabled={taskManagerWorkspacesLoading}
onClick={onRefreshTaskManagerWorkspaces}
>
<RefreshCw size={16} />
</IconButton>
</div>
<ClientProfileEditorForm
client={client}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onSave={(patch) => onUpdateClient(client.id, patch)}
/>
</GlassSurface>
<GlassSurface className="admin-wide-card">
<h3>Последние действия</h3>
<div className="activity-list">
{data.auditEvents.slice(0, 5).map((event) => (
<div key={event.id} className="activity-row">
<span>{formatDateTime(event.at)}</span>
<strong>{event.action}</strong>
<em>{event.objectName}</em>
</div>
))}
</div>
</GlassSurface>
</section>
);
}
function ClientsSection({
data,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
onRefreshTaskManagerWorkspaces,
onCreateClient,
onUpdateClient,
onDeleteClient,
}: {
data: LauncherData;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
onRefreshTaskManagerWorkspaces: () => void;
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
}) {
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null;
return (
<>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<h3>Компании</h3>
<IconButton label="Добавить компанию" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateClient}>
<Plus size={17} />
</IconButton>
</div>
<table className="admin-data-table">
<thead>
<tr>
<th>Название</th>
<th>Тип</th>
<th>Статус</th>
<th>Участников</th>
<th>Demo</th>
<th>Контакт</th>
<th aria-label="Редактирование" />
</tr>
</thead>
<tbody>
{data.clients.map((client) => (
<tr key={client.id}>
<td className="services-admin-table__service">
<input
className="admin-table-input admin-table-input--strong"
value={client.name}
onChange={(event) => onUpdateClient(client.id, { name: event.target.value })}
aria-label={`Название клиента ${client.name}`}
/>
<input
className="admin-table-input admin-table-input--muted"
value={client.legalName ?? ""}
onChange={(event) => onUpdateClient(client.id, { legalName: event.target.value || null })}
aria-label={`Юридическое название ${client.name}`}
/>
</td>
<td>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={client.type}
options={clientTypeOptions}
label={`Тип клиента ${client.name}`}
minMenuWidth={156}
onChange={(type) => onUpdateClient(client.id, { type })}
/>
</td>
<td>
<AdminStatusDropdown
value={client.status}
options={clientStatusOptions}
label={`Статус клиента ${client.name}`}
onChange={(status) => onUpdateClient(client.id, { status })}
/>
</td>
<td>{data.memberships.filter((membership) => membership.clientId === client.id).length}</td>
<td>
<NodeDcDateField
value={client.demoEndsAt ?? null}
label={`Demo до ${client.name}`}
onChange={(value) => onUpdateClient(client.id, { demoEndsAt: value })}
/>
</td>
<td>
<input
className="admin-table-input"
value={client.contactEmail ?? ""}
onChange={(event) => onUpdateClient(client.id, { contactEmail: event.target.value || null })}
aria-label={`Контакт клиента ${client.name}`}
/>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Редактировать клиента ${client.name}`}
className="admin-icon-action services-admin-table__edit"
type="button"
onClick={() => setEditingClientId(client.id)}
>
<Edit3 size={12} />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</GlassSurface>
{editingClient ? (
<ClientEditorModal
client={editingClient}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onClose={() => setEditingClientId(null)}
onSave={(patch) => {
onUpdateClient(editingClient.id, patch);
setEditingClientId(null);
}}
onDelete={() => {
onDeleteClient(editingClient.id);
setEditingClientId(null);
}}
canDelete={data.clients.length > 1}
/>
) : null}
</>
);
}
function UsersSection({
data,
clientId,
onCreateUser,
onUpdateUser,
}: {
data: LauncherData;
clientId: string;
onCreateUser: (command: CreateUserCommand) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
}) {
const [newUserEmail, setNewUserEmail] = useState("");
const [newUserName, setNewUserName] = useState("");
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
const [newUserGroupId, setNewUserGroupId] = useState<string>("none");
const rows = data.memberships
.filter((membership) => membership.clientId === clientId)
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
const groupOptions: Array<NodeDcSelectOption<string>> = [
{ value: "none", label: "Без группы" },
...clientGroups.map((group) => ({ value: group.id, label: group.name })),
];
function handleCreateUser() {
const email = newUserEmail.trim();
if (!email) {
return;
}
onCreateUser({
clientId,
email,
name: newUserName.trim() || undefined,
role: newUserRole,
groupIds: newUserGroupId === "none" ? [] : [newUserGroupId],
provisionAuth: true,
generatePassword: true,
});
setNewUserEmail("");
setNewUserName("");
setNewUserRole("member");
setNewUserGroupId("none");
}
return (
<>
<GlassSurface className="invite-form invite-form--compact">
<div className="table-toolbar">
<div>
<p className="eyebrow">Launcher Authentik</p>
<h3>Создать участника</h3>
</div>
<IconButton
label="Создать участника"
className="admin-circle-action admin-circle-action--solid"
type="button"
disabled={!newUserEmail.trim()}
onClick={handleCreateUser}
>
<Plus size={17} />
</IconButton>
</div>
<div className="invite-form__fields">
<input value={newUserEmail} onChange={(event) => setNewUserEmail(event.target.value)} placeholder="email@company.ru" />
<input value={newUserName} onChange={(event) => setNewUserName(event.target.value)} placeholder="Имя пользователя" />
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={newUserRole}
options={membershipRoleOptions}
label="Роль"
onChange={(role) => setNewUserRole(role)}
/>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={newUserGroupId}
options={groupOptions}
label="Группа"
onChange={(groupId) => setNewUserGroupId(groupId)}
/>
</div>
</GlassSurface>
<GlassSurface className="table-shell table-shell--users">
<div className="table-toolbar">
<h3>Участники</h3>
</div>
<table className="admin-data-table admin-data-table--users">
<thead>
<tr>
<th>Пользователь</th>
<th>Клиент</th>
<th>Кто пригласил</th>
<th>Телефон</th>
<th>Должность</th>
<th>Заметки</th>
<th>Статус аккаунта</th>
</tr>
</thead>
<tbody>
{rows.map(({ membership, user, client }) => {
const protectedUser = user.id === "user_root";
return (
<tr key={membership.id}>
<td className="admin-user-cell">
<div className="admin-user-cell__fields">
<input
className="admin-table-input admin-table-input--strong"
value={user.name}
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
aria-label={`Имя пользователя ${user.name}`}
/>
<input
className="admin-table-input admin-table-input--muted"
value={user.email}
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
aria-label={`Email пользователя ${user.name}`}
/>
</div>
</td>
<td>{client.name}</td>
<td>
<MembershipInviterCell data={data} membership={membership} />
</td>
<td>
<input
className="admin-table-input"
value={user.phone ?? ""}
onChange={(event) => onUpdateUser(user.id, { phone: event.target.value || null })}
placeholder="—"
aria-label={`Телефон пользователя ${user.name}`}
/>
</td>
<td>
<input
className="admin-table-input"
value={user.position ?? ""}
onChange={(event) => onUpdateUser(user.id, { position: event.target.value || null })}
placeholder="—"
aria-label={`Должность пользователя ${user.name}`}
/>
</td>
<td>
<input
className="admin-table-input"
value={user.notes ?? ""}
onChange={(event) => onUpdateUser(user.id, { notes: event.target.value || null })}
placeholder="—"
aria-label={`Заметки пользователя ${user.name}`}
/>
</td>
<td>
{protectedUser ? (
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
) : (
<AdminStatusDropdown
value={user.globalStatus}
options={userStatusOptions}
label={`Статус аккаунта ${user.name}`}
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</GlassSurface>
</>
);
}
function PlatformUsersSection({
data,
onUpdateUser,
onDeleteUser,
}: {
data: LauncherData;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onDeleteUser: (userId: string) => void;
}) {
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
const deletingUser = data.users.find((user) => user.id === deleteUserId) ?? null;
const rows = data.users
.map((user) => {
const memberships = data.memberships.filter((membership) => membership.userId === user.id);
return {
user,
memberships,
origin: getPlatformUserOrigin(data, user, memberships),
};
})
.sort((left, right) => {
if (left.user.id === "user_root") return -1;
if (right.user.id === "user_root") return 1;
return left.origin.label.localeCompare(right.origin.label, "ru") || left.user.name.localeCompare(right.user.name, "ru");
});
return (
<>
<GlassSurface className="table-shell table-shell--sticky-user-column table-shell--platform-users">
<div className="table-toolbar">
<div>
<h3>Пользователи платформы</h3>
<p className="admin-helper-note">
Глобальный реестр аккаунтов. Доступы к сервисам остаются в матрицах; здесь происхождение, статус аккаунта и полное удаление.
</p>
</div>
</div>
<table className="admin-data-table admin-data-table--sticky-user-column admin-data-table--platform-users">
<thead>
<tr>
<th>Пользователь</th>
<th>Происхождение</th>
<th>Контуры</th>
<th>Статус аккаунта</th>
<th>Создан</th>
<th aria-label="Удаление" />
</tr>
</thead>
<tbody>
{rows.map(({ user, memberships, origin }) => {
const protectedUser = user.id === "user_root";
const contextLabel =
memberships.length === 0
? "—"
: memberships
.map((membership) => getClient(data, membership.clientId).name)
.filter((value, index, list) => list.indexOf(value) === index)
.join(", ");
return (
<tr key={user.id}>
<td className="admin-user-cell">
<div className="admin-user-cell__fields">
<input
className="admin-table-input admin-table-input--strong"
value={user.name}
disabled={protectedUser}
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
aria-label={`Имя пользователя ${user.name}`}
/>
<input
className="admin-table-input admin-table-input--muted"
value={user.email}
disabled={protectedUser}
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
aria-label={`Email пользователя ${user.name}`}
/>
</div>
</td>
<td>
<div className="platform-user-origin">
<strong>{origin.label}</strong>
{origin.detail ? <small>{origin.detail}</small> : null}
</div>
</td>
<td>
<span className="admin-table-text" title={contextLabel}>
{contextLabel}
</span>
</td>
<td>
{protectedUser ? (
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
) : (
<AdminStatusDropdown
value={user.globalStatus}
options={userStatusOptions}
label={`Статус аккаунта ${user.name}`}
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
/>
)}
</td>
<td>{formatDate(user.createdAt)}</td>
<td className="services-admin-table__actions">
{protectedUser ? (
<span className="admin-table-action-placeholder" />
) : (
<IconButton
label={`Удалить пользователя ${user.email} полностью`}
className="admin-icon-action invite-icon-action"
type="button"
onClick={() => setDeleteUserId(user.id)}
>
<Trash2 size={12} />
</IconButton>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</GlassSurface>
<NodeDcDeleteModal
isOpen={Boolean(deletingUser)}
title="Удалить пользователя полностью"
description={
<>
Будут удалены профиль <strong>{deletingUser?.email}</strong>, членства, сервисные доступы, заявки, инвайты и sync-записи.
Действие необратимо.
</>
}
confirmLabel="Удалить везде"
onClose={() => setDeleteUserId(null)}
onConfirm={() => {
if (deletingUser) onDeleteUser(deletingUser.id);
setDeleteUserId(null);
}}
/>
</>
);
}
function GroupsSection({
data,
clientId,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
}: {
data: LauncherData;
clientId: string;
onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
}) {
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const groups = data.groups.filter((group) => group.clientId === clientId);
const editingGroup = groups.find((group) => group.id === editingGroupId) ?? null;
return (
<>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<h3>Группы</h3>
<IconButton label="Создать группу" className="admin-circle-action admin-circle-action--solid" type="button" onClick={() => onCreateGroup(clientId)}>
<Plus size={17} />
</IconButton>
</div>
<table className="admin-data-table">
<thead>
<tr>
<th>Название</th>
<th>Описание</th>
<th>Участников</th>
<th>Подключённые сервисы</th>
<th aria-label="Редактирование" />
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr key={group.id}>
<td>
<input
className="admin-table-input admin-table-input--strong"
value={group.name}
onChange={(event) => onUpdateGroup(group.id, { name: event.target.value })}
aria-label={`Название группы ${group.name}`}
/>
</td>
<td>
<input
className="admin-table-input"
value={group.description ?? ""}
onChange={(event) => onUpdateGroup(group.id, { description: event.target.value || null })}
aria-label={`Описание группы ${group.name}`}
/>
</td>
<td>{group.memberIds.length}</td>
<td>{data.grants.filter((grant) => grant.targetType === "group" && grant.targetId === group.id).length}</td>
<td className="services-admin-table__actions">
<IconButton
label={`Редактировать группу ${group.name}`}
className="admin-icon-action services-admin-table__edit"
type="button"
onClick={() => setEditingGroupId(group.id)}
>
<Edit3 size={12} />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</GlassSurface>
{editingGroup ? (
<GroupEditorModal
group={editingGroup}
users={data.memberships
.filter((membership) => membership.clientId === clientId)
.map((membership) => getUser(data, membership.userId))}
onClose={() => setEditingGroupId(null)}
onSave={(patch) => {
onUpdateGroup(editingGroup.id, patch);
setEditingGroupId(null);
}}
onDelete={() => {
onDeleteGroup(editingGroup.id);
setEditingGroupId(null);
}}
/>
) : null}
</>
);
}
type AdminStatusTone = "green" | "yellow" | "red" | "violet" | "muted";
type AdminStatusOption<T extends string> = { value: T; label: string; tone: AdminStatusTone };
const serviceStatusOptions: Array<AdminStatusOption<ServiceStatus>> = [
{ value: "active", label: "Активен", tone: "green" },
{ value: "maintenance", label: "Техработы", tone: "yellow" },
{ value: "hidden", label: "Скрыт", tone: "violet" },
{ value: "disabled", label: "Отключён", tone: "red" },
];
const clientTypeOptions: Array<NodeDcSelectOption<ClientType>> = [
{ value: "company", label: "Компания" },
{ value: "person", label: "Частное лицо" },
];
const membershipRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
{ value: "client_owner", label: "Owner", description: "Владелец клиента" },
{ value: "client_admin", label: "Admin", description: "Администратор клиента" },
{ value: "member", label: "Member", description: "Пользователь" },
];
const inviteRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
{ value: "member", label: "Member" },
{ value: "client_admin", label: "Client Admin" },
];
const clientStatusOptions: Array<AdminStatusOption<ClientStatus>> = [
{ value: "active", label: "Активен", tone: "green" },
{ value: "demo", label: "Demo", tone: "yellow" },
{ value: "suspended", label: "Приостановлен", tone: "red" },
{ value: "expired", label: "Истёк", tone: "red" },
];
const userStatusOptions: Array<AdminStatusOption<LauncherUserStatus>> = [
{ value: "active", label: "Активен", tone: "green" },
{ value: "invited", label: "Приглашён", tone: "yellow" },
{ value: "blocked", label: "Заблокирован", tone: "red" },
];
const mainStatusOptions: Array<NodeDcSelectOption<LauncherUserStatus>> = [
{ value: "active", label: "Активен", tone: "green" },
{ value: "blocked", label: "Заблокирован", tone: "red" },
{ value: "invited", label: "Приглашён", tone: "yellow", hidden: true },
];
const membershipStatusOptions: Array<AdminStatusOption<ClientMembershipStatus>> = [
{ value: "active", label: "Включён", tone: "green" },
{ value: "disabled", label: "Отключён", tone: "red" },
];
const inviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = [
{ value: "created", label: "Создан", tone: "muted" },
{ value: "sent", label: "Отправлен", tone: "green" },
{ value: "accepted", label: "Принят", tone: "green" },
{ value: "expired", label: "Истёк", tone: "red" },
{ value: "revoked", label: "Отозван", tone: "red" },
];
const editableInviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = inviteStatusOptions.filter(
(option) => option.value !== "accepted"
);
const accessRequestStatusOptions: Array<AdminStatusOption<AccessRequestStatus>> = [
{ value: "new", label: "Входящая", tone: "yellow" },
{ value: "approved", label: "Подтверждено", tone: "green" },
{ value: "rejected", label: "Отклонено", tone: "red" },
];
const taskerInviteRequestStatusOptions: Array<AdminStatusOption<TaskerInviteRequest["status"]>> = [
{ value: "new", label: "Ожидает", tone: "yellow" },
{ value: "approved", label: "Подтверждено", tone: "green" },
{ value: "rejected", label: "Отклонено", tone: "red" },
{ value: "cancelled", label: "Отозвано", tone: "red" },
];
const syncStatusOptions: Array<AdminStatusOption<SyncState>> = [
{ value: "synced", label: "Синхронизировано", tone: "green" },
{ value: "pending", label: "В очереди", tone: "yellow" },
{ value: "error", label: "Ошибка", tone: "red" },
{ value: "disabled", label: "Отключено", tone: "muted" },
];
const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error">> = [
{ value: "success", label: "Успех", tone: "green" },
{ value: "warning", label: "Внимание", tone: "yellow" },
{ value: "error", label: "Ошибка", tone: "red" },
];
const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ value: "unset", label: "—", description: "Не назначен" },
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" },
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
];
const publicOperationalCoreAccessOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ value: "unset", label: "—", description: "Не назначен", hidden: true },
{ value: "viewer", label: "Workspace Guest", description: "Выдаётся только через Tasker", tone: "green", hidden: true },
{ value: "member", label: "Workspace Member", description: "Доступ к приглашённому workspace", tone: "green" },
{ value: "admin", label: "Service Admin", description: "Self-service", tone: "green" },
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
];
const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
...accessAssignmentOptions,
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
];
const taskManagerProjectRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
{ value: "unset", label: "—", description: "Не назначен" },
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" },
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
];
function membershipRoleLabel(role: ClientMembershipRole): string {
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
}
function inviteSourceLabel(invite: Invite): string {
if (invite.source === "access_request") return "Заявка доступа";
if (invite.source === "tasker_workspace_invite") return "Workspace-инвайт";
return "Ручной инвайт";
}
function inviteCanBeCopied(invite: Invite): boolean {
return invite.status === "created" || invite.status === "sent";
}
function inviteTerminalLabel(invite: Invite): string {
if (invite.status === "accepted") return "Пользователь зарегистрирован";
if (invite.status === "revoked") return "Ссылка отозвана";
if (invite.status === "expired") return "Срок истёк";
return "Ссылка недоступна";
}
function statusOptionLabel<T extends string>(options: Array<AdminStatusOption<T>>, value: T): string {
return options.find((option) => option.value === value)?.label ?? value;
}
function mainStatusLabel(value: LauncherUserStatus): string {
return mainStatusOptions.find((option) => option.value === value)?.label ?? value;
}
function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorkspaceBinding[] {
const taskManager = client.integrations?.taskManager;
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
for (const workspace of taskManager?.workspaces ?? []) {
if (!workspace.slug) continue;
bySlug.set(workspace.slug, {
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary: workspace.isPrimary === true,
managedBy: workspace.managedBy ?? "launcher",
});
}
if (taskManager?.workspaceSlug && !bySlug.has(taskManager.workspaceSlug)) {
bySlug.set(taskManager.workspaceSlug, {
slug: taskManager.workspaceSlug,
name: taskManager.workspaceName ?? null,
isPrimary: true,
managedBy: "launcher",
});
}
const workspaces = [...bySlug.values()];
if (!workspaces.length) return [];
if (!workspaces.some((workspace) => workspace.isPrimary)) {
workspaces[0].isPrimary = true;
}
let primarySeen = false;
return workspaces.map((workspace) => {
const isPrimary = workspace.isPrimary === true && !primarySeen;
if (isPrimary) primarySeen = true;
return { ...workspace, isPrimary };
});
}
function getPrimaryTaskManagerWorkspace(client: Client): ClientTaskManagerWorkspaceBinding | null {
const workspaces = getClientTaskManagerWorkspaces(client);
return workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
}
function formatClientTaskManagerWorkspaces(client: Client): string {
const workspaces = getClientTaskManagerWorkspaces(client);
if (!workspaces.length) return "—";
return workspaces
.map((workspace) => `${workspace.name ?? workspace.slug}${workspace.isPrimary ? " · основной" : ""}`)
.join(", ");
}
function getTaskManagerMembershipRole(data: LauncherData, clientId: string, userId: string, workspaceSlug: string): TaskManagerWorkspaceMemberRole {
return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset";
}
function getTaskManagerProjectMembershipRole(
data: LauncherData,
clientId: string,
userId: string,
workspaceSlug: string,
projectId: string
): TaskManagerWorkspaceMemberRole {
return (
data.taskManagerProjectMemberships.find(
(membership) =>
membership.clientId === clientId &&
membership.userId === userId &&
membership.workspaceSlug === workspaceSlug &&
membership.projectId === projectId
)?.role ?? "unset"
);
}
function getWorkspaceCatalogProjects(workspace: ClientTaskManagerWorkspaceBinding, catalog: TaskManagerWorkspaceSummary[]): TaskManagerProjectSummary[] {
return catalog.find((item) => item.slug === workspace.slug)?.projects ?? [];
}
function normalizeClientTaskManagerWorkspaceDraft(workspaces: ClientTaskManagerWorkspaceBinding[]): ClientTaskManagerWorkspaceBinding[] {
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
for (const workspace of workspaces) {
if (!workspace.slug) continue;
bySlug.set(workspace.slug, {
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary: workspace.isPrimary === true,
managedBy: workspace.managedBy ?? "launcher",
});
}
const normalized = [...bySlug.values()];
if (!normalized.length) return [];
if (!normalized.some((workspace) => workspace.isPrimary)) {
normalized[0].isPrimary = true;
}
let primarySeen = false;
return normalized.map((workspace) => {
const isPrimary = workspace.isPrimary === true && !primarySeen;
if (isPrimary) primarySeen = true;
return { ...workspace, isPrimary };
});
}
function AdminStaticPill({ children }: { children: ReactNode }) {
return <span className="admin-static-pill">{children}</span>;
}
const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWorkspaceCreationPolicy>> = [
{
value: "any_authorized_user",
label: "Все с доступом",
description: "Пользователь с доступом к Operational Core может создать собственный workspace.",
tone: "green",
},
{
value: "task_admins_only",
label: "Только админы",
description: "Workspace создают только суперпользователь и админы Operational Core.",
tone: "yellow",
},
{
value: "disabled",
label: "Отключено",
description: "Создание workspace закрыто для всех через платформенную policy.",
tone: "red",
},
];
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
const modalActionAccentRgb = [247, 248, 244] as const;
type EntityModalDeleteConfig = {
label: string;
title: string;
description: ReactNode;
onConfirm: () => void;
};
function ServicesSection({
data,
isPublicPoolContext,
onUpdateService,
onReorderServices,
onCreateService,
onDeleteService,
}: {
data: LauncherData;
isPublicPoolContext: boolean;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
onDeleteService: (serviceId: string) => void;
}) {
const [contentServiceId, setContentServiceId] = useState<string | null>(null);
const [activeServiceId, setActiveServiceId] = useState<string | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const sortedServices = useMemo(() => data.services.slice().sort((a, b) => a.order - b.order), [data.services]);
const [orderedServiceIds, setOrderedServiceIds] = useState<string[]>(() => sortedServices.map((service) => service.id));
const displayedServices = useMemo(() => {
const servicesById = new Map(data.services.map((service) => [service.id, service]));
const orderedServices = orderedServiceIds.map((serviceId) => servicesById.get(serviceId)).filter(Boolean) as Service[];
const missingServices = sortedServices.filter((service) => !orderedServiceIds.includes(service.id));
return [...orderedServices, ...missingServices];
}, [data.services, orderedServiceIds, sortedServices]);
const contentService = data.services.find((service) => service.id === contentServiceId) ?? null;
useEffect(() => {
if (!activeServiceId) {
setOrderedServiceIds(sortedServices.map((service) => service.id));
}
}, [activeServiceId, sortedServices]);
function handleDragStart(event: DragStartEvent) {
setActiveServiceId(String(event.active.id));
}
function handleDragEnd(event: DragEndEvent) {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
setActiveServiceId(null);
if (!overId || activeId === overId) return;
const oldIndex = orderedServiceIds.indexOf(activeId);
const newIndex = orderedServiceIds.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return;
const nextIds = arrayMove(orderedServiceIds, oldIndex, newIndex);
setOrderedServiceIds(nextIds);
onReorderServices(nextIds);
}
function handleDragCancel() {
setActiveServiceId(null);
}
return (
<>
<GlassSurface className="table-shell services-table-shell">
<div className="table-toolbar">
<div>
<h3>{isPublicPoolContext ? "Каталог сервисов · открытый контур" : "Каталог сервисов"}</h3>
{isPublicPoolContext ? (
<p className="admin-helper-note">
Пока это общий каталог модулей. Видимость и запуск для public users регулируются grants в матрице доступа открытого контура.
</p>
) : null}
</div>
<IconButton label="Создать сервис" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateService}>
<Plus size={17} />
</IconButton>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<table className="services-admin-table">
<ServiceTableColGroup />
<thead>
<tr>
<th>Сервис</th>
<th>Slug</th>
<th>Статус</th>
<th>Ссылка запуска</th>
<th>Authentik</th>
<th aria-label="Редактирование" />
<th aria-label="Порядок" />
</tr>
</thead>
<SortableContext items={displayedServices.map((service) => service.id)} strategy={verticalListSortingStrategy}>
<tbody>
{displayedServices.map((service) => (
<SortableServiceRow
key={service.id}
service={service}
onUpdateService={onUpdateService}
onOpenContent={() => setContentServiceId(service.id)}
/>
))}
</tbody>
</SortableContext>
</table>
</DndContext>
</GlassSurface>
{contentService ? (
<ServiceContentModal
service={contentService}
onClose={() => setContentServiceId(null)}
onSave={(patch) => {
onUpdateService(contentService.id, patch);
setContentServiceId(null);
}}
onDelete={() => {
onDeleteService(contentService.id);
setContentServiceId(null);
}}
/>
) : null}
</>
);
}
function ServiceTableColGroup() {
return (
<colgroup>
<col style={{ width: "23%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "27%" }} />
<col style={{ width: "15%" }} />
<col style={{ width: "3.4rem" }} />
<col style={{ width: "3.1rem" }} />
</colgroup>
);
}
function SortableServiceRow({
service,
onUpdateService,
onOpenContent,
}: {
service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onOpenContent: () => void;
}) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<tr ref={setNodeRef} style={style} className={cn("services-admin-table__row", isDragging && "services-admin-table__row--dragging")}>
<ServiceTableCells
service={service}
onUpdateService={onUpdateService}
onOpenContent={onOpenContent}
dragAttributes={attributes}
dragListeners={listeners}
setDragHandleRef={setActivatorNodeRef}
/>
</tr>
);
}
function ServiceTableCells({
service,
onUpdateService,
onOpenContent,
dragAttributes,
dragListeners,
setDragHandleRef,
}: {
service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onOpenContent: () => void;
dragAttributes?: DraggableAttributes;
dragListeners?: DraggableSyntheticListeners;
setDragHandleRef?: (node: HTMLButtonElement | null) => void;
}) {
return (
<>
<td className="services-admin-table__service">
<input
className="admin-table-input admin-table-input--strong"
value={service.title}
onChange={(event) => onUpdateService(service.id, { title: event.target.value })}
aria-label={`Название сервиса ${service.title}`}
/>
<input
className="admin-table-input admin-table-input--muted"
value={service.subtitle ?? ""}
onChange={(event) => onUpdateService(service.id, { subtitle: event.target.value || null })}
aria-label={`Подзаголовок сервиса ${service.title}`}
/>
</td>
<td>
<input
className="admin-table-input"
value={service.slug}
onChange={(event) => onUpdateService(service.id, { slug: event.target.value })}
aria-label={`Slug сервиса ${service.title}`}
/>
</td>
<td>
<ServiceStatusDropdown
value={service.status}
label={`Статус сервиса ${service.title}`}
onChange={(status) => onUpdateService(service.id, { status })}
/>
</td>
<td className="services-admin-table__launch">
<input
className="admin-table-input"
value={getServiceLaunchLink(service)}
onChange={(event) => onUpdateService(service.id, createServiceLaunchLinkPatch(event.target.value))}
aria-label={`Ссылка запуска сервиса ${service.title}`}
/>
</td>
<td>
<input
className="admin-table-input"
value={service.authentikApplicationSlug ?? ""}
onChange={(event) => onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })}
aria-label={`Authentik slug сервиса ${service.title}`}
/>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Контент витрины ${service.title}`}
className="admin-icon-action services-admin-table__edit"
type="button"
onClick={onOpenContent}
>
<Edit3 size={12} />
</IconButton>
</td>
<td className="services-admin-table__drag-cell">
<button
ref={setDragHandleRef}
className="services-admin-table__drag-handle"
type="button"
aria-label={`Перетащить сервис ${service.title}`}
{...dragAttributes}
{...dragListeners}
>
<GripVertical size={16} strokeWidth={1.9} />
</button>
</td>
</>
);
}
function ServiceStatusDropdown({
value,
label,
onChange,
}: {
value: ServiceStatus;
label: string;
onChange: (status: ServiceStatus) => void;
}) {
const selectedOption = serviceStatusOptions.find((option) => option.value === value) ?? serviceStatusOptions[0];
return (
<NodeDcDropdown
className="service-status-dropdown"
minWidth={156}
surfaceClassName="service-status-menu"
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="service-status-trigger"
data-status={value}
data-tone={selectedOption.tone}
type="button"
aria-label={label}
aria-expanded={open}
onClick={toggle}
>
<span>{selectedOption.label}</span>
</button>
)}
>
{({ close }) => (
<div className="admin-status-menu__list">
{serviceStatusOptions.map((option) => (
<button
key={option.value}
className="service-status-menu__option nodedc-ui-option"
data-selected={option.value === value}
data-status={option.value}
data-tone={option.tone}
type="button"
onClick={() => {
onChange(option.value);
close();
}}
>
<span className="service-status-menu__mark" aria-hidden="true" />
<span>{option.label}</span>
</button>
))}
</div>
)}
</NodeDcDropdown>
);
}
function AdminStatusDropdown<T extends string>({
value,
options,
label,
onChange,
}: {
value: T;
options: Array<AdminStatusOption<T>>;
label: string;
onChange: (value: T) => void;
}) {
const selectedOption = options.find((option) => option.value === value) ?? options[0];
return (
<NodeDcDropdown
className="admin-status-dropdown"
minWidth={164}
surfaceClassName="admin-status-menu"
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="admin-status-trigger"
data-tone={selectedOption.tone}
type="button"
aria-label={label}
aria-expanded={open}
onClick={toggle}
>
<span>{selectedOption.label}</span>
</button>
)}
>
{({ close }) => (
<div className="admin-status-menu__list">
{options.map((option) => (
<button
key={option.value}
className="admin-status-menu__option nodedc-ui-option"
data-selected={option.value === value}
data-tone={option.tone}
type="button"
onClick={() => {
onChange(option.value);
close();
}}
>
<span className="admin-status-menu__mark" aria-hidden="true" />
<span>{option.label}</span>
</button>
))}
</div>
)}
</NodeDcDropdown>
);
}
function AdminStatusPill<T extends string>({ value, options }: { value: T; options: Array<AdminStatusOption<T>> }) {
const option = options.find((item) => item.value === value) ?? options[0];
return (
<span className="admin-status-trigger admin-status-trigger--static" data-tone={option.tone}>
<span>{option.label}</span>
</span>
);
}
function ServiceContentModal({
service,
onClose,
onSave,
onDelete,
}: {
service: Service;
onClose: () => void;
onSave: (patch: Partial<Service>) => void;
onDelete: () => void;
}) {
const [draft, setDraft] = useState<Service>(service);
const [mediaPreviewUrls, setMediaPreviewUrls] = useState<{ cover: string | null; ambient: string | null }>({
cover: service.coverImageUrl ?? null,
ambient: service.ambientVideoUrl ?? null,
});
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
setDraft(service);
setMediaPreviewUrls({
cover: service.coverImageUrl ?? null,
ambient: service.ambientVideoUrl ?? null,
});
setStorageError(null);
setUploadingSlot(null);
}, [service]);
useEffect(() => {
return () => {
Object.values(mediaPreviewUrls).forEach((previewUrl) => {
if (previewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
});
};
}, [mediaPreviewUrls]);
function update<K extends keyof Service>(key: K, value: Service[K]) {
setDraft((current) => ({ ...current, [key]: value }));
}
function updateMediaPreview(slot: "cover" | "ambient", previewUrl: string | null) {
setMediaPreviewUrls((current) => ({ ...current, [slot]: previewUrl }));
}
async function handleCoverUpload(file?: File) {
if (!file) return;
await uploadServiceMedia(file, "cover");
}
async function handleAmbientUpload(file?: File) {
if (!file) return;
await uploadServiceMedia(file, "ambient");
}
async function uploadServiceMedia(file: File, slot: "cover" | "ambient") {
const localPreviewUrl = URL.createObjectURL(file);
updateMediaPreview(slot, localPreviewUrl);
setStorageError(null);
setUploadingSlot(slot);
try {
const storedFile = await uploadStorageFile(file);
const mediaKind = mediaKindFromFile(file);
if (slot === "cover") {
update("coverImageUrl", storedFile.url);
update("coverMediaKind", mediaKind);
update("coverMediaSource", "file");
update("coverMediaFileName", storedFile.fileName);
} else {
update("ambientVideoUrl", storedFile.url);
update("ambientMediaKind", mediaKind);
update("ambientMediaSource", "file");
update("ambientMediaFileName", storedFile.fileName);
}
} catch (error) {
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage");
updateMediaPreview(slot, slot === "cover" ? (draft.coverImageUrl ?? null) : (draft.ambientVideoUrl ?? null));
} finally {
setUploadingSlot(null);
}
}
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Контент витрины ${service.title}`}>
<article className="service-content-modal">
<div className="service-content-modal__head">
<div>
<p className="eyebrow">Витрина сервиса</p>
<h3>{service.title}</h3>
</div>
<IconButton label="Закрыть редактор" className="admin-circle-action" type="button" onClick={onClose}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
<div className="service-content-modal__grid">
<label className="service-content-field">
<span>Название в витрине</span>
<input value={draft.title} onChange={(event) => update("title", event.target.value)} />
</label>
<label className="service-content-field">
<span>Подзаголовок</span>
<input value={draft.subtitle ?? ""} onChange={(event) => update("subtitle", event.target.value || null)} />
</label>
<label className="service-content-field service-content-field--wide">
<span>Короткое описание</span>
<textarea value={draft.description} onChange={(event) => update("description", event.target.value)} rows={3} />
</label>
<label className="service-content-field service-content-field--wide">
<span>Description</span>
<textarea value={draft.fullDescription ?? ""} onChange={(event) => update("fullDescription", event.target.value || null)} rows={5} />
</label>
<label className="service-content-field service-content-field--wide">
<span>
<Link2 size={14} /> Ссылка запуска
</span>
<input
value={getServiceLaunchLink(draft)}
onChange={(event) => setDraft((current) => ({ ...current, ...createServiceLaunchLinkPatch(event.target.value) }))}
/>
</label>
<MediaSourceField
label="Карточка"
icon={<ImageIcon size={14} />}
source={draft.coverMediaSource ?? "url"}
value={draft.coverImageUrl ?? ""}
fileName={draft.coverMediaFileName ?? null}
isUploading={uploadingSlot === "cover"}
previewSrc={mediaPreviewUrls.cover}
previewKind={draft.coverMediaKind}
onSourceChange={(source) => update("coverMediaSource", source)}
onUrlChange={(value) => {
update("coverImageUrl", value || null);
update("coverMediaSource", "url");
update("coverMediaKind", mediaKindFromUrl(value));
update("coverMediaFileName", null);
updateMediaPreview("cover", value || null);
}}
onFileChange={handleCoverUpload}
/>
<MediaSourceField
label="Фоновый контент"
icon={<Video size={14} />}
source={draft.ambientMediaSource ?? "url"}
value={draft.ambientVideoUrl ?? ""}
fileName={draft.ambientMediaFileName ?? null}
isUploading={uploadingSlot === "ambient"}
previewSrc={mediaPreviewUrls.ambient}
previewKind={draft.ambientMediaKind}
onSourceChange={(source) => update("ambientMediaSource", source)}
onUrlChange={(value) => {
update("ambientVideoUrl", value || null);
update("ambientMediaSource", "url");
update("ambientMediaKind", mediaKindFromUrl(value));
update("ambientMediaFileName", null);
updateMediaPreview("ambient", value || null);
}}
onFileChange={handleAmbientUpload}
/>
{storageError ? <p className="service-content-storage-error">{storageError}</p> : null}
</div>
<div className="service-content-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Отмена
</Button>
<div className="service-content-modal__foot-actions">
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
Удалить
</Button>
<Button
variant="accent"
surface="modal"
accentRgb={modalActionAccentRgb}
type="button"
disabled={uploadingSlot !== null}
icon={<Save size={16} />}
onClick={() =>
onSave({
title: draft.title,
subtitle: draft.subtitle,
description: draft.description,
fullDescription: draft.fullDescription,
url: draft.url,
launchUrl: draft.launchUrl,
coverImageUrl: draft.coverImageUrl,
coverMediaKind: draft.coverMediaKind,
coverMediaSource: draft.coverMediaSource,
coverMediaFileName: draft.coverMediaFileName,
ambientVideoUrl: draft.ambientVideoUrl,
ambientMediaKind: draft.ambientMediaKind,
ambientMediaSource: draft.ambientMediaSource,
ambientMediaFileName: draft.ambientMediaFileName,
})
}
>
{uploadingSlot ? "Сохраняем файл" : "Сохранить"}
</Button>
</div>
</div>
</article>
<NodeDcDeleteModal
isOpen={deleteOpen}
title="Удалить витрину"
description={
<>
Витрина <strong>{service.title}</strong> будет удалена из каталога, нижней панели и матрицы доступов.
</>
}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
onDelete();
}}
/>
</div>
);
}
function ClientEditorModal({
client,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
onRefreshTaskManagerWorkspaces,
onClose,
onSave,
onDelete,
canDelete,
}: {
client: Client;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
onRefreshTaskManagerWorkspaces: () => void;
onClose: () => void;
onSave: (patch: Partial<Client>) => void;
onDelete: () => void;
canDelete: boolean;
}) {
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
<article className="service-content-modal admin-entity-modal client-editor-modal">
<EntityModalHead
eyebrow="Клиент"
title={client.name}
onClose={onClose}
actions={
<IconButton
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
className="admin-circle-action admin-circle-action--solid"
type="button"
disabled={taskManagerWorkspacesLoading}
onClick={onRefreshTaskManagerWorkspaces}
>
<RefreshCw size={16} />
</IconButton>
}
/>
<ClientProfileEditorForm
client={client}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onCancel={onClose}
onSave={onSave}
deleteConfig={
canDelete
? {
label: "Удалить",
title: "Удалить компанию",
description: (
<>
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
</>
),
onConfirm: onDelete,
}
: undefined
}
/>
</article>
</div>
);
}
function ClientProfileEditorForm({
client,
taskManagerWorkspaces,
taskManagerWorkspacesError,
onCancel,
onSave,
deleteConfig,
}: {
client: Client;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesError: string | null;
onCancel?: () => void;
onSave: (patch: Partial<Client>) => void;
deleteConfig?: EntityModalDeleteConfig;
}) {
const [draft, setDraft] = useState<Client>(client);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(client.avatarUrl ?? null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [storageError, setStorageError] = useState<string | null>(null);
const taskManagerWorkspaceBindings = getClientTaskManagerWorkspaces(draft);
const selectedTaskManagerWorkspaceSlugs = new Set(taskManagerWorkspaceBindings.map((workspace) => workspace.slug));
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(draft);
useEffect(() => {
setDraft(client);
setAvatarPreviewUrl(client.avatarUrl ?? null);
setUploadingAvatar(false);
setStorageError(null);
}, [client]);
useEffect(() => {
return () => {
if (avatarPreviewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(avatarPreviewUrl);
}
};
}, [avatarPreviewUrl]);
function update<K extends keyof Client>(key: K, value: Client[K]) {
setDraft((current) => ({ ...current, [key]: value }));
}
function resetDraft() {
setDraft(client);
setAvatarPreviewUrl(client.avatarUrl ?? null);
setUploadingAvatar(false);
setStorageError(null);
onCancel?.();
}
function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) {
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
setDraft((current) => ({
...current,
integrations: {
...current.integrations,
taskManager: {
...current.integrations?.taskManager,
workspaceSlug: primaryWorkspace?.slug ?? null,
workspaceName: primaryWorkspace?.name ?? null,
workspaces,
},
},
}));
}
function toggleTaskManagerWorkspace(workspace: TaskManagerWorkspaceSummary) {
const currentWorkspaces = getClientTaskManagerWorkspaces(draft);
const exists = currentWorkspaces.some((item) => item.slug === workspace.slug);
const nextWorkspaces: ClientTaskManagerWorkspaceBinding[] = exists
? currentWorkspaces.filter((item) => item.slug !== workspace.slug)
: [
...currentWorkspaces,
{
slug: workspace.slug,
name: workspace.name,
isPrimary: currentWorkspaces.length === 0,
managedBy: "launcher",
},
];
updateTaskManagerWorkspaces(normalizeClientTaskManagerWorkspaceDraft(nextWorkspaces));
}
function setPrimaryTaskManagerWorkspace(workspaceSlug: string) {
const nextWorkspaces = getClientTaskManagerWorkspaces(draft).map((workspace) => ({
...workspace,
isPrimary: workspace.slug === workspaceSlug,
}));
updateTaskManagerWorkspaces(normalizeClientTaskManagerWorkspaceDraft(nextWorkspaces));
}
async function handleAvatarUpload(file?: File) {
if (!file) return;
const localPreviewUrl = URL.createObjectURL(file);
setAvatarPreviewUrl(localPreviewUrl);
setUploadingAvatar(true);
setStorageError(null);
try {
const storedFile = await uploadStorageFile(file);
update("avatarUrl", storedFile.url);
} catch (error) {
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить аватар компании");
setAvatarPreviewUrl(draft.avatarUrl ?? null);
} finally {
setUploadingAvatar(false);
}
}
return (
<>
<div className="service-content-modal__grid client-profile-editor-grid">
<label className="service-content-field">
<span>Название</span>
<input value={draft.name} onChange={(event) => update("name", event.target.value)} />
</label>
<label className="service-content-field">
<span>Юридическое название</span>
<input value={draft.legalName ?? ""} onChange={(event) => update("legalName", event.target.value || null)} />
</label>
<label className="service-content-field">
<span>ИНН</span>
<input value={draft.inn ?? ""} onChange={(event) => update("inn", event.target.value || null)} />
</label>
<label className="service-content-field">
<span>Тип</span>
<NodeDcSelect
className="admin-modal-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={draft.type}
options={clientTypeOptions}
label="Тип клиента"
onChange={(type) => update("type", type)}
/>
</label>
<div className="service-content-field">
<span>Статус</span>
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
</div>
<div className="service-content-field service-content-field--wide client-avatar-field">
<span>Аватар компании</span>
<div className="client-avatar-control">
<div className="client-avatar-preview" aria-hidden="true">
{avatarPreviewUrl ? <img className="client-avatar-preview__image" src={avatarPreviewUrl} alt="" /> : null}
</div>
<div className="client-avatar-control__copy">
<strong>{avatarPreviewUrl ? "Аватар подключён" : "Аватар не задан"}</strong>
<small>Показывается в верхнем переключателе компании.</small>
</div>
<label className="service-media-file-button client-avatar-upload-button">
{uploadingAvatar ? "Загрузка..." : "Выберите файл"}
<input
type="file"
accept="image/*"
disabled={uploadingAvatar}
onChange={(event) => {
void handleAvatarUpload(event.target.files?.[0]);
event.target.value = "";
}}
/>
</label>
{avatarPreviewUrl ? (
<button
className="admin-icon-action client-avatar-clear-action"
type="button"
onClick={() => {
update("avatarUrl", null);
setAvatarPreviewUrl(null);
}}
aria-label="Убрать аватар"
>
<X size={11} />
</button>
) : null}
</div>
{storageError ? <small className="client-avatar-error">{storageError}</small> : null}
</div>
<div className="service-content-field service-content-field--wide">
<span>Operational Core workspaces</span>
<div className="task-workspace-picker-card">
<div className="task-workspace-picker" role="list" aria-label="Operational Core workspaces клиента">
{taskManagerWorkspaces.length ? (
taskManagerWorkspaces.map((workspace) => {
const selected = selectedTaskManagerWorkspaceSlugs.has(workspace.slug);
const primary = primaryTaskManagerWorkspace?.slug === workspace.slug;
return (
<button
key={workspace.slug}
className={cn("task-workspace-chip", selected && "task-workspace-chip--selected")}
type="button"
aria-pressed={selected}
onClick={() => toggleTaskManagerWorkspace(workspace)}
>
<span>
<strong>{workspace.name}</strong>
<small>
{workspace.slug} · {workspace.memberCount} участников · Launcher-managed
</small>
</span>
{selected ? (
<em
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
setPrimaryTaskManagerWorkspace(workspace.slug);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
setPrimaryTaskManagerWorkspace(workspace.slug);
}}
>
{primary ? "Основной" : "Сделать основным"}
</em>
) : null}
</button>
);
})
) : (
<span className="task-workspace-picker__empty">Workspace Operational Core не загружены</span>
)}
</div>
</div>
<small>
{taskManagerWorkspacesError
? taskManagerWorkspacesError
: "Эти workspace доступны для детальных назначений пользователей в Operational Core."}
</small>
</div>
<label className="service-content-field">
<span>Контактное лицо</span>
<input value={draft.contactName ?? ""} onChange={(event) => update("contactName", event.target.value || null)} />
</label>
<label className="service-content-field">
<span>Email</span>
<input value={draft.contactEmail ?? ""} onChange={(event) => update("contactEmail", event.target.value || null)} />
</label>
<div className="service-content-field">
<span>Договор с</span>
<NodeDcDateField
value={draft.contractStartsAt ?? null}
rangeStart={draft.contractStartsAt ?? null}
rangeEnd={draft.contractEndsAt ?? null}
label="Договор с"
onChange={(value) => update("contractStartsAt", value)}
/>
</div>
<div className="service-content-field">
<span>Договор до</span>
<NodeDcDateField
value={draft.contractEndsAt ?? null}
rangeStart={draft.contractStartsAt ?? null}
rangeEnd={draft.contractEndsAt ?? null}
label="Договор до"
onChange={(value) => update("contractEndsAt", value)}
/>
</div>
<div className="service-content-field">
<span>Оплачено до</span>
<NodeDcDateField value={draft.paidUntil ?? null} label="Оплачено до" onChange={(value) => update("paidUntil", value)} />
</div>
<div className="service-content-field">
<span>Демо до</span>
<NodeDcDateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
</div>
<label className="service-content-field service-content-field--wide">
<span>Заметки</span>
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
</label>
</div>
<EntityModalFoot onClose={resetDraft} onSave={() => onSave(draft)} deleteConfig={deleteConfig} />
</>
);
}
function UserEditorModal({
user,
membership,
client,
onClose,
onSave,
onDelete,
}: {
user: LauncherUser;
membership: ClientMembership;
client: Client;
onClose: () => void;
onSave: (userPatch: Partial<LauncherUser>, membershipPatch: Partial<ClientMembership>) => void;
onDelete: () => void;
}) {
const [userDraft, setUserDraft] = useState<LauncherUser>(user);
const [membershipDraft, setMembershipDraft] = useState<ClientMembership>(membership);
useEffect(() => {
setUserDraft(user);
setMembershipDraft(membership);
}, [membership, user]);
function updateUser<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
setUserDraft((current) => ({ ...current, [key]: value }));
}
function updateMembership<K extends keyof ClientMembership>(key: K, value: ClientMembership[K]) {
setMembershipDraft((current) => ({ ...current, [key]: value }));
}
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор пользователя ${user.name}`}>
<article className="service-content-modal admin-entity-modal">
<EntityModalHead eyebrow={client.name} title={user.name} onClose={onClose} />
<div className="service-content-modal__grid">
<label className="service-content-field">
<span>Имя</span>
<input value={userDraft.name} onChange={(event) => updateUser("name", event.target.value)} />
</label>
<label className="service-content-field">
<span>Email</span>
<input value={userDraft.email} onChange={(event) => updateUser("email", event.target.value)} />
</label>
<label className="service-content-field">
<span>Телефон</span>
<input value={userDraft.phone ?? ""} onChange={(event) => updateUser("phone", event.target.value || null)} />
</label>
<label className="service-content-field">
<span>Должность</span>
<input value={userDraft.position ?? ""} onChange={(event) => updateUser("position", event.target.value || null)} />
</label>
<div className="service-content-field">
<span>Статус</span>
<AdminStatusDropdown value={userDraft.globalStatus} options={userStatusOptions} label="Статус пользователя" onChange={(status) => updateUser("globalStatus", status)} />
</div>
<label className="service-content-field">
<span>Роль в клиенте</span>
<NodeDcSelect
className="admin-modal-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={membershipDraft.role}
options={membershipRoleOptions}
label="Роль в клиенте"
onChange={(role) => updateMembership("role", role)}
/>
</label>
<div className="service-content-field">
<span>Доступ</span>
<AdminStatusDropdown
value={membershipDraft.status}
options={membershipStatusOptions}
label="Доступ пользователя"
onChange={(status) => updateMembership("status", status)}
/>
</div>
<label className="service-content-field service-content-field--wide">
<span>Заметки</span>
<textarea value={userDraft.notes ?? ""} onChange={(event) => updateUser("notes", event.target.value || null)} rows={4} />
</label>
</div>
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(userDraft, membershipDraft)}
deleteConfig={{
label: "Удалить",
title: "Удалить участника",
description: (
<>
Участник <strong>{user.name}</strong> будет удален из клиента <strong>{client.name}</strong>. Глобальный профиль пользователя останется в демо-справочнике.
</>
),
onConfirm: onDelete,
}}
/>
</article>
</div>
);
}
function GroupEditorModal({
group,
users,
onClose,
onSave,
onDelete,
}: {
group: ClientGroup;
users: LauncherUser[];
onClose: () => void;
onSave: (patch: Partial<ClientGroup>) => void;
onDelete: () => void;
}) {
const [draft, setDraft] = useState<ClientGroup>(group);
useEffect(() => setDraft(group), [group]);
function toggleUser(userId: string) {
setDraft((current) => ({
...current,
memberIds: current.memberIds.includes(userId)
? current.memberIds.filter((item) => item !== userId)
: [...current.memberIds, userId],
}));
}
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор группы ${group.name}`}>
<article className="service-content-modal admin-entity-modal">
<EntityModalHead eyebrow="Группа" title={group.name} onClose={onClose} />
<div className="service-content-modal__grid">
<label className="service-content-field">
<span>Название</span>
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label className="service-content-field service-content-field--wide">
<span>Описание</span>
<textarea
value={draft.description ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value || null }))}
rows={4}
/>
</label>
<div className="service-content-field service-content-field--wide">
<span>Участники</span>
<div className="admin-token-grid">
{users.map((user) => (
<button
key={user.id}
className="admin-token"
data-active={draft.memberIds.includes(user.id)}
type="button"
onClick={() => toggleUser(user.id)}
>
{user.name}
</button>
))}
</div>
</div>
</div>
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={{
label: "Удалить",
title: "Удалить группу",
description: (
<>
Группа <strong>{group.name}</strong> и привязанные к ней гранты сервисов будут удалены.
</>
),
onConfirm: onDelete,
}}
/>
</article>
</div>
);
}
function EntityModalHead({
eyebrow,
title,
onClose,
actions,
}: {
eyebrow: string;
title: string;
onClose: () => void;
actions?: ReactNode;
}) {
return (
<div className="service-content-modal__head">
<div>
<p className="eyebrow">{eyebrow}</p>
<h3>{title}</h3>
</div>
<div className="service-content-modal__head-actions">
{actions}
<IconButton label="Закрыть редактор" className="admin-circle-action" type="button" onClick={onClose}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
</div>
);
}
function EntityModalFoot({
onClose,
onSave,
deleteConfig,
}: {
onClose: () => void;
onSave: () => void;
deleteConfig?: EntityModalDeleteConfig;
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<>
<div className="service-content-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Отмена
</Button>
<div className="service-content-modal__foot-actions">
{deleteConfig ? (
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
{deleteConfig.label}
</Button>
) : null}
<Button variant="accent" surface="modal" accentRgb={modalActionAccentRgb} type="button" icon={<Save size={16} />} onClick={onSave}>
Сохранить
</Button>
</div>
</div>
{deleteConfig ? (
<NodeDcDeleteModal
isOpen={deleteOpen}
title={deleteConfig.title}
description={deleteConfig.description}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
deleteConfig.onConfirm();
}}
/>
) : null}
</>
);
}
function MediaSourceField({
label,
icon,
source,
value,
fileName,
isUploading = false,
previewSrc,
previewKind,
onSourceChange,
onUrlChange,
onFileChange,
}: {
label: string;
icon: ReactNode;
source: ServiceMediaSource;
value: string;
fileName?: string | null;
isUploading?: boolean;
previewSrc?: string | null;
previewKind?: MediaKind | null;
onSourceChange: (source: ServiceMediaSource) => void;
onUrlChange: (value: string) => void;
onFileChange: (file?: File) => void | Promise<void>;
}) {
const inputId = `${label.replace(/\s+/g, "-").toLowerCase()}-${source}`;
const displayFileName = isUploading ? "Сохраняем в storage..." : truncateText(fileName ?? "Файл не выбран", 15);
const fileTitle = isUploading ? undefined : (fileName ?? "Файл не выбран");
return (
<div className="service-content-field service-content-field--wide service-media-field">
<span>
{icon} {label}
</span>
<div className="service-media-control">
{source === "file" ? (
<div className="service-media-file-control">
<label className="service-media-file-button" htmlFor={inputId}>
Выберите файл
</label>
<span className="service-media-file-name" title={fileTitle}>
{displayFileName}
</span>
<input id={inputId} type="file" accept={mediaAccept} onChange={(event) => onFileChange(event.currentTarget.files?.[0])} />
</div>
) : (
<input className="service-media-url-input" value={value} onChange={(event) => onUrlChange(event.target.value)} placeholder="https://..." />
)}
<div className="service-media-source-switch" aria-label={`${label}: источник`}>
<button
type="button"
className="service-media-source-button"
data-active={source === "file"}
aria-label="Файл с диска"
onClick={() => onSourceChange("file")}
>
<HardDrive size={15} />
</button>
<button
type="button"
className="service-media-source-button"
data-active={source === "url"}
aria-label="Внешняя ссылка"
onClick={() => onSourceChange("url")}
>
<Globe2 size={15} />
</button>
</div>
<div className="service-media-preview" aria-hidden="true">
{previewSrc ? <MediaPreview src={previewSrc} kind={previewKind} /> : icon}
</div>
</div>
</div>
);
}
function truncateText(value: string, maxLength: number) {
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
function MediaPreview({ src, kind }: { src: string; kind?: MediaKind | null }) {
if (kind === "video" || mediaKindFromUrl(src) === "video") {
return <video src={src} autoPlay loop muted playsInline />;
}
return <img src={src} alt="" />;
}
function mediaKindFromFile(file: File): MediaKind {
if (file.type.startsWith("video/")) return "video";
if (file.type === "image/gif" || /\.gif$/i.test(file.name)) return "gif";
return "image";
}
function mediaKindFromUrl(value: string): MediaKind | null {
if (!value.trim()) return null;
if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(value)) return "video";
if (/\.gif(\?.*)?$/i.test(value)) return "gif";
return "image";
}
function AccessSection({
data,
matrix,
selectedCell,
onSelectCell,
onSetUserServiceAccess,
pendingAccessAssignments,
onUpdateUser,
onUpdateMembership,
pendingTaskManagerMemberships,
pendingTaskManagerProjectMemberships,
taskManagerWorkspaceCatalog,
onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole,
}: {
data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>;
selectedCell: AccessMatrixCell | null;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
pendingTaskManagerMemberships: Record<string, boolean>;
pendingTaskManagerProjectMemberships: Record<string, boolean>;
taskManagerWorkspaceCatalog: TaskManagerWorkspaceSummary[];
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
}) {
const hasUsers = matrix.users.length > 0;
const isPublicPoolContext = isPublicPoolClientId(matrix.client.id);
const clientTaskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client);
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(matrix.client);
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
const detailsService = detailsCell ? getService(data, detailsCell.serviceId) : null;
if (!hasUsers) {
return (
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
<GlassSurface className="access-matrix">
<div className="table-toolbar">
<h3>Матрица доступа · {matrix.client.name}</h3>
<span className="muted-text">Нет данных для матрицы</span>
</div>
<div className="access-empty-state">
<strong>У клиента пока нет участников</strong>
<span>Добавьте участника или инвайт, после этого здесь появятся ячейки доступа.</span>
</div>
</GlassSurface>
</div>
);
}
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
return (
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
<GlassSurface className="access-matrix">
<div className="table-toolbar">
<h3>Матрица доступа · {matrix.client.name}</h3>
<span className="muted-text">Клик по ячейке открывает назначение</span>
</div>
<div className="matrix-scroll">
<div className="access-matrix-grid" style={{ gridTemplateColumns: accessGridTemplateColumns }} role="table">
<div className="access-grid-head access-grid-sticky" role="columnheader">
Участник
</div>
<div className="access-grid-head" role="columnheader">
MAIN
</div>
<div className="access-grid-head" role="columnheader">
MAIN ROLE
</div>
{matrix.services.map((service) => (
<div key={service.id} className="access-grid-head" role="columnheader">
{service.title}
</div>
))}
{matrix.users.map((user) => {
const membership = data.memberships.find((item) => item.clientId === matrix.client.id && item.userId === user.id);
if (!membership) return null;
const protectedUser = user.id === "user_root";
const inviterMeta = getMembershipInviterMeta(data, membership);
const pendingKey = `${matrix.client.id}:${user.id}:${primaryTaskManagerWorkspace?.slug ?? "primary"}`;
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
const forcedTaskManagerAdmin = membership.role === "client_owner";
return (
<Fragment key={user.id}>
<div className="access-grid-cell access-grid-sticky access-user-cell" role="rowheader">
<strong>{user.name}</strong>
<small>{user.email}</small>
{inviterMeta.showInAccessMatrix ? (
<small className="access-user-cell__inviter" title={`${inviterMeta.title} · ${inviterMeta.sourceLabel}`}>
через {inviterMeta.title}
</small>
) : null}
</div>
<div className="access-grid-cell" role="cell">
<MainStatusControl
value={user.globalStatus}
protectedUser={protectedUser}
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
/>
</div>
<div className="access-grid-cell" role="cell">
<MainRoleControl
value={membership.role}
protectedUser={protectedUser}
onChange={(role) => onUpdateMembership(membership.id, { role })}
/>
</div>
{matrix.services.map((service) => {
const cell = matrix.cells.find((item) => item.userId === user.id && item.serviceId === service.id)!;
const active = selectedCell?.userId === user.id && selectedCell.serviceId === service.id;
const isTaskManagerService = isOperationalCoreService(service);
const usePublicTaskerAccess = isPublicPoolContext && isTaskManagerService;
return (
<div key={service.id} className="access-grid-cell" role="cell">
<AccessCellControl
cell={cell}
active={active}
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
busy={!usePublicTaskerAccess && isTaskManagerService && pendingTaskerAssignment}
publicSelfService={usePublicTaskerAccess}
onSelectCell={onSelectCell}
onSetAccess={(value) => {
const nextValue = value;
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
if (usePublicTaskerAccess) return;
if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
onSetTaskManagerWorkspaceMemberRole({
clientId: matrix.client.id,
userId: user.id,
workspaceSlug: primaryTaskManagerWorkspace.slug,
role: accessAssignmentToTaskManagerRole(nextValue),
});
}}
onOpenDetails={isTaskManagerService && !usePublicTaskerAccess ? () => setDetailsCell(cell) : undefined}
/>
</div>
);
})}
</Fragment>
);
})}
</div>
</div>
</GlassSurface>
{detailsCell && detailsService ? (
<OperationalCoreAccessModal
data={data}
client={matrix.client}
user={getUser(data, detailsCell.userId)}
service={detailsService}
cell={detailsCell}
workspaces={clientTaskManagerWorkspaces}
workspaceCatalog={taskManagerWorkspaceCatalog}
pendingAccessAssignments={pendingAccessAssignments}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
onClose={() => setDetailsCell(null)}
onSetUserServiceAccess={onSetUserServiceAccess}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
/>
) : null}
</div>
);
}
function PublicAccessUsersPanel({
data,
matrix,
selectedCell,
onSelectCell,
onSetUserServiceAccess,
pendingAccessAssignments,
onUpdateUser,
onUpdateMembership,
}: {
data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>;
selectedCell: AccessMatrixCell | null;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
}) {
const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null;
return (
<GlassSurface className="table-shell table-shell--users table-shell--public-access-users">
<div className="table-toolbar">
<div>
<h3>Пользователи открытого контура</h3>
<p className="admin-helper-note">
Блокировка аккаунта отключает вход через Authentik и не удаляет историю заявок, инвайтов и аудит.
</p>
</div>
</div>
<table className="admin-data-table admin-data-table--users admin-data-table--public-access-users">
<thead>
<tr>
<th>Пользователь</th>
<th>Кто пригласил</th>
<th>Роль в контуре</th>
<th>Статус в контуре</th>
<th>Статус аккаунта</th>
<th>Operational Core</th>
</tr>
</thead>
<tbody>
{matrix.users.map((user) => {
const membership = data.memberships.find((item) => item.clientId === matrix.client.id && item.userId === user.id);
if (!membership) return null;
const protectedUser = user.id === "user_root";
const inviterMeta = getMembershipInviterMeta(data, membership);
const operationalCoreCell = operationalCoreService
? matrix.cells.find((cell) => cell.userId === user.id && cell.serviceId === operationalCoreService.id) ?? null
: null;
return (
<tr key={membership.id}>
<td className="admin-user-cell">
<div className="admin-user-cell__fields">
<input
className="admin-table-input admin-table-input--strong"
value={user.name}
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
aria-label={`Имя пользователя ${user.name}`}
/>
<input
className="admin-table-input admin-table-input--muted"
value={user.email}
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
aria-label={`Email пользователя ${user.name}`}
/>
</div>
</td>
<td>
{inviterMeta.showInAccessMatrix ? (
<div className="membership-inviter-cell">
<span>{inviterMeta.title}</span>
<small>{inviterMeta.sourceLabel}</small>
</div>
) : (
<span className="muted-text"></span>
)}
</td>
<td>
{protectedUser ? (
<AdminStaticPill>{membershipRoleLabel(membership.role)}</AdminStaticPill>
) : (
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={membership.role}
options={membershipRoleOptions}
label={`Роль в открытом контуре ${user.name}`}
minMenuWidth={198}
onChange={(role) => onUpdateMembership(membership.id, { role })}
/>
)}
</td>
<td>
<AdminStatusDropdown
value={membership.status}
options={membershipStatusOptions}
label={`Статус в открытом контуре ${user.name}`}
onChange={(status) => onUpdateMembership(membership.id, { status })}
/>
</td>
<td>
{protectedUser ? (
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
) : (
<AdminStatusDropdown
value={user.globalStatus}
options={userStatusOptions}
label={`Статус аккаунта ${user.name}`}
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
/>
)}
</td>
<td>
{operationalCoreCell ? (
<AccessCellControl
cell={operationalCoreCell}
active={selectedCell?.userId === user.id && selectedCell.serviceId === operationalCoreCell.serviceId}
pendingValue={pendingAccessAssignments[accessCellKey(user.id, operationalCoreCell.serviceId)]}
publicSelfService
onSelectCell={onSelectCell}
onSetAccess={(value) =>
onSetUserServiceAccess({
userId: user.id,
serviceId: operationalCoreCell.serviceId,
value,
})
}
/>
) : (
<span className="muted-text"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</GlassSurface>
);
}
function MainStatusControl({
value,
protectedUser,
onChange,
}: {
value: LauncherUserStatus;
protectedUser: boolean;
onChange: (value: LauncherUserStatus) => void;
}) {
const label = mainStatusLabel(value);
const allowed = value === "active";
if (protectedUser) {
return (
<span className={cn("access-cell access-cell--main access-cell--readonly", allowed ? "access-cell--allowed" : "access-cell--exception")}>
<strong>{label}</strong>
<span>MAIN</span>
</span>
);
}
return (
<NodeDcSelect
value={value}
options={mainStatusOptions}
label="MAIN статус"
minMenuWidth={320}
menuClassName="access-cell-menu"
onChange={onChange}
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
<button
ref={setTriggerRef}
className={cn("access-cell access-cell--main", value === "active" ? "access-cell--allowed" : "access-cell--exception")}
type="button"
aria-expanded={open}
onClick={toggle}
>
<strong>{selectedOption.label}</strong>
<span>MAIN</span>
</button>
)}
/>
);
}
function MainRoleControl({
value,
protectedUser,
onChange,
}: {
value: ClientMembershipRole;
protectedUser: boolean;
onChange: (value: ClientMembershipRole) => void;
}) {
const label = membershipRoleLabel(value);
if (protectedUser) {
return (
<span className="access-cell access-cell--main access-cell--allowed access-cell--readonly">
<strong>{label}</strong>
<span>MAIN роль</span>
</span>
);
}
return (
<NodeDcSelect
value={value}
options={membershipRoleOptions}
label="MAIN роль"
minMenuWidth={198}
menuClassName="access-cell-menu"
onChange={onChange}
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
<button ref={setTriggerRef} className="access-cell access-cell--main access-cell--allowed" type="button" aria-expanded={open} onClick={toggle}>
<strong>{selectedOption.label}</strong>
<span>MAIN роль</span>
</button>
)}
/>
);
}
function OperationalCoreAccessModal({
data,
client,
user,
service,
cell,
workspaces,
workspaceCatalog,
pendingAccessAssignments,
pendingTaskManagerMemberships,
pendingTaskManagerProjectMemberships,
onClose,
onSetUserServiceAccess,
onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole,
}: {
data: LauncherData;
client: Client;
user: LauncherUser;
service: Service;
cell: AccessMatrixCell;
workspaces: ClientTaskManagerWorkspaceBinding[];
workspaceCatalog: TaskManagerWorkspaceSummary[];
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
pendingTaskManagerMemberships: Record<string, boolean>;
pendingTaskManagerProjectMemberships: Record<string, boolean>;
onClose: () => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
}) {
const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id);
const protectedUser = user.id === "user_root" || membership?.role === "client_owner";
const basePendingValue = pendingAccessAssignments[accessCellKey(user.id, service.id)];
const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell);
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Operational Core доступы ${user.name}`}>
<article className="service-content-modal admin-entity-modal task-access-modal">
<EntityModalHead eyebrow="Доступы → Operational Core" title={user.name} onClose={onClose} />
<div className="task-access-summary">
<InfoLine label="Клиент" value={client.name} />
<InfoLine label="Сервис" value={service.title} />
<InfoLine label="Пользователь" value={user.email} />
</div>
<div className="task-workspace-access-list">
{workspaces.length ? (
workspaces.map((workspace) => {
const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug);
const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog);
const pendingKey = `${client.id}:${user.id}:${workspace.slug}`;
const pending = Boolean(pendingTaskManagerMemberships[pendingKey]) || basePendingValue !== undefined;
const value: OperationalCoreRoleSelectValue = pending
? "pending"
: baseAssignmentValue === "deny"
? "deny"
: taskManagerRoleToAccessAssignment(role);
return (
<section key={workspace.slug} className="task-workspace-access-card">
<div className="task-workspace-access-card__head">
<div>
<strong>{workspace.name ?? workspace.slug}</strong>
<small>
{workspace.slug}
{workspace.isPrimary ? " · основной workspace" : ""}
</small>
</div>
{protectedUser ? (
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill>
) : (
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={value}
options={operationalCoreRoleOptions}
label={`Роль ${user.name} в ${workspace.name ?? workspace.slug}`}
minMenuWidth={180}
disabled={pending}
onChange={(nextValue) => {
if (nextValue === "pending") return;
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
onSetTaskManagerWorkspaceMemberRole({
clientId: client.id,
userId: user.id,
workspaceSlug: workspace.slug,
role: accessAssignmentToTaskManagerRole(nextValue),
});
}}
/>
)}
</div>
<div className="task-project-access-list">
<div className="task-project-access-list__head">
<strong>Проекты</strong>
<span>{projects.length ? "Точечные роли внутри выбранного workspace" : "В этом workspace пока нет проектов"}</span>
</div>
{projects.map((project) => {
const projectRole = getTaskManagerProjectMembershipRole(data, client.id, user.id, workspace.slug, project.id);
const projectPendingKey = `${client.id}:${user.id}:${workspace.slug}:${project.id}`;
const projectPending = Boolean(pendingTaskManagerProjectMemberships[projectPendingKey]);
const projectValue: OperationalCoreRoleSelectValue = projectPending ? "pending" : taskManagerRoleToAccessAssignment(projectRole);
return (
<div key={project.id} className="task-project-access-row">
<div className="task-project-access-row__meta">
<strong>{project.name}</strong>
<small>
{project.identifier}
{project.memberCount ? ` · ${project.memberCount} участников` : ""}
</small>
</div>
{protectedUser ? (
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill>
) : (
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={projectValue}
options={taskManagerProjectRoleOptions}
label={`Роль ${user.name} в проекте ${project.name}`}
minMenuWidth={180}
disabled={projectPending}
onChange={(nextValue) => {
if (nextValue === "pending") return;
const nextTaskManagerRole = accessAssignmentToTaskManagerRole(nextValue);
if (nextTaskManagerRole !== "unset") {
if (baseAssignmentValue === "unset" || baseAssignmentValue === "deny") {
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
}
if (role === "unset") {
onSetTaskManagerWorkspaceMemberRole({
clientId: client.id,
userId: user.id,
workspaceSlug: workspace.slug,
role: nextTaskManagerRole,
});
}
}
onSetTaskManagerProjectMemberRole({
clientId: client.id,
userId: user.id,
workspaceSlug: workspace.slug,
projectId: project.id,
role: nextTaskManagerRole,
});
}}
/>
)}
</div>
);
})}
</div>
</section>
);
})
) : (
<div className="access-empty-state">
<strong>Workspace не привязаны к клиенту</strong>
<span>Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения.</span>
</div>
)}
</div>
<div className="service-content-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Закрыть
</Button>
</div>
</article>
</div>
);
}
function AccessCellControl({
cell,
active,
pendingValue,
busy = false,
publicSelfService = false,
onSelectCell,
onSetAccess,
onOpenDetails,
}: {
cell: AccessMatrixCell;
active: boolean;
pendingValue?: AccessAssignmentValue;
busy?: boolean;
publicSelfService?: boolean;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetAccess: (value: AccessAssignmentValue) => void;
onOpenDetails?: () => void;
}) {
const isPending = pendingValue !== undefined || busy;
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
const selectValue = publicSelfService ? publicOperationalCoreSelectValue(assignmentValue) : assignmentValue;
const selectOptions = publicSelfService ? publicOperationalCoreAccessOptions : accessAssignmentOptions;
const displayTitle = isPending
? publicSelfService
? publicAccessAssignmentLabel(assignmentValue)
: accessAssignmentLabel(assignmentValue)
: publicSelfService
? publicOperationalCoreCellTitle(cell)
: accessCellTitle(cell);
const displaySource = isPending
? "Сохраняем..."
: publicSelfService
? publicOperationalCoreCellSubtitle(cell)
: sourceLabel(cell.effectiveAccess.source);
const cellClassName = cn(
"access-cell",
onOpenDetails && "access-cell--modal",
cell.effectiveAccess.allowed && "access-cell--allowed",
!cell.effectiveAccess.allowed && "access-cell--denied",
cell.effectiveAccess.source === "exception" && !publicSelfService && "access-cell--exception",
isPending && "access-cell--pending",
active && "access-cell--active"
);
if (onOpenDetails) {
return (
<button
className={cellClassName}
type="button"
aria-busy={isPending}
disabled={isPending}
onClick={() => {
onSelectCell(cell);
onOpenDetails();
}}
>
<strong>{displayTitle}</strong>
<span>{displaySource}</span>
</button>
);
}
return (
<NodeDcSelect
value={selectValue}
options={selectOptions}
label={`Назначить доступ ${cell.userId} / ${cell.serviceId}`}
minMenuWidth={172}
menuClassName="access-cell-menu"
disabled={isPending}
onChange={(value) => onSetAccess(value)}
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className={cellClassName}
type="button"
aria-expanded={open}
aria-busy={isPending}
onClick={() => {
onSelectCell(cell);
toggle();
}}
>
<strong>{displayTitle}</strong>
<span>{displaySource}</span>
</button>
)}
/>
);
}
function InvitesSection({
data,
clientId,
actorUserId,
isPublicPoolContext,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
onUpdateAccessRequest,
onApproveAccessRequest,
onRejectAccessRequest,
onApproveTaskerInviteRequest,
onRejectTaskerInviteRequest,
}: {
data: LauncherData;
clientId: string;
actorUserId: string;
isPublicPoolContext: boolean;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
onUpdateAccessRequest: (
accessRequestId: string,
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
) => void;
onApproveAccessRequest: (
accessRequestId: string,
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
) => void;
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
}) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<ClientMembershipRole>("member");
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
const invites = data.invites.filter((invite) => invite.clientId === clientId);
const incomingRequestsTotal =
data.accessRequests.length + data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length;
const pendingIncomingRequests =
data.accessRequests.filter((request) => request.status === "new").length +
data.taskerInviteRequests.filter((request) => request.status === "new").length;
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
const actor = getUser(data, actorUserId);
const clientOptions: Array<NodeDcSelectOption<string>> = [
{ value: PUBLIC_POOL_CLIENT_ID, label: PUBLIC_POOL_CONTEXT_LABEL, description: PUBLIC_POOL_CONTEXT_DESCRIPTION },
...data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined })),
];
function handleCreateInvite() {
if (!email.trim()) return;
onCreateInvite({ clientId, email: email.trim(), role });
setEmail("");
setRole("member");
}
async function handleCopyInvite(invite: Invite) {
const inviteUrl = buildInviteUrl(invite.token);
try {
await copyToClipboard(inviteUrl);
} catch {
return;
}
setCopiedInviteId(invite.id);
if (invite.status === "created") {
onUpdateInvite(invite.id, { status: "sent" });
}
window.setTimeout(() => {
setCopiedInviteId((currentInviteId) => (currentInviteId === invite.id ? null : currentInviteId));
}, 1800);
}
return (
<div className="invites-layout invites-layout--catalog">
{isPublicPoolContext ? (
<GlassSurface className="admin-tabs-card">
<div className="admin-tabs">
<button
type="button"
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
onClick={() => setPublicInviteTab("incoming")}
>
Входящие · {incomingRequestsTotal}
</button>
<button
type="button"
className={cn("admin-tab-button", publicInviteTab === "outgoing" && "admin-tab-button--active")}
onClick={() => setPublicInviteTab("outgoing")}
>
Исходящие · {invites.length}
</button>
</div>
<p className="admin-helper-note">
{publicInviteTab === "incoming"
? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.`
: "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}
</p>
</GlassSurface>
) : null}
{isPublicPoolContext && publicInviteTab === "incoming" ? (
<AccessRequestsPanel
data={data}
clientOptions={clientOptions}
copiedInviteId={copiedInviteId}
onCopyInvite={handleCopyInvite}
onUpdateAccessRequest={onUpdateAccessRequest}
onApproveAccessRequest={onApproveAccessRequest}
onRejectAccessRequest={onRejectAccessRequest}
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
/>
) : (
<>
<GlassSurface className="invite-form invite-form--compact">
<div className="table-toolbar">
<div>
<p className="eyebrow">Инвайт от {actor.name}</p>
<h3>Создать приглашение</h3>
</div>
<IconButton
label="Сгенерировать ссылку"
className="admin-circle-action admin-circle-action--solid"
type="button"
disabled={!email.trim()}
onClick={handleCreateInvite}
>
<MailPlus size={16} />
</IconButton>
</div>
<div className="invite-form__fields">
<input value={email} onChange={(event) => setEmail(event.target.value)} placeholder="email@company.ru" />
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={role}
options={inviteRoleOptions}
label="Роль инвайта"
onChange={(nextRole) => setRole(nextRole)}
/>
</div>
</GlassSurface>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>{isPublicPoolContext ? "Исходящие инвайты" : "Инвайты"}</h3>
{isPublicPoolContext ? (
<p className="admin-helper-note">
Отзыв блокирует только неиспользованную ссылку. Уже принятый инвайт управляется через Root Admin Пользователи.
</p>
) : null}
</div>
</div>
<table className="admin-data-table">
<thead>
<tr>
<th>Email</th>
<th>Источник</th>
<th>Роль</th>
<th>Статус</th>
<th>Ссылка</th>
<th>Истекает</th>
<th aria-label="Удаление" />
</tr>
</thead>
<tbody>
{invites.map((invite) => {
const inviteUrl = buildInviteUrl(invite.token);
const isCopied = copiedInviteId === invite.id;
const isAccepted = invite.status === "accepted";
const canCopyInvite = inviteCanBeCopied(invite);
return (
<tr key={invite.id}>
<td>
{isAccepted ? (
<span className="admin-table-text admin-table-text--strong">{invite.email}</span>
) : (
<input
className="admin-table-input admin-table-input--strong"
value={invite.email}
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
aria-label={`Email инвайта ${invite.email}`}
/>
)}
</td>
<td>
<div className="membership-inviter-cell">
<span>{inviteSourceLabel(invite)}</span>
{invite.sourceWorkspaceName || invite.sourceWorkspaceSlug ? (
<small>{invite.sourceWorkspaceName ?? invite.sourceWorkspaceSlug}</small>
) : null}
</div>
</td>
<td>
{isAccepted ? (
<AdminStaticPill>{membershipRoleLabel(invite.role)}</AdminStaticPill>
) : (
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={invite.role}
options={inviteRoleOptions}
label={`Роль инвайта ${invite.email}`}
minMenuWidth={172}
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
/>
)}
</td>
<td>
{isAccepted ? (
<AdminStatusPill value={invite.status} options={inviteStatusOptions} />
) : (
<AdminStatusDropdown
value={invite.status}
options={editableInviteStatusOptions}
label={`Статус инвайта ${invite.email}`}
onChange={(status) => onUpdateInvite(invite.id, { status })}
/>
)}
</td>
<td>
{canCopyInvite ? (
<div className="invite-link-cell">
<code title={inviteUrl}>{inviteUrl}</code>
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
<IconButton
label={isCopied ? `Инвайт ${invite.email} скопирован` : `Скопировать инвайт ${invite.email}`}
className="admin-icon-action invite-icon-action"
type="button"
onClick={() => void handleCopyInvite(invite)}
>
<Copy size={11} />
</IconButton>
</div>
) : (
<span className="muted-text">{inviteTerminalLabel(invite)}</span>
)}
</td>
<td>
{isAccepted ? (
<span className="muted-text">{formatDate(invite.expiresAt)}</span>
) : (
<NodeDcDateField
value={invite.expiresAt}
label={`Инвайт истекает ${invite.email}`}
onChange={(value) => {
if (value) onUpdateInvite(invite.id, { expiresAt: value });
}}
/>
)}
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Удалить запись инвайта ${invite.email}`}
className="admin-icon-action invite-icon-action"
type="button"
onClick={() => setDeleteInviteId(invite.id)}
>
<Trash2 size={12} />
</IconButton>
</td>
</tr>
);
})}
</tbody>
</table>
</GlassSurface>
<NodeDcDeleteModal
isOpen={Boolean(deletingInvite)}
title="Удалить запись инвайта"
description={
<>
Будет удалена только запись invite-ссылки для <strong>{deletingInvite?.email}</strong>. Пользователь, доступы и
история аккаунта не удаляются.
</>
}
onClose={() => setDeleteInviteId(null)}
onConfirm={() => {
if (deletingInvite) onDeleteInvite(deletingInvite.id);
setDeleteInviteId(null);
}}
/>
</>
)}
</div>
);
}
function AccessRequestsPanel({
data,
clientOptions,
copiedInviteId,
onCopyInvite,
onUpdateAccessRequest,
onApproveAccessRequest,
onRejectAccessRequest,
onApproveTaskerInviteRequest,
onRejectTaskerInviteRequest,
}: {
data: LauncherData;
clientOptions: Array<NodeDcSelectOption<string>>;
copiedInviteId: string | null;
onCopyInvite: (invite: Invite) => Promise<void>;
onUpdateAccessRequest: (
accessRequestId: string,
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
) => void;
onApproveAccessRequest: (
accessRequestId: string,
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
) => void;
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
}) {
const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const taskerInviteRequests = data.taskerInviteRequests
.filter((request) => request.status !== "cancelled")
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return (
<>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>Входящие запросы доступа</h3>
<p className="admin-helper-note">
Перед approve выберите целевой контур. Аккаунт уже создан в Authentik и активируется после подтверждения.
</p>
</div>
</div>
{accessRequests.length === 0 ? (
<div className="access-empty-state">
<strong>Входящих заявок пока нет</strong>
<span>Кнопка Запросить доступ на login будет отправлять пользователей в эту очередь.</span>
</div>
) : (
<div className="access-request-table-scroll">
<table className="admin-data-table admin-data-table--access-requests">
<thead>
<tr>
<th>Заявитель</th>
<th>Контакты</th>
<th>Компания</th>
<th>Назначение</th>
<th>Роль</th>
<th>Статус</th>
<th>Аккаунт</th>
<th aria-label="Действия" />
</tr>
</thead>
<tbody>
{accessRequests.map((accessRequest) => {
const approvedInvite = accessRequest.approvedInviteId
? data.invites.find((invite) => invite.id === accessRequest.approvedInviteId) ?? null
: null;
const isTerminal = accessRequest.status !== "new";
const isCopied = Boolean(approvedInvite && copiedInviteId === approvedInvite.id);
const approvedInviteCanBeCopied = Boolean(approvedInvite && inviteCanBeCopied(approvedInvite));
return (
<tr key={accessRequest.id}>
<td>
<div className="access-request-applicant">
<strong>{formatAccessRequestName(accessRequest)}</strong>
<small>{formatDateTime(accessRequest.createdAt)}</small>
</div>
</td>
<td>
<div className="access-request-contact">
<span>{accessRequest.email}</span>
<small>{accessRequest.phone}</small>
</div>
</td>
<td>{accessRequest.company}</td>
<td>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={accessRequest.targetClientId}
options={clientOptions}
label={`Назначение заявки ${accessRequest.email}`}
minMenuWidth={220}
disabled={isTerminal}
onChange={(targetClientId) => onUpdateAccessRequest(accessRequest.id, { targetClientId })}
/>
</td>
<td>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={accessRequest.role}
options={inviteRoleOptions}
label={`Роль заявки ${accessRequest.email}`}
minMenuWidth={172}
disabled={isTerminal}
onChange={(role) => onUpdateAccessRequest(accessRequest.id, { role })}
/>
</td>
<td>
<AdminStatusPill value={accessRequest.status} options={accessRequestStatusOptions} />
</td>
<td>
{approvedInvite && approvedInviteCanBeCopied ? (
<div className="invite-link-cell">
<code title={buildInviteUrl(approvedInvite.token)}>{buildInviteUrl(approvedInvite.token)}</code>
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
<IconButton
label={`Скопировать инвайт ${approvedInvite.email}`}
className="admin-icon-action invite-icon-action"
type="button"
onClick={() => void onCopyInvite(approvedInvite)}
>
<Copy size={11} />
</IconButton>
</div>
) : approvedInvite ? (
<span className="muted-text">{inviteTerminalLabel(approvedInvite)}</span>
) : accessRequest.status === "approved" ? (
<span className="muted-text">Активен</span>
) : (
<span className="muted-text"></span>
)}
</td>
<td>
{accessRequest.status === "new" ? (
<div className="access-request-decision-cluster">
<button
aria-label={`Подтвердить заявку ${accessRequest.email}`}
className="access-request-decision-button access-request-decision-button--accept"
type="button"
onClick={() =>
onApproveAccessRequest(accessRequest.id, {
targetClientId: accessRequest.targetClientId,
role: accessRequest.role,
})
}
>
<Check size={16} strokeWidth={2.6} />
</button>
<button
aria-label={`Отклонить заявку ${accessRequest.email}`}
className="access-request-decision-button access-request-decision-button--decline"
type="button"
onClick={() => onRejectAccessRequest(accessRequest.id, {})}
>
<X size={16} strokeWidth={2.5} />
</button>
</div>
) : (
<span className="muted-text"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</GlassSurface>
<TaskerInviteRequestsPanel
requests={taskerInviteRequests}
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
/>
</>
);
}
function TaskerInviteRequestsPanel({
requests,
onApproveTaskerInviteRequest,
onRejectTaskerInviteRequest,
}: {
requests: TaskerInviteRequest[];
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
}) {
const [copiedRequestId, setCopiedRequestId] = useState<string | null>(null);
async function handleCopyTaskerInvite(request: TaskerInviteRequest) {
if (!request.platformInviteToken) return;
try {
await copyToClipboard(buildInviteUrl(request.platformInviteToken));
} catch {
return;
}
setCopiedRequestId(request.id);
window.setTimeout(() => {
setCopiedRequestId((currentRequestId) => (currentRequestId === request.id ? null : currentRequestId));
}, 1800);
}
return (
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>Запросы workspace-инвайтов</h3>
<p className="admin-helper-note">
Self-service админы Operational Core создают эти заявки из настроек workspace. После approve ссылка становится доступна
пригласившему пользователю.
</p>
</div>
</div>
{requests.length === 0 ? (
<div className="access-empty-state">
<strong>Workspace-инвайтов пока нет</strong>
<span>Когда self-service admin добавит участника в Operational Core, заявка появится здесь.</span>
</div>
) : (
<div className="access-request-table-scroll">
<table className="admin-data-table admin-data-table--access-requests">
<thead>
<tr>
<th>Workspace</th>
<th>Приглашённый</th>
<th>Инициатор</th>
<th>Роль</th>
<th>Статус</th>
<th>Ссылка</th>
<th aria-label="Действия" />
</tr>
</thead>
<tbody>
{requests.map((request) => {
const isCopied = copiedRequestId === request.id;
return (
<tr key={request.id}>
<td>
<div className="access-request-applicant">
<strong>{request.workspaceName}</strong>
<small>{request.workspaceSlug}</small>
</div>
</td>
<td>
<div className="access-request-contact">
<span>{request.inviteeEmail}</span>
<small>{formatDateTime(request.createdAt)}</small>
</div>
</td>
<td>
<div className="access-request-contact">
<span>{request.inviterName}</span>
<small>{request.inviterEmail}</small>
</div>
</td>
<td>{taskerInviteRoleLabel(request.role)}</td>
<td>
<AdminStatusPill value={request.status} options={taskerInviteRequestStatusOptions} />
</td>
<td>
{request.platformInviteToken ? (
<div className="invite-link-cell">
<code title={buildInviteUrl(request.platformInviteToken)}>{buildInviteUrl(request.platformInviteToken)}</code>
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
<IconButton
label={`Скопировать workspace-инвайт ${request.inviteeEmail}`}
className="admin-icon-action invite-icon-action"
type="button"
onClick={() => void handleCopyTaskerInvite(request)}
>
<Copy size={11} />
</IconButton>
</div>
) : (
<span className="muted-text"></span>
)}
</td>
<td>
{request.status === "new" ? (
<div className="access-request-decision-cluster">
<button
aria-label={`Подтвердить workspace-инвайт ${request.inviteeEmail}`}
className="access-request-decision-button access-request-decision-button--accept"
type="button"
onClick={() => onApproveTaskerInviteRequest(request.id, {})}
>
<Check size={16} strokeWidth={2.6} />
</button>
<button
aria-label={`Отклонить workspace-инвайт ${request.inviteeEmail}`}
className="access-request-decision-button access-request-decision-button--decline"
type="button"
onClick={() => onRejectTaskerInviteRequest(request.id, {})}
>
<X size={16} strokeWidth={2.5} />
</button>
</div>
) : (
<span className="muted-text"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</GlassSurface>
);
}
function formatAccessRequestName(accessRequest: AccessRequest) {
return [accessRequest.lastName, accessRequest.firstName, accessRequest.middleName].filter(Boolean).join(" ");
}
function getMembershipInviterMeta(data: LauncherData, membership: ClientMembership) {
const inviter = membership.invitedByUserId ? data.users.find((user) => user.id === membership.invitedByUserId) ?? null : null;
const taskerRequest = membership.sourceTaskerInviteRequestId
? data.taskerInviteRequests.find((request) => request.id === membership.sourceTaskerInviteRequestId)
: null;
if (membership.source === "tasker_workspace_invite") {
const title = inviter?.name ?? taskerRequest?.inviterName ?? "Operational Core";
const subtitle = inviter?.email ?? taskerRequest?.inviterEmail ?? "Self-service invite";
const workspaceLabel = taskerRequest?.workspaceName || taskerRequest?.workspaceSlug;
return {
title,
subtitle,
sourceLabel: workspaceLabel ? `Operational Core · ${workspaceLabel}` : "Operational Core",
showInAccessMatrix: true,
};
}
if (membership.source === "access_request") {
return {
title: "Публичная заявка",
subtitle: inviter ? `approve: ${inviter.name}` : "NODE.DC",
sourceLabel: "Заявка доступа",
showInAccessMatrix: true,
};
}
if (membership.inviteId) {
return {
title: inviter?.name ?? "NODE.DC",
subtitle: inviter?.email ?? "Launcher invite",
sourceLabel: "Launcher invite",
showInAccessMatrix: true,
};
}
return {
title: inviter?.name ?? "NODE.DC",
subtitle: inviter?.email ?? "Ручное добавление",
sourceLabel: "Ручное добавление",
showInAccessMatrix: false,
};
}
function getPlatformUserOrigin(data: LauncherData, user: LauncherUser, memberships: ClientMembership[]) {
const publicMembership = memberships.find((membership) => isPublicPoolClientId(membership.clientId));
if (publicMembership) {
const inviterMeta = getMembershipInviterMeta(data, publicMembership);
if (publicMembership.source === "tasker_workspace_invite") {
return {
label: "Открытый контур · self-host invite",
detail: `${inviterMeta.title} · ${inviterMeta.sourceLabel}`,
};
}
if (publicMembership.source === "access_request") {
const request = data.accessRequests.find((item) => item.email.toLowerCase() === user.email.toLowerCase());
return {
label: "Открытый контур · прямой запрос",
detail: request?.company ? `Компания из заявки: ${request.company}` : inviterMeta.subtitle,
};
}
if (publicMembership.inviteId) {
return {
label: "Открытый контур · ручной инвайт",
detail: inviterMeta.subtitle,
};
}
return {
label: "Открытый контур",
detail: inviterMeta.subtitle,
};
}
if (memberships.length > 0) {
const primaryMembership = memberships[0];
const client = getClient(data, primaryMembership.clientId);
const extraContexts = memberships.length > 1 ? `, ещё ${memberships.length - 1}` : "";
return {
label: `Компания · ${client.name}`,
detail: `${client.legalName ?? client.name}${extraContexts}`,
};
}
const pendingInvite = data.invites.find((invite) => invite.email.toLowerCase() === user.email.toLowerCase());
const pendingRequest = data.accessRequests.find((request) => request.email.toLowerCase() === user.email.toLowerCase());
if (pendingInvite) {
return {
label: "Инвайт · без контура",
detail: inviteSourceLabel(pendingInvite),
};
}
if (pendingRequest) {
return {
label: "Заявка · без контура",
detail: pendingRequest.company,
};
}
return {
label: "Без контура",
detail: "Нет активных memberships",
};
}
function MembershipInviterCell({ data, membership }: { data: LauncherData; membership: ClientMembership }) {
const inviterMeta = getMembershipInviterMeta(data, membership);
return (
<div className="membership-inviter-cell">
<span>{inviterMeta.title}</span>
<small>{inviterMeta.subtitle}</small>
<small>{inviterMeta.sourceLabel}</small>
</div>
);
}
function buildInviteUrl(token: string) {
if (typeof window === "undefined") return `/invite/${token}`;
return new URL(`/invite/${token}`, window.location.origin).toString();
}
async function copyToClipboard(value: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
if (typeof document === "undefined") {
throw new Error("Clipboard API is not available");
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.append(input);
input.select();
const copied = document.execCommand("copy");
input.remove();
if (!copied) {
throw new Error("Fallback copy failed");
}
}
function SyncSection({
data,
clientId,
isRoot,
onRetrySync,
}: {
data: LauncherData;
clientId: string;
isRoot: boolean;
onRetrySync: (syncId: string) => void;
}) {
const syncRows = data.syncStatuses.filter((sync) => isRoot || sync.objectId === clientId || sync.objectName.includes(getClient(data, clientId).name));
return (
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>Синхронизация</h3>
<p className="admin-helper-note">Контроль доставки изменений в Authentik, сервисы и внешние контуры. Ошибочные строки можно отправить повторно.</p>
</div>
</div>
<table className="admin-data-table">
<thead>
<tr>
<th>Объект</th>
<th>Тип</th>
<th>Цель</th>
<th>Статус</th>
<th>Последняя sync</th>
<th>Ошибка</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{syncRows.map((sync) => (
<tr key={sync.id}>
<td>{sync.objectName}</td>
<td>{sync.objectType}</td>
<td>{targetLabel(sync.target)}</td>
<td>
<AdminStatusPill value={sync.state} options={syncStatusOptions} />
</td>
<td>{formatDateTime(sync.lastSyncAt ?? sync.updatedAt)}</td>
<td>{sync.error ?? "—"}</td>
<td>
<IconButton
label={`Повторить синхронизацию ${sync.objectName}`}
className="admin-icon-action services-admin-table__edit"
type="button"
onClick={() => onRetrySync(sync.id)}
>
<RefreshCw size={12} />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</GlassSurface>
);
}
function AuditSection({ data }: { data: LauncherData }) {
return (
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>Аудит</h3>
<p className="admin-helper-note">Журнал действий: кто менял доступы, каталоги, инвайты и синхронизацию. Нужен для разбора спорных изменений.</p>
</div>
</div>
<table className="admin-data-table">
<thead>
<tr>
<th>Дата</th>
<th>Пользователь</th>
<th>Действие</th>
<th>Объект</th>
<th>Результат</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
{data.auditEvents.map((event) => (
<tr key={event.id}>
<td>{formatDateTime(event.at)}</td>
<td>{event.actorName}</td>
<td>{event.action}</td>
<td>{event.objectName}</td>
<td>
<AdminStatusPill value={event.result} options={auditResultOptions} />
</td>
<td>{event.details ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</GlassSurface>
);
}
function MiscSection({
data,
onUpdateSettings,
}: {
data: LauncherData;
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
}) {
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState(
data.settings.taskManager.workspaceCreationPolicy
);
useEffect(() => {
setLogoLinkUrl(data.settings.brand.logoLinkUrl);
setWorkspaceCreationPolicy(data.settings.taskManager.workspaceCreationPolicy);
}, [data.settings.brand.logoLinkUrl, data.settings.taskManager.workspaceCreationPolicy]);
const normalizedLogoLinkUrl = logoLinkUrl.trim() || "/";
const hasChanges =
normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl ||
workspaceCreationPolicy !== data.settings.taskManager.workspaceCreationPolicy;
return (
<GlassSurface className="table-shell admin-settings-panel">
<div className="table-toolbar">
<div>
<h3>Разное</h3>
<p className="admin-helper-note">
Общие настройки платформы, которые должны применяться в лаунчере, Task Manager и системных auth-экранах.
</p>
</div>
<Button
variant="accent"
type="button"
icon={<Save size={16} />}
disabled={!hasChanges}
onClick={() =>
onUpdateSettings({
brand: { logoLinkUrl: normalizedLogoLinkUrl },
taskManager: { workspaceCreationPolicy },
})
}
>
Сохранить
</Button>
</div>
<div className="admin-settings-grid">
<label className="admin-settings-field">
<span>Ссылка логотипа</span>
<input
className="admin-table-input admin-settings-field__input"
value={logoLinkUrl}
placeholder="/"
onChange={(event) => setLogoLinkUrl(event.target.value)}
/>
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
</label>
<label className="admin-settings-field">
<span>Operational Core: создание workspace</span>
<NodeDcSelect
className="admin-modal-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={workspaceCreationPolicy}
options={taskManagerWorkspacePolicyOptions}
label="Политика создания workspace в Operational Core"
onChange={(value) => setWorkspaceCreationPolicy(value)}
/>
<small>Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.</small>
</label>
</div>
</GlassSurface>
);
}
function CompanySection({ data, clientId }: { data: LauncherData; clientId: string }) {
const client = getClient(data, clientId);
return (
<GlassSurface className="company-panel">
<p className="eyebrow">Профиль компании</p>
<h3>{client.name}</h3>
<div className="explanation-stack">
<InfoLine label="Юридическое название" value={client.legalName ?? "—"} />
<InfoLine label="Тип" value={client.type === "company" ? "Компания" : "Частное лицо"} />
<InfoLine label="Контакт" value={`${client.contactName ?? "—"} · ${client.contactEmail ?? "—"}`} />
<InfoLine label="Demo до" value={formatDate(client.demoEndsAt)} />
<InfoLine label="Заметки" value={client.notes ?? "—"} />
</div>
</GlassSurface>
);
}
function MetricCard({ label, value, hint, danger = false }: { label: string; value: number; hint: string; danger?: boolean }) {
return (
<GlassSurface className={cn("metric-card", danger && "metric-card--danger")}>
<span>{label}</span>
<strong>{value}</strong>
<small>{hint}</small>
</GlassSurface>
);
}
function InfoLine({ label, value }: { label: string; value: string }) {
return (
<div className="info-line">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function roleLabel(role: string): string {
const labels: Record<string, string> = {
root_admin: "Root Admin",
support_admin: "Support Admin",
client_owner: "Client Owner",
client_admin: "Client Admin",
member: "Member",
};
return labels[role] ?? role;
}
function taskerInviteRoleLabel(role: TaskerInviteRequest["role"]): string {
const labels: Record<TaskerInviteRequest["role"], string> = {
guest: "Guest",
member: "Member",
admin: "Admin",
};
return labels[role] ?? role;
}
function sectionTitle(section: AdminSection): string {
const labels: Record<AdminSection, string> = {
overview: "Обзор",
clients: "Клиенты",
users: "Участники",
groups: "Группы",
services: "Каталог сервисов",
access: "Доступы",
invites: "Инвайты",
sync: "Синхронизация",
audit: "Аудит",
misc: "Разное",
company: "Профиль компании",
};
return labels[section];
}
function accessCellTitle(cell: AccessMatrixCell): string {
if (!cell.effectiveAccess.allowed) return cell.effectiveAccess.source === "exception" ? accessAssignmentLabel("deny") : "—";
return accessAssignmentRoleLabel(cell.effectiveAccess.appRole);
}
function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue {
if (cell.effectiveAccess.source === "exception" && !cell.effectiveAccess.allowed) return "deny";
if (cell.effectiveAccess.source === "user" && cell.effectiveAccess.appRole) {
return cell.effectiveAccess.appRole === "owner" ? "admin" : cell.effectiveAccess.appRole;
}
return "unset";
}
function accessAssignmentLabel(value: AccessAssignmentValue): string {
return accessAssignmentOptions.find((option) => option.value === value)?.label ?? value;
}
function accessAssignmentRoleLabel(role?: ServiceAppRole | null): string {
if (!role) return "—";
return accessAssignmentLabel(role === "owner" ? "admin" : role);
}
function publicOperationalCoreCellTitle(cell: AccessMatrixCell): string {
if (!cell.effectiveAccess.allowed) return "—";
if (cell.effectiveAccess.appRole === "admin" || cell.effectiveAccess.appRole === "owner") return "Service Admin";
if (cell.effectiveAccess.appRole === "viewer") return "Workspace Guest";
return "Workspace Member";
}
function publicOperationalCoreCellSubtitle(cell: AccessMatrixCell): string {
if (!cell.effectiveAccess.allowed) return "Не назначен";
if (cell.effectiveAccess.appRole === "admin" || cell.effectiveAccess.appRole === "owner") return "Self-service";
return "Workspace invite";
}
function publicAccessAssignmentLabel(value: AccessAssignmentValue): string {
if (value === "admin") return "Service Admin";
if (value === "viewer") return "Workspace Guest";
if (value === "member") return "Workspace Member";
if (value === "deny") return "—";
return accessAssignmentLabel(value);
}
function publicOperationalCoreSelectValue(value: AccessAssignmentValue): AccessAssignmentValue {
if (value === "viewer" || value === "member" || value === "admin" || value === "deny") return value;
return "unset";
}
function accessCellKey(userId: string, serviceId: string): string {
return `${userId}:${serviceId}`;
}
function isOperationalCoreService(service: Service): boolean {
return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager";
}
function accessAssignmentToTaskManagerRole(value: AccessAssignmentValue): TaskManagerWorkspaceMemberRole {
if (value === "admin" || value === "member") return value;
if (value === "viewer") return "guest";
return "unset";
}
function taskManagerRoleToAccessAssignment(role: TaskManagerWorkspaceMemberRole): AccessAssignmentValue {
if (role === "admin" || role === "member") return role;
if (role === "guest") return "viewer";
return "unset";
}
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
if (!source) return "—";
const labels = {
client: "Клиент",
group: "Группа",
user: "Пользователь",
exception: "Исключение",
};
return labels[source];
}
function targetLabel(target: SyncStatus["target"]): string {
const labels: Record<SyncStatus["target"], string> = {
authentik: "Authentik",
task_manager: "Task Manager",
nodedc: "NodeDC",
service: "Сервис",
};
return labels[target];
}