ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher control plane и доступы

This commit is contained in:
DCCONSTRUCTIONS
2026-05-04 17:16:47 +03:00
parent de0a0d2948
commit b221ccb83e
19 changed files with 4137 additions and 999 deletions
+271 -325
View File
@@ -3,8 +3,30 @@ 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 { SyncStatus } from "../entities/sync/types";
import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types";
import {
createAdminClient,
createAdminGroup,
createAdminInvite,
createAdminService,
createAdminUser,
deleteAdminClient,
deleteAdminGroup,
deleteAdminInvite,
deleteAdminMembership,
deleteAdminService,
fetchControlPlaneSnapshot,
reorderAdminServices,
retryAdminSync,
setAdminUserServiceAccess,
updateAdminClient,
updateAdminGroup,
updateAdminInvite,
updateAdminMembership,
updateAdminService,
updateAdminUserProfile,
type ControlPlaneMutationResult,
} from "../shared/api/adminApi";
import {
buildLauncherServices,
buildMe,
@@ -12,9 +34,22 @@ import {
profileOptions,
type LauncherData,
} from "../shared/api/mockApi";
import { fetchAuthSession, fetchAvailableApps, type AuthSession, type LauncherAuthApp } from "../shared/api/authApi";
import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
import { AdminOverlay, type SetUserServiceAccessCommand } from "../widgets/admin-overlay/AdminOverlay";
import {
fetchAuthSession,
fetchAvailableApps,
type AuthenticatedSession,
type AuthSession,
type LauncherAuthApp,
} from "../shared/api/authApi";
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
import { loadPersistedLauncherData } from "../shared/api/storageApi";
import {
AdminOverlay,
type AccessAssignmentValue,
type CreateUserCommand,
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 } from "../widgets/top-bar/TopBar";
@@ -25,12 +60,14 @@ export function LauncherApp() {
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
const [adminOpen, setAdminOpen] = useState(false);
const [storageHydrated, setStorageHydrated] = useState(false);
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
const runtimeMe = useMemo(() => {
if (!authSession?.authenticated) return me;
@@ -39,14 +76,16 @@ export function LauncherApp() {
user: {
...me.user,
authentikUserId: authSession.user.sub,
email: authSession.user.email || me.user.email,
name: authSession.user.name || me.user.name,
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,
},
};
@@ -150,17 +189,16 @@ export function LauncherApp() {
useEffect(() => {
if (!authSession?.authenticated) return;
const nextProfileId = authSession.isSuperAdmin ? "user_root" : "user_vasya";
const nextProfile = profileOptions.find((profile) => profile.userId === nextProfileId);
const nextContext = resolveAuthenticatedContext(data, authSession, activeProfileId, activeClientId);
if (activeProfileId !== nextProfileId) {
setActiveProfileId(nextProfileId);
if (activeProfileId !== nextContext.profileId) {
setActiveProfileId(nextContext.profileId);
}
if (nextProfile && activeClientId !== nextProfile.defaultClientId) {
setActiveClientId(nextProfile.defaultClientId);
if (activeClientId !== nextContext.clientId) {
setActiveClientId(nextContext.clientId);
}
}, [activeClientId, activeProfileId, authSession]);
}, [activeClientId, activeProfileId, authSession, data]);
useEffect(() => {
let isMounted = true;
@@ -170,11 +208,6 @@ export function LauncherApp() {
if (isMounted && persistedData) {
setData(syncLauncherServiceLinks(persistedData));
}
})
.finally(() => {
if (isMounted) {
setStorageHydrated(true);
}
});
return () => {
@@ -183,16 +216,78 @@ export function LauncherApp() {
}, []);
useEffect(() => {
if (!storageHydrated) return;
if (!authSession?.authenticated || !canUseAdminApi(authSession)) return;
const saveTimer = window.setTimeout(() => {
persistLauncherData(data).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось сохранить состояние витрины");
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");
});
}, 350);
return () => window.clearTimeout(saveTimer);
}, [data, storageHydrated]);
return () => {
isMounted = false;
};
}, [authSession]);
useEffect(() => {
if (!authSession?.authenticated) return;
let isMounted = true;
const refreshRuntimeState = async () => {
try {
const nextSession = await fetchAuthSession();
if (!isMounted) return;
setAuthSession(nextSession);
setAuthError(null);
if (!nextSession.authenticated) {
setAuthApps([]);
return;
}
const [persistedData, apps] = await Promise.all([
canUseAdminApi(nextSession)
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
: loadPersistedLauncherData(),
fetchAvailableApps(),
]);
if (!isMounted) return;
if (persistedData) {
setData(syncLauncherServiceLinks(persistedData));
}
setAuthApps(apps);
} catch (error: unknown) {
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
}
};
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("nodedc-runtime", () => {
void refreshRuntimeState();
});
eventSource.onerror = () => {
console.warn("Launcher event stream disconnected; browser will retry automatically");
};
return () => {
isMounted = false;
eventSource.close();
};
}, [authSession?.authenticated]);
function handleProfileChange(userId: string) {
const profile = profileOptions.find((option) => option.userId === userId);
@@ -227,200 +322,84 @@ export function LauncherApp() {
});
}
function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>) {
request
.then((result) => {
setData(syncLauncherServiceLinks(result.data));
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию");
});
}
function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) {
setData((current) => {
const now = new Date().toISOString();
const directGrant = current.grants.find(
(grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId
);
const grantsWithoutDirect = current.grants.filter(
(grant) => !(grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId)
);
const exceptionsWithoutDirect = current.exceptions.filter(
(exception) => !(exception.serviceId === serviceId && exception.userId === userId)
);
const assignmentKey = accessAssignmentKey(userId, serviceId);
if (value === "unset") {
return {
...current,
grants: grantsWithoutDirect,
exceptions: exceptionsWithoutDirect,
};
}
if (pendingAccessAssignments[assignmentKey]) {
return;
}
if (value === "deny") {
return {
...current,
grants: grantsWithoutDirect,
exceptions: [
...exceptionsWithoutDirect,
{
id: `exception_mock_${Date.now()}`,
serviceId,
userId,
type: "deny",
reason: "Создано из матрицы доступа.",
createdAt: now,
updatedAt: now,
},
],
};
}
return {
...current,
grants: [
...grantsWithoutDirect,
{
id: directGrant?.id ?? `grant_mock_${Date.now()}`,
serviceId,
targetType: "user",
targetId: userId,
appRole: value,
status: "active",
createdAt: directGrant?.createdAt ?? now,
updatedAt: now,
},
],
exceptions: exceptionsWithoutDirect,
};
});
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 handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
setData((current) => ({
...current,
invites: [
{
...invite,
id: `invite_mock_${Date.now()}`,
invitedByUserId: runtimeMe.user.id,
token: `mock-${Date.now()}`,
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
status: "created",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
...current.invites,
],
}));
applyControlPlaneMutation(createAdminInvite(invite));
}
function handleUpdateInvite(inviteId: string, patch: Partial<Invite>) {
setData((current) => ({
...current,
invites: current.invites.map((invite) =>
invite.id === inviteId
? {
...invite,
...patch,
updatedAt: new Date().toISOString(),
}
: invite
),
}));
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
}
function handleDeleteInvite(inviteId: string) {
setData((current) => ({
...current,
invites: current.invites.filter((invite) => invite.id !== inviteId),
}));
applyControlPlaneMutation(deleteAdminInvite(inviteId));
}
function handleRetrySync(syncId: string) {
setData((current) => ({
...current,
syncStatuses: current.syncStatuses.map((sync): SyncStatus =>
sync.id === syncId
? {
...sync,
state: "pending",
error: null,
updatedAt: new Date().toISOString(),
}
: sync
),
}));
applyControlPlaneMutation(retryAdminSync(syncId));
}
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
setData((current) => ({
...current,
services: current.services.map((service) =>
service.id === serviceId
? syncServiceLaunchLink({
...service,
...patch,
updatedAt: new Date().toISOString(),
})
: service
),
}));
applyControlPlaneMutation(updateAdminService(serviceId, patch));
}
function handleCreateClient() {
const createdAt = new Date().toISOString();
const index = data.clients.length + 1;
setData((current) => ({
...current,
clients: [
...current.clients,
{
id: `client_mock_${Date.now()}`,
type: "company",
name: `Новый клиент ${index}`,
legalName: `Новый клиент ${index}`,
status: "demo",
demoEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
contactName: "",
contactEmail: "",
notes: "",
createdAt,
updatedAt: createdAt,
},
],
}));
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>) {
setData((current) => ({
...current,
clients: current.clients.map((client) =>
client.id === clientId
? {
...client,
...patch,
updatedAt: new Date().toISOString(),
}
: client
),
}));
applyControlPlaneMutation(updateAdminClient(clientId, patch));
}
function handleDeleteClient(clientId: string) {
const nextClientId = data.clients.find((client) => client.id !== clientId)?.id ?? activeClientId;
setData((current) => {
if (current.clients.length <= 1) return current;
const deletedGroupIds = new Set(current.groups.filter((group) => group.clientId === clientId).map((group) => group.id));
return {
...current,
clients: current.clients.filter((client) => client.id !== clientId),
memberships: current.memberships.filter((membership) => membership.clientId !== clientId),
groups: current.groups.filter((group) => group.clientId !== clientId),
grants: current.grants.filter(
(grant) =>
!(grant.targetType === "client" && grant.targetId === clientId) &&
!(grant.targetType === "group" && deletedGroupIds.has(grant.targetId))
),
invites: current.invites.filter((invite) => invite.clientId !== clientId),
syncStatuses: current.syncStatuses.filter((sync) => sync.objectId !== clientId),
};
});
applyControlPlaneMutation(deleteAdminClient(clientId));
if (activeClientId === clientId) {
setActiveClientId(nextClientId);
@@ -428,166 +407,63 @@ export function LauncherApp() {
}
function handleUpdateUser(userId: string, patch: Partial<LauncherUser>) {
setData((current) => ({
...current,
users: current.users.map((user) =>
user.id === userId
? {
...user,
...patch,
updatedAt: new Date().toISOString(),
}
: user
),
}));
applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
}
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>) {
setData((current) => ({
...current,
memberships: current.memberships.map((membership) =>
membership.id === membershipId
? {
...membership,
...patch,
updatedAt: new Date().toISOString(),
}
: membership
),
}));
applyControlPlaneMutation(updateAdminMembership(membershipId, patch));
}
function handleDeleteMembership(membershipId: string) {
setData((current) => {
const membership = current.memberships.find((item) => item.id === membershipId);
if (!membership) return current;
return {
...current,
memberships: current.memberships.filter((item) => item.id !== membershipId),
groups: current.groups.map((group) =>
group.clientId === membership.clientId
? {
...group,
memberIds: group.memberIds.filter((userId) => userId !== membership.userId),
updatedAt: new Date().toISOString(),
}
: group
),
};
});
applyControlPlaneMutation(deleteAdminMembership(membershipId));
}
function handleCreateGroup(clientId: string) {
const createdAt = new Date().toISOString();
setData((current) => ({
...current,
groups: [
...current.groups,
{
id: `group_mock_${Date.now()}`,
clientId,
name: "Новая группа",
description: "Описание группы",
memberIds: [],
createdAt,
updatedAt: createdAt,
},
],
}));
applyControlPlaneMutation(createAdminGroup({ clientId, name: "Новая группа", description: "Описание группы", memberIds: [] }));
}
function handleUpdateGroup(groupId: string, patch: Partial<ClientGroup>) {
setData((current) => ({
...current,
groups: current.groups.map((group) =>
group.id === groupId
? {
...group,
...patch,
updatedAt: new Date().toISOString(),
}
: group
),
}));
applyControlPlaneMutation(updateAdminGroup(groupId, patch));
}
function handleDeleteGroup(groupId: string) {
setData((current) => ({
...current,
groups: current.groups.filter((group) => group.id !== groupId),
grants: current.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId)),
}));
applyControlPlaneMutation(deleteAdminGroup(groupId));
}
function handleReorderServices(orderedServiceIds: string[]) {
setData((current) => {
const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10]));
const now = new Date().toISOString();
return {
...current,
services: current.services.map((service) => {
const nextOrder = orderById.get(service.id);
return nextOrder
? {
...service,
order: nextOrder,
updatedAt: now,
}
: service;
}),
};
});
applyControlPlaneMutation(reorderAdminServices(orderedServiceIds));
}
function handleCreateService() {
const createdAt = new Date().toISOString();
setData((current) => {
const nextOrder = Math.max(0, ...current.services.map((service) => service.order)) + 10;
const id = `service_mock_${Date.now()}`;
return {
...current,
services: [
...current.services,
{
id,
slug: `new-service-${current.services.length + 1}`,
title: "New Service",
subtitle: "Новый сервис",
description: "Описание сервиса для витрины.",
fullDescription: "Заполните описание, медиа и ссылку запуска в редакторе контента.",
url: "https://service.handhdc.ru/sso/launch",
launchUrl: "https://service.handhdc.ru/sso/launch",
accentColor: "#F7F8F4",
fallbackGradient: "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)",
coverMediaSource: "url",
coverMediaKind: "image",
ambientMediaSource: "url",
ambientMediaKind: "gif",
status: "hidden",
order: nextOrder,
authentikApplicationSlug: `new-service-${current.services.length + 1}`,
authentikGroupName: `service-new-${current.services.length + 1}`,
createdAt,
updatedAt: createdAt,
},
],
};
});
applyControlPlaneMutation(createAdminService());
}
function handleDeleteService(serviceId: string) {
setData((current) => ({
...current,
services: current.services.filter((service) => service.id !== serviceId),
grants: current.grants.filter((grant) => grant.serviceId !== serviceId),
exceptions: current.exceptions.filter((exception) => exception.serviceId !== serviceId),
}));
applyControlPlaneMutation(deleteAdminService(serviceId));
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
}
@@ -613,6 +489,7 @@ export function LauncherApp() {
onClientChange={setActiveClientId}
onToggleAdmin={() => setAdminOpen((current) => !current)}
onOpenShowcase={() => setAdminOpen(false)}
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
onLogout={() => window.location.assign(authSession.logoutUrl)}
/>
@@ -638,9 +515,11 @@ export function LauncherApp() {
onCreateClient={handleCreateClient}
onUpdateClient={handleUpdateClient}
onDeleteClient={handleDeleteClient}
onCreateUser={handleCreateUser}
onUpdateUser={handleUpdateUser}
onUpdateMembership={handleUpdateMembership}
onDeleteMembership={handleDeleteMembership}
pendingAccessAssignments={pendingAccessAssignments}
onCreateGroup={handleCreateGroup}
onUpdateGroup={handleUpdateGroup}
onDeleteGroup={handleDeleteGroup}
@@ -650,6 +529,14 @@ export function LauncherApp() {
onDeleteService={handleDeleteService}
/>
) : null}
{profileSettingsOpen && activeProfileUser ? (
<ProfileSettingsPanel
user={activeProfileUser}
onClose={() => setProfileSettingsOpen(false)}
onSaveProfile={handleUpdateOwnProfile}
onChangePassword={handleUpdateOwnPassword}
/>
) : null}
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
</main>
</div>
@@ -663,6 +550,65 @@ function syncLauncherServiceLinks(data: LauncherData): LauncherData {
};
}
function accessAssignmentKey(userId: string, serviceId: string) {
return `${userId}:${serviceId}`;
}
function canUseAdminApi(session: AuthSession): boolean {
return (
session.authenticated &&
(session.isSuperAdmin || session.groups.includes("nodedc:launcher:admin") || session.groups.includes("nodedc:superadmin"))
);
}
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 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 AuthStateScreen({
title,
description,