4644 lines
182 KiB
TypeScript
4644 lines
182 KiB
TypeScript
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];
|
||
}
|