ФУНКЦИИ - NODEDC LAUNCHER: управление пользователями платформы

This commit is contained in:
DCCONSTRUCTIONS
2026-05-11 12:20:46 +03:00
parent 8be33c53da
commit 2b34cf9f1b
9 changed files with 1130 additions and 245 deletions
+6
View File
@@ -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}
+4
View File
@@ -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;
+18
View File
@@ -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,
+124
View File
@@ -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);
+489 -59
View File
@@ -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);