Compare commits

..

No commits in common. "3094464f62361700316d804f3f061f7b329fe060" and "39843b7737694687799c398ae74fdd91ff5ca627" have entirely different histories.

6 changed files with 172 additions and 167 deletions

View File

@ -1543,19 +1543,7 @@ if (process.env.NODE_ENV === "production") {
throw new Error("Launcher production build is missing. Run npm run build before starting the server.");
}
app.use(express.static(distRoot, {
index: false,
immutable: true,
maxAge: "1y",
setHeaders(res, assetPath) {
if (assetPath === indexHtmlPath) {
res.setHeader("Cache-Control", noStoreCacheControl);
return;
}
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
},
}));
app.use(express.static(distRoot, { index: false }));
app.use((req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") {
next();
@ -2557,16 +2545,12 @@ function isPublicPoolUser(data, user) {
function resolveUserServiceModules(data, user, serviceSlug, clientId) {
if (!user?.id) return {};
const normalizedClientId = normalizeOptionalText(clientId);
if (!normalizedClientId) return {};
const service = data.services.find(
(candidate) => candidate.slug === serviceSlug || candidate.authentikApplicationSlug === serviceSlug
);
if (!service?.id) return {};
if (hasSystemServiceModuleEntitlement(user, service, "codex_agents")) {
return { codex_agents: true };
}
const normalizedClientId = normalizeOptionalText(clientId);
if (!normalizedClientId) return {};
return Object.fromEntries(
(data.serviceModuleEntitlements ?? [])
@ -2581,10 +2565,6 @@ function resolveUserServiceModules(data, user, serviceSlug, clientId) {
);
}
function hasSystemServiceModuleEntitlement(user, service, moduleId) {
return user?.id === "user_root" && moduleId === "codex_agents" && service?.slug === "task-manager";
}
function getFrontchannelLogoutUrls() {
const urls = [config.taskLogoutUrl];
const launcherData = readLauncherData();

View File

@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
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";
@ -21,6 +21,7 @@ import {
ensureAdminTaskManagerProjectMembership,
ensureAdminTaskManagerWorkspaceMembership,
fetchAdminTaskManagerWorkspaces,
fetchControlPlaneSnapshot,
reorderAdminServices,
retryAdminSync,
rejectAdminAccessRequest,
@ -63,24 +64,21 @@ import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteRespo
import type { AccessRequest, CreateAccessRequestCommand } from "../entities/access-request/types";
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
import { loadPersistedLauncherData } from "../shared/api/storageApi";
import type {
AccessAssignmentValue,
CreateUserCommand,
EnsureTaskManagerProjectMemberCommand,
SetServiceModuleEntitlementCommand,
SetUserServiceAccessCommand,
import {
AdminOverlay,
type AccessAssignmentValue,
type CreateUserCommand,
type EnsureTaskManagerProjectMemberCommand,
type SetServiceModuleEntitlementCommand,
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;
const AdminOverlay = lazy(() => import("../widgets/admin-overlay/AdminOverlay").then((module) => ({ default: module.AdminOverlay })));
const ProfileSettingsPanel = lazy(() =>
import("../widgets/profile-settings-panel/ProfileSettingsPanel").then((module) => ({ default: module.ProfileSettingsPanel }))
);
type InviteFlowState =
| { status: "loading" }
| { status: "ready"; payload: PublicInviteResponse }
@ -103,7 +101,6 @@ export function LauncherApp() {
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [runtimeReady, setRuntimeReady] = useState(false);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
const [pendingServiceModuleEntitlements, setPendingServiceModuleEntitlements] = useState<Record<string, boolean>>({});
@ -116,7 +113,6 @@ export function LauncherApp() {
const runtimeDataRef = useRef(data);
const runtimeProfileIdRef = useRef(activeProfileId);
const runtimeClientIdRef = useRef(activeClientId);
const runtimeRefreshInFlightRef = useRef<Promise<void> | null>(null);
const resolvedProfileId = useMemo(
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
[activeProfileId, authSession, data]
@ -223,63 +219,38 @@ export function LauncherApp() {
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
const refreshRuntimeState = useCallback(async () => {
if (runtimeRefreshInFlightRef.current) {
return runtimeRefreshInFlightRef.current;
}
const request = (async () => {
try {
const nextSession = await fetchAuthSession();
setAuthSession(nextSession);
if (!nextSession.authenticated) {
setAuthApps([]);
setRuntimeReady(true);
return;
}
const [persistedData, apps] = await Promise.all([
loadPersistedLauncherData(),
fetchAvailableApps(),
]);
if (persistedData) {
setData(syncLauncherServiceLinks(persistedData));
}
setAuthApps(apps);
setRuntimeReady(true);
} catch (error: unknown) {
setRuntimeReady(true);
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
}
})().finally(() => {
runtimeRefreshInFlightRef.current = null;
});
runtimeRefreshInFlightRef.current = request;
return request;
}, []);
useEffect(() => {
let isMounted = true;
refreshRuntimeState()
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([]);
setRuntimeReady(true);
console.warn(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
});
return () => {
isMounted = false;
};
}, [refreshRuntimeState]);
}, []);
useEffect(() => {
if (!authSession || authSession.authenticated) return;
@ -367,6 +338,41 @@ export function LauncherApp() {
}
}, [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();
@ -379,6 +385,32 @@ export function LauncherApp() {
setAdminMode("admin");
}, [runtimeMe.permissions.canOpenAdmin]);
const refreshRuntimeState = useCallback(async () => {
try {
const nextSession = await fetchAuthSession();
setAuthSession(nextSession);
if (!nextSession.authenticated) {
setAuthApps([]);
return;
}
const [persistedData, apps] = await Promise.all([
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;
@ -391,6 +423,10 @@ export function LauncherApp() {
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("nodedc-ready", () => {
void refreshMountedRuntimeState();
});
eventSource.addEventListener("nodedc-runtime", () => {
void refreshMountedRuntimeState();
});
@ -430,7 +466,7 @@ export function LauncherApp() {
if (document.visibilityState === "visible") {
void refreshRuntimeState();
}
}, 60000);
}, 5000);
return () => {
window.clearInterval(intervalId);
@ -835,7 +871,7 @@ export function LauncherApp() {
);
}
if (!authSession || (authSession.authenticated && !runtimeReady)) {
if (!authSession) {
return null;
}
@ -887,62 +923,58 @@ export function LauncherApp() {
onSelectNext={() => handleStageStep("next")}
/>
{adminOpen && me.permissions.canOpenAdmin ? (
<Suspense fallback={null}>
<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}
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
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}
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
/>
</Suspense>
<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}
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
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}
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
/>
) : null}
{profileSettingsOpen && activeProfileUser ? (
<Suspense fallback={null}>
<ProfileSettingsPanel
user={activeProfileUser}
onClose={() => setProfileSettingsOpen(false)}
onSaveProfile={handleUpdateOwnProfile}
onChangePassword={handleUpdateOwnPassword}
/>
</Suspense>
<ProfileSettingsPanel
user={activeProfileUser}
onClose={() => setProfileSettingsOpen(false)}
onSaveProfile={handleUpdateOwnProfile}
onChangePassword={handleUpdateOwnPassword}
/>
) : null}
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
</main>

View File

@ -754,6 +754,7 @@ code {
content: "";
opacity: 0.34;
transform: rotate(-2deg);
animation: stageVideoDrift 16s linear infinite;
mix-blend-mode: screen;
}
@ -1044,6 +1045,15 @@ code {
background: color-mix(in srgb, var(--service-accent) 45%, rgba(255, 255, 255, 0.16));
}
@keyframes stageVideoDrift {
from {
transform: translate3d(0, 0, 0) rotate(-2deg);
}
to {
transform: translate3d(-7rem, -7rem, 0) rotate(-2deg);
}
}
@keyframes stageFigureFloat {
from {
transform: translate3d(-0.8rem, 0, 0) rotate(-9deg) scale(1);
@ -1191,7 +1201,7 @@ code {
flex: 0 0 var(--service-rail-card-size);
overflow: hidden;
border-radius: 1rem;
background: #050506;
background: color-mix(in srgb, var(--tile-accent) 36%, rgba(255, 255, 255, 0.12));
}
.service-tile__media-asset {

View File

@ -3425,8 +3425,7 @@ function OperationalCoreAccessModal({
</div>
<div className="task-module-access-list">
{operationalCoreModules.map((module) => {
const systemEnabled = hasSystemServiceModuleEntitlement(user, module.id);
const enabled = systemEnabled || hasServiceModuleEntitlement(data, client.id, user.id, service.id, module.id);
const enabled = hasServiceModuleEntitlement(data, client.id, user.id, service.id, module.id);
const pendingKey = serviceModuleEntitlementKey(client.id, user.id, service.id, module.id);
const pending = Boolean(pendingServiceModuleEntitlements[pendingKey]);
@ -3436,7 +3435,7 @@ function OperationalCoreAccessModal({
className={cn("task-module-access-row", enabled && "task-module-access-row--enabled", pending && "task-module-access-row--pending")}
type="button"
aria-pressed={enabled}
disabled={pending || systemEnabled}
disabled={pending}
onClick={() =>
onSetServiceModuleEntitlement({
clientId: client.id,
@ -3455,7 +3454,7 @@ function OperationalCoreAccessModal({
<span className="task-module-checker" aria-hidden="true">
{enabled ? <span /> : null}
</span>
<span>{pending ? "Сохраняем..." : systemEnabled ? "Системно включён" : enabled ? module.enabledLabel : module.disabledLabel}</span>
<span>{pending ? "Сохраняем..." : enabled ? module.enabledLabel : module.disabledLabel}</span>
</span>
</button>
);
@ -4814,10 +4813,6 @@ function hasServiceModuleEntitlement(data: LauncherData, clientId: string, userI
);
}
function hasSystemServiceModuleEntitlement(user: LauncherUser, moduleId: ServiceModuleId): boolean {
return user.id === "user_root" && moduleId === "codex_agents";
}
function isOperationalCoreService(service: Service): boolean {
return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager";
}

View File

@ -1,5 +1,5 @@
import { ChevronRight } from "lucide-react";
import { resolveAmbientMedia } from "../../entities/service/media";
import { DEFAULT_AMBIENT_MEDIA, resolveAmbientMedia } from "../../entities/service/media";
import type { LauncherServiceView } from "../../entities/service/types";
import { cn } from "../../shared/lib/cn";
@ -34,8 +34,8 @@ export function ServiceRail({
>
<RailMedia
className="service-tile__media-asset"
src={service.media.coverImage ?? service.media.thumbnail ?? null}
kind={service.media.coverKind ?? "image"}
src={service.media.coverImage ?? service.media.ambientVideo ?? DEFAULT_AMBIENT_MEDIA}
kind={service.media.coverKind ?? service.media.ambientKind}
/>
</span>
<span className="service-tile__content">
@ -57,17 +57,15 @@ function RailMedia({
kind,
className,
}: {
src?: string | null;
src: string;
kind?: LauncherServiceView["media"]["coverKind"];
className: string;
}) {
if (!src) return null;
if (kind === "video" || isVideoSource(src)) {
return <video className={className} src={src} autoPlay loop muted playsInline preload="metadata" />;
return <video className={className} src={src} autoPlay loop muted playsInline />;
}
return <img className={className} src={src} alt="" loading="lazy" decoding="async" />;
return <img className={className} src={src} alt="" />;
}
function isVideoSource(src: string) {

View File

@ -62,7 +62,7 @@ export function ServiceStage({
<section className="service-stage" style={style}>
<div className="stage-video-shell">
<div className="stage-video-stream" aria-hidden="true">
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} priority={!service} />
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} />
</div>
{service ? (
@ -155,22 +155,12 @@ function ServiceIcon({ slug }: { slug: string }) {
return <Bot size={18} />;
}
function StageMedia({
src,
kind,
className,
priority = false,
}: {
src: string;
kind?: LauncherServiceView["media"]["coverKind"];
className: string;
priority?: boolean;
}) {
function StageMedia({ src, kind, className }: { src: string; kind?: LauncherServiceView["media"]["coverKind"]; className: string }) {
if (kind === "video" || isVideoSource(src)) {
return <video className={className} src={src} autoPlay loop muted playsInline preload="metadata" />;
return <video className={className} src={src} autoPlay loop muted playsInline />;
}
return <img className={className} src={src} alt="" loading={priority ? "eager" : "lazy"} decoding="async" />;
return <img className={className} src={src} alt="" />;
}
function RichDescription({ text }: { text: string }) {