ФУНКЦИИ - NODEDC LAUNCHER: управление пользователями платформы
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
deleteAdminInvite,
|
||||
deleteAdminMembership,
|
||||
deleteAdminService,
|
||||
deleteAdminUser,
|
||||
ensureAdminTaskManagerProjectMembership,
|
||||
ensureAdminTaskManagerWorkspaceMembership,
|
||||
fetchAdminTaskManagerWorkspaces,
|
||||
@@ -732,6 +733,10 @@ export function LauncherApp() {
|
||||
applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
|
||||
}
|
||||
|
||||
function handleDeleteUser(userId: string) {
|
||||
applyControlPlaneMutation(deleteAdminUser(userId));
|
||||
}
|
||||
|
||||
async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) {
|
||||
const result = await updateOwnProfile(patch);
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
@@ -892,6 +897,7 @@ export function LauncherApp() {
|
||||
onDeleteClient={handleDeleteClient}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onUpdateMembership={handleUpdateMembership}
|
||||
onDeleteMembership={handleDeleteMembership}
|
||||
pendingAccessAssignments={pendingAccessAssignments}
|
||||
|
||||
@@ -181,6 +181,10 @@ export async function updateAdminUserProfile(userId: string, patch: Partial<Laun
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(userId: string): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: {
|
||||
clientId: string;
|
||||
email: string;
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface LauncherData {
|
||||
exceptions: ServiceAccessException[];
|
||||
invites: Invite[];
|
||||
accessRequests: AccessRequest[];
|
||||
revokedAccounts: RevokedAccount[];
|
||||
taskerInviteRequests: TaskerInviteRequest[];
|
||||
syncStatuses: SyncStatus[];
|
||||
auditEvents: typeof mockAuditEvents;
|
||||
@@ -72,6 +73,21 @@ export interface LauncherData {
|
||||
settings: LauncherSettings;
|
||||
}
|
||||
|
||||
export interface RevokedAccount {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
sourceUserId?: string | null;
|
||||
authentikUserId?: string | null;
|
||||
reason: string;
|
||||
revokedByUserId?: string | null;
|
||||
revokedByUserEmail?: string | null;
|
||||
revokedByUserName?: string | null;
|
||||
revokedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskManagerMembershipAssignment {
|
||||
id: string;
|
||||
clientId: string;
|
||||
@@ -152,6 +168,7 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({
|
||||
exceptions: mockExceptions,
|
||||
invites: mockInvites,
|
||||
accessRequests: mockAccessRequests,
|
||||
revokedAccounts: [],
|
||||
taskerInviteRequests: mockTaskerInviteRequests,
|
||||
syncStatuses: mockSyncStatuses,
|
||||
auditEvents: mockAuditEvents,
|
||||
@@ -199,6 +216,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
||||
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
||||
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
||||
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
||||
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],
|
||||
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
||||
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||
|
||||
@@ -2336,6 +2336,108 @@ code {
|
||||
width: 10.2rem;
|
||||
}
|
||||
|
||||
.table-shell--platform-users {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users {
|
||||
min-width: 82rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users th,
|
||||
.admin-data-table--platform-users td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users th:nth-child(1),
|
||||
.admin-data-table--platform-users td:nth-child(1) {
|
||||
width: 17rem;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users th:nth-child(2),
|
||||
.admin-data-table--platform-users td:nth-child(2) {
|
||||
width: 18rem;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users th:nth-child(3),
|
||||
.admin-data-table--platform-users td:nth-child(3) {
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users th:nth-child(4),
|
||||
.admin-data-table--platform-users td:nth-child(4) {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users th:nth-child(5),
|
||||
.admin-data-table--platform-users td:nth-child(5) {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.admin-data-table--platform-users th:nth-child(6),
|
||||
.admin-data-table--platform-users td:nth-child(6) {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.platform-user-origin {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-user-origin strong,
|
||||
.platform-user-origin small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-user-origin strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.platform-user-origin small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.admin-data-table--public-access-users {
|
||||
min-width: 86rem;
|
||||
}
|
||||
|
||||
.admin-data-table--public-access-users th:nth-child(1),
|
||||
.admin-data-table--public-access-users td:nth-child(1) {
|
||||
width: 17rem;
|
||||
min-width: 17rem;
|
||||
}
|
||||
|
||||
.admin-data-table--public-access-users th:nth-child(2),
|
||||
.admin-data-table--public-access-users td:nth-child(2) {
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
.admin-data-table--public-access-users th:nth-child(3),
|
||||
.admin-data-table--public-access-users td:nth-child(3),
|
||||
.admin-data-table--public-access-users th:nth-child(4),
|
||||
.admin-data-table--public-access-users td:nth-child(4),
|
||||
.admin-data-table--public-access-users th:nth-child(5),
|
||||
.admin-data-table--public-access-users td:nth-child(5) {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.admin-data-table--public-access-users th:nth-child(6),
|
||||
.admin-data-table--public-access-users td:nth-child(6) {
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
.admin-data-table--public-access-users .access-cell {
|
||||
max-width: 12.5rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.membership-inviter-cell {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
@@ -2510,6 +2612,20 @@ code {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.admin-table-text {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table-text--strong {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.admin-table-input--select {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
@@ -3415,6 +3531,14 @@ code {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.access-layout--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.access-tabs-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.matrix-scroll {
|
||||
overflow: auto;
|
||||
border-radius: calc(var(--launcher-radius-card) - 0.85rem);
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface EnsureTaskManagerProjectMemberCommand {
|
||||
const platformSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||
{ id: "clients", label: "Компании", icon: <Building2 size={16} /> },
|
||||
{ id: "users", label: "Пользователи", icon: <UsersRound size={16} /> },
|
||||
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||||
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
||||
@@ -174,6 +175,7 @@ export function AdminOverlay({
|
||||
onDeleteClient,
|
||||
onCreateUser,
|
||||
onUpdateUser,
|
||||
onDeleteUser,
|
||||
onUpdateMembership,
|
||||
onDeleteMembership,
|
||||
pendingAccessAssignments,
|
||||
@@ -220,6 +222,7 @@ export function AdminOverlay({
|
||||
onDeleteClient: (clientId: string) => void;
|
||||
onCreateUser: (command: CreateUserCommand) => void;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onDeleteUser: (userId: string) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
onDeleteMembership: (membershipId: string) => void;
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
@@ -468,12 +471,16 @@ export function AdminOverlay({
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "users" ? (
|
||||
<UsersSection
|
||||
data={data}
|
||||
clientId={scopedClientId}
|
||||
onCreateUser={onCreateUser}
|
||||
onUpdateUser={onUpdateUser}
|
||||
/>
|
||||
isPlatformMode ? (
|
||||
<PlatformUsersSection data={data} onUpdateUser={onUpdateUser} onDeleteUser={onDeleteUser} />
|
||||
) : (
|
||||
<UsersSection
|
||||
data={data}
|
||||
clientId={scopedClientId}
|
||||
onCreateUser={onCreateUser}
|
||||
onUpdateUser={onUpdateUser}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{activeSection === "groups" ? (
|
||||
<GroupsSection
|
||||
@@ -1005,6 +1012,150 @@ function UsersSection({
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformUsersSection({
|
||||
data,
|
||||
onUpdateUser,
|
||||
onDeleteUser,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onDeleteUser: (userId: string) => void;
|
||||
}) {
|
||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
|
||||
const deletingUser = data.users.find((user) => user.id === deleteUserId) ?? null;
|
||||
const rows = data.users
|
||||
.map((user) => {
|
||||
const memberships = data.memberships.filter((membership) => membership.userId === user.id);
|
||||
return {
|
||||
user,
|
||||
memberships,
|
||||
origin: getPlatformUserOrigin(data, user, memberships),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.user.id === "user_root") return -1;
|
||||
if (right.user.id === "user_root") return 1;
|
||||
return left.origin.label.localeCompare(right.origin.label, "ru") || left.user.name.localeCompare(right.user.name, "ru");
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlassSurface className="table-shell table-shell--platform-users">
|
||||
<div className="table-toolbar">
|
||||
<div>
|
||||
<h3>Пользователи платформы</h3>
|
||||
<p className="admin-helper-note">
|
||||
Глобальный реестр аккаунтов. Доступы к сервисам остаются в матрицах; здесь — происхождение, статус аккаунта и полное удаление.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<table className="admin-data-table admin-data-table--platform-users">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Пользователь</th>
|
||||
<th>Происхождение</th>
|
||||
<th>Контуры</th>
|
||||
<th>Статус аккаунта</th>
|
||||
<th>Создан</th>
|
||||
<th aria-label="Удаление" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ user, memberships, origin }) => {
|
||||
const protectedUser = user.id === "user_root";
|
||||
const contextLabel =
|
||||
memberships.length === 0
|
||||
? "—"
|
||||
: memberships
|
||||
.map((membership) => getClient(data, membership.clientId).name)
|
||||
.filter((value, index, list) => list.indexOf(value) === index)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td className="admin-user-cell">
|
||||
<div className="admin-user-cell__fields">
|
||||
<input
|
||||
className="admin-table-input admin-table-input--strong"
|
||||
value={user.name}
|
||||
disabled={protectedUser}
|
||||
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
|
||||
aria-label={`Имя пользователя ${user.name}`}
|
||||
/>
|
||||
<input
|
||||
className="admin-table-input admin-table-input--muted"
|
||||
value={user.email}
|
||||
disabled={protectedUser}
|
||||
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
||||
aria-label={`Email пользователя ${user.name}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="platform-user-origin">
|
||||
<strong>{origin.label}</strong>
|
||||
{origin.detail ? <small>{origin.detail}</small> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-table-text" title={contextLabel}>
|
||||
{contextLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{protectedUser ? (
|
||||
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
|
||||
) : (
|
||||
<AdminStatusDropdown
|
||||
value={user.globalStatus}
|
||||
options={userStatusOptions}
|
||||
label={`Статус аккаунта ${user.name}`}
|
||||
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatDate(user.createdAt)}</td>
|
||||
<td className="services-admin-table__actions">
|
||||
{protectedUser ? (
|
||||
<span className="admin-table-action-placeholder" />
|
||||
) : (
|
||||
<IconButton
|
||||
label={`Удалить пользователя ${user.email} полностью`}
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</GlassSurface>
|
||||
|
||||
<NodeDcDeleteModal
|
||||
isOpen={Boolean(deletingUser)}
|
||||
title="Удалить пользователя полностью"
|
||||
description={
|
||||
<>
|
||||
Будут удалены профиль <strong>{deletingUser?.email}</strong>, членства, сервисные доступы, заявки, инвайты и sync-записи.
|
||||
Действие необратимо.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Удалить везде"
|
||||
onClose={() => setDeleteUserId(null)}
|
||||
onConfirm={() => {
|
||||
if (deletingUser) onDeleteUser(deletingUser.id);
|
||||
setDeleteUserId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsSection({
|
||||
data,
|
||||
clientId,
|
||||
@@ -1156,6 +1307,9 @@ const inviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = [
|
||||
{ value: "expired", label: "Истёк", tone: "red" },
|
||||
{ value: "revoked", label: "Отозван", tone: "red" },
|
||||
];
|
||||
const editableInviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = inviteStatusOptions.filter(
|
||||
(option) => option.value !== "accepted"
|
||||
);
|
||||
|
||||
const accessRequestStatusOptions: Array<AdminStatusOption<AccessRequestStatus>> = [
|
||||
{ value: "new", label: "Входящая", tone: "yellow" },
|
||||
@@ -1216,6 +1370,23 @@ function membershipRoleLabel(role: ClientMembershipRole): string {
|
||||
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
||||
}
|
||||
|
||||
function inviteSourceLabel(invite: Invite): string {
|
||||
if (invite.source === "access_request") return "Заявка доступа";
|
||||
if (invite.source === "tasker_workspace_invite") return "Workspace-инвайт";
|
||||
return "Ручной инвайт";
|
||||
}
|
||||
|
||||
function inviteCanBeCopied(invite: Invite): boolean {
|
||||
return invite.status === "created" || invite.status === "sent";
|
||||
}
|
||||
|
||||
function inviteTerminalLabel(invite: Invite): string {
|
||||
if (invite.status === "accepted") return "Пользователь зарегистрирован";
|
||||
if (invite.status === "revoked") return "Ссылка отозвана";
|
||||
if (invite.status === "expired") return "Срок истёк";
|
||||
return "Ссылка недоступна";
|
||||
}
|
||||
|
||||
function statusOptionLabel<T extends string>(options: Array<AdminStatusOption<T>>, value: T): string {
|
||||
return options.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
@@ -2715,7 +2886,7 @@ function AccessSection({
|
||||
|
||||
if (!hasUsers) {
|
||||
return (
|
||||
<div className="access-layout">
|
||||
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||
<GlassSurface className="access-matrix">
|
||||
<div className="table-toolbar">
|
||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||
@@ -2733,7 +2904,7 @@ function AccessSection({
|
||||
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
|
||||
|
||||
return (
|
||||
<div className="access-layout">
|
||||
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||
<GlassSurface className="access-matrix">
|
||||
<div className="table-toolbar">
|
||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||
@@ -2854,6 +3025,151 @@ function AccessSection({
|
||||
);
|
||||
}
|
||||
|
||||
function PublicAccessUsersPanel({
|
||||
data,
|
||||
matrix,
|
||||
selectedCell,
|
||||
onSelectCell,
|
||||
onSetUserServiceAccess,
|
||||
pendingAccessAssignments,
|
||||
onUpdateUser,
|
||||
onUpdateMembership,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
matrix: ReturnType<typeof buildAccessMatrix>;
|
||||
selectedCell: AccessMatrixCell | null;
|
||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
}) {
|
||||
const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null;
|
||||
|
||||
return (
|
||||
<GlassSurface className="table-shell table-shell--users table-shell--public-access-users">
|
||||
<div className="table-toolbar">
|
||||
<div>
|
||||
<h3>Пользователи открытого контура</h3>
|
||||
<p className="admin-helper-note">
|
||||
Блокировка аккаунта отключает вход через Authentik и не удаляет историю заявок, инвайтов и аудит.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<table className="admin-data-table admin-data-table--users admin-data-table--public-access-users">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Пользователь</th>
|
||||
<th>Кто пригласил</th>
|
||||
<th>Роль в контуре</th>
|
||||
<th>Статус в контуре</th>
|
||||
<th>Статус аккаунта</th>
|
||||
<th>Operational Core</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrix.users.map((user) => {
|
||||
const membership = data.memberships.find((item) => item.clientId === matrix.client.id && item.userId === user.id);
|
||||
if (!membership) return null;
|
||||
|
||||
const protectedUser = user.id === "user_root";
|
||||
const inviterMeta = getMembershipInviterMeta(data, membership);
|
||||
const operationalCoreCell = operationalCoreService
|
||||
? matrix.cells.find((cell) => cell.userId === user.id && cell.serviceId === operationalCoreService.id) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr key={membership.id}>
|
||||
<td className="admin-user-cell">
|
||||
<div className="admin-user-cell__fields">
|
||||
<input
|
||||
className="admin-table-input admin-table-input--strong"
|
||||
value={user.name}
|
||||
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
|
||||
aria-label={`Имя пользователя ${user.name}`}
|
||||
/>
|
||||
<input
|
||||
className="admin-table-input admin-table-input--muted"
|
||||
value={user.email}
|
||||
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
||||
aria-label={`Email пользователя ${user.name}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{inviterMeta.showInAccessMatrix ? (
|
||||
<div className="membership-inviter-cell">
|
||||
<span>{inviterMeta.title}</span>
|
||||
<small>{inviterMeta.sourceLabel}</small>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-text">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{protectedUser ? (
|
||||
<AdminStaticPill>{membershipRoleLabel(membership.role)}</AdminStaticPill>
|
||||
) : (
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={membership.role}
|
||||
options={membershipRoleOptions}
|
||||
label={`Роль в открытом контуре ${user.name}`}
|
||||
minMenuWidth={198}
|
||||
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusDropdown
|
||||
value={membership.status}
|
||||
options={membershipStatusOptions}
|
||||
label={`Статус в открытом контуре ${user.name}`}
|
||||
onChange={(status) => onUpdateMembership(membership.id, { status })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{protectedUser ? (
|
||||
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
|
||||
) : (
|
||||
<AdminStatusDropdown
|
||||
value={user.globalStatus}
|
||||
options={userStatusOptions}
|
||||
label={`Статус аккаунта ${user.name}`}
|
||||
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{operationalCoreCell ? (
|
||||
<AccessCellControl
|
||||
cell={operationalCoreCell}
|
||||
active={selectedCell?.userId === user.id && selectedCell.serviceId === operationalCoreCell.serviceId}
|
||||
pendingValue={pendingAccessAssignments[accessCellKey(user.id, operationalCoreCell.serviceId)]}
|
||||
publicSelfService
|
||||
onSelectCell={onSelectCell}
|
||||
onSetAccess={(value) =>
|
||||
onSetUserServiceAccess({
|
||||
userId: user.id,
|
||||
serviceId: operationalCoreCell.serviceId,
|
||||
value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="muted-text">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function MainStatusControl({
|
||||
value,
|
||||
protectedUser,
|
||||
@@ -3245,6 +3561,8 @@ function InvitesSection({
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
|
||||
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
||||
const incomingRequestsTotal =
|
||||
data.accessRequests.length + data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length;
|
||||
const pendingIncomingRequests =
|
||||
data.accessRequests.filter((request) => request.status === "new").length +
|
||||
data.taskerInviteRequests.filter((request) => request.status === "new").length;
|
||||
@@ -3293,7 +3611,7 @@ function InvitesSection({
|
||||
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
|
||||
onClick={() => setPublicInviteTab("incoming")}
|
||||
>
|
||||
Входящие · {pendingIncomingRequests}
|
||||
Входящие · {incomingRequestsTotal}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -3304,7 +3622,9 @@ function InvitesSection({
|
||||
</button>
|
||||
</div>
|
||||
<p className="admin-helper-note">
|
||||
Входящие — заявки из формы “Запросить доступ”. Исходящие — инвайты, которые root-admin выпускает руками без заявки.
|
||||
{publicInviteTab === "incoming"
|
||||
? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.`
|
||||
: "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}
|
||||
</p>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
@@ -3354,12 +3674,20 @@ function InvitesSection({
|
||||
|
||||
<GlassSurface className="table-shell">
|
||||
<div className="table-toolbar">
|
||||
<h3>Инвайты</h3>
|
||||
<div>
|
||||
<h3>{isPublicPoolContext ? "Исходящие инвайты" : "Инвайты"}</h3>
|
||||
{isPublicPoolContext ? (
|
||||
<p className="admin-helper-note">
|
||||
Отзыв блокирует только неиспользованную ссылку. Уже принятый инвайт управляется через Root Admin → Пользователи.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<table className="admin-data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Источник</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Ссылка</th>
|
||||
@@ -3371,62 +3699,92 @@ function InvitesSection({
|
||||
{invites.map((invite) => {
|
||||
const inviteUrl = buildInviteUrl(invite.token);
|
||||
const isCopied = copiedInviteId === invite.id;
|
||||
const isAccepted = invite.status === "accepted";
|
||||
const canCopyInvite = inviteCanBeCopied(invite);
|
||||
|
||||
return (
|
||||
<tr key={invite.id}>
|
||||
<td>
|
||||
<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}`}
|
||||
/>
|
||||
{isAccepted ? (
|
||||
<span className="admin-table-text admin-table-text--strong">{invite.email}</span>
|
||||
) : (
|
||||
<input
|
||||
className="admin-table-input admin-table-input--strong"
|
||||
value={invite.email}
|
||||
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
|
||||
aria-label={`Email инвайта ${invite.email}`}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<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 className="membership-inviter-cell">
|
||||
<span>{inviteSourceLabel(invite)}</span>
|
||||
{invite.sourceWorkspaceName || invite.sourceWorkspaceSlug ? (
|
||||
<small>{invite.sourceWorkspaceName ?? invite.sourceWorkspaceSlug}</small>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcDateField
|
||||
value={invite.expiresAt}
|
||||
label={`Инвайт истекает ${invite.email}`}
|
||||
onChange={(value) => {
|
||||
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
||||
}}
|
||||
/>
|
||||
{isAccepted ? (
|
||||
<AdminStaticPill>{membershipRoleLabel(invite.role)}</AdminStaticPill>
|
||||
) : (
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={invite.role}
|
||||
options={inviteRoleOptions}
|
||||
label={`Роль инвайта ${invite.email}`}
|
||||
minMenuWidth={172}
|
||||
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{isAccepted ? (
|
||||
<AdminStatusPill value={invite.status} options={inviteStatusOptions} />
|
||||
) : (
|
||||
<AdminStatusDropdown
|
||||
value={invite.status}
|
||||
options={editableInviteStatusOptions}
|
||||
label={`Статус инвайта ${invite.email}`}
|
||||
onChange={(status) => onUpdateInvite(invite.id, { status })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{canCopyInvite ? (
|
||||
<div className="invite-link-cell">
|
||||
<code title={inviteUrl}>{inviteUrl}</code>
|
||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||
<IconButton
|
||||
label={isCopied ? `Инвайт ${invite.email} скопирован` : `Скопировать инвайт ${invite.email}`}
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => void handleCopyInvite(invite)}
|
||||
>
|
||||
<Copy size={11} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-text">{inviteTerminalLabel(invite)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{isAccepted ? (
|
||||
<span className="muted-text">{formatDate(invite.expiresAt)}</span>
|
||||
) : (
|
||||
<NodeDcDateField
|
||||
value={invite.expiresAt}
|
||||
label={`Инвайт истекает ${invite.email}`}
|
||||
onChange={(value) => {
|
||||
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Удалить инвайт ${invite.email}`}
|
||||
label={`Удалить запись инвайта ${invite.email}`}
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => setDeleteInviteId(invite.id)}
|
||||
@@ -3442,10 +3800,11 @@ function InvitesSection({
|
||||
</GlassSurface>
|
||||
<NodeDcDeleteModal
|
||||
isOpen={Boolean(deletingInvite)}
|
||||
title="Удалить инвайт"
|
||||
title="Удалить запись инвайта"
|
||||
description={
|
||||
<>
|
||||
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
|
||||
Будет удалена только запись invite-ссылки для <strong>{deletingInvite?.email}</strong>. Пользователь, доступы и
|
||||
история аккаунта не удаляются.
|
||||
</>
|
||||
}
|
||||
onClose={() => setDeleteInviteId(null)}
|
||||
@@ -3532,6 +3891,7 @@ function AccessRequestsPanel({
|
||||
: null;
|
||||
const isTerminal = accessRequest.status !== "new";
|
||||
const isCopied = Boolean(approvedInvite && copiedInviteId === approvedInvite.id);
|
||||
const approvedInviteCanBeCopied = Boolean(approvedInvite && inviteCanBeCopied(approvedInvite));
|
||||
|
||||
return (
|
||||
<tr key={accessRequest.id}>
|
||||
@@ -3576,7 +3936,7 @@ function AccessRequestsPanel({
|
||||
<AdminStatusPill value={accessRequest.status} options={accessRequestStatusOptions} />
|
||||
</td>
|
||||
<td>
|
||||
{approvedInvite ? (
|
||||
{approvedInvite && approvedInviteCanBeCopied ? (
|
||||
<div className="invite-link-cell">
|
||||
<code title={buildInviteUrl(approvedInvite.token)}>{buildInviteUrl(approvedInvite.token)}</code>
|
||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||
@@ -3589,6 +3949,8 @@ function AccessRequestsPanel({
|
||||
<Copy size={11} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : approvedInvite ? (
|
||||
<span className="muted-text">{inviteTerminalLabel(approvedInvite)}</span>
|
||||
) : accessRequest.status === "approved" ? (
|
||||
<span className="muted-text">Активен</span>
|
||||
) : (
|
||||
@@ -3784,7 +4146,7 @@ function formatAccessRequestName(accessRequest: AccessRequest) {
|
||||
}
|
||||
|
||||
function getMembershipInviterMeta(data: LauncherData, membership: ClientMembership) {
|
||||
const inviter = membership.invitedByUserId ? getUser(data, membership.invitedByUserId) : null;
|
||||
const inviter = membership.invitedByUserId ? data.users.find((user) => user.id === membership.invitedByUserId) ?? null : null;
|
||||
const taskerRequest = membership.sourceTaskerInviteRequestId
|
||||
? data.taskerInviteRequests.find((request) => request.id === membership.sourceTaskerInviteRequestId)
|
||||
: null;
|
||||
@@ -3828,6 +4190,74 @@ function getMembershipInviterMeta(data: LauncherData, membership: ClientMembersh
|
||||
};
|
||||
}
|
||||
|
||||
function getPlatformUserOrigin(data: LauncherData, user: LauncherUser, memberships: ClientMembership[]) {
|
||||
const publicMembership = memberships.find((membership) => isPublicPoolClientId(membership.clientId));
|
||||
|
||||
if (publicMembership) {
|
||||
const inviterMeta = getMembershipInviterMeta(data, publicMembership);
|
||||
|
||||
if (publicMembership.source === "tasker_workspace_invite") {
|
||||
return {
|
||||
label: "Открытый контур · self-host invite",
|
||||
detail: `${inviterMeta.title} · ${inviterMeta.sourceLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (publicMembership.source === "access_request") {
|
||||
const request = data.accessRequests.find((item) => item.email.toLowerCase() === user.email.toLowerCase());
|
||||
return {
|
||||
label: "Открытый контур · прямой запрос",
|
||||
detail: request?.company ? `Компания из заявки: ${request.company}` : inviterMeta.subtitle,
|
||||
};
|
||||
}
|
||||
|
||||
if (publicMembership.inviteId) {
|
||||
return {
|
||||
label: "Открытый контур · ручной инвайт",
|
||||
detail: inviterMeta.subtitle,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Открытый контур",
|
||||
detail: inviterMeta.subtitle,
|
||||
};
|
||||
}
|
||||
|
||||
if (memberships.length > 0) {
|
||||
const primaryMembership = memberships[0];
|
||||
const client = getClient(data, primaryMembership.clientId);
|
||||
const extraContexts = memberships.length > 1 ? `, ещё ${memberships.length - 1}` : "";
|
||||
|
||||
return {
|
||||
label: `Компания · ${client.name}`,
|
||||
detail: `${client.legalName ?? client.name}${extraContexts}`,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingInvite = data.invites.find((invite) => invite.email.toLowerCase() === user.email.toLowerCase());
|
||||
const pendingRequest = data.accessRequests.find((request) => request.email.toLowerCase() === user.email.toLowerCase());
|
||||
|
||||
if (pendingInvite) {
|
||||
return {
|
||||
label: "Инвайт · без контура",
|
||||
detail: inviteSourceLabel(pendingInvite),
|
||||
};
|
||||
}
|
||||
|
||||
if (pendingRequest) {
|
||||
return {
|
||||
label: "Заявка · без контура",
|
||||
detail: pendingRequest.company,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Без контура",
|
||||
detail: "Нет активных memberships",
|
||||
};
|
||||
}
|
||||
|
||||
function MembershipInviterCell({ data, membership }: { data: LauncherData; membership: ClientMembership }) {
|
||||
const inviterMeta = getMembershipInviterMeta(data, membership);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user