Refine launcher admin access workflows

This commit is contained in:
DCCONSTRUCTIONS 2026-05-27 11:46:29 +03:00
parent 3b006303a2
commit ec021d1a95
6 changed files with 676 additions and 95 deletions

View File

@ -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,

View File

@ -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];

View File

@ -104,6 +104,7 @@ export function LauncherApp() {
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
const [adminOpen, setAdminOpen] = useState(false);
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
const [adminTemporarilyHidden, setAdminTemporarilyHidden] = useState(false);
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(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 ? (
<div
className={adminTemporarilyHidden ? "launcher-admin-mount launcher-admin-mount--hidden" : "launcher-admin-mount"}
aria-hidden={adminTemporarilyHidden}
>
<Suspense fallback={null}>
<AdminOverlay
data={data}
me={runtimeMe}
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
activeClientId={resolvedClientId}
onClose={() => setAdminOpen(false)}
onClose={() => {
setAdminTemporarilyHidden(false);
setAdminOpen(false);
}}
onSetUserServiceAccess={handleSetUserServiceAccess}
onCreateInvite={handleCreateInvite}
onUpdateInvite={handleUpdateInvite}
@ -1007,6 +1032,7 @@ export function LauncherApp() {
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
/>
</Suspense>
</div>
) : null}
{profileSettingsOpen && activeProfileUser ? (
<Suspense fallback={null}>

View File

@ -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<AuthentikSyncHealth> {
return requestJson<AuthentikSyncHealth>("/api/admin/sync/authentik/health");
}
export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> {
return requestJson<ControlPlaneMutationResult>("/api/admin/clients", {
method: "POST",

View File

@ -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;

View File

@ -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<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & {
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<PublicInviteTab>("incoming");
const [publicInviteSourceFilter, setPublicInviteSourceFilter] = useState<PublicInviteSourceFilter>("all");
const [authentikSyncHealth, setAuthentikSyncHealth] = useState<AuthentikSyncHealth | null>(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" ? (
<InviteSourceFilterTabs
value={publicInviteSourceFilter}
onChange={(sourceFilter) =>
setPublicInviteSourceFilter((current) => (current === sourceFilter ? "all" : sourceFilter))
}
/>
) : null}
</AdminHeader>
<div className="admin-panel-content__body">
{activeSection === "overview" ? (
<OverviewSection
@ -579,6 +618,9 @@ export function AdminOverlay({
clientId={scopedClientId}
actorUserId={me.user.id}
isPublicPoolContext={isPublicPoolContext}
publicInviteTab={publicInviteTab}
publicInviteSourceFilter={publicInviteSourceFilter}
onPublicInviteTabChange={setPublicInviteTab}
onCreateInvite={onCreateInvite}
onUpdateInvite={onUpdateInvite}
onDeleteInvite={onDeleteInvite}
@ -591,7 +633,15 @@ export function AdminOverlay({
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
/>
) : null}
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
{activeSection === "sync" ? (
<SyncSection
data={data}
clientId={scopedClientId}
isRoot={isRoot}
authentikHealth={authentikSyncHealth}
onRetrySync={onRetrySync}
/>
) : null}
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
</div>
@ -605,13 +655,16 @@ function AdminHeader({
isFullscreen,
onToggleFullscreen,
onCloseContent,
children,
}: {
isFullscreen: boolean;
onToggleFullscreen: () => void;
onCloseContent: () => void;
children?: ReactNode;
}) {
return (
<div className="admin-header">
<div className="admin-header__filters">{children}</div>
<div className="admin-header__actions">
<IconButton
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
@ -630,6 +683,36 @@ function AdminHeader({
);
}
function InviteSourceFilterTabs({
value,
onChange,
}: {
value: PublicInviteSourceFilter;
onChange: (sourceFilter: Exclude<PublicInviteSourceFilter, "all">) => void;
}) {
const options: Array<{ id: Exclude<PublicInviteSourceFilter, "all">; label: string }> = [
{ id: "ndc", label: "NDC" },
{ id: "ops", label: "OPS" },
{ id: "engine", label: "ENGINE" },
];
return (
<div className="admin-header-source-tabs" aria-label="Тип входящих инвайтов">
{options.map((option) => (
<button
key={option.id}
type="button"
className={cn("admin-header-source-tab", value === option.id && "admin-header-source-tab--active")}
aria-pressed={value === option.id}
onClick={() => onChange(option.id)}
>
{option.label}
</button>
))}
</div>
);
}
function OverviewSection({
data,
clientId,
@ -1398,6 +1481,13 @@ const syncStatusOptions: Array<AdminStatusOption<SyncState>> = [
{ value: "disabled", label: "Отключено", tone: "muted" },
];
const authentikSyncHealthOptions: Array<AdminStatusOption<AuthentikSyncHealthState>> = [
{ 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<AdminStatusOption<"success" | "warning" | "error">> = [
{ value: "success", label: "Успех", tone: "green" },
{ value: "warning", label: "Внимание", tone: "yellow" },
@ -3046,7 +3136,7 @@ function AccessSection({
return (
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
<GlassSurface className="access-matrix">
<div className="table-toolbar">
<div className="table-toolbar table-toolbar--access-matrix">
<h3>Матрица доступа · {matrix.client.name}</h3>
<span className="muted-text">Нет данных для матрицы</span>
</div>
@ -3064,7 +3154,7 @@ function AccessSection({
return (
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
<GlassSurface className="access-matrix">
<div className="table-toolbar">
<div className="table-toolbar table-toolbar--access-matrix">
<h3>Матрица доступа · {matrix.client.name}</h3>
<span className="muted-text">Клик по ячейке открывает назначение</span>
</div>
@ -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<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
@ -3879,16 +3975,16 @@ function InvitesSection({
const [role, setRole] = useState<ClientMembershipRole>("member");
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(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<NodeDcSelectOption<string>> = [
@ -3932,14 +4028,14 @@ function InvitesSection({
<button
type="button"
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
onClick={() => setPublicInviteTab("incoming")}
onClick={() => onPublicInviteTabChange("incoming")}
>
Входящие · {incomingRequestsTotal}
</button>
<button
type="button"
className={cn("admin-tab-button", publicInviteTab === "outgoing" && "admin-tab-button--active")}
onClick={() => setPublicInviteTab("outgoing")}
onClick={() => onPublicInviteTabChange("outgoing")}
>
Исходящие · {invites.length}
</button>
@ -3956,6 +4052,7 @@ function InvitesSection({
<AccessRequestsPanel
data={data}
clientOptions={clientOptions}
sourceFilter={publicInviteSourceFilter}
copiedInviteId={copiedInviteId}
onCopyInvite={handleCopyInvite}
onUpdateAccessRequest={onUpdateAccessRequest}
@ -4147,6 +4244,7 @@ function InvitesSection({
function AccessRequestsPanel({
data,
clientOptions,
sourceFilter,
copiedInviteId,
onCopyInvite,
onUpdateAccessRequest,
@ -4159,6 +4257,7 @@ function AccessRequestsPanel({
}: {
data: LauncherData;
clientOptions: Array<NodeDcSelectOption<string>>;
sourceFilter: PublicInviteSourceFilter;
copiedInviteId: string | null;
onCopyInvite: (invite: Invite) => Promise<void>;
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 ? (
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
@ -4333,16 +4436,21 @@ function AccessRequestsPanel({
</div>
)}
</GlassSurface>
) : null}
{showTaskerRequests ? (
<TaskerInviteRequestsPanel
requests={taskerInviteRequests}
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
/>
) : null}
{showEngineRequests ? (
<EngineWorkflowAccessRequestsPanel
requests={engineWorkflowAccessRequests}
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
/>
) : null}
</>
);
}
@ -4506,7 +4614,7 @@ function EngineWorkflowAccessRequestsPanel({
<div>
<h3>NODE.DC Engine: запросы доступа</h3>
<p className="admin-helper-note">
Здесь workflow-шаринг и запросы повышения глобальной роли Engine из режима только чтения.
Здесь только workflow-шаринг Engine. Повышения роли Engine обрабатываются через уведомления и аудит.
</p>
</div>
</div>
@ -4530,14 +4638,12 @@ function EngineWorkflowAccessRequestsPanel({
</tr>
</thead>
<tbody>
{requests.map((request) => {
const isServiceRoleRequest = request.requestType === "service_role";
return (
{requests.map((request) => (
<tr key={request.id}>
<td>
<div className="access-request-applicant">
<strong>{isServiceRoleRequest ? "Повышение роли Engine" : request.workflowName}</strong>
<small>{isServiceRoleRequest ? `Текущая роль: ${engineServiceRoleLabel(request.currentServiceRole)}` : request.workflowId}</small>
<strong>{request.workflowName}</strong>
<small>{request.workflowId}</small>
</div>
</td>
<td>
@ -4552,7 +4658,7 @@ function EngineWorkflowAccessRequestsPanel({
<small>{request.requesterEmail}</small>
</div>
</td>
<td>{isServiceRoleRequest ? engineServiceRoleLabel(request.requestedServiceRole) : engineWorkflowRoleLabel(request.role)}</td>
<td>{engineWorkflowRoleLabel(request.role)}</td>
<td>
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
</td>
@ -4563,10 +4669,7 @@ function EngineWorkflowAccessRequestsPanel({
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
className="access-request-decision-button access-request-decision-button--accept"
type="button"
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, isServiceRoleRequest
? { serviceRole: request.requestedServiceRole ?? "member" }
: {}
)}
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, {})}
>
<Check size={16} strokeWidth={2.6} />
</button>
@ -4584,8 +4687,7 @@ function EngineWorkflowAccessRequestsPanel({
)}
</td>
</tr>
);
})}
))}
</tbody>
</table>
</div>
@ -4778,24 +4880,88 @@ 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 (
<div className="sync-diagnostics">
<GlassSurface className="sync-health-card">
<div className="sync-health-card__main">
<p className="eyebrow">Authentik</p>
<h3>Диагностика синхронизации</h3>
<p className="admin-helper-note">
Экран показывает только реальные ошибки и свежие операции. Старые pending-строки свернуты отдельно как исторический след.
</p>
</div>
<AdminStatusPill value={healthState} options={authentikSyncHealthOptions} />
<div className="sync-health-card__metrics" aria-label="Сводка Authentik sync">
<span>
<strong>{counts.errors}</strong>
Ошибки
</span>
<span>
<strong>{counts.freshPending}</strong>
Свежая очередь
</span>
<span>
<strong>{counts.stalePending}</strong>
Старый pending
</span>
<span>
<strong>{counts.synced}</strong>
Synced
</span>
</div>
</GlassSurface>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>Синхронизация</h3>
<p className="admin-helper-note">Контроль доставки изменений в Authentik, сервисы и внешние контуры. Ошибочные строки можно отправить повторно.</p>
<h3>Требует внимания</h3>
<p className="admin-helper-note">Ошибки и pending за последние 5 минут. Retry запускает реальный Authentik provision для затронутых пользователей.</p>
</div>
</div>
<table className="admin-data-table">
{activeRows.length ? (
<SyncRowsTable rows={activeRows} onRetrySync={onRetrySync} />
) : (
<div className="access-empty-state">
<strong>Активных проблем нет</strong>
<span>Если доступы работают, старые pending-строки ниже можно считать историческим шумом до отдельной чистки ledger.</span>
</div>
)}
</GlassSurface>
{historicalPendingRows.length ? (
<GlassSurface className="table-shell sync-history-card">
<details>
<summary>Исторические pending · {historicalPendingRows.length}</summary>
<p className="admin-helper-note">
Эти строки давно висят в очереди и не являются надежным признаком падения Authentik. Их можно перепроверить точечно через retry.
</p>
<SyncRowsTable rows={historicalPendingRows} onRetrySync={onRetrySync} />
</details>
</GlassSurface>
) : null}
</div>
);
}
function SyncRowsTable({ rows, onRetrySync }: { rows: SyncStatus[]; onRetrySync: (syncId: string) => void }) {
return (
<table className="admin-data-table admin-data-table--sync">
<thead>
<tr>
<th>Объект</th>
@ -4808,7 +4974,7 @@ function SyncSection({
</tr>
</thead>
<tbody>
{syncRows.map((sync) => (
{rows.map((sync) => (
<tr key={sync.id}>
<td>{sync.objectName}</td>
<td>{sync.objectType}</td>
@ -4832,10 +4998,36 @@ function SyncSection({
))}
</tbody>
</table>
</GlassSurface>
);
}
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 (
<GlassSurface className="table-shell">