NODEDC_LAUNCHER/src/app/LauncherApp.tsx

1579 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)}
/>
);
}
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) {
const url = new URL(loginUrl || "/auth/login", window.location.origin);
if (!url.searchParams.has("returnTo")) {
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (returnTo && returnTo !== "/") {
url.searchParams.set("returnTo", returnTo);
}
}
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : url.toString();
}
function redirectToLogin(loginUrl?: string) {
const redirectUrl = buildLoginRedirectUrl(loginUrl);
const now = Date.now();
if (lastAuthRedirect && now - lastAuthRedirect.startedAt < 1500) {
return;
}
lastAuthRedirect = { url: redirectUrl, startedAt: now };
window.location.replace(redirectUrl);
}