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

3351 lines
126 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DraggableAttributes,
type DraggableSyntheticListeners,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Building2,
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 { ServiceAppRole } from "../../entities/access/types";
import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types";
import type { Invite, InviteStatus } from "../../entities/invite/types";
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 {
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 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 rootSections: 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: "groups", label: "Группы", icon: <ListChecks 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} /> },
];
const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
];
export function AdminOverlay({
data,
me,
activeClientId,
onClose,
onSetUserServiceAccess,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
onRetrySync,
onCreateClient,
onUpdateClient,
onDeleteClient,
onCreateUser,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
pendingAccessAssignments,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
onUpdateService,
onReorderServices,
onCreateService,
onDeleteService,
onUpdateSettings,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
pendingTaskManagerMemberships,
pendingTaskManagerProjectMemberships,
onRefreshTaskManagerWorkspaces,
onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole,
}: {
data: LauncherData;
me: MeResponse;
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;
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;
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 sections = isRoot ? rootSections : clientSections;
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
const selectedClientExists = data.clients.some((client) => client.id === selectedClientId);
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
const currentClient = getClient(data, scopedClientId);
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(() => {
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>Администрирование</h2>
</div>
<IconButton label="Закрыть администрирование" className="admin-panel-close" onClick={onClose}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
{isRoot ? (
<NodeDcSelect
value={selectedClientId}
options={data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))}
label="Выбрать клиента"
searchable
minMenuWidth={292}
onChange={(clientId) => {
setSelectedClientId(clientId);
setSelectedCell(null);
}}
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="admin-panel-client-select"
type="button"
aria-label="Выбрать клиента"
aria-expanded={open}
onClick={toggle}
>
<span className="admin-panel-client-select__icon">
<Building2 size={16} />
</span>
<span className="admin-panel-client-select__name">{selectedOption?.label ?? currentClient.name}</span>
<span className="admin-panel-client-select__chevron" aria-hidden="true" />
</button>
)}
/>
) : (
<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} isRoot={isRoot} /> : null}
{activeSection === "clients" && isRoot ? (
<ClientsSection
data={data}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onCreateClient={onCreateClient}
onUpdateClient={onUpdateClient}
onDeleteClient={onDeleteClient}
/>
) : null}
{activeSection === "users" ? (
<UsersSection
data={data}
clientId={scopedClientId}
isRoot={isRoot}
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}
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}
onCreateInvite={onCreateInvite}
onUpdateInvite={onUpdateInvite}
onDeleteInvite={onDeleteInvite}
/>
) : 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}
{activeSection === "company" ? <CompanySection data={data} clientId={scopedClientId} /> : 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, isRoot }: { data: LauncherData; clientId: string; isRoot: boolean }) {
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" && (isRoot || sync.objectId === clientId)).length;
return (
<section className="admin-section-grid">
<MetricCard label={isRoot ? "Клиентов" : "Участников"} value={isRoot ? data.clients.length : 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">
<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,
isRoot,
onCreateUser,
onUpdateUser,
}: {
data: LauncherData;
clientId: string;
isRoot: boolean;
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 = isRoot
? data.memberships.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }))
: 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>
</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>
<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 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 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 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 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,
});
}
if (taskManager?.workspaceSlug && !bySlug.has(taskManager.workspaceSlug)) {
bySlug.set(taskManager.workspaceSlug, {
slug: taskManager.workspaceSlug,
name: taskManager.workspaceName ?? null,
isPrimary: true,
});
}
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 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,
});
}
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;
function ServicesSection({
data,
onUpdateService,
onReorderServices,
onCreateService,
onDeleteService,
}: {
data: LauncherData;
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">
<h3>Каталог сервисов</h3>
<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 [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
setDraft(service);
setStorageError(null);
setUploadingSlot(null);
}, [service]);
function update<K extends keyof Service>(key: K, value: Service[K]) {
setDraft((current) => ({ ...current, [key]: value }));
}
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") {
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");
} 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"}
onSourceChange={(source) => update("coverMediaSource", source)}
onUrlChange={(value) => {
update("coverImageUrl", value || null);
update("coverMediaSource", "url");
update("coverMediaKind", mediaKindFromUrl(value));
update("coverMediaFileName", null);
}}
onFileChange={handleCoverUpload}
/>
<MediaSourceField
label="Фоновый контент"
icon={<Video size={14} />}
source={draft.ambientMediaSource ?? "url"}
value={draft.ambientVideoUrl ?? ""}
fileName={draft.ambientMediaFileName ?? null}
isUploading={uploadingSlot === "ambient"}
onSourceChange={(source) => update("ambientMediaSource", source)}
onUrlChange={(value) => {
update("ambientVideoUrl", value || null);
update("ambientMediaSource", "url");
update("ambientMediaKind", mediaKindFromUrl(value));
update("ambientMediaFileName", null);
}}
onFileChange={handleAmbientUpload}
/>
<div className="service-content-preview service-content-preview--image">
{draft.coverImageUrl ? <MediaPreview src={draft.coverImageUrl} kind={draft.coverMediaKind} /> : <ImageIcon size={30} />}
</div>
<div className="service-content-preview service-content-preview--video">
{draft.ambientVideoUrl ? <MediaPreview src={draft.ambientVideoUrl} kind={draft.ambientMediaKind} /> : <Video size={30} />}
</div>
{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;
}) {
const [draft, setDraft] = useState<Client>(client);
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);
setUploadingAvatar(false);
setStorageError(null);
}, [client]);
function update<K extends keyof Client>(key: K, value: Client[K]) {
setDraft((current) => ({ ...current, [key]: value }));
}
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 = exists
? currentWorkspaces.filter((item) => item.slug !== workspace.slug)
: [
...currentWorkspaces,
{
slug: workspace.slug,
name: workspace.name,
isPrimary: currentWorkspaces.length === 0,
},
];
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;
setUploadingAvatar(true);
setStorageError(null);
try {
const storedFile = await uploadStorageFile(file);
update("avatarUrl", storedFile.url);
} catch (error) {
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить аватар компании");
} finally {
setUploadingAvatar(false);
}
}
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>
}
/>
<div className="service-content-modal__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">
{draft.avatarUrl ? <img src={draft.avatarUrl} alt="" /> : null}
</div>
<div className="client-avatar-control__copy">
<strong>{draft.avatarUrl ? "Аватар подключён" : "Аватар не задан"}</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>
{draft.avatarUrl ? (
<button className="admin-icon-action client-avatar-clear-action" type="button" onClick={() => update("avatarUrl", 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} участников
</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={onClose}
onSave={() => onSave(draft)}
deleteConfig={
canDelete
? {
label: "Удалить",
title: "Удалить компанию",
description: (
<>
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
</>
),
onConfirm: onDelete,
}
: undefined
}
/>
</article>
</div>
);
}
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?: {
label: string;
title: string;
description: ReactNode;
onConfirm: () => void;
};
}) {
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,
onSourceChange,
onUrlChange,
onFileChange,
}: {
label: string;
icon: ReactNode;
source: ServiceMediaSource;
value: string;
fileName?: string | null;
isUploading?: boolean;
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-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>
</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 clientTaskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client);
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(matrix.client);
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
if (!hasUsers) {
return (
<div className="access-layout">
<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="access-layout">
<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 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>
</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);
return (
<div key={service.id} className="access-grid-cell" role="cell">
<AccessCellControl
cell={cell}
active={active}
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
busy={isTaskManagerService && pendingTaskerAssignment}
onSelectCell={onSelectCell}
onSetAccess={(value) => {
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
onSetTaskManagerWorkspaceMemberRole({
clientId: matrix.client.id,
userId: user.id,
workspaceSlug: primaryTaskManagerWorkspace.slug,
role: accessAssignmentToTaskManagerRole(value),
});
}}
onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined}
/>
</div>
);
})}
</Fragment>
);
})}
</div>
</div>
</GlassSurface>
{detailsCell ? (
<OperationalCoreAccessModal
data={data}
client={matrix.client}
user={getUser(data, detailsCell.userId)}
service={getService(data, detailsCell.serviceId)}
cell={detailsCell}
workspaces={clientTaskManagerWorkspaces}
workspaceCatalog={taskManagerWorkspaceCatalog}
pendingAccessAssignments={pendingAccessAssignments}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
onClose={() => setDetailsCell(null)}
onSetUserServiceAccess={onSetUserServiceAccess}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
/>
) : null}
</div>
);
}
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={172}
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,
onSelectCell,
onSetAccess,
onOpenDetails,
}: {
cell: AccessMatrixCell;
active: boolean;
pendingValue?: AccessAssignmentValue;
busy?: boolean;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetAccess: (value: AccessAssignmentValue) => void;
onOpenDetails?: () => void;
}) {
const isPending = pendingValue !== undefined || busy;
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
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" && "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>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
</button>
);
}
return (
<NodeDcSelect
value={assignmentValue}
options={accessAssignmentOptions}
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>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
</button>
)}
/>
);
}
function InvitesSection({
data,
clientId,
actorUserId,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
}: {
data: LauncherData;
clientId: string;
actorUserId: string;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => 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 invites = data.invites.filter((invite) => invite.clientId === clientId);
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
const actor = getUser(data, actorUserId);
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">
<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">
<h3>Инвайты</h3>
</div>
<table className="admin-data-table">
<thead>
<tr>
<th>Email</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;
return (
<tr key={invite.id}>
<td>
<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>
<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>
<AdminStatusDropdown
value={invite.status}
options={inviteStatusOptions}
label={`Статус инвайта ${invite.email}`}
onChange={(status) => onUpdateInvite(invite.id, { status })}
/>
</td>
<td>
<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>
</td>
<td>
<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={
<>
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
</>
}
onClose={() => setDeleteInviteId(null)}
onConfirm={() => {
if (deletingInvite) onDeleteInvite(deletingInvite.id);
setDeleteInviteId(null);
}}
/>
</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 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 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];
}