From ec021d1a954cacdbf42d9fe17dc6a7c7fb4c7b7f Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 27 May 2026 11:46:29 +0300 Subject: [PATCH] Refine launcher admin access workflows --- server/control-plane-store.mjs | 38 +++ server/dev-server.mjs | 155 +++++++++- src/app/LauncherApp.tsx | 42 ++- src/shared/api/adminApi.ts | 23 ++ src/styles/globals.css | 173 ++++++++++- src/widgets/admin-overlay/AdminOverlay.tsx | 340 ++++++++++++++++----- 6 files changed, 676 insertions(+), 95 deletions(-) diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 3301b1b..3893d52 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -41,6 +41,7 @@ const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejecte const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]); const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]); const engineServiceRoles = new Set(["viewer", "member"]); +const syncStates = new Set(["synced", "pending", "error", "disabled"]); const publicPoolClientId = "client_public_pool"; const protectedLauncherUserIds = new Set(["user_root"]); const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"]; @@ -2043,6 +2044,42 @@ export function createControlPlaneStore({ projectRoot }) { return { syncStatus, data }; } + async function updateSyncStatus(syncId, patch, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const syncStatus = findById(data.syncStatuses, syncId, "sync status"); + const now = isoNow(); + + if ("state" in (patch ?? {})) { + syncStatus.state = pickEnum(patch.state, syncStates, syncStatus.state); + } + + if ("error" in (patch ?? {})) { + syncStatus.error = nullableString(patch.error); + } + + if ("objectName" in (patch ?? {})) { + syncStatus.objectName = optionalString(patch.objectName, syncStatus.objectName); + } + + if (syncStatus.state === "synced") { + syncStatus.lastSyncAt = nullableString(patch?.lastSyncAt) ?? now; + syncStatus.error = null; + } + + syncStatus.updatedAt = now; + addAuditEvent(data, actor, { + action: "Статус sync обновлён", + objectType: syncStatus.objectType, + objectName: syncStatus.objectName, + result: syncStatus.state === "error" ? "error" : "success", + details: `Target: ${syncStatus.target}; State: ${syncStatus.state}`, + }); + + await writeData(data); + return { syncStatus, data }; + } + async function markUserAuthentikProvisioned(userId, provisioning, identity) { const data = readData(); const actor = resolveActor(data, identity); @@ -2191,6 +2228,7 @@ export function createControlPlaneStore({ projectRoot }) { replaceData, reorderServices, retrySync, + updateSyncStatus, markUserAuthentikProvisioned, recordTaskManagerProjectMembership, recordTaskManagerWorkspaceMembership, diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 68c2526..6ec0db2 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -2196,11 +2196,50 @@ app.post("/api/admin/access/service-modules", requireLauncherAdmin, asyncRoute(a })); app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { - const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user); - publishControlPlaneEvent("admin.sync.retry"); - res.json(result); + const retryResult = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user); + const affectedUserIds = resolveSyncStatusAffectedUserIds(retryResult.data, retryResult.syncStatus); + + if (!authentikSyncClient.isConfigured()) { + const result = await controlPlaneStore.updateSyncStatus( + req.params.syncId, + { state: "error", error: "Authentik API не настроен на сервере launcher." }, + req.nodedcSession.user + ); + publishControlPlaneEvent("admin.sync.retry.failed"); + res.json(scopeAdminMutationResult(req, { ...result, ok: false, message: result.syncStatus.error })); + return; + } + + try { + const syncResult = affectedUserIds.length + ? await syncUsersToAuthentik(retryResult.data, affectedUserIds, req.nodedcSession.user) + : { data: retryResult.data, userIds: [] }; + const result = await controlPlaneStore.updateSyncStatus( + req.params.syncId, + { state: "synced", error: null }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.sync.retry", syncResult.userIds); + res.json(scopeAdminMutationResult(req, { ...result, ok: true, syncedUserIds: syncResult.userIds })); + } catch (error) { + const message = error instanceof Error ? error.message : "Не удалось выполнить синхронизацию Authentik"; + const result = await controlPlaneStore.updateSyncStatus( + req.params.syncId, + { state: "error", error: message }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.sync.retry.failed"); + res.json(scopeAdminMutationResult(req, { ...result, ok: false, message })); + } })); +app.get("/api/admin/sync/authentik/health", requireLauncherAdmin, requireRootLauncherAdmin, (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + res.json(buildAuthentikSyncHealth(snapshot.data)); +}); + app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => { res.json(controlPlaneStore.buildAuthentikSyncPlan()); }); @@ -2664,6 +2703,116 @@ async function syncUsersToAuthentik(data, userIds, identity) { return { data: latestData, userIds: uniqueUserIds }; } +function buildAuthentikSyncHealth(data) { + const rows = data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik"); + const errors = rows.filter((syncStatus) => syncStatus.state === "error"); + const pending = rows.filter((syncStatus) => syncStatus.state === "pending"); + const freshPending = pending.filter(isFreshPendingSyncStatus); + const stalePending = pending.filter((syncStatus) => !isFreshPendingSyncStatus(syncStatus)); + const state = !authentikSyncClient.isConfigured() + ? "not_configured" + : errors.length > 0 + ? "error" + : freshPending.length > 0 + ? "pending" + : "ok"; + + return { + configured: authentikSyncClient.isConfigured(), + state, + checkedAt: new Date().toISOString(), + counts: { + total: rows.length, + synced: rows.filter((syncStatus) => syncStatus.state === "synced").length, + pending: pending.length, + freshPending: freshPending.length, + stalePending: stalePending.length, + errors: errors.length, + disabled: rows.filter((syncStatus) => syncStatus.state === "disabled").length, + }, + }; +} + +function isFreshPendingSyncStatus(syncStatus) { + if (syncStatus.state !== "pending") return false; + const timestamp = Date.parse(syncStatus.updatedAt ?? syncStatus.lastSyncAt ?? ""); + if (!Number.isFinite(timestamp)) return false; + return Date.now() - timestamp <= 5 * 60 * 1000; +} + +function resolveSyncStatusAffectedUserIds(data, syncStatus) { + if (!syncStatus || syncStatus.target !== "authentik") { + return []; + } + + if (syncStatus.objectType === "user") { + return data.users.some((user) => user.id === syncStatus.objectId) ? [syncStatus.objectId] : []; + } + + if (syncStatus.objectType === "group") { + return data.groups.find((group) => group.id === syncStatus.objectId)?.memberIds ?? []; + } + + if (syncStatus.objectType === "client") { + return data.memberships.filter((membership) => membership.clientId === syncStatus.objectId).map((membership) => membership.userId); + } + + if (syncStatus.objectType === "service") { + return resolveServiceSyncUserIds(data, syncStatus.objectId); + } + + if (syncStatus.objectType === "grant") { + return resolveGrantSyncUserIds(data, syncStatus); + } + + if (syncStatus.objectType === "invite") { + const invite = data.invites.find((candidate) => candidate.id === syncStatus.objectId); + return invite ? resolveUserIdsByEmail(data, invite.email) : resolveUserIdsByEmail(data, syncStatus.objectName); + } + + return []; +} + +function resolveServiceSyncUserIds(data, serviceId) { + const userIds = new Set(); + + for (const grant of data.grants.filter((candidate) => candidate.serviceId === serviceId && candidate.status === "active")) { + for (const userId of resolveGrantTargetUserIds(data, grant.targetType, grant.targetId)) { + userIds.add(userId); + } + } + + for (const exception of data.exceptions.filter((candidate) => candidate.serviceId === serviceId)) { + userIds.add(exception.userId); + } + + return [...userIds]; +} + +function resolveGrantSyncUserIds(data, syncStatus) { + const existingGrant = data.grants.find((grant) => grant.id === syncStatus.objectId); + + if (existingGrant) { + return resolveGrantTargetUserIds(data, existingGrant.targetType, existingGrant.targetId); + } + + const [serviceId, userId] = String(syncStatus.objectId).split(":"); + if (serviceId && userId && data.users.some((user) => user.id === userId)) { + return [userId]; + } + + return resolveUserIdsByEmail(data, syncStatus.objectName); +} + +function resolveUserIdsByEmail(data, value) { + const normalizedValue = String(value ?? "").toLowerCase(); + if (!normalizedValue) return []; + + return data.users + .filter((user) => normalizedValue.includes(String(user.email ?? "").toLowerCase())) + .map((user) => user.id); +} + function resolveGrantTargetUserIds(data, targetType, targetId) { if (targetType === "user") { return [targetId]; diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index d2ef293..bed7128 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -104,6 +104,7 @@ export function LauncherApp() { const [selectedServiceId, setSelectedServiceId] = useState(); const [adminOpen, setAdminOpen] = useState(false); const [adminMode, setAdminMode] = useState("admin"); + const [adminTemporarilyHidden, setAdminTemporarilyHidden] = useState(false); const [authSession, setAuthSession] = useState(null); const [authApps, setAuthApps] = useState(null); const [runtimeReady, setRuntimeReady] = useState(false); @@ -397,6 +398,7 @@ export function LauncherApp() { useEffect(() => { if (runtimeMe.permissions.canOpenAdmin) return; + setAdminTemporarilyHidden(false); setAdminOpen(false); setAdminMode("admin"); }, [runtimeMe.permissions.canOpenAdmin]); @@ -489,6 +491,7 @@ export function LauncherApp() { const profile = profileOptions.find((option) => option.userId === userId); setActiveProfileId(userId); setActiveClientId(profile?.defaultClientId ?? activeClientId); + setAdminTemporarilyHidden(false); setAdminOpen(false); } @@ -498,7 +501,17 @@ export function LauncherApp() { } function handleServiceSelect(serviceId: string) { - setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId)); + if (selectedServiceId === serviceId) { + setSelectedServiceId(undefined); + setAdminTemporarilyHidden(false); + return; + } + + if (adminOpen) { + setAdminTemporarilyHidden(true); + } + + setSelectedServiceId(serviceId); } function handleStageStep(direction: "previous" | "next") { @@ -917,20 +930,25 @@ export function LauncherApp() { profileOptions={profileOptions} activeProfileId={resolvedProfileId} activeClientId={resolvedClientId} - adminOpen={adminOpen} + adminOpen={adminOpen && !adminTemporarilyHidden} adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"} onProfileChange={handleProfileChange} onClientChange={setActiveClientId} onOpenAdmin={() => { + setAdminTemporarilyHidden(false); setAdminMode("admin"); - setAdminOpen((current) => !(current && adminMode === "admin")); + setAdminOpen((current) => (adminTemporarilyHidden ? true : !(current && adminMode === "admin"))); }} onOpenPlatform={() => { if (runtimeMe.launcherRole !== "root_admin") return; + setAdminTemporarilyHidden(false); setAdminMode("platform"); - setAdminOpen((current) => !(current && adminMode === "platform")); + setAdminOpen((current) => (adminTemporarilyHidden ? true : !(current && adminMode === "platform"))); + }} + onOpenShowcase={() => { + setAdminTemporarilyHidden(false); + setAdminOpen(false); }} - onOpenShowcase={() => setAdminOpen(false)} onOpenProfileSettings={() => setProfileSettingsOpen(true)} onLogout={handleLogout} brandLinkUrl={data.settings.brand.logoLinkUrl} @@ -959,13 +977,20 @@ export function LauncherApp() { onSelectNext={() => handleStageStep("next")} /> {adminOpen && me.permissions.canOpenAdmin ? ( - +
+ setAdminOpen(false)} + onClose={() => { + setAdminTemporarilyHidden(false); + setAdminOpen(false); + }} onSetUserServiceAccess={handleSetUserServiceAccess} onCreateInvite={handleCreateInvite} onUpdateInvite={handleUpdateInvite} @@ -1006,7 +1031,8 @@ export function LauncherApp() { onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole} onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement} /> - + +
) : null} {profileSettingsOpen && activeProfileUser ? ( diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 3ccac30..c57f6f8 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -24,6 +24,8 @@ export interface ControlPlaneSnapshot { export interface ControlPlaneMutationResult { data: LauncherData; + ok?: boolean; + message?: string; provisioning?: { authentikUserId: string; email: string; @@ -34,6 +36,23 @@ export interface ControlPlaneMutationResult { } | null; } +export type AuthentikSyncHealthState = "ok" | "pending" | "error" | "not_configured"; + +export interface AuthentikSyncHealth { + configured: boolean; + state: AuthentikSyncHealthState; + checkedAt: string; + counts: { + total: number; + synced: number; + pending: number; + freshPending: number; + stalePending: number; + errors: number; + disabled: number; + }; +} + export interface AccessRequestMutationResult extends ControlPlaneMutationResult { accessRequest: AccessRequest; } @@ -168,6 +187,10 @@ export async function fetchAdminTaskManagerWorkspaces(): Promise<{ ok: boolean; return requestJson<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }>("/api/admin/task-manager/workspaces"); } +export async function fetchAdminAuthentikSyncHealth(): Promise { + return requestJson("/api/admin/sync/authentik/health"); +} + export async function createAdminClient(payload: Partial): Promise { return requestJson("/api/admin/clients", { method: "POST", diff --git a/src/styles/globals.css b/src/styles/globals.css index 4562000..906858d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -681,11 +681,19 @@ code { background: #050506; } -.launcher-main:has(.admin-panel-layer) .service-stage { +.launcher-admin-mount { + display: contents; +} + +.launcher-admin-mount--hidden { + display: none; +} + +.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer) .service-stage { padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap)); } -.launcher-main:has(.admin-panel-layer--content-open) .service-stage { +.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .service-stage { padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-content-width) + (var(--admin-panel-gap) * 2)); } @@ -693,13 +701,13 @@ code { padding-right: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap)); } -.launcher-main:has(.admin-panel-layer--content-open) .stage-service-overlay, -.launcher-main:has(.admin-panel-layer--content-open) .stage-video-controls, -.launcher-main:has(.admin-panel-layer--content-open) .stage-timeline-strip { +.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .stage-service-overlay, +.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .stage-video-controls, +.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .stage-timeline-strip { display: none; } -.launcher-main:has(.admin-panel-layer--fullscreen) .service-rail { +.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--fullscreen) .service-rail { opacity: 0; pointer-events: none; transform: translateY(calc(var(--launcher-rail-height) + var(--launcher-rail-bottom) + var(--launcher-stage-rail-gap))); @@ -724,7 +732,7 @@ code { background 220ms ease; } -.launcher-main:has(.admin-panel-layer--fullscreen) .service-stage { +.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--fullscreen) .service-stage { transform: translateX(calc(100vw + var(--launcher-page-pad))); pointer-events: none; } @@ -1953,6 +1961,14 @@ code { overflow: auto; } +.admin-panel-content__body:has(> .access-layout) { + overflow: hidden; +} + +.admin-panel-content__body > .access-layout { + height: 100%; +} + .admin-panel-content__body:has(.service-content-modal-layer) { overflow: visible; } @@ -2127,11 +2143,17 @@ code { .admin-header { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: 1rem; min-height: var(--admin-control-ring); } +.admin-header__filters { + display: flex; + min-width: 0; + align-items: center; +} + .admin-header__actions, .table-toolbar, .access-actions { @@ -2141,6 +2163,39 @@ code { gap: 0.65rem; } +.admin-header-source-tabs { + display: flex; + align-items: center; + gap: 0.42rem; +} + +.admin-header-source-tab { + min-width: 4.7rem; + min-height: var(--admin-control-ring); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.045); + color: var(--text-secondary); + padding: 0 0.95rem; + font-size: 0.72rem; + font-weight: 860; + letter-spacing: 0.02em; + cursor: pointer; +} + +.admin-header-source-tab:hover, +.admin-header-source-tab:focus-visible { + border-color: rgba(255, 255, 255, 0.26); + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} + +.admin-header-source-tab--active { + border-color: transparent; + background: rgba(247, 248, 244, 0.96); + color: rgb(var(--nodedc-on-accent-rgb)); +} + .admin-circle-action { width: var(--admin-control-ring); height: var(--admin-control-ring); @@ -2271,9 +2326,27 @@ code { .access-matrix { --access-matrix-table-bg: rgb(20, 20, 22); + --access-matrix-toolbar-height: 4.35rem; + position: relative; + padding: 0 0 1rem; background: var(--access-matrix-table-bg) !important; } +.access-matrix .table-toolbar--access-matrix { + position: sticky; + top: 0; + left: 0; + z-index: 16; + min-height: var(--access-matrix-toolbar-height); + margin: 0; + padding: 1rem 1rem 0.7rem; + background: var(--access-matrix-table-bg); +} + +.access-matrix > .access-empty-state { + margin: 0 1rem; +} + .table-shell--users, .table-shell--sticky-user-column { position: relative; @@ -3572,7 +3645,7 @@ code { } .matrix-scroll { - overflow: auto; + overflow: visible; border-radius: calc(var(--launcher-radius-card) - 0.85rem); background: var(--access-matrix-table-bg); } @@ -3595,6 +3668,9 @@ code { } .access-grid-head { + position: sticky; + top: var(--access-matrix-toolbar-height); + z-index: 12; min-height: 2.1rem; color: var(--text-muted); font-size: 0.66rem; @@ -3616,7 +3692,7 @@ code { } .access-grid-head.access-grid-sticky { - z-index: 6; + z-index: 18; } .access-user-cell { @@ -4296,6 +4372,83 @@ code { width: min(24rem, 42vw); } +.sync-diagnostics { + display: grid; + gap: 1rem; +} + +.sync-health-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: start; + padding: 1rem; +} + +.sync-health-card__main { + display: grid; + gap: 0.28rem; + min-width: 0; +} + +.sync-health-card__main .eyebrow, +.sync-health-card__main h3, +.sync-health-card__main p { + margin: 0; +} + +.sync-health-card__metrics { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.55rem; +} + +.sync-health-card__metrics span { + display: grid; + gap: 0.16rem; + min-height: 3.2rem; + align-content: center; + border-radius: var(--launcher-radius-control); + background: rgba(255, 255, 255, 0.055); + color: var(--text-muted); + padding: 0.55rem 0.7rem; + font-size: 0.72rem; + font-weight: 760; +} + +.sync-health-card__metrics strong { + color: var(--text-primary); + font-size: 1.15rem; + line-height: 1; +} + +.admin-data-table--sync td { + vertical-align: middle; +} + +.sync-history-card { + padding: 0; +} + +.sync-history-card details { + padding: 1rem; +} + +.sync-history-card summary { + cursor: pointer; + color: var(--text-secondary); + font-weight: 850; +} + +.sync-history-card summary:hover { + color: var(--text-primary); +} + +.sync-history-card .admin-helper-note { + margin: 0.65rem 0 0.85rem; +} + .access-request-decision-cluster { display: inline-flex; align-items: center; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index d21a275..deb913b 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -70,7 +70,14 @@ import { type MeResponse, type TaskManagerWorkspaceCreationPolicy, } from "../../shared/api/mockApi"; -import type { TaskManagerProjectSummary, TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi"; +import { + fetchAdminAuthentikSyncHealth, + type AuthentikSyncHealth, + type AuthentikSyncHealthState, + type TaskManagerProjectSummary, + type TaskManagerWorkspaceMemberRole, + type TaskManagerWorkspaceSummary, +} from "../../shared/api/adminApi"; import { uploadStorageFile } from "../../shared/api/storageApi"; import { cn } from "../../shared/lib/cn"; import { formatDate, formatDateTime } from "../../shared/lib/format"; @@ -92,6 +99,8 @@ type AdminSection = | "company"; type AdminOverlayMode = "admin" | "platform"; type AdminMutationOutcome = { ok: true } | { ok: false; message: string }; +type PublicInviteTab = "incoming" | "outgoing"; +type PublicInviteSourceFilter = "all" | "ndc" | "ops" | "engine"; type EngineWorkflowAccessRequestDecisionPatch = Partial> & { serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"]; @@ -303,6 +312,9 @@ export function AdminOverlay({ data.clients.some((client) => client.id === activeClientId) ? activeClientId : data.clients[0]?.id ?? activeClientId ); const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null); + const [publicInviteTab, setPublicInviteTab] = useState("incoming"); + const [publicInviteSourceFilter, setPublicInviteSourceFilter] = useState("all"); + const [authentikSyncHealth, setAuthentikSyncHealth] = useState(null); const fallbackClientId = data.clients[0]?.id ?? activeClientId; const selectedCompanyClientExists = data.clients.some((client) => client.id === selectedCompanyClientId); @@ -320,6 +332,24 @@ export function AdminOverlay({ accessMatrix.cells[0] ?? null; + useEffect(() => { + if (!isRoot || activeSection !== "sync") return; + + let cancelled = false; + + fetchAdminAuthentikSyncHealth() + .then((health) => { + if (!cancelled) setAuthentikSyncHealth(health); + }) + .catch(() => { + if (!cancelled) setAuthentikSyncHealth(null); + }); + + return () => { + cancelled = true; + }; + }, [activeSection, data.syncStatuses, isRoot]); + useEffect(() => { if (isRoot && !selectedClientExists && data.clients.length) { setSelectedClientId(data.clients[0].id); @@ -494,7 +524,16 @@ export function AdminOverlay({ isFullscreen={isContentFullscreen} onToggleFullscreen={() => setIsContentFullscreen((current) => !current)} onCloseContent={() => setActiveSection(null)} - /> + > + {isPublicPoolContext && activeSection === "invites" && publicInviteTab === "incoming" ? ( + + setPublicInviteSourceFilter((current) => (current === sourceFilter ? "all" : sourceFilter)) + } + /> + ) : null} +
{activeSection === "overview" ? ( ) : null} - {activeSection === "sync" ? : null} + {activeSection === "sync" ? ( + + ) : null} {activeSection === "audit" && isRoot ? : null} {activeSection === "misc" && isRoot ? : null}
@@ -605,13 +655,16 @@ function AdminHeader({ isFullscreen, onToggleFullscreen, onCloseContent, + children, }: { isFullscreen: boolean; onToggleFullscreen: () => void; onCloseContent: () => void; + children?: ReactNode; }) { return (
+
{children}
) => void; +}) { + const options: Array<{ id: Exclude; label: string }> = [ + { id: "ndc", label: "NDC" }, + { id: "ops", label: "OPS" }, + { id: "engine", label: "ENGINE" }, + ]; + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + function OverviewSection({ data, clientId, @@ -1398,6 +1481,13 @@ const syncStatusOptions: Array> = [ { value: "disabled", label: "Отключено", tone: "muted" }, ]; +const authentikSyncHealthOptions: Array> = [ + { value: "ok", label: "OK", tone: "green" }, + { value: "pending", label: "Очередь", tone: "yellow" }, + { value: "error", label: "Ошибка", tone: "red" }, + { value: "not_configured", label: "Не настроен", tone: "red" }, +]; + const auditResultOptions: Array> = [ { value: "success", label: "Успех", tone: "green" }, { value: "warning", label: "Внимание", tone: "yellow" }, @@ -3046,7 +3136,7 @@ function AccessSection({ return (
-
+

Матрица доступа · {matrix.client.name}

Нет данных для матрицы
@@ -3064,7 +3154,7 @@ function AccessSection({ return (
-
+

Матрица доступа · {matrix.client.name}

Клик по ячейке открывает назначение
@@ -3837,6 +3927,9 @@ function InvitesSection({ clientId, actorUserId, isPublicPoolContext, + publicInviteTab, + publicInviteSourceFilter, + onPublicInviteTabChange, onCreateInvite, onUpdateInvite, onDeleteInvite, @@ -3852,6 +3945,9 @@ function InvitesSection({ clientId: string; actorUserId: string; isPublicPoolContext: boolean; + publicInviteTab: PublicInviteTab; + publicInviteSourceFilter: PublicInviteSourceFilter; + onPublicInviteTabChange: (tab: PublicInviteTab) => void; onCreateInvite: (invite: Pick) => void; onUpdateInvite: (inviteId: string, patch: Partial) => void; onDeleteInvite: (inviteId: string) => void; @@ -3879,16 +3975,16 @@ function InvitesSection({ const [role, setRole] = useState("member"); const [deleteInviteId, setDeleteInviteId] = useState(null); const [copiedInviteId, setCopiedInviteId] = useState(null); - const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming"); const invites = data.invites.filter((invite) => invite.clientId === clientId); + const engineWorkflowInviteRequests = data.engineWorkflowAccessRequests.filter((request) => request.requestType !== "service_role"); const incomingRequestsTotal = data.accessRequests.length + data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length + - data.engineWorkflowAccessRequests.filter((request) => request.status !== "cancelled").length; + engineWorkflowInviteRequests.filter((request) => request.status !== "cancelled").length; const pendingIncomingRequests = data.accessRequests.filter((request) => request.status === "new").length + data.taskerInviteRequests.filter((request) => request.status === "new").length + - data.engineWorkflowAccessRequests.filter((request) => request.status === "new").length; + engineWorkflowInviteRequests.filter((request) => request.status === "new").length; const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null; const actor = getUser(data, actorUserId); const clientOptions: Array> = [ @@ -3932,14 +4028,14 @@ function InvitesSection({ @@ -3956,6 +4052,7 @@ function InvitesSection({ >; + sourceFilter: PublicInviteSourceFilter; copiedInviteId: string | null; onCopyInvite: (invite: Invite) => Promise; onUpdateAccessRequest: ( @@ -4187,12 +4286,16 @@ function AccessRequestsPanel({ .slice() .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); const engineWorkflowAccessRequests = data.engineWorkflowAccessRequests - .filter((request) => request.status !== "cancelled") + .filter((request) => request.requestType !== "service_role" && request.status !== "cancelled") .slice() .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const showNodeDcRequests = sourceFilter === "all" || sourceFilter === "ndc"; + const showTaskerRequests = sourceFilter === "all" || sourceFilter === "ops"; + const showEngineRequests = sourceFilter === "all" || sourceFilter === "engine"; return ( <> + {showNodeDcRequests ? (
@@ -4333,16 +4436,21 @@ function AccessRequestsPanel({
)} + ) : null} + {showTaskerRequests ? ( + ) : null} + {showEngineRequests ? ( + ) : null} ); } @@ -4506,7 +4614,7 @@ function EngineWorkflowAccessRequestsPanel({

NODE.DC Engine: запросы доступа

- Здесь workflow-шаринг и запросы повышения глобальной роли Engine из режима только чтения. + Здесь только workflow-шаринг Engine. Повышения роли Engine обрабатываются через уведомления и аудит.

@@ -4530,14 +4638,12 @@ function EngineWorkflowAccessRequestsPanel({ - {requests.map((request) => { - const isServiceRoleRequest = request.requestType === "service_role"; - return ( + {requests.map((request) => (
- {isServiceRoleRequest ? "Повышение роли Engine" : request.workflowName} - {isServiceRoleRequest ? `Текущая роль: ${engineServiceRoleLabel(request.currentServiceRole)}` : request.workflowId} + {request.workflowName} + {request.workflowId}
@@ -4552,23 +4658,20 @@ function EngineWorkflowAccessRequestsPanel({ {request.requesterEmail}
- {isServiceRoleRequest ? engineServiceRoleLabel(request.requestedServiceRole) : engineWorkflowRoleLabel(request.role)} + {engineWorkflowRoleLabel(request.role)} {request.status === "new" ? (
-
@@ -4778,64 +4880,154 @@ function SyncSection({ data, clientId, isRoot, + authentikHealth, onRetrySync, }: { data: LauncherData; clientId: string; isRoot: boolean; + authentikHealth: AuthentikSyncHealth | null; onRetrySync: (syncId: string) => void; }) { const syncRows = data.syncStatuses.filter((sync) => isRoot || sync.objectId === clientId || sync.objectName.includes(getClient(data, clientId).name)); + const authentikRows = syncRows.filter((sync) => sync.target === "authentik"); + const activeRows = authentikRows.filter((sync) => sync.state === "error" || isFreshPendingSync(sync)); + const historicalPendingRows = authentikRows.filter((sync) => sync.state === "pending" && !isFreshPendingSync(sync)); + const healthState = authentikHealth?.state ?? deriveAuthentikHealthState(authentikRows); + const counts = authentikHealth?.counts ?? buildLocalAuthentikSyncCounts(authentikRows); return ( - -
-
-

Синхронизация

-

Контроль доставки изменений в Authentik, сервисы и внешние контуры. Ошибочные строки можно отправить повторно.

+
+ +
+

Authentik

+

Диагностика синхронизации

+

+ Экран показывает только реальные ошибки и свежие операции. Старые pending-строки свернуты отдельно как исторический след. +

-
- - - - - - - - - - - - - - {syncRows.map((sync) => ( - - - - - - - - - - ))} - -
ОбъектТипЦельСтатусПоследняя syncОшибкаДействие
{sync.objectName}{sync.objectType}{targetLabel(sync.target)} - - {formatDateTime(sync.lastSyncAt ?? sync.updatedAt)}{sync.error ?? "—"} - onRetrySync(sync.id)} - > - - -
- + +
+ + {counts.errors} + Ошибки + + + {counts.freshPending} + Свежая очередь + + + {counts.stalePending} + Старый pending + + + {counts.synced} + Synced + +
+ + + +
+
+

Требует внимания

+

Ошибки и pending за последние 5 минут. Retry запускает реальный Authentik provision для затронутых пользователей.

+
+
+ {activeRows.length ? ( + + ) : ( +
+ Активных проблем нет + Если доступы работают, старые pending-строки ниже можно считать историческим шумом до отдельной чистки ledger. +
+ )} +
+ + {historicalPendingRows.length ? ( + +
+ Исторические pending · {historicalPendingRows.length} +

+ Эти строки давно висят в очереди и не являются надежным признаком падения Authentik. Их можно перепроверить точечно через retry. +

+ +
+
+ ) : null} +
); } +function SyncRowsTable({ rows, onRetrySync }: { rows: SyncStatus[]; onRetrySync: (syncId: string) => void }) { + return ( + + + + + + + + + + + + + + {rows.map((sync) => ( + + + + + + + + + + ))} + +
ОбъектТипЦельСтатусПоследняя syncОшибкаДействие
{sync.objectName}{sync.objectType}{targetLabel(sync.target)} + + {formatDateTime(sync.lastSyncAt ?? sync.updatedAt)}{sync.error ?? "—"} + onRetrySync(sync.id)} + > + + +
+ ); +} + +function deriveAuthentikHealthState(rows: SyncStatus[]): AuthentikSyncHealthState { + if (rows.some((sync) => sync.state === "error")) return "error"; + if (rows.some(isFreshPendingSync)) return "pending"; + return "ok"; +} + +function buildLocalAuthentikSyncCounts(rows: SyncStatus[]): AuthentikSyncHealth["counts"] { + const pendingRows = rows.filter((sync) => sync.state === "pending"); + + return { + total: rows.length, + synced: rows.filter((sync) => sync.state === "synced").length, + pending: pendingRows.length, + freshPending: pendingRows.filter(isFreshPendingSync).length, + stalePending: pendingRows.filter((sync) => !isFreshPendingSync(sync)).length, + errors: rows.filter((sync) => sync.state === "error").length, + disabled: rows.filter((sync) => sync.state === "disabled").length, + }; +} + +function isFreshPendingSync(sync: SyncStatus) { + if (sync.state !== "pending") return false; + const timestamp = Date.parse(sync.updatedAt ?? sync.lastSyncAt ?? ""); + if (!Number.isFinite(timestamp)) return false; + return Date.now() - timestamp <= 5 * 60 * 1000; +} + function AuditSection({ data }: { data: LauncherData }) { return (