From fd1cc0b25ac17380d58a76329eaa0cccd2d3b0a8 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 10 May 2026 19:57:25 +0300 Subject: [PATCH] feat(launcher): add platform admin and public access flows --- server/control-plane-store.mjs | 150 ++++- server/dev-server.mjs | 35 +- src/app/LauncherApp.tsx | 102 ++- src/entities/access-request/types.ts | 1 + src/shared/api/adminApi.ts | 4 +- src/styles/globals.css | 126 +++- src/widgets/admin-overlay/AdminOverlay.tsx | 700 +++++++++++++-------- src/widgets/top-bar/TopBar.tsx | 29 +- 8 files changed, 833 insertions(+), 314 deletions(-) diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 2023b94..99b9778 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -588,6 +588,27 @@ export function createControlPlaneStore({ projectRoot }) { const now = isoNow(); const requestPayload = sanitizeAccessRequestPayload(payload); const email = requestPayload.email.toLowerCase(); + const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email); + + if (existingUser?.globalStatus === "active") { + const hasOnlyPendingAccessRequest = + data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") && + data.memberships.some( + (membership) => + membership.userId === existingUser.id && + membership.clientId === publicPoolClientId && + membership.source === "access_request" && + membership.status === "disabled" + ); + + if (!hasOnlyPendingAccessRequest) { + throw new Error("Аккаунт с этой почтой уже существует. Войдите в NODE.DC или обратитесь к администратору."); + } + } + + const user = upsertAccessRequestUser(data, requestPayload, now); + upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now }); + markPendingSync(data, user, "user", user.email); const existingRequest = data.accessRequests.find( (candidate) => candidate.email.toLowerCase() === email && candidate.status === "new" ); @@ -608,7 +629,7 @@ export function createControlPlaneStore({ projectRoot }) { }); await writeData(data); - return { accessRequest: existingRequest, data }; + return { accessRequest: existingRequest, user, data }; } const accessRequest = { @@ -637,7 +658,7 @@ export function createControlPlaneStore({ projectRoot }) { }); await writeData(data); - return { accessRequest, data }; + return { accessRequest, user, data }; } async function updateAccessRequest(accessRequestId, payload, identity) { @@ -685,33 +706,35 @@ export function createControlPlaneStore({ projectRoot }) { accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role); accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null); - if (accessRequest.status === "approved" && accessRequest.approvedInviteId) { - const existingInvite = findById(data.invites, accessRequest.approvedInviteId, "invite"); - return { accessRequest, invite: existingInvite, data }; + const user = upsertAccessRequestUser(data, accessRequest, now); + const client = findClientById(data, accessRequest.targetClientId); + const membership = upsertAccessRequestMembership(data, user, accessRequest, { + status: "active", + clientId: client.id, + invitedByUserId: actor.id, + now, + }); + + if (client.id !== publicPoolClientId) { + data.memberships = data.memberships.filter( + (candidate) => + !( + candidate.userId === user.id && + candidate.clientId === publicPoolClientId && + candidate.source === "access_request" && + candidate.status === "disabled" + ) + ); } - const client = findClientById(data, accessRequest.targetClientId); - const invite = { - id: uniqueId(data.invites, "invite", accessRequest.email), - clientId: client.id, - email: accessRequest.email, - role: accessRequest.role, - invitedByUserId: actor.id, - source: "access_request", - sourceTaskerInviteRequestId: null, - sourceTaskerInviteId: null, - sourceWorkspaceSlug: null, - sourceWorkspaceName: null, - token: randomUUID(), - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), - status: "created", - createdAt: now, - updatedAt: now, - }; + if (accessRequest.status === "approved") { + markPendingSync(data, user, "user", user.email); + await writeData(data); + return { accessRequest, user, membership, invite: null, data }; + } - data.invites.push(invite); accessRequest.status = "approved"; - accessRequest.approvedInviteId = invite.id; + accessRequest.approvedInviteId = null; accessRequest.reviewedByUserId = actor.id; accessRequest.reviewedAt = now; accessRequest.updatedAt = now; @@ -722,12 +745,12 @@ export function createControlPlaneStore({ projectRoot }) { objectName: accessRequest.email, clientId: client.id === publicPoolClientId ? null : client.id, result: "success", - details: `Invite: ${invite.id}; target: ${client.name}; role: ${invite.role}`, + details: `Account activated; target: ${client.name}; role: ${membership.role}`, }); - markPendingSync(data, invite, "invite", invite.email); + markPendingSync(data, user, "user", user.email); await writeData(data); - return { accessRequest, invite, data }; + return { accessRequest, user, membership, invite: null, data }; } async function rejectAccessRequest(accessRequestId, payload, identity) { @@ -2312,6 +2335,77 @@ function sanitizeAccessRequestPayload(payload) { }; } +function upsertAccessRequestUser(data, requestPayload, now) { + const email = requestPayload.email.toLowerCase(); + const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email); + const userName = buildAccessRequestUserName(requestPayload); + + if (existingUser) { + existingUser.name = userName; + existingUser.phone = requestPayload.phone; + existingUser.notes = `Public access request: ${requestPayload.company}`; + existingUser.globalStatus = existingUser.globalStatus === "blocked" ? "blocked" : "active"; + existingUser.updatedAt = now; + return existingUser; + } + + const user = { + id: uniqueId(data.users, "user", email), + authentikUserId: null, + email, + name: userName, + phone: requestPayload.phone, + position: null, + notes: `Public access request: ${requestPayload.company}`, + avatarUrl: null, + globalStatus: "active", + createdAt: now, + updatedAt: now, + }; + + data.users.push(user); + return user; +} + +function upsertAccessRequestMembership(data, user, requestPayload, options = {}) { + const now = options.now ?? isoNow(); + const clientId = options.clientId ?? publicPoolClientId; + const role = pickEnum(requestPayload.role, membershipRoles, "member"); + const existingMembership = data.memberships.find( + (membership) => membership.userId === user.id && membership.clientId === clientId + ); + + if (existingMembership) { + existingMembership.role = role; + existingMembership.status = options.status ?? existingMembership.status; + existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null; + existingMembership.source = existingMembership.source ?? "access_request"; + existingMembership.updatedAt = now; + return existingMembership; + } + + const membership = { + id: uniqueId(data.memberships, "mem", `${clientId}-${user.id}`), + clientId, + userId: user.id, + role, + status: options.status ?? "disabled", + invitedByUserId: options.invitedByUserId ?? null, + inviteId: null, + source: "access_request", + sourceTaskerInviteRequestId: null, + createdAt: now, + updatedAt: now, + }; + + data.memberships.push(membership); + return membership; +} + +function buildAccessRequestUserName(requestPayload) { + return [requestPayload.lastName, requestPayload.firstName, requestPayload.middleName].filter(Boolean).join(" "); +} + function normalizeTaskManagerInviteRole(value) { const normalized = typeof value === "string" ? value.trim().toLowerCase() : value; diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 7959d77..45ae5e5 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -70,8 +70,26 @@ app.get("/api/public/brand", (_req, res) => { app.post("/api/access-requests", asyncRoute(async (req, res) => { try { + const password = sanitizeNewPassword(req.body?.password); + + if (!authentikSyncClient.isConfigured()) { + res.status(503).json({ error: "Authentik API не настроен. Заявку с паролем сейчас создать нельзя." }); + return; + } + const result = await controlPlaneStore.createAccessRequest(req.body); - publishControlPlaneEvent("access-request.created"); + const provisioning = await authentikSyncClient.provisionUser({ + data: result.data, + userId: result.user.id, + password, + }); + await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, { + sub: "public-access-request", + name: "NODE.DC public request", + email: result.user.email, + }); + + publishControlPlaneEvent("access-request.created", [result.user.id]); res.status(201).json({ accessRequest: result.accessRequest }); } catch (error) { sendAccessRequestApiError(res, error); @@ -1111,8 +1129,19 @@ app.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, r app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { try { - const result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user); - publishControlPlaneEvent("admin.access-request.approved"); + let result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user); + let provisioning = null; + + if (result.user && authentikSyncClient.isConfigured()) { + provisioning = await authentikSyncClient.provisionUser({ + data: result.data, + userId: result.user.id, + }); + const syncResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, req.nodedcSession.user); + result = { ...result, data: syncResult.data, user: syncResult.user, provisioning }; + } + + publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []); res.json(scopeAdminMutationResult(req, result)); } catch (error) { sendAccessRequestApiError(res, error); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 77db55d..47bb6f5 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -59,7 +59,7 @@ import { } from "../shared/api/authApi"; import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi"; -import type { CreateAccessRequestCommand } from "../entities/access-request/types"; +import type { AccessRequest, CreateAccessRequestCommand } from "../entities/access-request/types"; import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { @@ -72,7 +72,7 @@ import { import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel"; import { ServiceRail } from "../widgets/service-rail/ServiceRail"; import { ServiceStage } from "../widgets/service-stage/ServiceStage"; -import { TopBar } from "../widgets/top-bar/TopBar"; +import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar"; let lastAuthRedirect: { url: string; startedAt: number } | null = null; @@ -93,6 +93,7 @@ export function LauncherApp() { const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId); const [selectedServiceId, setSelectedServiceId] = useState(); const [adminOpen, setAdminOpen] = useState(false); + const [adminMode, setAdminMode] = useState("admin"); const [authSession, setAuthSession] = useState(null); const [authApps, setAuthApps] = useState(null); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); @@ -115,6 +116,12 @@ export function LauncherApp() { const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0]; + const currentAccessRequest = useMemo(() => { + if (!authSession?.authenticated || !authSession.user.email) return null; + + const sessionEmail = authSession.user.email.toLowerCase(); + return data.accessRequests.find((request) => request.email.toLowerCase() === sessionEmail && request.status !== "approved") ?? null; + }, [authSession, data.accessRequests]); const runtimeMe = useMemo(() => { if (!authSession?.authenticated) return me; @@ -824,6 +831,10 @@ export function LauncherApp() { window.location.replace(authSession.logoutUrl); }; + if (currentAccessRequest) { + return ; + } + return (
setAdminOpen((current) => !current)} + onOpenAdmin={() => { + setAdminMode("admin"); + setAdminOpen((current) => !(current && adminMode === "admin")); + }} + onOpenPlatform={() => { + if (runtimeMe.launcherRole !== "root_admin") return; + setAdminMode("platform"); + setAdminOpen((current) => !(current && adminMode === "platform")); + }} onOpenShowcase={() => setAdminOpen(false)} onOpenProfileSettings={() => setProfileSettingsOpen(true)} onLogout={handleLogout} @@ -854,6 +874,7 @@ export function LauncherApp() { setAdminOpen(false)} onSetUserServiceAccess={handleSetUserServiceAccess} @@ -926,18 +947,21 @@ function AccessRequestScreen({ onSubmit: (command: CreateAccessRequestCommand) => Promise; onLogin: () => void; }) { - const [values, setValues] = useState({ + const [values, setValues] = useState({ email: "", firstName: "", lastName: "", middleName: "", phone: "", company: "", + password: "", + passwordConfirm: "", }); const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle"); const [message, setMessage] = useState(null); const isSubmitted = status === "submitted"; const normalizedEmail = values.email.trim().toLowerCase(); + const passwordMismatch = Boolean(values.passwordConfirm && values.password !== values.passwordConfirm); const canSubmit = Boolean( normalizedEmail.includes("@") && values.firstName.trim() && @@ -945,10 +969,12 @@ function AccessRequestScreen({ values.middleName.trim() && values.phone.trim() && values.company.trim() && + values.password.length >= 8 && + values.password === values.passwordConfirm && status !== "submitting" ); - const updateField = (field: keyof CreateAccessRequestCommand, value: string) => { + const updateField = (field: keyof typeof values, value: string) => { setValues((current) => ({ ...current, [field]: value })); }; @@ -964,10 +990,11 @@ function AccessRequestScreen({ {!isSubmitted ? (

- Заполните обязательные поля. Заявка попадёт в очередь NODE.DC, после approve администратор передаст ссылку инвайта. + Заполните обязательные поля и задайте пароль. После подтверждения вы войдёте в NODE.DC по этой почте и паролю.

) : null} {message ?

{message}

: null} + {passwordMismatch ?

Пароли не совпадают.

: null} {isSubmitted ? (
@@ -991,10 +1018,11 @@ function AccessRequestScreen({ middleName: values.middleName.trim(), phone: values.phone.trim(), company: values.company.trim(), + password: values.password, }) .then(() => { setStatus("submitted"); - setMessage("Заявка отправлена администратору. Администратор проверит данные. Дождитесь результатов."); + setMessage("Заявка отправлена администратору. После подтверждения войдите в NODE.DC по указанному паролю."); }) .catch((error) => { setStatus("error"); @@ -1055,6 +1083,28 @@ function AccessRequestScreen({ onChange={(event) => updateField("company", event.target.value)} /> +
+ + +
@@ -1069,6 +1119,44 @@ function AccessRequestScreen({ ); } +function AccessRequestPendingScreen({ + accessRequest, + onLogout, +}: { + accessRequest: AccessRequest; + onLogout: () => void; +}) { + const isRejected = accessRequest.status === "rejected"; + + return ( +
+ +
+
+
+

NODE.DC.

+

{isRejected ? "Заявка отклонена." : "Заявка ожидает подтверждения."}

+
+
+ Почта: {accessRequest.email} + Компания: {accessRequest.company} +
+

+ {isRejected + ? "Администратор отклонил заявку. Если это ошибка, отправьте новый запрос или свяжитесь с NODE.DC." + : "Администратор проверит данные. После подтверждения вы попадёте в Launcher без отдельной регистрации по инвайту."} +

+
+ +
+
+
+
+ ); +} + function resolveAuthenticatedContext( data: LauncherData, session: AuthenticatedSession, diff --git a/src/entities/access-request/types.ts b/src/entities/access-request/types.ts index 704ee45..00ddb74 100644 --- a/src/entities/access-request/types.ts +++ b/src/entities/access-request/types.ts @@ -28,4 +28,5 @@ export interface CreateAccessRequestCommand { middleName: string; phone: string; company: string; + password: string; } diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index ce9fc82..8d0fe41 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -38,7 +38,9 @@ export interface AccessRequestMutationResult extends ControlPlaneMutationResult } export interface AccessRequestApproveResult extends AccessRequestMutationResult { - invite: Invite; + invite?: Invite | null; + membership?: ClientMembership | null; + user?: LauncherUser | null; } export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationResult { diff --git a/src/styles/globals.css b/src/styles/globals.css index e794b8d..724103d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1690,24 +1690,92 @@ code { border: 0; border-radius: var(--launcher-radius-circle); outline: none; - background: rgba(64, 64, 64, 0.48); + background: rgba(255, 255, 255, 0.04); padding: var(--admin-control-inset) calc(var(--admin-control-inset) + 1.9rem) var(--admin-control-inset) var(--admin-control-inset); - color: var(--text-primary); + color: rgba(255, 255, 255, 0.66); font: inherit; text-align: left; + opacity: 0.66; box-shadow: none; cursor: pointer; + transition: + background 160ms ease, + color 160ms ease, + opacity 160ms ease; +} + +.admin-panel-context-switcher { + display: grid; + gap: 0.48rem; +} + +.admin-panel-context-group { + display: grid; + gap: 0.28rem; +} + +.admin-panel-context-group__label { + padding-inline: 0.35rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 850; + letter-spacing: 0.12em; + text-transform: uppercase; } .admin-panel-client-select:hover, .admin-panel-client-select:focus, .admin-panel-client-select:focus-visible, -.admin-panel-client-select[aria-expanded="true"] { +.admin-panel-client-select[aria-expanded="true"], +.admin-panel-client-select--active { border: 0; outline: none; box-shadow: none; background: rgba(74, 74, 74, 0.5); + color: var(--text-primary); + opacity: 1; +} + +.admin-panel-client-select--company { + display: grid; + grid-template-columns: minmax(0, 1fr) calc(var(--admin-control-ring) + 0.22rem); + gap: 0; + padding: 0; +} + +.admin-panel-client-select__main { + display: flex; + min-width: 0; + min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2)); + align-items: center; + gap: 0.65rem; + border: 0; + background: transparent; + color: inherit; + font: inherit; + padding: var(--admin-control-inset) 0 var(--admin-control-inset) var(--admin-control-inset); + text-align: left; + cursor: pointer; +} + +.admin-panel-client-select__toggle { + display: grid; + width: calc(var(--admin-control-ring) + 0.22rem); + min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2)); + place-items: center; + border: 0; + border-radius: var(--launcher-radius-circle); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.admin-panel-client-select__toggle:hover, +.admin-panel-client-select__toggle:focus-visible { + background: rgba(255, 255, 255, 0.07); + color: var(--text-primary); + outline: none; } .admin-panel-client-select__icon, @@ -1735,17 +1803,33 @@ code { white-space: nowrap; } -.admin-panel-client-select__chevron { - position: absolute; - top: 50%; - right: var(--admin-control-inset); +.admin-panel-client-select__body { display: grid; - width: 1.85rem; - height: 1.85rem; - place-items: center; + min-width: 0; + gap: 0.12rem; +} + +.admin-panel-client-select__description { + min-width: 0; + overflow: hidden; color: var(--text-muted); - transform: translateY(-50%); - pointer-events: none; + font-size: 0.72rem; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-panel-client-select__chevron { + position: relative; + top: auto; + right: auto; + display: block; + width: 0.44rem; + height: 0.44rem; + border-right: 1.6px solid currentColor; + border-bottom: 1.6px solid currentColor; + transform: translateY(-0.12rem) rotate(45deg); + pointer-events: auto; } .admin-panel-client-select select { @@ -2121,6 +2205,24 @@ code { padding: 1rem; } +.client-profile-card { + display: grid; + gap: 1rem; +} + +.client-profile-card__head { + align-items: flex-start; +} + +.client-profile-card .service-content-modal__grid { + overflow: visible; + padding-right: 0; +} + +.client-profile-card .service-content-modal__foot { + margin-top: 0.15rem; +} + .activity-list { display: grid; gap: 0.5rem; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 1d27963..d324730 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -89,6 +89,7 @@ type AdminSection = | "audit" | "misc" | "company"; +type AdminOverlayMode = "admin" | "platform"; type AccessAssignmentRole = Exclude; export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset"; @@ -125,14 +126,10 @@ export interface EnsureTaskManagerProjectMemberCommand { role: TaskManagerWorkspaceMemberRole; } -const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ +const platformSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ { id: "overview", label: "Обзор", icon: }, - { id: "clients", label: "Клиенты", icon: }, - { id: "users", label: "Участники", icon: }, - { id: "groups", label: "Группы", icon: }, + { id: "clients", label: "Компании", icon: }, { id: "services", label: "Каталог сервисов", icon: }, - { id: "access", label: "Доступы", icon: }, - { id: "invites", label: "Инвайты", icon: }, { id: "sync", label: "Синхронизация", icon: }, { id: "audit", label: "Аудит", icon: }, { id: "misc", label: "Разное", icon: }, @@ -140,11 +137,10 @@ const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNo const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ { id: "overview", label: "Обзор", icon: }, - { id: "users", label: "Участники", icon: }, - { id: "groups", label: "Группы", icon: }, - { id: "access", label: "Доступы", icon: }, { id: "invites", label: "Инвайты", icon: }, - { id: "company", label: "Профиль компании", icon: }, + { id: "users", label: "Участники", icon: }, + { id: "access", label: "Доступы", icon: }, + { id: "groups", label: "Группы", icon: }, ]; const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ @@ -160,6 +156,7 @@ const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.R export function AdminOverlay({ data, me, + mode, activeClientId, onClose, onSetUserServiceAccess, @@ -199,6 +196,7 @@ export function AdminOverlay({ }: { data: LauncherData; me: MeResponse; + mode: AdminOverlayMode; activeClientId: string; onClose: () => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; @@ -243,18 +241,25 @@ export function AdminOverlay({ onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; }) { const isRoot = me.launcherRole === "root_admin"; + const isPlatformMode = isRoot && mode === "platform"; const [activeSection, setActiveSection] = useState(null); const [isContentFullscreen, setIsContentFullscreen] = useState(false); const [selectedClientId, setSelectedClientId] = useState(activeClientId); + const [selectedCompanyClientId, setSelectedCompanyClientId] = useState( + data.clients.some((client) => client.id === activeClientId) ? activeClientId : data.clients[0]?.id ?? activeClientId + ); const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null); const fallbackClientId = data.clients[0]?.id ?? activeClientId; + const selectedCompanyClientExists = data.clients.some((client) => client.id === selectedCompanyClientId); + const scopedCompanyClientId = selectedCompanyClientExists ? selectedCompanyClientId : fallbackClientId; const selectedContextIsPublicPool = isRoot && isPublicPoolClientId(selectedClientId); const selectedClientExists = selectedContextIsPublicPool || data.clients.some((client) => client.id === selectedClientId); const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId; const isPublicPoolContext = isRoot && isPublicPoolClientId(scopedClientId); const currentClient = getClient(data, scopedClientId); - const sections = isPublicPoolContext ? publicPoolSections : isRoot ? rootSections : clientSections; + const selectedCompanyClient = getClient(data, scopedCompanyClientId); + const sections = isPlatformMode ? platformSections : isPublicPoolContext ? publicPoolSections : clientSections; const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]); const selectedAccessCell = accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ?? @@ -268,6 +273,12 @@ export function AdminOverlay({ } }, [data.clients, isRoot, selectedClientExists]); + useEffect(() => { + if (isRoot && !selectedCompanyClientExists && data.clients.length) { + setSelectedCompanyClientId(data.clients[0].id); + } + }, [data.clients, isRoot, selectedCompanyClientExists]); + useEffect(() => { if (activeSection && !sections.some((section) => section.id === activeSection)) { setActiveSection(sections[0]?.id ?? null); @@ -299,44 +310,99 @@ export function AdminOverlay({

NODE.DC

-

Администрирование

+

{isPlatformMode ? "Платформа" : "Администрирование"}

- +
- {isRoot ? ( - ({ value: client.id, label: client.name, description: client.legalName ?? undefined })), - ]} - label="Выбрать контур администрирования" - searchable - minMenuWidth={292} - onChange={(clientId) => { - setSelectedClientId(clientId); - setSelectedCell(null); - }} - trigger={({ open, selectedOption, toggle, setTriggerRef }) => ( - - )} - /> + {isPlatformMode ? ( +
+ + + + Root Admin +
+ ) : isRoot ? ( +
+ + +
+ Компании + ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))} + label="Выбрать компанию" + searchable + minMenuWidth={292} + onChange={(clientId) => { + setSelectedCompanyClientId(clientId); + setSelectedClientId(clientId); + setSelectedCell(null); + }} + trigger={({ open, selectedOption, toggle, setTriggerRef }) => ( +
+ + +
+ )} + /> +
+
) : (
@@ -377,9 +443,19 @@ export function AdminOverlay({ />
{activeSection === "overview" ? ( - + ) : null} - {activeSection === "clients" && isRoot ? ( + {activeSection === "clients" && isPlatformMode ? ( : null} {activeSection === "audit" && isRoot ? : null} {activeSection === "misc" && isRoot ? : null} - {activeSection === "company" ? : null}
) : null} @@ -494,17 +569,28 @@ function AdminHeader({ function OverviewSection({ data, clientId, - isRoot, + isPlatformMode, isPublicPoolContext, + taskManagerWorkspaces, + taskManagerWorkspacesLoading, + taskManagerWorkspacesError, + onRefreshTaskManagerWorkspaces, + onUpdateClient, }: { data: LauncherData; clientId: string; - isRoot: boolean; + isPlatformMode: boolean; isPublicPoolContext: boolean; + taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; + taskManagerWorkspacesLoading: boolean; + taskManagerWorkspacesError: string | null; + onRefreshTaskManagerWorkspaces: () => void; + onUpdateClient: (clientId: string, patch: Partial) => void; }) { + const client = getClient(data, clientId); const clientUsers = getClientUsers(data, clientId); const clientServiceCount = data.grants.filter((grant) => grant.targetType === "client" && grant.targetId === clientId).length; - const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isRoot || sync.objectId === clientId)).length; + const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isPlatformMode || sync.objectId === clientId)).length; const newAccessRequests = data.accessRequests.filter((request) => request.status === "new").length; const approvedAccessRequests = data.accessRequests.filter((request) => request.status === "approved").length; const publicInvites = data.invites.filter((invite) => invite.clientId === PUBLIC_POOL_CLIENT_ID).length; @@ -537,13 +623,61 @@ function OverviewSection({ ); } + if (isPlatformMode) { + return ( +
+ + service.status === "active").length} hint="В каталоге" /> + grant.targetType === "client").length} hint="Назначения контуров" /> + 0} /> + + +

Последние действия платформы

+
+ {data.auditEvents.slice(0, 5).map((event) => ( +
+ {formatDateTime(event.at)} + {event.action} + {event.objectName} +
+ ))} +
+
+
+ ); + } + return (
- + service.status === "active").length} hint="В каталоге" /> 0} /> + +
+
+

Клиент

+

Профиль контура

+
+ + + +
+ onUpdateClient(client.id, patch)} + /> +
+

Последние действия

@@ -586,8 +720,8 @@ function ClientsSection({ <>
-

Клиенты

- +

Компании

+
@@ -1133,6 +1267,15 @@ function getPrimaryTaskManagerWorkspace(client: Client): ClientTaskManagerWorksp return workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null; } +function formatClientTaskManagerWorkspaces(client: Client): string { + const workspaces = getClientTaskManagerWorkspaces(client); + if (!workspaces.length) return "—"; + + return workspaces + .map((workspace) => `${workspace.name ?? workspace.slug}${workspace.isPrimary ? " · основной" : ""}`) + .join(", "); +} + function getTaskManagerMembershipRole(data: LauncherData, clientId: string, userId: string, workspaceSlug: string): TaskManagerWorkspaceMemberRole { return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset"; } @@ -1215,6 +1358,13 @@ const taskManagerWorkspacePolicyOptions: Array void; +}; + function ServicesSection({ data, isPublicPoolContext, @@ -1849,6 +1999,66 @@ function ClientEditorModal({ onSave: (patch: Partial) => void; onDelete: () => void; canDelete: boolean; +}) { + return ( +
+
+ + + + } + /> + + Компания {client.name}, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных. + + ), + onConfirm: onDelete, + } + : undefined + } + /> +
+
+ ); +} + +function ClientProfileEditorForm({ + client, + taskManagerWorkspaces, + taskManagerWorkspacesError, + onCancel, + onSave, + deleteConfig, +}: { + client: Client; + taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; + taskManagerWorkspacesError: string | null; + onCancel?: () => void; + onSave: (patch: Partial) => void; + deleteConfig?: EntityModalDeleteConfig; }) { const [draft, setDraft] = useState(client); const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(client.avatarUrl ?? null); @@ -1877,6 +2087,14 @@ function ClientEditorModal({ setDraft((current) => ({ ...current, [key]: value })); } + function resetDraft() { + setDraft(client); + setAvatarPreviewUrl(client.avatarUrl ?? null); + setUploadingAvatar(false); + setStorageError(null); + onCancel?.(); + } + function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) { const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null; setDraft((current) => ({ @@ -1939,206 +2157,171 @@ function ClientEditorModal({ } return ( -
-
- - - - } - /> -
- - - - -
- Статус - update("status", status)} /> -
-
- Аватар компании -
- -
- {avatarPreviewUrl ? "Аватар подключён" : "Аватар не задан"} - Показывается в верхнем переключателе компании. -
- - {avatarPreviewUrl ? ( - - ) : null} -
- {storageError ? {storageError} : null} -
-
- Operational Core workspaces -
-
- {taskManagerWorkspaces.length ? ( - taskManagerWorkspaces.map((workspace) => { - const selected = selectedTaskManagerWorkspaceSlugs.has(workspace.slug); - const primary = primaryTaskManagerWorkspace?.slug === workspace.slug; - - return ( - - ); - }) - ) : ( - Workspace Operational Core не загружены - )} -
-
- - {taskManagerWorkspacesError - ? taskManagerWorkspacesError - : "Эти workspace доступны для детальных назначений пользователей в Operational Core."} - -
- - -
- Договор с - update("contractStartsAt", value)} - /> -
-
- Договор до - update("contractEndsAt", value)} - /> -
-
- Оплачено до - update("paidUntil", value)} /> -
-
- Демо до - update("demoEndsAt", value)} /> -
-