FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: АДМИНКА РОЛЕЙ TASKER
This commit is contained in:
+16
-4
@@ -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 ? (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user