import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Client } from "../entities/client/types"; import type { Invite } from "../entities/invite/types"; import { syncServiceLaunchLink } from "../entities/service/links"; import type { LauncherServiceView, Service } from "../entities/service/types"; import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types"; import { approveAdminAccessRequest, approveAdminTaskerInviteRequest, createAdminClient, createAdminGroup, createAdminInvite, createAdminService, createAdminUser, deleteAdminClient, deleteAdminGroup, deleteAdminInvite, deleteAdminMembership, deleteAdminService, deleteAdminUser, ensureAdminTaskManagerProjectMembership, ensureAdminTaskManagerWorkspaceMembership, fetchAdminTaskManagerWorkspaces, fetchControlPlaneSnapshot, reorderAdminServices, retryAdminSync, rejectAdminAccessRequest, rejectAdminTaskerInviteRequest, removeAdminTaskManagerProjectMembership, removeAdminTaskManagerWorkspaceMembership, setAdminUserServiceAccess, updateAdminClient, updateAdminAccessRequest, updateAdminGroup, updateAdminInvite, updateAdminMembership, updateAdminService, updateAdminSettings, updateAdminUserProfile, type ControlPlaneMutationResult, type TaskManagerWorkspaceMemberRole, type TaskManagerWorkspaceSummary, } from "../shared/api/adminApi"; import { createAccessRequest, type CreateAccessRequestResponse } from "../shared/api/accessRequestApi"; import { buildLauncherServices, buildMe, initialLauncherData, normalizeLauncherData, profileOptions, type LauncherData, type LauncherSettings, } from "../shared/api/mockApi"; import { fetchAuthSession, fetchAvailableApps, type AuthenticatedSession, type AuthSession, type LauncherAuthApp, } 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 { AccessRequest, CreateAccessRequestCommand } from "../entities/access-request/types"; import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { AdminOverlay, type AccessAssignmentValue, type CreateUserCommand, type EnsureTaskManagerProjectMemberCommand, type SetUserServiceAccessCommand, } from "../widgets/admin-overlay/AdminOverlay"; import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel"; import { ServiceRail } from "../widgets/service-rail/ServiceRail"; import { ServiceStage } from "../widgets/service-stage/ServiceStage"; import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar"; let lastAuthRedirect: { url: string; startedAt: number } | null = null; type InviteFlowState = | { status: "loading" } | { status: "ready"; payload: PublicInviteResponse } | { status: "accepting"; payload: PublicInviteResponse } | { status: "accepted"; payload: PublicInviteResponse } | { status: "registering"; payload: PublicInviteResponse } | { status: "registered"; payload: PublicInviteResponse; loginUrl: string } | { status: "error"; message: string; payload?: PublicInviteResponse }; type ControlPlaneMutationOutcome = { ok: true; data: LauncherData } | { ok: false; message: string }; export function LauncherApp() { const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []); const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []); const [data, setData] = useState(() => syncLauncherServiceLinks(initialLauncherData)); const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId); 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); const [pendingAccessAssignments, setPendingAccessAssignments] = useState>({}); const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState>({}); const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState>({}); const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState([]); const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false); const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState(null); const [inviteFlow, setInviteFlow] = useState(() => (inviteToken ? { status: "loading" } : null)); const runtimeDataRef = useRef(data); const runtimeProfileIdRef = useRef(activeProfileId); const runtimeClientIdRef = useRef(activeClientId); const resolvedProfileId = useMemo( () => resolveRuntimeProfileId(data, authSession, activeProfileId), [activeProfileId, authSession, data] ); useEffect(() => { runtimeDataRef.current = data; runtimeProfileIdRef.current = resolvedProfileId; runtimeClientIdRef.current = activeClientId; }, [activeClientId, data, resolvedProfileId]); const me = useMemo(() => buildMe(data, resolvedProfileId, activeClientId), [data, resolvedProfileId, activeClientId]); const activeProfileUser = data.users.find((user) => user.id === resolvedProfileId) ?? 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; return { ...me, user: { ...me.user, authentikUserId: authSession.user.sub, email: me.user.email || authSession.user.email, name: me.user.name || authSession.user.name, avatarUrl: me.user.avatarUrl ?? authSession.user.avatarUrl, }, mockAuthentikClaims: { ...me.mockAuthentikClaims, sub: authSession.user.sub, email: authSession.user.email || me.mockAuthentikClaims.email, name: authSession.user.name || me.mockAuthentikClaims.name, avatarUrl: authSession.user.avatarUrl ?? null, groups: authSession.groups, }, }; }, [authSession, me]); const resolvedClientId = me.activeClientId; const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin); const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]); const launcherServices = useMemo( () => { const services = buildLauncherServices(data, resolvedProfileId, resolvedClientId); if (!authSession?.authenticated || authApps === null) { return []; } return services.map((service) => { const app = authAppsBySlug.get(service.slug); if (!app) { return { ...service, userAccess: "denied" as const, openUrl: null, effectiveAccess: { ...service.effectiveAccess, allowed: false, visible: true, openEnabled: false, reason: "Нет доступа", }, }; } const appVisible = app.status !== "hidden" && app.status !== "disabled"; const allowed = app.hasAccess && appVisible && service.effectiveAccess.allowed; const openEnabled = allowed && app.status === "active" && service.effectiveAccess.openEnabled; return { ...service, title: app.title || service.title, description: app.description || service.description, openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null, userAccess: allowed ? ("allowed" as const) : ("denied" as const), effectiveAccess: { ...service.effectiveAccess, allowed, visible: appVisible, openEnabled, reason: !app.hasAccess ? app.accessReason || "Нет доступа" : service.effectiveAccess.reason, }, }; }).filter((service) => service.effectiveAccess.visible); }, [authApps, authAppsBySlug, authSession, data, resolvedProfileId, resolvedClientId] ); useEffect(() => { if (!launcherServices.length) { setSelectedServiceId(undefined); return; } if (selectedServiceId && !launcherServices.some((service) => service.id === selectedServiceId)) { setSelectedServiceId(undefined); } }, [launcherServices, selectedServiceId]); const selectedService = launcherServices.find((service) => service.id === selectedServiceId); useEffect(() => { let isMounted = true; fetchAuthSession() .then(async (session) => { if (!isMounted) return; setAuthSession(session); if (!session.authenticated) { setAuthApps([]); return; } const apps = await fetchAvailableApps(); if (isMounted) { setAuthApps(apps); } }) .catch((error: unknown) => { if (!isMounted) return; setAuthSession({ authenticated: false, loginUrl: "/auth/login" }); setAuthApps([]); console.warn(error instanceof Error ? error.message : "Не удалось проверить сессию платформы"); }); return () => { isMounted = false; }; }, []); useEffect(() => { if (!authSession || authSession.authenticated) return; if (inviteToken || isAccessRequestRoute) return; redirectToLogin(authSession.loginUrl); }, [authSession, inviteToken, isAccessRequestRoute]); useEffect(() => { if (!inviteToken) return; let isMounted = true; setInviteFlow({ status: "loading" }); fetchPublicInvite(inviteToken) .then((payload) => { if (!isMounted) return; setInviteFlow({ status: "ready", payload }); }) .catch((error: unknown) => { if (!isMounted) return; setInviteFlow({ status: "error", message: error instanceof Error ? error.message : "Инвайт не найден" }); }); return () => { isMounted = false; }; }, [inviteToken]); useEffect(() => { let isRedirecting = false; return subscribeToNodeDCLogoutEvents(() => { if (isRedirecting) return; isRedirecting = true; redirectToLogin("/auth/login?prompt=login"); }); }, []); useEffect(() => { let isMounted = true; const validateRestoredSession = (event: PageTransitionEvent) => { if (!event.persisted) return; fetchAuthSession() .then((session) => { if (!isMounted) return; if (!session.authenticated) { if (inviteToken || isAccessRequestRoute) return; redirectToLogin(session.loginUrl); return; } setAuthSession(session); }) .catch(() => { if (isMounted && !inviteToken && !isAccessRequestRoute) { redirectToLogin("/auth/login"); } }); }; window.addEventListener("pageshow", validateRestoredSession); return () => { isMounted = false; window.removeEventListener("pageshow", validateRestoredSession); }; }, [inviteToken, isAccessRequestRoute]); useEffect(() => { if (!authSession?.authenticated) return; const nextContext = resolveAuthenticatedContext(data, authSession, activeProfileId, activeClientId); if (activeProfileId !== nextContext.profileId) { setActiveProfileId(nextContext.profileId); } if (activeClientId !== nextContext.clientId) { setActiveClientId(nextContext.clientId); } }, [activeClientId, activeProfileId, authSession, data]); useEffect(() => { let isMounted = true; loadPersistedLauncherData() .then((persistedData) => { if (isMounted && persistedData) { setData(syncLauncherServiceLinks(persistedData)); } }); return () => { isMounted = false; }; }, []); useEffect(() => { if (!canOpenAdminApi) return; let isMounted = true; fetchControlPlaneSnapshot() .then((snapshot) => { if (isMounted) { setData(syncLauncherServiceLinks(snapshot.data)); } }) .catch((error: unknown) => { console.warn(error instanceof Error ? error.message : "Не удалось загрузить control-plane snapshot"); }); return () => { isMounted = false; }; }, [canOpenAdminApi]); useEffect(() => { if (!adminOpen || !canOpenAdminApi) return; void refreshTaskManagerWorkspaces(); }, [adminOpen, canOpenAdminApi]); const refreshRuntimeState = useCallback(async () => { try { const nextSession = await fetchAuthSession(); setAuthSession(nextSession); if (!nextSession.authenticated) { setAuthApps([]); return; } const currentData = runtimeDataRef.current; const nextContext = resolveAuthenticatedContext( currentData, nextSession, runtimeProfileIdRef.current, runtimeClientIdRef.current ); const nextMe = buildMe(currentData, nextContext.profileId, nextContext.clientId); const [persistedData, apps] = await Promise.all([ nextSession.isSuperAdmin || nextMe.permissions.canOpenAdmin ? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data) : loadPersistedLauncherData(), fetchAvailableApps(), ]); if (persistedData) { setData(syncLauncherServiceLinks(persistedData)); } setAuthApps(apps); } catch (error: unknown) { console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher"); } }, []); useEffect(() => { if (!authSession?.authenticated) return; let isMounted = true; const refreshMountedRuntimeState = async () => { await refreshRuntimeState(); if (!isMounted) return; }; const eventSource = new EventSource("/api/events"); eventSource.addEventListener("nodedc-ready", () => { void refreshMountedRuntimeState(); }); eventSource.addEventListener("nodedc-runtime", () => { void refreshMountedRuntimeState(); }); eventSource.onerror = () => { console.warn("Launcher event stream disconnected; browser will retry automatically"); }; return () => { isMounted = false; eventSource.close(); }; }, [authSession?.authenticated, refreshRuntimeState]); useEffect(() => { if (!authSession?.authenticated) return; const refreshVisibleRuntimeState = () => { if (document.visibilityState === "visible") { void refreshRuntimeState(); } }; window.addEventListener("focus", refreshVisibleRuntimeState); document.addEventListener("visibilitychange", refreshVisibleRuntimeState); return () => { window.removeEventListener("focus", refreshVisibleRuntimeState); document.removeEventListener("visibilitychange", refreshVisibleRuntimeState); }; }, [authSession?.authenticated, refreshRuntimeState]); function handleProfileChange(userId: string) { const profile = profileOptions.find((option) => option.userId === userId); setActiveProfileId(userId); setActiveClientId(profile?.defaultClientId ?? activeClientId); setAdminOpen(false); } function handleLaunch(service: LauncherServiceView) { if (!service.openUrl || !service.effectiveAccess.openEnabled) return; window.open(service.openUrl, "_blank", "noopener,noreferrer"); } function handleServiceSelect(serviceId: string) { setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId)); } function handleStageStep(direction: "previous" | "next") { if (!launcherServices.length) return; setSelectedServiceId((current) => { const currentIndex = current ? launcherServices.findIndex((service) => service.id === current) : -1; const fallbackIndex = direction === "next" ? 0 : launcherServices.length - 1; const nextIndex = currentIndex === -1 ? fallbackIndex : direction === "next" ? (currentIndex + 1) % launcherServices.length : (currentIndex - 1 + launcherServices.length) % launcherServices.length; return launcherServices[nextIndex]?.id; }); } async function applyControlPlaneMutation(request: Promise): Promise { try { const result = await request; setData(syncLauncherServiceLinks(result.data)); return { ok: true, data: result.data }; } catch (error: unknown) { const message = error instanceof Error ? error.message : "Не удалось выполнить admin API операцию"; console.warn(message); return { ok: false, message }; } } async function refreshTaskManagerWorkspaces() { setTaskManagerWorkspacesLoading(true); setTaskManagerWorkspacesError(null); try { const result = await fetchAdminTaskManagerWorkspaces(); setTaskManagerWorkspaces(result.workspaces ?? []); } catch (error: unknown) { setTaskManagerWorkspacesError(error instanceof Error ? error.message : "Не удалось загрузить workspace Operational Core"); } finally { setTaskManagerWorkspacesLoading(false); } } function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) { const assignmentKey = accessAssignmentKey(userId, serviceId); if (pendingAccessAssignments[assignmentKey]) { return; } setPendingAccessAssignments((current) => ({ ...current, [assignmentKey]: value })); setAdminUserServiceAccess({ userId, serviceId, value }) .then((result) => { setData(syncLauncherServiceLinks(result.data)); }) .catch((error: unknown) => { console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию"); }) .finally(() => { setPendingAccessAssignments((current) => { const { [assignmentKey]: _completed, ...rest } = current; return rest; }); }); } function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole; workspaceSlug?: string }) { const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug ?? "primary"}`; if (pendingTaskManagerMemberships[membershipKey]) { return; } setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true })); const request = command.role === "unset" ? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId, workspaceSlug: command.workspaceSlug }) : ensureAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId, workspaceSlug: command.workspaceSlug, role: command.role, setLastWorkspace: true, }); request .then((result) => { setData(syncLauncherServiceLinks(result.data)); }) .catch((error: unknown) => { console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать Tasker"); }) .finally(() => { setPendingTaskManagerMemberships((current) => { const { [membershipKey]: _completed, ...rest } = current; return rest; }); }); } function handleSetTaskManagerProjectMemberRole(command: EnsureTaskManagerProjectMemberCommand) { const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug}:${command.projectId}`; if (pendingTaskManagerProjectMemberships[membershipKey]) { return; } setPendingTaskManagerProjectMemberships((current) => ({ ...current, [membershipKey]: true })); const request = command.role === "unset" ? removeAdminTaskManagerProjectMembership({ clientId: command.clientId, userId: command.userId, workspaceSlug: command.workspaceSlug, projectId: command.projectId, }) : ensureAdminTaskManagerProjectMembership({ clientId: command.clientId, userId: command.userId, workspaceSlug: command.workspaceSlug, projectId: command.projectId, role: command.role, }); request .then((result) => { setData(syncLauncherServiceLinks(result.data)); }) .catch((error: unknown) => { console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать проект Tasker"); }) .finally(() => { setPendingTaskManagerProjectMemberships((current) => { const { [membershipKey]: _completed, ...rest } = current; return rest; }); }); } function handleCreateInvite(invite: Pick) { applyControlPlaneMutation(createAdminInvite(invite)); } async function handleAcceptInvite() { if (!inviteToken || inviteFlow?.status !== "ready") return; setInviteFlow({ status: "accepting", payload: inviteFlow.payload }); try { const result = await acceptInvite(inviteToken); setData(syncLauncherServiceLinks(result.data)); if (result.redirectUrl && result.redirectUrl !== "/") { window.location.assign(result.redirectUrl); return; } setInviteFlow({ status: "accepted", payload: inviteFlow.payload }); } catch (error) { setInviteFlow({ status: "error", payload: inviteFlow.payload, message: error instanceof Error ? error.message : "Не удалось принять инвайт", }); } } async function handleRegisterInvite(command: RegisterInviteCommand) { if (!inviteToken || !inviteFlow || !("payload" in inviteFlow) || !inviteFlow.payload || (inviteFlow.status !== "ready" && inviteFlow.status !== "error")) return; const payload = inviteFlow.payload; setInviteFlow({ status: "registering", payload }); try { const result = await registerInvite(inviteToken, command); setData(syncLauncherServiceLinks(result.data)); setInviteFlow({ status: "registered", payload, loginUrl: result.loginUrl }); window.location.replace(result.redirectUrl || "/"); } catch (error) { setInviteFlow({ status: "error", message: error instanceof Error ? error.message : "Не удалось зарегистрироваться по инвайту", payload, }); } } function handleUpdateInvite(inviteId: string, patch: Partial) { applyControlPlaneMutation(updateAdminInvite(inviteId, patch)); } function handleDeleteInvite(inviteId: string) { applyControlPlaneMutation(deleteAdminInvite(inviteId)); } function handleUpdateAccessRequest(accessRequestId: string, patch: Parameters[1]) { applyControlPlaneMutation(updateAdminAccessRequest(accessRequestId, patch)); } function handleApproveAccessRequest(accessRequestId: string, patch: Parameters[1]) { applyControlPlaneMutation(approveAdminAccessRequest(accessRequestId, patch)); } function handleRejectAccessRequest(accessRequestId: string, patch: Parameters[1]) { applyControlPlaneMutation(rejectAdminAccessRequest(accessRequestId, patch)); } function handleApproveTaskerInviteRequest( taskerInviteRequestId: string, patch: Parameters[1] ) { applyControlPlaneMutation(approveAdminTaskerInviteRequest(taskerInviteRequestId, patch)); } function handleRejectTaskerInviteRequest( taskerInviteRequestId: string, patch: Parameters[1] ) { applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch)); } function handleRetrySync(syncId: string) { applyControlPlaneMutation(retryAdminSync(syncId)); } function handleUpdateSettings(patch: Partial) { return applyControlPlaneMutation(updateAdminSettings(patch)); } function handleUpdateService(serviceId: string, patch: Partial) { return applyControlPlaneMutation(updateAdminService(serviceId, patch)); } function handleCreateClient() { const index = data.clients.length + 1; applyControlPlaneMutation( createAdminClient({ type: "company", name: `Новый клиент ${index}`, legalName: `Новый клиент ${index}`, status: "demo", demoEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), contactName: "", contactEmail: "", notes: "", }) ); } function handleUpdateClient(clientId: string, patch: Partial) { applyControlPlaneMutation(updateAdminClient(clientId, patch)); } function handleDeleteClient(clientId: string) { const nextClientId = data.clients.find((client) => client.id !== clientId)?.id ?? activeClientId; applyControlPlaneMutation(deleteAdminClient(clientId)); if (activeClientId === clientId) { setActiveClientId(nextClientId); } } function handleUpdateUser(userId: string, patch: Partial) { applyControlPlaneMutation(updateAdminUserProfile(userId, patch)); } function handleDeleteUser(userId: string) { applyControlPlaneMutation(deleteAdminUser(userId)); } async function handleUpdateOwnProfile(patch: Partial) { const result = await updateOwnProfile(patch); setData(syncLauncherServiceLinks(result.data)); } async function handleUpdateOwnPassword(newPassword: string) { const result = await updateOwnPassword(newPassword); setData(syncLauncherServiceLinks(result.data)); } function handleCreateUser(command: CreateUserCommand) { createAdminUser(command) .then((result) => { setData(syncLauncherServiceLinks(result.data)); if (result.provisioning?.temporaryPassword) { window.alert(`Пользователь создан. Временный пароль: ${result.provisioning.temporaryPassword}`); } }) .catch((error: unknown) => { console.warn(error instanceof Error ? error.message : "Не удалось создать пользователя"); }); } function handleUpdateMembership(membershipId: string, patch: Partial) { applyControlPlaneMutation(updateAdminMembership(membershipId, patch)); } function handleDeleteMembership(membershipId: string) { applyControlPlaneMutation(deleteAdminMembership(membershipId)); } function handleCreateGroup(clientId: string) { applyControlPlaneMutation(createAdminGroup({ clientId, name: "Новая группа", description: "Описание группы", memberIds: [] })); } function handleUpdateGroup(groupId: string, patch: Partial) { applyControlPlaneMutation(updateAdminGroup(groupId, patch)); } function handleDeleteGroup(groupId: string) { applyControlPlaneMutation(deleteAdminGroup(groupId)); } function handleReorderServices(orderedServiceIds: string[]) { applyControlPlaneMutation(reorderAdminServices(orderedServiceIds)); } function handleCreateService() { applyControlPlaneMutation(createAdminService()); } function handleDeleteService(serviceId: string) { applyControlPlaneMutation(deleteAdminService(serviceId)); setSelectedServiceId((current) => (current === serviceId ? undefined : current)); } if (isAccessRequestRoute) { return ( redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl, { returnTo: "/" })} /> ); } if (inviteToken) { return ( void handleAcceptInvite()} onRegister={(command) => void handleRegisterInvite(command)} onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)} onSwitchAccount={() => { const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`; window.location.replace(`/auth/logout?global=1&returnTo=${encodeURIComponent(returnTo)}`); }} onGoHome={() => { window.history.replaceState(null, "", "/"); window.location.replace("/"); }} /> ); } if (!authSession) { return null; } if (!authSession.authenticated) { return null; } const handleLogout = () => { window.location.replace(authSession.logoutUrl); }; if (currentAccessRequest) { return ; } return (
{ 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} brandLinkUrl={data.settings.brand.logoLinkUrl} />
0} onLaunch={handleLaunch} onSelectPrevious={() => handleStageStep("previous")} onSelectNext={() => handleStageStep("next")} /> {adminOpen && me.permissions.canOpenAdmin ? ( setAdminOpen(false)} onSetUserServiceAccess={handleSetUserServiceAccess} onCreateInvite={handleCreateInvite} onUpdateInvite={handleUpdateInvite} onDeleteInvite={handleDeleteInvite} onUpdateAccessRequest={handleUpdateAccessRequest} onApproveAccessRequest={handleApproveAccessRequest} onRejectAccessRequest={handleRejectAccessRequest} onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest} onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest} onRetrySync={handleRetrySync} onCreateClient={handleCreateClient} onUpdateClient={handleUpdateClient} onDeleteClient={handleDeleteClient} onCreateUser={handleCreateUser} onUpdateUser={handleUpdateUser} onDeleteUser={handleDeleteUser} onUpdateMembership={handleUpdateMembership} onDeleteMembership={handleDeleteMembership} pendingAccessAssignments={pendingAccessAssignments} onCreateGroup={handleCreateGroup} onUpdateGroup={handleUpdateGroup} onDeleteGroup={handleDeleteGroup} onUpdateService={handleUpdateService} onReorderServices={handleReorderServices} onCreateService={handleCreateService} onDeleteService={handleDeleteService} onUpdateSettings={handleUpdateSettings} taskManagerWorkspaces={taskManagerWorkspaces} taskManagerWorkspacesLoading={taskManagerWorkspacesLoading} taskManagerWorkspacesError={taskManagerWorkspacesError} pendingTaskManagerMemberships={pendingTaskManagerMemberships} pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships} onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()} onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole} onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole} /> ) : null} {profileSettingsOpen && activeProfileUser ? ( setProfileSettingsOpen(false)} onSaveProfile={handleUpdateOwnProfile} onChangePassword={handleUpdateOwnPassword} /> ) : null}
); } function syncLauncherServiceLinks(data: Partial): LauncherData { const normalizedData = normalizeLauncherData(data); return { ...normalizedData, services: normalizedData.services.map(syncServiceLaunchLink), }; } function accessAssignmentKey(userId: string, serviceId: string) { return `${userId}:${serviceId}`; } function AccessRequestScreen({ onSubmit, onLogin, }: { onSubmit: (command: CreateAccessRequestCommand) => Promise; onLogin: () => void; }) { 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() && values.lastName.trim() && values.middleName.trim() && values.phone.trim() && values.company.trim() && values.password.length >= 8 && values.password === values.passwordConfirm && status !== "submitting" ); const updateField = (field: keyof typeof values, value: string) => { setValues((current) => ({ ...current, [field]: value })); }; return (

NODE.DC.

{isSubmitted ? "Вы запросили доступ." : "Работайте во всех измерениях."}

{!isSubmitted ? (

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

) : null} {message ?

{message}

: null} {passwordMismatch ?

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

: null} {isSubmitted ? (
) : (
{ event.preventDefault(); if (!canSubmit) return; setStatus("submitting"); setMessage(null); onSubmit({ email: normalizedEmail, firstName: values.firstName.trim(), lastName: values.lastName.trim(), middleName: values.middleName.trim(), phone: values.phone.trim(), company: values.company.trim(), password: values.password, }) .then(() => { setStatus("submitted"); setMessage("Заявка отправлена администратору. После подтверждения войдите в NODE.DC по указанному паролю."); }) .catch((error) => { setStatus("error"); setMessage(error instanceof Error ? error.message : "Не удалось отправить заявку."); }); }} >
)}
); } 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, currentProfileId: string, currentClientId: string ): { profileId: string; clientId: string } { const sessionEmail = session.user.email?.toLowerCase(); const sessionSub = session.user.sub; const profile = data.users.find( (user) => (sessionSub && user.authentikUserId === sessionSub) || (sessionEmail && user.email.toLowerCase() === sessionEmail) ) ?? (session.isSuperAdmin ? data.users.find((user) => user.id === "user_root") : undefined) ?? data.users.find((user) => user.id === currentProfileId) ?? data.users[0]; if (!profile) { return { profileId: currentProfileId, clientId: currentClientId }; } return { profileId: profile.id, clientId: resolveDefaultClientId(data, profile.id, currentClientId), }; } function resolveRuntimeProfileId(data: LauncherData, session: AuthSession | null, currentProfileId: string): string { if (data.users.some((user) => user.id === currentProfileId)) { return currentProfileId; } if (session?.authenticated) { const sessionEmail = session.user.email?.toLowerCase(); const sessionSub = session.user.sub; const sessionUser = data.users.find( (user) => (sessionSub && user.authentikUserId === sessionSub) || (sessionEmail && user.email.toLowerCase() === sessionEmail) ); if (sessionUser) { return sessionUser.id; } } return data.users[0]?.id ?? currentProfileId; } function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string { const user = data.users.find((item) => item.id === userId); const isRoot = user?.id === "user_root"; const availableClientIds = isRoot ? data.clients.map((client) => client.id) : data.memberships.filter((membership) => membership.userId === userId && membership.status === "active").map((membership) => membership.clientId); if (requestedClientId && availableClientIds.includes(requestedClientId)) { return requestedClientId; } const defaultClientId = profileOptions.find((profile) => profile.userId === userId)?.defaultClientId; if (defaultClientId && availableClientIds.includes(defaultClientId)) { return defaultClientId; } return availableClientIds[0] ?? data.clients[0]?.id ?? requestedClientId; } function InviteFlowScreen({ state, authenticatedEmail, onAccept, onRegister, onLogin, onSwitchAccount, onGoHome, }: { state: InviteFlowState; authenticatedEmail: string | null; onAccept: () => void; onRegister: (command: RegisterInviteCommand) => void; onLogin: () => void; onSwitchAccount: () => void; onGoHome: () => void; }) { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const payload = "payload" in state ? state.payload : undefined; const inviteStatus = payload?.invite.status; const inviteEmail = payload?.account.email ?? payload?.invite.email ?? ""; const normalizedInviteEmail = inviteEmail.toLowerCase(); const existingAccount = Boolean(payload?.account.exists); const isAuthenticated = Boolean(authenticatedEmail); const isAuthenticatedAsInvitee = Boolean( authenticatedEmail && normalizedInviteEmail && authenticatedEmail.toLowerCase() === normalizedInviteEmail ); const isAuthenticatedAsDifferentUser = Boolean( authenticatedEmail && normalizedInviteEmail && authenticatedEmail.toLowerCase() !== normalizedInviteEmail ); const isAccepting = state.status === "accepting"; const isRegistering = state.status === "registering"; const inviteTargetUrl = payload?.redirectUrl; const canOpenInviteTarget = Boolean( payload?.invite.source === "tasker_workspace_invite" && inviteTargetUrl && inviteTargetUrl !== "/" && (state.status === "accepted" || inviteStatus === "accepted") ); const requiresAccountSwitch = state.status === "error" && state.message.includes("другую почту"); const canAccept = Boolean( state.status === "ready" && isAuthenticatedAsInvitee && inviteStatus !== "accepted" && inviteStatus !== "expired" && inviteStatus !== "revoked" ); const isTerminalInvite = inviteStatus === "accepted" || inviteStatus === "expired" || inviteStatus === "revoked"; const canShowRegistrationForm = Boolean( payload && !isAuthenticated && !existingAccount && !isTerminalInvite && (state.status === "ready" || state.status === "registering" || state.status === "error") ); const passwordMismatch = Boolean(passwordConfirm && password !== passwordConfirm); const normalizedEmail = email.trim(); const canRegister = Boolean( canShowRegistrationForm && state.status !== "registering" && normalizedEmail.includes("@") && name.trim() && password.length >= 8 && password === passwordConfirm ); const details = payload ? payload.invite.source === "tasker_workspace_invite" ? [ `Контур: ${payload.client.name}`, `Workspace: ${payload.invite.sourceWorkspaceName ?? payload.invite.sourceWorkspaceSlug ?? "Operational Core"}`, `Роль: ${membershipRoleLabel(payload.invite.role)}`, ] : [ `Рабочая область: ${payload.client.name}`, `Роль: ${membershipRoleLabel(payload.invite.role)}`, ] : ["Проверяем приглашение и платформенную сессию"]; const statusMessage = resolveInviteStatusMessage(state, { existingAccount, inviteEmail, inviteStatus, isAuthenticated, isAuthenticatedAsInvitee, isAuthenticatedAsDifferentUser, }); return (

Работайте во всех измерениях.

Приглашение в NODE.DC.

{details.map((detail) => ( {detail} ))}
{statusMessage ?

{statusMessage}

: null} {state.status === "error" ?

{state.message}

: null} {passwordMismatch ?

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

: null} {canShowRegistrationForm ? (
{ event.preventDefault(); if (!canRegister) return; onRegister({ email: normalizedEmail, name: name.trim(), password }); }} >
) : existingAccount && !isAuthenticated && !isTerminalInvite ? ( ) : (existingAccount && isAuthenticatedAsDifferentUser && !isTerminalInvite) || requiresAccountSwitch ? ( ) : state.status === "registered" ? ( ) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? ( ) : ( )}
); } function NodeDcAuthBrandHeader() { return (
NODE DC
); } function resolveInviteStatusMessage( state: InviteFlowState, context: { existingAccount: boolean; inviteEmail: string; inviteStatus?: Invite["status"]; isAuthenticated: boolean; isAuthenticatedAsInvitee: boolean; isAuthenticatedAsDifferentUser: boolean; } ) { const { existingAccount, inviteEmail, inviteStatus, isAuthenticated, isAuthenticatedAsInvitee, isAuthenticatedAsDifferentUser, } = context; if (state.status === "loading") return "Проверяем приглашение."; if (state.status === "accepting") return "Подключаем доступ к рабочей области."; if (state.status === "registering") return "Создаём аккаунт и подключаем доступ."; if (state.status === "registered") return "Аккаунт создан. Теперь войдите в NODE.DC."; if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён."; if (inviteStatus === "expired") return "Срок действия приглашения истёк."; if (inviteStatus === "revoked") return "Приглашение отозвано."; if (existingAccount && !isAuthenticated) return `Аккаунт ${inviteEmail} уже есть в NODE.DC. Войдите под этой почтой, чтобы принять приглашение.`; if (existingAccount && isAuthenticatedAsDifferentUser) return `Сейчас открыт другой аккаунт. Смените пользователя и войдите под ${inviteEmail}.`; if (existingAccount && isAuthenticatedAsInvitee) return "Аккаунт найден. Подтвердите подключение к workspace."; if (!isAuthenticated) return "Введите почту, имя и пароль для регистрации по приглашению."; return null; } function AuthStateScreen({ title, description, error, loginUrl, }: { title: string; description: string; error?: string | null; loginUrl?: string; }) { return (
NODE.DC

{title}

{description}

{error ?

{error}

: null} {loginUrl ? ( ) : null}
); } function parseInviteToken(pathname: string) { const match = /^\/invite\/([^/?#]+)\/?$/.exec(pathname); return match?.[1] ? decodeURIComponent(match[1]) : null; } function isAccessRequestPath(pathname: string) { return /^\/(?:request-access|access-request)\/?$/.test(pathname); } function membershipRoleLabel(role: ClientMembership["role"]) { return { client_owner: "Владелец клиента", client_admin: "Администратор клиента", member: "Участник", }[role]; } function buildLoginRedirectUrl(loginUrl?: string, options: { returnTo?: string | null } = {}) { const url = new URL(loginUrl || "/auth/login", window.location.origin); if (options.returnTo === null) { url.searchParams.delete("returnTo"); } else if (!url.searchParams.has("returnTo")) { const returnTo = options.returnTo ?? `${window.location.pathname}${window.location.search}${window.location.hash}`; if (returnTo && returnTo !== "/") { url.searchParams.set("returnTo", returnTo); } else if (options.returnTo === "/") { url.searchParams.set("returnTo", "/"); } } return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : url.toString(); } function redirectToLogin(loginUrl?: string, options?: { returnTo?: string | null }) { const redirectUrl = buildLoginRedirectUrl(loginUrl, options); const now = Date.now(); if (lastAuthRedirect && now - lastAuthRedirect.startedAt < 1500) { return; } lastAuthRedirect = { url: redirectUrl, startedAt: now }; window.location.replace(redirectUrl); }