FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: АДМИНКА ДОСТУПОВ И УЧАСТНИКОВ

This commit is contained in:
DCCONSTRUCTIONS
2026-05-07 22:28:31 +03:00
parent 5f461d57ea
commit 784b3ca5c3
5 changed files with 1042 additions and 241 deletions
+323 -164
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
@@ -27,6 +27,8 @@ import {
Link2,
ListChecks,
MailPlus,
Maximize2,
Minimize2,
Plus,
RefreshCw,
Save,
@@ -131,7 +133,6 @@ const clientSections: Array<{ id: AdminSection; label: string; icon: React.React
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
];
export function AdminOverlay({
@@ -202,6 +203,7 @@ export function AdminOverlay({
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);
@@ -231,8 +233,18 @@ export function AdminOverlay({
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")}>
<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>
@@ -305,7 +317,11 @@ export function AdminOverlay({
{activeSection ? (
<section className="admin-panel-content">
<AdminHeader onCloseContent={() => setActiveSection(null)} />
<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 ? (
@@ -327,10 +343,6 @@ export function AdminOverlay({
isRoot={isRoot}
onCreateUser={onCreateUser}
onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
/>
) : null}
{activeSection === "groups" ? (
@@ -359,6 +371,10 @@ export function AdminOverlay({
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
onSetUserServiceAccess={onSetUserServiceAccess}
pendingAccessAssignments={pendingAccessAssignments}
onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
/>
) : null}
{activeSection === "invites" ? (
@@ -382,15 +398,29 @@ export function AdminOverlay({
);
}
function AdminHeader({ onCloseContent }: { onCloseContent: () => void }) {
function AdminHeader({
isFullscreen,
onToggleFullscreen,
onCloseContent,
}: {
isFullscreen: boolean;
onToggleFullscreen: () => void;
onCloseContent: () => void;
}) {
return (
<div className="admin-header">
<div className="admin-header__actions">
<IconButton label="Проверить доступы" className="admin-circle-action" type="button">
<SearchCheck size={16} />
</IconButton>
<IconButton label="Синхронизация" className="admin-circle-action admin-circle-action--solid" type="button">
<RefreshCw size={16} />
<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} />
@@ -568,22 +598,13 @@ function UsersSection({
isRoot,
onCreateUser,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
pendingTaskManagerMemberships,
onSetTaskManagerWorkspaceMemberRole,
}: {
data: LauncherData;
clientId: string;
isRoot: boolean;
onCreateUser: (command: CreateUserCommand) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
pendingTaskManagerMemberships: Record<string, boolean>;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const [newUserEmail, setNewUserEmail] = useState("");
const [newUserName, setNewUserName] = useState("");
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
@@ -593,7 +614,6 @@ function UsersSection({
: data.memberships
.filter((membership) => membership.clientId === clientId)
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
const editingRow = rows.find((row) => row.membership.id === editingMembershipId) ?? null;
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
const groupOptions: Array<NodeDcSelectOption<string>> = [
{ value: "none", label: "Без группы" },
@@ -670,110 +690,74 @@ function UsersSection({
<thead>
<tr>
<th>Пользователь</th>
{isRoot ? <th>Клиент</th> : null}
<th>Роль</th>
<th>Группы</th>
<th>Статус</th>
<th>Доступ</th>
<th>Tasker</th>
<th aria-label="Редактирование" />
<th>Клиент</th>
<th>Телефон</th>
<th>Должность</th>
<th>Заметки</th>
<th>Статус аккаунта</th>
</tr>
</thead>
<tbody>
{rows.map(({ membership, user, client }) => {
const taskManagerWorkspace = client.integrations?.taskManager?.workspaceSlug ?? null;
const pendingKey = `${client.id}:${user.id}`;
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
const forcedTaskManagerAdmin = membership.role === "client_owner";
const taskManagerAssignment = data.taskManagerMemberships.find(
(candidate) => candidate.clientId === client.id && candidate.userId === user.id && candidate.workspaceSlug === taskManagerWorkspace
);
const taskManagerRole = taskManagerAssignment?.role ?? (forcedTaskManagerAdmin ? "admin" : "unset");
const taskManagerRoleOptions = buildTaskManagerRoleOptions({
hasWorkspace: Boolean(taskManagerWorkspace),
disabled: pendingTaskerAssignment || forcedTaskManagerAdmin || membership.status !== "active" || user.globalStatus !== "active",
});
const protectedUser = user.id === "user_root";
return (
<tr key={membership.id}>
<td className="services-admin-table__service">
<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 admin-table-input--strong"
value={user.name}
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
aria-label={`Имя пользователя ${user.name}`}
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 admin-table-input--muted"
value={user.email}
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
aria-label={`Email пользователя ${user.name}`}
/>
</td>
{isRoot ? <td>{client.name}</td> : null}
<td>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={membership.role}
options={membershipRoleOptions}
label={`Роль ${user.name}`}
minMenuWidth={198}
onChange={(role) => onUpdateMembership(membership.id, { role })}
className="admin-table-input"
value={user.position ?? ""}
onChange={(event) => onUpdateUser(user.id, { position: event.target.value || null })}
placeholder="—"
aria-label={`Должность пользователя ${user.name}`}
/>
</td>
<td>
{data.groups
.filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id))
.map((group) => group.name)
.join(", ") || "—"}
</td>
<td>
<AdminStatusDropdown
value={user.globalStatus}
options={userStatusOptions}
label={`Статус пользователя ${user.name}`}
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
<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>
<AdminStatusDropdown
value={membership.status}
options={membershipStatusOptions}
label={`Доступ пользователя ${user.name}`}
onChange={(status) => onUpdateMembership(membership.id, { status })}
/>
</td>
<td>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={pendingTaskerAssignment ? "pending" : taskManagerRole}
options={taskManagerRoleOptions}
label={`Роль Tasker ${user.name}`}
minMenuWidth={180}
disabled={
!taskManagerWorkspace ||
pendingTaskerAssignment ||
forcedTaskManagerAdmin ||
membership.status !== "active" ||
user.globalStatus !== "active"
}
onChange={(role) => {
if (role === "pending") return;
onSetTaskManagerWorkspaceMemberRole({ clientId: client.id, userId: user.id, role });
}}
/>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Редактировать пользователя ${user.name}`}
className="admin-circle-action services-admin-table__edit"
type="button"
onClick={() => setEditingMembershipId(membership.id)}
>
<Edit3 size={15} />
</IconButton>
{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>
);
@@ -781,24 +765,6 @@ function UsersSection({
</tbody>
</table>
</GlassSurface>
{editingRow ? (
<UserEditorModal
user={editingRow.user}
membership={editingRow.membership}
client={editingRow.client}
onClose={() => setEditingMembershipId(null)}
onSave={(userPatch, membershipPatch) => {
onUpdateUser(editingRow.user.id, userPatch);
onUpdateMembership(editingRow.membership.id, membershipPatch);
setEditingMembershipId(null);
}}
onDelete={() => {
onDeleteMembership(editingRow.membership.id);
setEditingMembershipId(null);
}}
/>
) : null}
</>
);
}
@@ -936,6 +902,12 @@ const userStatusOptions: Array<AdminStatusOption<LauncherUserStatus>> = [
{ 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" },
@@ -986,6 +958,34 @@ function buildTaskManagerRoleOptions({
];
}
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 taskManagerRoleLabel(role: TaskManagerWorkspaceMemberRole | TaskManagerRoleSelectValue): string {
const labels: Record<TaskManagerRoleSelectValue, string> = {
unset: "—",
guest: "Гость",
member: "Участник",
admin: "Админ",
pending: "Сохраняем...",
};
return labels[role];
}
function AdminStaticPill({ children }: { children: ReactNode }) {
return <span className="admin-static-pill">{children}</span>;
}
const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWorkspaceCreationPolicy>> = [
{
value: "any_authorized_user",
@@ -2102,6 +2102,10 @@ function AccessSection({
onSelectCell,
onSetUserServiceAccess,
pendingAccessAssignments,
onUpdateUser,
onUpdateMembership,
pendingTaskManagerMemberships,
onSetTaskManagerWorkspaceMemberRole,
}: {
data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>;
@@ -2109,10 +2113,15 @@ function AccessSection({
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>;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null;
const hasUsers = matrix.users.length > 0;
const taskManagerWorkspace = matrix.client.integrations?.taskManager?.workspaceSlug ?? null;
if (!hasMatrixData) {
if (!hasUsers) {
return (
<div className="access-layout">
<GlassSurface className="access-matrix">
@@ -2138,8 +2147,9 @@ function AccessSection({
);
}
const selectedUser = getUser(data, selectedCell.userId);
const selectedService = getService(data, selectedCell.serviceId);
const selectedUser = selectedCell ? getUser(data, selectedCell.userId) : null;
const selectedService = selectedCell ? getService(data, selectedCell.serviceId) : null;
const accessGridTemplateColumns = `15rem repeat(${matrix.services.length + 2}, 9.7rem)`;
return (
<div className="access-layout">
@@ -2149,75 +2159,214 @@ function AccessSection({
<span className="muted-text">Клик по ячейке открывает назначение</span>
</div>
<div className="matrix-scroll">
<table>
<thead>
<tr>
<th>Участник</th>
{matrix.services.map((service) => (
<th key={service.id}>{service.title}</th>
))}
</tr>
</thead>
<tbody>
{matrix.users.map((user) => (
<tr key={user.id}>
<td>
<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}`;
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>
</td>
</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 active = selectedCell?.userId === user.id && selectedCell.serviceId === service.id;
const isTaskManagerService = isOperationalCoreService(service);
return (
<td key={service.id}>
<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 })}
onSetAccess={(value) => {
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
if (!isTaskManagerService || !taskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
onSetTaskManagerWorkspaceMemberRole({
clientId: matrix.client.id,
userId: user.id,
role: accessAssignmentToTaskManagerRole(value),
});
}}
/>
</td>
</div>
);
})}
</tr>
))}
</tbody>
</table>
</Fragment>
);
})}
</div>
</div>
</GlassSurface>
<GlassSurface className="access-explanation">
<p className="eyebrow">Explanation panel</p>
<h3>
{selectedUser.name} / {selectedService.title}
</h3>
<div className="explanation-stack">
<InfoLine label="Итог" value={selectedCell.effectiveAccess.allowed ? "Есть доступ" : "Нет доступа"} />
<InfoLine label="Можно открыть" value={selectedCell.effectiveAccess.openEnabled ? "Да" : "Нет"} />
<InfoLine label="Причина" value={selectedCell.effectiveAccess.reason} />
<InfoLine label="Источник" value={sourceLabel(selectedCell.effectiveAccess.source)} />
<InfoLine label="Роль" value={selectedCell.effectiveAccess.appRole ?? "—"} />
</div>
{selectedCell && selectedUser && selectedService ? (
<>
<h3>
{selectedUser.name} / {selectedService.title}
</h3>
<div className="explanation-stack">
<InfoLine label="Итог" value={selectedCell.effectiveAccess.allowed ? "Есть доступ" : "Нет доступа"} />
<InfoLine label="Можно открыть" value={selectedCell.effectiveAccess.openEnabled ? "Да" : "Нет"} />
<InfoLine label="Причина" value={selectedCell.effectiveAccess.reason} />
<InfoLine label="Источник" value={sourceLabel(selectedCell.effectiveAccess.source)} />
<InfoLine label="Роль" value={selectedCell.effectiveAccess.appRole ?? "—"} />
</div>
</>
) : (
<>
<h3>Сервис не выбран</h3>
<div className="explanation-stack">
<InfoLine label="Итог" value="Выберите сервисную ячейку" />
<InfoLine label="MAIN" value="Базовые роли и статусы применяются сразу" />
</div>
</>
)}
</GlassSurface>
</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 AccessCellControl({
cell,
active,
pendingValue,
busy = false,
onSelectCell,
onSetAccess,
}: {
cell: AccessMatrixCell;
active: boolean;
pendingValue?: AccessAssignmentValue;
busy?: boolean;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetAccess: (value: AccessAssignmentValue) => void;
}) {
const isPending = pendingValue !== undefined;
const isPending = pendingValue !== undefined || busy;
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
return (
@@ -2738,6 +2887,16 @@ 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 sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
if (!source) return "—";
const labels = {