1583 lines
58 KiB
TypeScript
1583 lines
58 KiB
TypeScript
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<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
|
||
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
||
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
|
||
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
|
||
const [adminOpen, setAdminOpen] = useState(false);
|
||
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
|
||
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
||
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||
const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState<Record<string, boolean>>({});
|
||
const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState<Record<string, boolean>>({});
|
||
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
|
||
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
|
||
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
|
||
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (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<ControlPlaneMutationResult>): Promise<ControlPlaneMutationOutcome> {
|
||
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<Invite, "clientId" | "email" | "role">) {
|
||
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<Invite>) {
|
||
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
|
||
}
|
||
|
||
function handleDeleteInvite(inviteId: string) {
|
||
applyControlPlaneMutation(deleteAdminInvite(inviteId));
|
||
}
|
||
|
||
function handleUpdateAccessRequest(accessRequestId: string, patch: Parameters<typeof updateAdminAccessRequest>[1]) {
|
||
applyControlPlaneMutation(updateAdminAccessRequest(accessRequestId, patch));
|
||
}
|
||
|
||
function handleApproveAccessRequest(accessRequestId: string, patch: Parameters<typeof approveAdminAccessRequest>[1]) {
|
||
applyControlPlaneMutation(approveAdminAccessRequest(accessRequestId, patch));
|
||
}
|
||
|
||
function handleRejectAccessRequest(accessRequestId: string, patch: Parameters<typeof rejectAdminAccessRequest>[1]) {
|
||
applyControlPlaneMutation(rejectAdminAccessRequest(accessRequestId, patch));
|
||
}
|
||
|
||
function handleApproveTaskerInviteRequest(
|
||
taskerInviteRequestId: string,
|
||
patch: Parameters<typeof approveAdminTaskerInviteRequest>[1]
|
||
) {
|
||
applyControlPlaneMutation(approveAdminTaskerInviteRequest(taskerInviteRequestId, patch));
|
||
}
|
||
|
||
function handleRejectTaskerInviteRequest(
|
||
taskerInviteRequestId: string,
|
||
patch: Parameters<typeof rejectAdminTaskerInviteRequest>[1]
|
||
) {
|
||
applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch));
|
||
}
|
||
|
||
function handleRetrySync(syncId: string) {
|
||
applyControlPlaneMutation(retryAdminSync(syncId));
|
||
}
|
||
|
||
function handleUpdateSettings(patch: Partial<LauncherSettings>) {
|
||
return applyControlPlaneMutation(updateAdminSettings(patch));
|
||
}
|
||
|
||
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
||
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<Client>) {
|
||
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<LauncherUser>) {
|
||
applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
|
||
}
|
||
|
||
function handleDeleteUser(userId: string) {
|
||
applyControlPlaneMutation(deleteAdminUser(userId));
|
||
}
|
||
|
||
async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) {
|
||
const result = await updateOwnProfile(patch);
|
||
setData(syncLauncherServiceLinks(result.data));
|
||
}
|
||
|
||
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<ClientMembership>) {
|
||
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<ClientGroup>) {
|
||
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 (
|
||
<AccessRequestScreen
|
||
onSubmit={createAccessRequest}
|
||
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl, { returnTo: "/" })}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (inviteToken) {
|
||
return (
|
||
<InviteFlowScreen
|
||
state={inviteFlow ?? { status: "loading" }}
|
||
authenticatedEmail={authSession?.authenticated ? authSession.user.email : null}
|
||
onAccept={() => 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 <AccessRequestPendingScreen accessRequest={currentAccessRequest} onLogout={handleLogout} />;
|
||
}
|
||
|
||
return (
|
||
<div className="launcher-app">
|
||
<TopBar
|
||
me={runtimeMe}
|
||
clients={data.clients}
|
||
profileOptions={profileOptions}
|
||
activeProfileId={resolvedProfileId}
|
||
activeClientId={resolvedClientId}
|
||
adminOpen={adminOpen}
|
||
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
||
onProfileChange={handleProfileChange}
|
||
onClientChange={setActiveClientId}
|
||
onOpenAdmin={() => {
|
||
setAdminMode("admin");
|
||
setAdminOpen((current) => !(current && adminMode === "admin"));
|
||
}}
|
||
onOpenPlatform={() => {
|
||
if (runtimeMe.launcherRole !== "root_admin") return;
|
||
setAdminMode("platform");
|
||
setAdminOpen((current) => !(current && adminMode === "platform"));
|
||
}}
|
||
onOpenShowcase={() => setAdminOpen(false)}
|
||
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
||
onLogout={handleLogout}
|
||
brandLinkUrl={data.settings.brand.logoLinkUrl}
|
||
/>
|
||
|
||
<main className="launcher-main">
|
||
<ServiceStage
|
||
service={selectedService}
|
||
hasServices={launcherServices.length > 0}
|
||
onLaunch={handleLaunch}
|
||
onSelectPrevious={() => handleStageStep("previous")}
|
||
onSelectNext={() => handleStageStep("next")}
|
||
/>
|
||
{adminOpen && me.permissions.canOpenAdmin ? (
|
||
<AdminOverlay
|
||
data={data}
|
||
me={runtimeMe}
|
||
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
||
activeClientId={resolvedClientId}
|
||
onClose={() => 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 ? (
|
||
<ProfileSettingsPanel
|
||
user={activeProfileUser}
|
||
onClose={() => setProfileSettingsOpen(false)}
|
||
onSaveProfile={handleUpdateOwnProfile}
|
||
onChangePassword={handleUpdateOwnPassword}
|
||
/>
|
||
) : null}
|
||
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function syncLauncherServiceLinks(data: Partial<LauncherData>): 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<CreateAccessRequestResponse>;
|
||
onLogin: () => void;
|
||
}) {
|
||
const [values, setValues] = useState<CreateAccessRequestCommand & { passwordConfirm: string }>({
|
||
email: "",
|
||
firstName: "",
|
||
lastName: "",
|
||
middleName: "",
|
||
phone: "",
|
||
company: "",
|
||
password: "",
|
||
passwordConfirm: "",
|
||
});
|
||
const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle");
|
||
const [message, setMessage] = useState<string | null>(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 (
|
||
<div className="launcher-app nodedc-auth-page">
|
||
<NodeDcAuthBrandHeader />
|
||
<main className="nodedc-auth-page__main">
|
||
<section className="nodedc-auth-card nodedc-access-request-card" aria-live="polite">
|
||
<div className="nodedc-auth-card__copy">
|
||
<h1>NODE.DC.</h1>
|
||
<p>{isSubmitted ? "Вы запросили доступ." : "Работайте во всех измерениях."}</p>
|
||
</div>
|
||
|
||
{!isSubmitted ? (
|
||
<p className="nodedc-auth-card__status">
|
||
Заполните обязательные поля и задайте пароль. После подтверждения вы войдёте в NODE.DC по этой почте и паролю.
|
||
</p>
|
||
) : null}
|
||
{message ? <p className="nodedc-auth-card__status">{message}</p> : null}
|
||
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
|
||
|
||
{isSubmitted ? (
|
||
<div className="nodedc-auth-card__form">
|
||
<button className="button button--primary" type="button" onClick={onLogin}>
|
||
Войти в NODE.DC
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<form
|
||
className="nodedc-auth-card__form"
|
||
onSubmit={(event) => {
|
||
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 : "Не удалось отправить заявку.");
|
||
});
|
||
}}
|
||
>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Эл. почта</span>
|
||
<input
|
||
value={values.email}
|
||
type="email"
|
||
placeholder="email@company.ru"
|
||
autoComplete="email"
|
||
onChange={(event) => updateField("email", event.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="nodedc-auth-card__field-grid">
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Фамилия</span>
|
||
<input
|
||
value={values.lastName}
|
||
placeholder="Иванов"
|
||
autoComplete="family-name"
|
||
onChange={(event) => updateField("lastName", event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Имя</span>
|
||
<input
|
||
value={values.firstName}
|
||
placeholder="Иван"
|
||
autoComplete="given-name"
|
||
onChange={(event) => updateField("firstName", event.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Отчество</span>
|
||
<input value={values.middleName} placeholder="Иванович" onChange={(event) => updateField("middleName", event.target.value)} />
|
||
</label>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Телефон</span>
|
||
<input
|
||
value={values.phone}
|
||
type="tel"
|
||
placeholder="+7 999 000-00-00"
|
||
autoComplete="tel"
|
||
onChange={(event) => updateField("phone", event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Компания</span>
|
||
<input
|
||
value={values.company}
|
||
placeholder="Название компании"
|
||
autoComplete="organization"
|
||
onChange={(event) => updateField("company", event.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="nodedc-auth-card__field-grid">
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Пароль</span>
|
||
<input
|
||
value={values.password}
|
||
type="password"
|
||
placeholder="Минимум 8 символов"
|
||
autoComplete="new-password"
|
||
onChange={(event) => updateField("password", event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Повторите пароль</span>
|
||
<input
|
||
value={values.passwordConfirm}
|
||
type="password"
|
||
placeholder="Ещё раз"
|
||
autoComplete="new-password"
|
||
onChange={(event) => updateField("passwordConfirm", event.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<button className="button button--primary" type="submit" disabled={!canSubmit}>
|
||
{status === "submitting" ? "Отправляем заявку" : "Запросить доступ"}
|
||
</button>
|
||
<button className="button button--secondary" type="button" onClick={onLogin}>
|
||
Уже есть аккаунт
|
||
</button>
|
||
</form>
|
||
)}
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccessRequestPendingScreen({
|
||
accessRequest,
|
||
onLogout,
|
||
}: {
|
||
accessRequest: AccessRequest;
|
||
onLogout: () => void;
|
||
}) {
|
||
const isRejected = accessRequest.status === "rejected";
|
||
|
||
return (
|
||
<div className="launcher-app nodedc-auth-page">
|
||
<NodeDcAuthBrandHeader />
|
||
<main className="nodedc-auth-page__main">
|
||
<section className="nodedc-auth-card nodedc-access-request-card" aria-live="polite">
|
||
<div className="nodedc-auth-card__copy">
|
||
<h1>NODE.DC.</h1>
|
||
<p>{isRejected ? "Заявка отклонена." : "Заявка ожидает подтверждения."}</p>
|
||
</div>
|
||
<div className="nodedc-invite-card__details">
|
||
<span>Почта: {accessRequest.email}</span>
|
||
<span>Компания: {accessRequest.company}</span>
|
||
</div>
|
||
<p className="nodedc-auth-card__status">
|
||
{isRejected
|
||
? "Администратор отклонил заявку. Если это ошибка, отправьте новый запрос или свяжитесь с NODE.DC."
|
||
: "Администратор проверит данные. После подтверждения вы попадёте в Launcher без отдельной регистрации по инвайту."}
|
||
</p>
|
||
<div className="nodedc-auth-card__form">
|
||
<button className="button button--primary" type="button" onClick={onLogout}>
|
||
Вернуться ко входу
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="launcher-app nodedc-auth-page">
|
||
<NodeDcAuthBrandHeader />
|
||
<main className="nodedc-auth-page__main">
|
||
<section className="nodedc-auth-card nodedc-invite-card" aria-live="polite">
|
||
<div className="nodedc-auth-card__copy">
|
||
<h1>Работайте во всех измерениях.</h1>
|
||
<p>Приглашение в NODE.DC.</p>
|
||
</div>
|
||
|
||
<div className="nodedc-invite-card__details">
|
||
{details.map((detail) => (
|
||
<span key={detail}>{detail}</span>
|
||
))}
|
||
</div>
|
||
|
||
{statusMessage ? <p className="nodedc-auth-card__status">{statusMessage}</p> : null}
|
||
{state.status === "error" ? <p className="nodedc-auth-card__status">{state.message}</p> : null}
|
||
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
|
||
|
||
{canShowRegistrationForm ? (
|
||
<form
|
||
className="nodedc-auth-card__form"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (!canRegister) return;
|
||
onRegister({ email: normalizedEmail, name: name.trim(), password });
|
||
}}
|
||
>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Эл. почта</span>
|
||
<input
|
||
value={email}
|
||
type="email"
|
||
placeholder="email@company.ru"
|
||
autoComplete="email"
|
||
onChange={(event) => setEmail(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Имя</span>
|
||
<input value={name} placeholder="Ваше имя" autoComplete="name" onChange={(event) => setName(event.target.value)} />
|
||
</label>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Пароль</span>
|
||
<input
|
||
value={password}
|
||
type="password"
|
||
placeholder="Минимум 8 символов"
|
||
autoComplete="new-password"
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="nodedc-auth-card__field">
|
||
<span>Повторите пароль</span>
|
||
<input
|
||
value={passwordConfirm}
|
||
type="password"
|
||
placeholder="Повторите пароль"
|
||
autoComplete="new-password"
|
||
onChange={(event) => setPasswordConfirm(event.target.value)}
|
||
/>
|
||
</label>
|
||
<button className="button button--primary" type="submit" disabled={!canRegister || isRegistering}>
|
||
{isRegistering ? "Создаём аккаунт" : "Создать аккаунт"}
|
||
</button>
|
||
<button className="button button--secondary" type="button" onClick={onLogin}>
|
||
Уже есть аккаунт
|
||
</button>
|
||
</form>
|
||
) : existingAccount && !isAuthenticated && !isTerminalInvite ? (
|
||
<button className="button button--primary" type="button" onClick={onLogin}>
|
||
Войти и принять приглашение
|
||
</button>
|
||
) : (existingAccount && isAuthenticatedAsDifferentUser && !isTerminalInvite) || requiresAccountSwitch ? (
|
||
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
|
||
Сменить аккаунт
|
||
</button>
|
||
) : state.status === "registered" ? (
|
||
<button className="button button--primary" type="button" onClick={() => redirectToLogin(state.loginUrl)}>
|
||
Войти в NODE.DC
|
||
</button>
|
||
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
|
||
<button
|
||
className="button button--primary"
|
||
type="button"
|
||
onClick={() => {
|
||
if (canOpenInviteTarget && inviteTargetUrl) {
|
||
window.location.assign(inviteTargetUrl);
|
||
return;
|
||
}
|
||
onGoHome();
|
||
}}
|
||
>
|
||
{canOpenInviteTarget ? "Перейти в workspace" : "Перейти в витрину"}
|
||
</button>
|
||
) : (
|
||
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
|
||
{state.status === "loading" ? "Проверяем" : isAccepting ? "Подключаем доступ" : "Принять приглашение"}
|
||
</button>
|
||
)}
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NodeDcAuthBrandHeader() {
|
||
return (
|
||
<header className="nodedc-auth-brand-shell">
|
||
<a href="/" className="nodedc-expanded-brand-link" aria-label="NODE.DC">
|
||
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
|
||
</a>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="launcher-app">
|
||
<main
|
||
style={{
|
||
display: "grid",
|
||
minHeight: "100vh",
|
||
placeItems: "center",
|
||
padding: "2rem",
|
||
}}
|
||
>
|
||
<section
|
||
style={{
|
||
display: "grid",
|
||
width: "min(34rem, 100%)",
|
||
gap: "1rem",
|
||
padding: "2rem",
|
||
borderRadius: "1.75rem",
|
||
background: "rgba(255, 255, 255, 0.08)",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
<img src="/nodedc-logo.svg" alt="NODE.DC" style={{ justifySelf: "center", width: "11rem" }} />
|
||
<h1 style={{ margin: 0 }}>{title}</h1>
|
||
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
|
||
{error ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{error}</p> : null}
|
||
{loginUrl ? (
|
||
<button className="button button--primary" type="button" onClick={() => redirectToLogin(loginUrl)}>
|
||
Войти
|
||
</button>
|
||
) : null}
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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);
|
||
}
|