FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: АДМИНКА РОЛЕЙ TASKER

This commit is contained in:
DCCONSTRUCTIONS
2026-05-06 15:30:56 +03:00
parent d4eba0ff3a
commit 5f461d57ea
9 changed files with 604 additions and 58 deletions
+16 -4
View File
@@ -20,6 +20,7 @@ import {
fetchControlPlaneSnapshot,
reorderAdminServices,
retryAdminSync,
removeAdminTaskManagerWorkspaceMembership,
setAdminUserServiceAccess,
updateAdminClient,
updateAdminGroup,
@@ -29,6 +30,7 @@ import {
updateAdminSettings,
updateAdminUserProfile,
type ControlPlaneMutationResult,
type TaskManagerWorkspaceMemberRole,
type TaskManagerWorkspaceSummary,
} from "../shared/api/adminApi";
import {
@@ -467,7 +469,7 @@ export function LauncherApp() {
});
}
function handleEnsureTaskManagerWorkspaceMember(command: { clientId: string; userId: string; role?: "member" | "admin" }) {
function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole }) {
const membershipKey = `${command.clientId}:${command.userId}`;
if (pendingTaskManagerMemberships[membershipKey]) {
@@ -475,12 +477,22 @@ export function LauncherApp() {
}
setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true }));
ensureAdminTaskManagerWorkspaceMembership({ ...command, setLastWorkspace: true })
const request =
command.role === "unset"
? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId })
: ensureAdminTaskManagerWorkspaceMembership({
clientId: command.clientId,
userId: command.userId,
role: command.role,
setLastWorkspace: true,
});
request
.then((result) => {
setData(syncLauncherServiceLinks(result.data));
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось назначить workspace Operational Core");
console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать Tasker");
})
.finally(() => {
setPendingTaskManagerMemberships((current) => {
@@ -737,7 +749,7 @@ export function LauncherApp() {
taskManagerWorkspacesError={taskManagerWorkspacesError}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
onEnsureTaskManagerWorkspaceMember={handleEnsureTaskManagerWorkspaceMember}
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
/>
) : null}
{profileSettingsOpen && activeProfileUser ? (
+26 -1
View File
@@ -52,6 +52,8 @@ export interface TaskManagerWorkspaceMembershipResult {
isBanned: boolean;
}
export type TaskManagerWorkspaceMemberRole = "unset" | "guest" | "member" | "admin";
export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult {
taskManager: {
ok: boolean;
@@ -59,6 +61,19 @@ export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPla
};
}
export interface TaskManagerWorkspaceMembershipRemoveMutationResult extends ControlPlaneMutationResult {
taskManager: {
ok: boolean;
removed: boolean;
workspace: TaskManagerWorkspaceSummary;
member: {
id: string;
email: string;
displayName: string;
};
};
}
export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> {
return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane");
}
@@ -135,7 +150,7 @@ export async function deleteAdminMembership(membershipId: string): Promise<Contr
export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
clientId: string;
userId: string;
role?: "member" | "admin";
role?: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
setLastWorkspace?: boolean;
}): Promise<TaskManagerWorkspaceMembershipMutationResult> {
return requestJson<TaskManagerWorkspaceMembershipMutationResult>("/api/admin/task-manager/workspace-memberships/ensure", {
@@ -144,6 +159,16 @@ export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
});
}
export async function removeAdminTaskManagerWorkspaceMembership(payload: {
clientId: string;
userId: string;
}): Promise<TaskManagerWorkspaceMembershipRemoveMutationResult> {
return requestJson<TaskManagerWorkspaceMembershipRemoveMutationResult>("/api/admin/task-manager/workspace-memberships/remove", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function createAdminGroup(payload: Pick<ClientGroup, "clientId"> & Partial<ClientGroup>): Promise<ControlPlaneMutationResult> {
return requestJson<ControlPlaneMutationResult>("/api/admin/groups", {
method: "POST",
+14
View File
@@ -60,9 +60,22 @@ export interface LauncherData {
invites: Invite[];
syncStatuses: SyncStatus[];
auditEvents: typeof mockAuditEvents;
taskManagerMemberships: TaskManagerMembershipAssignment[];
settings: LauncherSettings;
}
export interface TaskManagerMembershipAssignment {
id: string;
clientId: string;
userId: string;
workspaceSlug: string;
workspaceName?: string | null;
role: "guest" | "member" | "admin";
planeUserId?: string | null;
planeRole?: number | null;
updatedAt: string;
}
export interface LauncherSettings {
brand: {
logoLinkUrl: string;
@@ -160,6 +173,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
settings: normalizeLauncherSettings(payload.settings),
};
}
+5 -3
View File
@@ -10,6 +10,7 @@ export interface NodeDcSelectOption<T extends string> {
icon?: ReactNode;
tone?: string;
disabled?: boolean;
hidden?: boolean;
}
interface NodeDcSelectTriggerApi<T extends string> {
@@ -59,14 +60,15 @@ export function NodeDcSelect<T extends string>({
const [query, setQuery] = useState("");
const selectedOption = options.find((option) => option.value === value) ?? options[0];
const normalizedQuery = query.trim().toLowerCase();
const menuOptions = useMemo(() => options.filter((option) => !option.hidden), [options]);
const visibleOptions = useMemo(() => {
if (!normalizedQuery) return options;
if (!normalizedQuery) return menuOptions;
return options.filter((option) => {
return menuOptions.filter((option) => {
const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [normalizedQuery, options]);
}, [menuOptions, normalizedQuery]);
return (
<NodeDcDropdown
+48 -21
View File
@@ -2070,6 +2070,53 @@ code {
padding: 1rem;
}
.admin-data-table--users {
min-width: 82rem;
table-layout: fixed;
}
.admin-data-table--users th,
.admin-data-table--users td {
white-space: nowrap;
}
.admin-data-table--users th:nth-child(1),
.admin-data-table--users td:nth-child(1) {
width: 17rem;
}
.admin-data-table--users th:nth-child(2),
.admin-data-table--users td:nth-child(2) {
width: 8.2rem;
}
.admin-data-table--users th:nth-child(3),
.admin-data-table--users td:nth-child(3) {
width: 8.5rem;
}
.admin-data-table--users th:nth-child(4),
.admin-data-table--users td:nth-child(4) {
width: 13rem;
}
.admin-data-table--users th:nth-child(5),
.admin-data-table--users td:nth-child(5),
.admin-data-table--users th:nth-child(6),
.admin-data-table--users td:nth-child(6) {
width: 10rem;
}
.admin-data-table--users th:nth-child(7),
.admin-data-table--users td:nth-child(7) {
width: 9.6rem;
}
.admin-data-table--users th:nth-child(8),
.admin-data-table--users td:nth-child(8) {
width: 3.6rem;
}
.table-toolbar {
margin-bottom: 0.7rem;
}
@@ -2194,29 +2241,9 @@ code {
cursor: pointer;
}
.admin-inline-action {
display: inline-flex;
min-height: 2.1rem;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--launcher-radius-circle);
background: rgba(181, 255, 90, 0.92);
color: #050805;
padding: 0 0.85rem;
font-size: 0.72rem;
font-weight: 850;
cursor: pointer;
}
.admin-inline-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.admin-field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) 2.85rem;
gap: 0.6rem;
align-items: center;
}
+63 -22
View File
@@ -64,7 +64,7 @@ import {
type MeResponse,
type TaskManagerWorkspaceCreationPolicy,
} from "../../shared/api/mockApi";
import type { TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
import type { 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";
@@ -87,6 +87,7 @@ type AdminSection =
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
type TaskManagerRoleSelectValue = TaskManagerWorkspaceMemberRole | "pending";
export interface SetUserServiceAccessCommand {
userId: string;
@@ -107,7 +108,7 @@ export interface CreateUserCommand {
export interface EnsureTaskManagerWorkspaceMemberCommand {
clientId: string;
userId: string;
role?: "member" | "admin";
role: TaskManagerWorkspaceMemberRole;
}
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
@@ -164,7 +165,7 @@ export function AdminOverlay({
taskManagerWorkspacesError,
pendingTaskManagerMemberships,
onRefreshTaskManagerWorkspaces,
onEnsureTaskManagerWorkspaceMember,
onSetTaskManagerWorkspaceMemberRole,
}: {
data: LauncherData;
me: MeResponse;
@@ -196,7 +197,7 @@ export function AdminOverlay({
taskManagerWorkspacesError: string | null;
pendingTaskManagerMemberships: Record<string, boolean>;
onRefreshTaskManagerWorkspaces: () => void;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const isRoot = me.launcherRole === "root_admin";
const sections = isRoot ? rootSections : clientSections;
@@ -329,7 +330,7 @@ export function AdminOverlay({
onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onEnsureTaskManagerWorkspaceMember={onEnsureTaskManagerWorkspaceMember}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
/>
) : null}
{activeSection === "groups" ? (
@@ -570,7 +571,7 @@ function UsersSection({
onUpdateMembership,
onDeleteMembership,
pendingTaskManagerMemberships,
onEnsureTaskManagerWorkspaceMember,
onSetTaskManagerWorkspaceMemberRole,
}: {
data: LauncherData;
clientId: string;
@@ -580,7 +581,7 @@ function UsersSection({
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
pendingTaskManagerMemberships: Record<string, boolean>;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const [newUserEmail, setNewUserEmail] = useState("");
@@ -661,11 +662,11 @@ function UsersSection({
</div>
</GlassSurface>
<GlassSurface className="table-shell">
<GlassSurface className="table-shell table-shell--users">
<div className="table-toolbar">
<h3>Участники</h3>
</div>
<table className="admin-data-table">
<table className="admin-data-table admin-data-table--users">
<thead>
<tr>
<th>Пользователь</th>
@@ -683,7 +684,15 @@ function UsersSection({
const taskManagerWorkspace = client.integrations?.taskManager?.workspaceSlug ?? null;
const pendingKey = `${client.id}:${user.id}`;
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
const taskManagerRole = membership.role === "client_owner" || membership.role === "client_admin" ? "admin" : "member";
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",
});
return (
<tr key={membership.id}>
@@ -736,15 +745,25 @@ function UsersSection({
/>
</td>
<td>
<button
className="admin-inline-action"
type="button"
disabled={!taskManagerWorkspace || pendingTaskerAssignment || membership.status !== "active" || user.globalStatus !== "active"}
title={taskManagerWorkspace ? `Workspace: ${taskManagerWorkspace}` : "У клиента не выбран workspace Operational Core"}
onClick={() => onEnsureTaskManagerWorkspaceMember({ clientId: client.id, userId: user.id, role: taskManagerRole })}
>
{pendingTaskerAssignment ? "Назначаем" : taskManagerWorkspace ? "Назначить" : "Нет workspace"}
</button>
<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
@@ -951,6 +970,22 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
];
function buildTaskManagerRoleOptions({
hasWorkspace,
disabled,
}: {
hasWorkspace: boolean;
disabled: boolean;
}): Array<NodeDcSelectOption<TaskManagerRoleSelectValue>> {
return [
{ value: "unset", label: hasWorkspace ? "—" : "Workspace не выбран" },
{ value: "guest", label: "Гость", disabled: !hasWorkspace || disabled },
{ value: "member", label: "Участник", disabled: !hasWorkspace || disabled },
{ value: "admin", label: "Админ", disabled: !hasWorkspace || disabled },
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
];
}
const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWorkspaceCreationPolicy>> = [
{
value: "any_authorized_user",
@@ -1651,9 +1686,15 @@ function ClientEditorModal({
minMenuWidth={280}
onChange={updateTaskManagerWorkspace}
/>
<button className="admin-inline-action" type="button" disabled={taskManagerWorkspacesLoading} onClick={onRefreshTaskManagerWorkspaces}>
{taskManagerWorkspacesLoading ? "Обновляем" : "Обновить"}
</button>
<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>
<small>
{taskManagerWorkspacesError