ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher control plane и доступы
This commit is contained in:
+271
-325
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user