feat: add launcher frontend MVP
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ServiceAccessException, ServiceGrant } from "../entities/access/types";
|
||||
import type { Invite } from "../entities/invite/types";
|
||||
import type { LauncherServiceView, Service } from "../entities/service/types";
|
||||
import type { SyncStatus } from "../entities/sync/types";
|
||||
import {
|
||||
buildLauncherServices,
|
||||
buildMe,
|
||||
initialLauncherData,
|
||||
profileOptions,
|
||||
type LauncherData,
|
||||
} from "../shared/api/mockApi";
|
||||
import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
|
||||
import { AdminOverlay } from "../widgets/admin-overlay/AdminOverlay";
|
||||
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||
import { TopBar } from "../widgets/top-bar/TopBar";
|
||||
|
||||
export function LauncherApp() {
|
||||
const [data, setData] = useState<LauncherData>(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 [storageHydrated, setStorageHydrated] = useState(false);
|
||||
|
||||
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
||||
const resolvedClientId = me.activeClientId;
|
||||
const launcherServices = useMemo(
|
||||
() => buildLauncherServices(data, activeProfileId, resolvedClientId),
|
||||
[data, activeProfileId, 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;
|
||||
|
||||
loadPersistedLauncherData()
|
||||
.then((persistedData) => {
|
||||
if (isMounted && persistedData) {
|
||||
setData(persistedData);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) {
|
||||
setStorageHydrated(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageHydrated) return;
|
||||
|
||||
const saveTimer = window.setTimeout(() => {
|
||||
persistLauncherData(data).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось сохранить состояние витрины");
|
||||
});
|
||||
}, 350);
|
||||
|
||||
return () => window.clearTimeout(saveTimer);
|
||||
}, [data, storageHydrated]);
|
||||
|
||||
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 handleCreateGrant(grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
grants: [
|
||||
...current.grants,
|
||||
{
|
||||
...grant,
|
||||
id: `grant_mock_${Date.now()}`,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateDenyException(exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
exceptions: [
|
||||
...current.exceptions.filter(
|
||||
(item) => !(item.serviceId === exception.serviceId && item.userId === exception.userId && item.type === "deny")
|
||||
),
|
||||
{
|
||||
...exception,
|
||||
id: `exception_mock_${Date.now()}`,
|
||||
type: "deny",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRemoveException(exceptionId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
exceptions: current.exceptions.filter((exception) => exception.id !== exceptionId),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
invites: [
|
||||
{
|
||||
...invite,
|
||||
id: `invite_mock_${Date.now()}`,
|
||||
invitedByUserId: me.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,
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
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
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
services: current.services.map((service) =>
|
||||
service.id === serviceId
|
||||
? {
|
||||
...service,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: service
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
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",
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="launcher-app">
|
||||
<TopBar
|
||||
me={me}
|
||||
clients={data.clients}
|
||||
profileOptions={profileOptions}
|
||||
activeProfileId={activeProfileId}
|
||||
activeClientId={resolvedClientId}
|
||||
adminOpen={adminOpen}
|
||||
onProfileChange={handleProfileChange}
|
||||
onClientChange={setActiveClientId}
|
||||
onOpenAdmin={() => setAdminOpen(true)}
|
||||
onOpenShowcase={() => setAdminOpen(false)}
|
||||
/>
|
||||
|
||||
<main className="launcher-main">
|
||||
<ServiceStage service={selectedService} hasServices={launcherServices.length > 0} onLaunch={handleLaunch} />
|
||||
{adminOpen && me.permissions.canOpenAdmin ? (
|
||||
<AdminOverlay
|
||||
data={data}
|
||||
me={me}
|
||||
activeClientId={resolvedClientId}
|
||||
onClose={() => setAdminOpen(false)}
|
||||
onCreateGrant={handleCreateGrant}
|
||||
onCreateDenyException={handleCreateDenyException}
|
||||
onRemoveException={handleRemoveException}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
onRetrySync={handleRetrySync}
|
||||
onUpdateService={handleUpdateService}
|
||||
onCreateService={handleCreateService}
|
||||
onDeleteService={handleDeleteService}
|
||||
/>
|
||||
) : null}
|
||||
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeEffectiveAccess } from "./computeEffectiveAccess";
|
||||
import type { Client } from "../client/types";
|
||||
import type { Service } from "../service/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../user/types";
|
||||
import type { ServiceAccessException, ServiceGrant } from "./types";
|
||||
|
||||
const client: Client = {
|
||||
id: "client_a",
|
||||
type: "company",
|
||||
name: "ООО Тест",
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const user: LauncherUser = {
|
||||
id: "user_a",
|
||||
name: "Пользователь",
|
||||
email: "user@example.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const membership: ClientMembership = {
|
||||
id: "membership_a",
|
||||
clientId: client.id,
|
||||
userId: user.id,
|
||||
role: "member",
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const group: ClientGroup = {
|
||||
id: "group_a",
|
||||
clientId: client.id,
|
||||
name: "Группа",
|
||||
memberIds: [user.id],
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const service: Service = {
|
||||
id: "service_a",
|
||||
slug: "service-a",
|
||||
title: "Service A",
|
||||
description: "Demo service",
|
||||
url: "https://example.ru",
|
||||
status: "active",
|
||||
order: 1,
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const baseInput = {
|
||||
client,
|
||||
user,
|
||||
membership,
|
||||
userGroups: [group],
|
||||
service,
|
||||
grants: [] as ServiceGrant[],
|
||||
exceptions: [] as ServiceAccessException[],
|
||||
};
|
||||
|
||||
describe("computeEffectiveAccess", () => {
|
||||
it("returns false when client is suspended", () => {
|
||||
const result = computeEffectiveAccess({
|
||||
...baseInput,
|
||||
client: { ...client, status: "suspended" },
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("Клиент");
|
||||
});
|
||||
|
||||
it("returns false when user is blocked", () => {
|
||||
const result = computeEffectiveAccess({
|
||||
...baseInput,
|
||||
user: { ...user, globalStatus: "blocked" },
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("Пользователь");
|
||||
});
|
||||
|
||||
it("returns false when no grant exists", () => {
|
||||
const result = computeEffectiveAccess(baseInput);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true from client grant", () => {
|
||||
const result = computeEffectiveAccess({
|
||||
...baseInput,
|
||||
grants: [grant("client", client.id)],
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.source).toBe("client");
|
||||
});
|
||||
|
||||
it("returns true from group grant", () => {
|
||||
const result = computeEffectiveAccess({
|
||||
...baseInput,
|
||||
grants: [grant("group", group.id)],
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.source).toBe("group");
|
||||
});
|
||||
|
||||
it("returns true from user grant", () => {
|
||||
const result = computeEffectiveAccess({
|
||||
...baseInput,
|
||||
grants: [grant("user", user.id)],
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.source).toBe("user");
|
||||
});
|
||||
|
||||
it("deny exception overrides user grant", () => {
|
||||
const result = computeEffectiveAccess({
|
||||
...baseInput,
|
||||
grants: [grant("user", user.id)],
|
||||
exceptions: [deny()],
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.source).toBe("exception");
|
||||
});
|
||||
|
||||
it("maintenance service is visible but openEnabled is false", () => {
|
||||
const result = computeEffectiveAccess({
|
||||
...baseInput,
|
||||
service: { ...service, status: "maintenance" },
|
||||
grants: [grant("client", client.id)],
|
||||
});
|
||||
|
||||
expect(result.visible).toBe(true);
|
||||
expect(result.openEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function grant(targetType: ServiceGrant["targetType"], targetId: string): ServiceGrant {
|
||||
return {
|
||||
id: `grant_${targetType}`,
|
||||
serviceId: service.id,
|
||||
targetType,
|
||||
targetId,
|
||||
appRole: "member",
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function deny(): ServiceAccessException {
|
||||
return {
|
||||
id: "exception_deny",
|
||||
serviceId: service.id,
|
||||
userId: user.id,
|
||||
type: "deny",
|
||||
reason: "Тестовый deny",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Client } from "../client/types";
|
||||
import type { Service } from "../service/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../user/types";
|
||||
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "./types";
|
||||
|
||||
export function computeEffectiveAccess(input: {
|
||||
client: Client;
|
||||
user: LauncherUser;
|
||||
membership: ClientMembership;
|
||||
userGroups: ClientGroup[];
|
||||
service: Service;
|
||||
grants: ServiceGrant[];
|
||||
exceptions: ServiceAccessException[];
|
||||
}): EffectiveAccessResult {
|
||||
if (input.client.status === "suspended" || input.client.status === "expired") {
|
||||
return blocked(input, "Клиент приостановлен или срок доступа истёк");
|
||||
}
|
||||
|
||||
if (input.user.globalStatus === "blocked" || input.membership.status === "disabled") {
|
||||
return blocked(input, "Пользователь заблокирован или отключён внутри клиента");
|
||||
}
|
||||
|
||||
if (input.service.status === "disabled") {
|
||||
return blocked(input, "Сервис отключён");
|
||||
}
|
||||
|
||||
if (input.service.status === "hidden") {
|
||||
return blocked(input, "Сервис скрыт");
|
||||
}
|
||||
|
||||
const deny = input.exceptions.find(
|
||||
(item) => item.serviceId === input.service.id && item.userId === input.user.id && item.type === "deny"
|
||||
);
|
||||
|
||||
if (deny) {
|
||||
return {
|
||||
serviceId: input.service.id,
|
||||
userId: input.user.id,
|
||||
allowed: false,
|
||||
visible: false,
|
||||
openEnabled: false,
|
||||
source: "exception",
|
||||
sourceId: deny.id,
|
||||
reason: "Доступ отключён индивидуальным исключением",
|
||||
};
|
||||
}
|
||||
|
||||
const allow = input.exceptions.find(
|
||||
(item) => item.serviceId === input.service.id && item.userId === input.user.id && item.type === "allow"
|
||||
);
|
||||
|
||||
if (allow) {
|
||||
return {
|
||||
serviceId: input.service.id,
|
||||
userId: input.user.id,
|
||||
allowed: true,
|
||||
visible: true,
|
||||
openEnabled: input.service.status === "active",
|
||||
source: "exception",
|
||||
sourceId: allow.id,
|
||||
reason: "Доступ выдан индивидуальным allow-исключением",
|
||||
};
|
||||
}
|
||||
|
||||
const userGrant = input.grants.find(
|
||||
(grant) =>
|
||||
grant.serviceId === input.service.id &&
|
||||
grant.targetType === "user" &&
|
||||
grant.targetId === input.user.id &&
|
||||
grant.status === "active"
|
||||
);
|
||||
|
||||
if (userGrant) {
|
||||
return {
|
||||
serviceId: input.service.id,
|
||||
userId: input.user.id,
|
||||
allowed: true,
|
||||
visible: true,
|
||||
openEnabled: input.service.status === "active",
|
||||
appRole: userGrant.appRole,
|
||||
source: "user",
|
||||
sourceId: userGrant.id,
|
||||
reason: "Доступ выдан пользователю напрямую",
|
||||
};
|
||||
}
|
||||
|
||||
const groupIds = input.userGroups.map((group) => group.id);
|
||||
const groupGrant = input.grants.find(
|
||||
(grant) =>
|
||||
grant.serviceId === input.service.id &&
|
||||
grant.targetType === "group" &&
|
||||
groupIds.includes(grant.targetId) &&
|
||||
grant.status === "active"
|
||||
);
|
||||
|
||||
if (groupGrant) {
|
||||
return {
|
||||
serviceId: input.service.id,
|
||||
userId: input.user.id,
|
||||
allowed: true,
|
||||
visible: true,
|
||||
openEnabled: input.service.status === "active",
|
||||
appRole: groupGrant.appRole,
|
||||
source: "group",
|
||||
sourceId: groupGrant.id,
|
||||
reason: "Доступ выдан группе пользователя",
|
||||
};
|
||||
}
|
||||
|
||||
const clientGrant = input.grants.find(
|
||||
(grant) =>
|
||||
grant.serviceId === input.service.id &&
|
||||
grant.targetType === "client" &&
|
||||
grant.targetId === input.client.id &&
|
||||
grant.status === "active"
|
||||
);
|
||||
|
||||
if (clientGrant) {
|
||||
return {
|
||||
serviceId: input.service.id,
|
||||
userId: input.user.id,
|
||||
allowed: true,
|
||||
visible: true,
|
||||
openEnabled: input.service.status === "active",
|
||||
appRole: clientGrant.appRole,
|
||||
source: "client",
|
||||
sourceId: clientGrant.id,
|
||||
reason: "Доступ выдан всему клиенту",
|
||||
};
|
||||
}
|
||||
|
||||
return blocked(input, "Доступ к сервису не выдан");
|
||||
}
|
||||
|
||||
function blocked(input: {
|
||||
service: Service;
|
||||
user: LauncherUser;
|
||||
}, reason: string): EffectiveAccessResult {
|
||||
return {
|
||||
serviceId: input.service.id,
|
||||
userId: input.user.id,
|
||||
allowed: false,
|
||||
visible: false,
|
||||
openEnabled: false,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ServiceGrantTargetType = "client" | "group" | "user";
|
||||
export type ServiceAppRole = "viewer" | "member" | "admin" | "owner";
|
||||
export type ServiceGrantStatus = "active" | "disabled";
|
||||
|
||||
export interface ServiceGrant {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
targetType: ServiceGrantTargetType;
|
||||
targetId: string;
|
||||
appRole: ServiceAppRole;
|
||||
status: ServiceGrantStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ServiceAccessExceptionType = "deny" | "allow";
|
||||
|
||||
export interface ServiceAccessException {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
userId: string;
|
||||
type: ServiceAccessExceptionType;
|
||||
reason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EffectiveAccessResult {
|
||||
serviceId: string;
|
||||
userId: string;
|
||||
allowed: boolean;
|
||||
visible: boolean;
|
||||
openEnabled: boolean;
|
||||
appRole?: ServiceAppRole;
|
||||
reason: string;
|
||||
source?: ServiceGrantTargetType | "exception";
|
||||
sourceId?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
at: string;
|
||||
actorUserId: string;
|
||||
actorName: string;
|
||||
action: string;
|
||||
objectType: string;
|
||||
objectName: string;
|
||||
clientId?: string | null;
|
||||
result: "success" | "warning" | "error";
|
||||
details?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type ClientType = "company" | "person";
|
||||
export type ClientStatus = "active" | "suspended" | "demo" | "expired";
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
type: ClientType;
|
||||
name: string;
|
||||
legalName?: string | null;
|
||||
status: ClientStatus;
|
||||
demoEndsAt?: string | null;
|
||||
contactName?: string | null;
|
||||
contactEmail?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ClientMembershipRole } from "../user/types";
|
||||
|
||||
export type InviteStatus = "created" | "sent" | "accepted" | "expired" | "revoked";
|
||||
|
||||
export interface Invite {
|
||||
id: string;
|
||||
clientId: string;
|
||||
email: string;
|
||||
role: ClientMembershipRole;
|
||||
invitedByUserId: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
status: InviteStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { EffectiveAccessResult, ServiceAppRole } from "../access/types";
|
||||
|
||||
export type ServiceStatus = "active" | "maintenance" | "hidden" | "disabled";
|
||||
export type MediaKind = "image" | "video" | "gif" | "gradient";
|
||||
export type ServiceMediaSource = "url" | "file";
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
description: string;
|
||||
fullDescription?: string | null;
|
||||
url: string;
|
||||
launchUrl?: string | null;
|
||||
iconUrl?: string | null;
|
||||
coverImageUrl?: string | null;
|
||||
coverMediaKind?: MediaKind | null;
|
||||
coverMediaSource?: ServiceMediaSource | null;
|
||||
coverMediaFileName?: string | null;
|
||||
previewVideoUrl?: string | null;
|
||||
ambientVideoUrl?: string | null;
|
||||
ambientMediaKind?: MediaKind | null;
|
||||
ambientMediaSource?: ServiceMediaSource | null;
|
||||
ambientMediaFileName?: string | null;
|
||||
accentColor?: string | null;
|
||||
fallbackGradient?: string | null;
|
||||
status: ServiceStatus;
|
||||
order: number;
|
||||
authentikApplicationSlug?: string | null;
|
||||
authentikGroupName?: string | null;
|
||||
isAvailableForAllNewClients?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceMedia {
|
||||
kind: MediaKind;
|
||||
url?: string;
|
||||
posterUrl?: string;
|
||||
fallbackGradient?: string;
|
||||
}
|
||||
|
||||
export interface LauncherServiceView {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
description: string;
|
||||
fullDescription?: string | null;
|
||||
status: ServiceStatus;
|
||||
userAccess: "allowed" | "denied";
|
||||
appRole?: ServiceAppRole;
|
||||
openUrl?: string | null;
|
||||
accentColor?: string | null;
|
||||
media: {
|
||||
icon?: string | null;
|
||||
thumbnail?: string | null;
|
||||
coverImage?: string | null;
|
||||
coverKind?: MediaKind | null;
|
||||
coverSource?: ServiceMediaSource | null;
|
||||
coverFileName?: string | null;
|
||||
previewVideo?: string | null;
|
||||
ambientVideo?: string | null;
|
||||
ambientKind?: MediaKind | null;
|
||||
ambientSource?: ServiceMediaSource | null;
|
||||
ambientFileName?: string | null;
|
||||
fallbackGradient?: string | null;
|
||||
};
|
||||
effectiveAccess: EffectiveAccessResult;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type SyncTarget = "authentik" | "task_manager" | "nodedc" | "service";
|
||||
export type SyncState = "synced" | "pending" | "error" | "disabled";
|
||||
|
||||
export interface SyncStatus {
|
||||
id: string;
|
||||
objectId: string;
|
||||
objectName: string;
|
||||
objectType: "client" | "user" | "group" | "service" | "grant" | "invite";
|
||||
target: SyncTarget;
|
||||
state: SyncState;
|
||||
lastSyncAt?: string | null;
|
||||
error?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type LauncherGlobalRole =
|
||||
| "root_admin"
|
||||
| "support_admin"
|
||||
| "client_owner"
|
||||
| "client_admin"
|
||||
| "member";
|
||||
|
||||
export type LauncherUserStatus = "invited" | "active" | "blocked";
|
||||
|
||||
export interface LauncherUser {
|
||||
id: string;
|
||||
authentikUserId?: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
globalStatus: LauncherUserStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ClientMembershipRole = "client_owner" | "client_admin" | "member";
|
||||
export type ClientMembershipStatus = "active" | "disabled";
|
||||
|
||||
export interface ClientMembership {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
role: ClientMembershipRole;
|
||||
status: ClientMembershipStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ClientGroup {
|
||||
id: string;
|
||||
clientId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
memberIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { LauncherApp } from "./app/LauncherApp";
|
||||
import "./styles/globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<LauncherApp />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,344 @@
|
||||
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
|
||||
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { LauncherServiceView, Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type {
|
||||
ClientGroup,
|
||||
ClientMembership,
|
||||
ClientMembershipRole,
|
||||
LauncherGlobalRole,
|
||||
LauncherUser,
|
||||
} from "../../entities/user/types";
|
||||
import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions";
|
||||
import {
|
||||
mockAuditEvents,
|
||||
mockClients,
|
||||
mockExceptions,
|
||||
mockGrants,
|
||||
mockGroups,
|
||||
mockInvites,
|
||||
mockMemberships,
|
||||
mockServices,
|
||||
mockSyncStatuses,
|
||||
mockUsers,
|
||||
} from "./mockData";
|
||||
|
||||
export interface AuthentikClaimsMock {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
groups: string[];
|
||||
activeClientId: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: Pick<LauncherUser, "id" | "authentikUserId" | "name" | "email" | "avatarUrl">;
|
||||
launcherRole: LauncherGlobalRole;
|
||||
memberships: Array<{
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
role: ClientMembershipRole;
|
||||
status: ClientMembership["status"];
|
||||
}>;
|
||||
activeClientId: string;
|
||||
permissions: LauncherPermissions;
|
||||
mockAuthentikClaims: AuthentikClaimsMock;
|
||||
}
|
||||
|
||||
export interface LauncherData {
|
||||
clients: Client[];
|
||||
users: LauncherUser[];
|
||||
memberships: ClientMembership[];
|
||||
groups: ClientGroup[];
|
||||
services: Service[];
|
||||
grants: ServiceGrant[];
|
||||
exceptions: ServiceAccessException[];
|
||||
invites: Invite[];
|
||||
syncStatuses: SyncStatus[];
|
||||
auditEvents: typeof mockAuditEvents;
|
||||
}
|
||||
|
||||
export interface ProfileOption {
|
||||
userId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultClientId: string;
|
||||
}
|
||||
|
||||
export interface AccessMatrixCell {
|
||||
userId: string;
|
||||
serviceId: string;
|
||||
effectiveAccess: EffectiveAccessResult;
|
||||
}
|
||||
|
||||
export interface AccessMatrix {
|
||||
client: Client;
|
||||
users: LauncherUser[];
|
||||
groups: ClientGroup[];
|
||||
services: Service[];
|
||||
cells: AccessMatrixCell[];
|
||||
}
|
||||
|
||||
export const initialLauncherData: LauncherData = {
|
||||
clients: mockClients,
|
||||
users: mockUsers,
|
||||
memberships: mockMemberships,
|
||||
groups: mockGroups,
|
||||
services: mockServices,
|
||||
grants: mockGrants,
|
||||
exceptions: mockExceptions,
|
||||
invites: mockInvites,
|
||||
syncStatuses: mockSyncStatuses,
|
||||
auditEvents: mockAuditEvents,
|
||||
};
|
||||
|
||||
export const profileOptions: ProfileOption[] = [
|
||||
{
|
||||
userId: "user_root",
|
||||
label: "Root Admin",
|
||||
description: "Полный каталог и все клиенты",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_ivan",
|
||||
label: "Client Owner",
|
||||
description: "Иван, владелец Ромашки и админ демо-клиента",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_vera",
|
||||
label: "Client Admin",
|
||||
description: "Вера, админ ООО Ромашка",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_vasya",
|
||||
label: "Member",
|
||||
description: "Василий, обычный участник",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_lena",
|
||||
label: "Member + deny",
|
||||
description: "Лена, участник с deny-исключением",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_maria",
|
||||
label: "Client Owner demo",
|
||||
description: "Мария, владелец демо-клиента",
|
||||
defaultClientId: "client_roga_kopyta",
|
||||
},
|
||||
];
|
||||
|
||||
export function buildMe(data: LauncherData, userId: string, requestedClientId?: string): MeResponse {
|
||||
const user = getUser(data, userId);
|
||||
const isRoot = user.id === "user_root";
|
||||
const availableMemberships = isRoot
|
||||
? data.clients.map((client) => ({
|
||||
clientId: client.id,
|
||||
clientName: client.name,
|
||||
role: "client_owner" as const,
|
||||
status: "active" as const,
|
||||
}))
|
||||
: data.memberships
|
||||
.filter((membership) => membership.userId === user.id)
|
||||
.map((membership) => ({
|
||||
clientId: membership.clientId,
|
||||
clientName: getClient(data, membership.clientId).name,
|
||||
role: membership.role,
|
||||
status: membership.status,
|
||||
}));
|
||||
|
||||
const fallbackClientId =
|
||||
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ?? availableMemberships[0]?.clientId;
|
||||
const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
|
||||
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
|
||||
const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);
|
||||
const launcherRole = resolveLauncherRole({ isRoot, membershipRole: activeMembership?.role });
|
||||
const permissions = resolvePermissions({
|
||||
launcherRole,
|
||||
membershipStatus: activeMembership?.status,
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
authentikUserId: user.authentikUserId,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
launcherRole,
|
||||
memberships: availableMemberships,
|
||||
activeClientId,
|
||||
permissions,
|
||||
mockAuthentikClaims: {
|
||||
sub: user.authentikUserId ?? user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
groups: buildMockGroups(data, user.id, activeClientId, launcherRole),
|
||||
activeClientId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLauncherServices(data: LauncherData, userId: string, activeClientId: string): LauncherServiceView[] {
|
||||
const me = buildMe(data, userId, activeClientId);
|
||||
const user = getUser(data, userId);
|
||||
const client = getClient(data, activeClientId);
|
||||
const membership = getRuntimeMembership(data, user.id, activeClientId, me.launcherRole === "root_admin");
|
||||
const userGroups = getUserGroups(data, user.id, activeClientId);
|
||||
const isRoot = me.launcherRole === "root_admin";
|
||||
|
||||
return data.services
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((service) => {
|
||||
const effectiveAccess = computeEffectiveAccess({
|
||||
client,
|
||||
user,
|
||||
membership,
|
||||
userGroups,
|
||||
service,
|
||||
grants: data.grants,
|
||||
exceptions: data.exceptions,
|
||||
});
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
slug: service.slug,
|
||||
title: service.title,
|
||||
subtitle: service.subtitle,
|
||||
description: service.description,
|
||||
fullDescription: service.fullDescription,
|
||||
status: service.status,
|
||||
userAccess: effectiveAccess.allowed ? ("allowed" as const) : ("denied" as const),
|
||||
appRole: effectiveAccess.appRole,
|
||||
openUrl: effectiveAccess.openEnabled ? service.launchUrl ?? service.url : null,
|
||||
accentColor: service.accentColor,
|
||||
media: {
|
||||
icon: service.iconUrl,
|
||||
thumbnail: service.coverImageUrl,
|
||||
coverImage: service.coverImageUrl,
|
||||
coverKind: service.coverMediaKind,
|
||||
coverSource: service.coverMediaSource,
|
||||
coverFileName: service.coverMediaFileName,
|
||||
previewVideo: service.previewVideoUrl,
|
||||
ambientVideo: service.ambientVideoUrl,
|
||||
ambientKind: service.ambientMediaKind,
|
||||
ambientSource: service.ambientMediaSource,
|
||||
ambientFileName: service.ambientMediaFileName,
|
||||
fallbackGradient: service.fallbackGradient,
|
||||
},
|
||||
effectiveAccess,
|
||||
};
|
||||
})
|
||||
.filter((service) => isRoot || service.effectiveAccess.visible);
|
||||
}
|
||||
|
||||
export function buildAccessMatrix(data: LauncherData, clientId: string, includeAllServices: boolean): AccessMatrix {
|
||||
const client = getClient(data, clientId);
|
||||
const memberships = data.memberships.filter((membership) => membership.clientId === clientId);
|
||||
const users = memberships.map((membership) => getUser(data, membership.userId));
|
||||
const groups = data.groups.filter((group) => group.clientId === clientId);
|
||||
const clientServiceIds = new Set(
|
||||
data.grants
|
||||
.filter((grant) => grant.targetType === "client" && grant.targetId === clientId && grant.status === "active")
|
||||
.map((grant) => grant.serviceId)
|
||||
);
|
||||
const services = data.services
|
||||
.filter((service) => includeAllServices || clientServiceIds.has(service.id) || service.status === "active")
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const cells = users.flatMap((user) => {
|
||||
const membership = memberships.find((item) => item.userId === user.id) ?? getRuntimeMembership(data, user.id, clientId);
|
||||
const userGroups = getUserGroups(data, user.id, clientId);
|
||||
|
||||
return services.map((service) => ({
|
||||
userId: user.id,
|
||||
serviceId: service.id,
|
||||
effectiveAccess: computeEffectiveAccess({
|
||||
client,
|
||||
user,
|
||||
membership,
|
||||
userGroups,
|
||||
service,
|
||||
grants: data.grants,
|
||||
exceptions: data.exceptions,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
return { client, users, groups, services, cells };
|
||||
}
|
||||
|
||||
export function getClient(data: LauncherData, clientId: string): Client {
|
||||
const client = data.clients.find((item) => item.id === clientId);
|
||||
if (!client) throw new Error(`Unknown client: ${clientId}`);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getUser(data: LauncherData, userId: string): LauncherUser {
|
||||
const user = data.users.find((item) => item.id === userId);
|
||||
if (!user) throw new Error(`Unknown user: ${userId}`);
|
||||
return user;
|
||||
}
|
||||
|
||||
export function getService(data: LauncherData, serviceId: string): Service {
|
||||
const service = data.services.find((item) => item.id === serviceId);
|
||||
if (!service) throw new Error(`Unknown service: ${serviceId}`);
|
||||
return service;
|
||||
}
|
||||
|
||||
export function getClientUsers(data: LauncherData, clientId: string): LauncherUser[] {
|
||||
const userIds = new Set(data.memberships.filter((membership) => membership.clientId === clientId).map((item) => item.userId));
|
||||
return data.users.filter((user) => userIds.has(user.id));
|
||||
}
|
||||
|
||||
export function getUserGroups(data: LauncherData, userId: string, clientId: string): ClientGroup[] {
|
||||
return data.groups.filter((group) => group.clientId === clientId && group.memberIds.includes(userId));
|
||||
}
|
||||
|
||||
export function getRuntimeMembership(
|
||||
data: LauncherData,
|
||||
userId: string,
|
||||
clientId: string,
|
||||
allowVirtualRoot = false
|
||||
): ClientMembership {
|
||||
const membership = data.memberships.find((item) => item.userId === userId && item.clientId === clientId);
|
||||
if (membership) return membership;
|
||||
|
||||
if (allowVirtualRoot) {
|
||||
return {
|
||||
id: `virtual_root_${clientId}`,
|
||||
clientId,
|
||||
userId,
|
||||
role: "client_owner",
|
||||
status: "active",
|
||||
createdAt: "2026-05-01T09:00:00Z",
|
||||
updatedAt: "2026-05-01T09:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `missing_${clientId}_${userId}`,
|
||||
clientId,
|
||||
userId,
|
||||
role: "member",
|
||||
status: "disabled",
|
||||
createdAt: "2026-05-01T09:00:00Z",
|
||||
updatedAt: "2026-05-01T09:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockGroups(
|
||||
data: LauncherData,
|
||||
userId: string,
|
||||
activeClientId: string,
|
||||
launcherRole: LauncherGlobalRole
|
||||
): string[] {
|
||||
const groups = getUserGroups(data, userId, activeClientId).map((group) => `client:${activeClientId}:group:${group.name}`);
|
||||
return [`launcher:${launcherRole}`, `client:${activeClientId}`, ...groups];
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { AuditEvent } from "../../entities/audit/types";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||
import type { ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||
|
||||
const now = "2026-05-01T09:00:00Z";
|
||||
|
||||
export const mockClients: Client[] = [
|
||||
{
|
||||
id: "client_romashka",
|
||||
type: "company",
|
||||
name: "ООО Ромашка",
|
||||
legalName: "ООО Ромашка",
|
||||
status: "active",
|
||||
demoEndsAt: null,
|
||||
contactName: "Иван Петров",
|
||||
contactEmail: "ivan@romashka.ru",
|
||||
notes: "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "client_roga_kopyta",
|
||||
type: "company",
|
||||
name: "ООО Рога и Копыта",
|
||||
legalName: "ООО Рога и Копыта",
|
||||
status: "demo",
|
||||
demoEndsAt: "2026-06-01T00:00:00Z",
|
||||
contactName: "Мария Иванова",
|
||||
contactEmail: "maria@example.ru",
|
||||
notes: "Клиент на демо-доступе, подключены только базовые сервисы.",
|
||||
createdAt: "2026-04-10T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "client_private_architect",
|
||||
type: "person",
|
||||
name: "Илья Архитектор",
|
||||
legalName: null,
|
||||
status: "suspended",
|
||||
demoEndsAt: "2026-04-20T00:00:00Z",
|
||||
contactName: "Илья Архитектор",
|
||||
contactEmail: "ilya@example.ru",
|
||||
notes: "Пример приостановленного частного клиента.",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockUsers: LauncherUser[] = [
|
||||
{
|
||||
id: "user_root",
|
||||
authentikUserId: "ak-root",
|
||||
name: "Root Admin",
|
||||
email: "root@nodedc.local",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_ivan",
|
||||
authentikUserId: "ak-ivan",
|
||||
name: "Иван Петров",
|
||||
email: "ivan@romashka.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_vera",
|
||||
authentikUserId: "ak-vera",
|
||||
name: "Вера Соколова",
|
||||
email: "vera@romashka.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-02T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_vasya",
|
||||
authentikUserId: "ak-vasya",
|
||||
name: "Василий Орлов",
|
||||
email: "vasya@romashka.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-05T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_lena",
|
||||
authentikUserId: "ak-lena",
|
||||
name: "Лена Волкова",
|
||||
email: "lena@romashka.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-08T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_maria",
|
||||
authentikUserId: "ak-maria",
|
||||
name: "Мария Иванова",
|
||||
email: "maria@example.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-10T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_blocked",
|
||||
authentikUserId: "ak-blocked",
|
||||
name: "Олег Заблокирован",
|
||||
email: "oleg@romashka.ru",
|
||||
globalStatus: "blocked",
|
||||
createdAt: "2026-04-12T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockMemberships: ClientMembership[] = [
|
||||
membership("mem_ivan_romashka", "client_romashka", "user_ivan", "client_owner"),
|
||||
membership("mem_vera_romashka", "client_romashka", "user_vera", "client_admin"),
|
||||
membership("mem_vasya_romashka", "client_romashka", "user_vasya", "member"),
|
||||
membership("mem_lena_romashka", "client_romashka", "user_lena", "member"),
|
||||
membership("mem_blocked_romashka", "client_romashka", "user_blocked", "member", "disabled"),
|
||||
membership("mem_maria_roga", "client_roga_kopyta", "user_maria", "client_owner"),
|
||||
membership("mem_ivan_roga", "client_roga_kopyta", "user_ivan", "client_admin"),
|
||||
];
|
||||
|
||||
export const mockGroups: ClientGroup[] = [
|
||||
group("group_romashka_leads", "client_romashka", "Руководство", "Собственники и руководители клиента.", [
|
||||
"user_ivan",
|
||||
"user_vera",
|
||||
]),
|
||||
group("group_romashka_accounting", "client_romashka", "Бухгалтерия", "1C и финансовые сценарии.", [
|
||||
"user_lena",
|
||||
]),
|
||||
group("group_romashka_ops", "client_romashka", "Операторы", "Ежедневная работа в задачах и тендерах.", [
|
||||
"user_vasya",
|
||||
"user_lena",
|
||||
]),
|
||||
group("group_roga_demo", "client_roga_kopyta", "Демо-команда", "Пилотный контур клиента.", ["user_maria", "user_ivan"]),
|
||||
];
|
||||
|
||||
export const mockServices: Service[] = [
|
||||
{
|
||||
id: "service_nodedc",
|
||||
slug: "nodedc",
|
||||
title: "NodeDC",
|
||||
subtitle: "Агентная платформа",
|
||||
description: "Сборка, запуск и мониторинг агентных workflow.",
|
||||
fullDescription:
|
||||
"NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
|
||||
url: "https://dev.handhdc.ru",
|
||||
launchUrl: "https://dev.handhdc.ru/sso/launch",
|
||||
accentColor: "#B5FF5A",
|
||||
fallbackGradient: "linear-gradient(128deg, rgba(181, 255, 90, 0.84), rgba(37, 58, 36, 0.86) 42%, #0A0D10 82%)",
|
||||
status: "active",
|
||||
order: 10,
|
||||
authentikApplicationSlug: "nodedc",
|
||||
authentikGroupName: "service-nodedc",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "service_task_manager",
|
||||
slug: "task-manager",
|
||||
title: "Task Manager",
|
||||
subtitle: "Операционный слой",
|
||||
description: "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
|
||||
fullDescription: "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
|
||||
url: "https://tasks.handhdc.ru",
|
||||
launchUrl: "https://tasks.handhdc.ru/sso/launch",
|
||||
accentColor: "#D7C8FF",
|
||||
fallbackGradient: "linear-gradient(132deg, rgba(215, 200, 255, 0.82), rgba(51, 41, 79, 0.9) 46%, #0B0D10 84%)",
|
||||
status: "active",
|
||||
order: 20,
|
||||
authentikApplicationSlug: "task-manager",
|
||||
authentikGroupName: "service-task-manager",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "service_1c",
|
||||
slug: "1c-assistant",
|
||||
title: "1C Assistant",
|
||||
subtitle: "Бухгалтерский ассистент",
|
||||
description: "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
|
||||
fullDescription: "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
|
||||
url: "https://1c.handhdc.ru",
|
||||
launchUrl: "https://1c.handhdc.ru/sso/launch",
|
||||
accentColor: "#8FD7FF",
|
||||
fallbackGradient: "linear-gradient(126deg, rgba(143, 215, 255, 0.8), rgba(32, 61, 80, 0.9) 44%, #080B0F 84%)",
|
||||
status: "maintenance",
|
||||
order: 30,
|
||||
authentikApplicationSlug: "1c-assistant",
|
||||
authentikGroupName: "service-1c-assistant",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "service_tender",
|
||||
slug: "tender-agent",
|
||||
title: "Tender Agent",
|
||||
subtitle: "Госзакупки и тендеры",
|
||||
description: "Поиск, анализ и подготовка тендерных решений.",
|
||||
fullDescription: "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
|
||||
url: "https://tender.handhdc.ru",
|
||||
launchUrl: "https://tender.handhdc.ru/sso/launch",
|
||||
accentColor: "#FFD166",
|
||||
fallbackGradient: "linear-gradient(135deg, rgba(255, 209, 102, 0.84), rgba(74, 53, 19, 0.92) 42%, #0B0D10 86%)",
|
||||
status: "active",
|
||||
order: 40,
|
||||
authentikApplicationSlug: "tender-agent",
|
||||
authentikGroupName: "service-tender-agent",
|
||||
createdAt: "2026-04-03T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "service_digital_twin",
|
||||
slug: "digital-twin",
|
||||
title: "Digital Twin",
|
||||
subtitle: "3D и пространственные данные",
|
||||
description: "Просмотр цифровых двойников, карт и объектных сцен.",
|
||||
fullDescription: "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
|
||||
url: "https://twin.handhdc.ru",
|
||||
launchUrl: "https://twin.handhdc.ru/sso/launch",
|
||||
accentColor: "#76E4F7",
|
||||
fallbackGradient: "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
|
||||
status: "active",
|
||||
order: 50,
|
||||
authentikApplicationSlug: "digital-twin",
|
||||
authentikGroupName: "service-digital-twin",
|
||||
createdAt: "2026-04-05T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "service_dm",
|
||||
slug: "digital-modules",
|
||||
title: "Digital Modules",
|
||||
subtitle: "Будущие модули",
|
||||
description: "Скрытый каталог модулей для root-admin preview.",
|
||||
fullDescription: "Площадка для будущих цифровых модулей NODE.DC.",
|
||||
url: "https://dm.handhdc.ru",
|
||||
launchUrl: "https://dm.handhdc.ru/sso/launch",
|
||||
accentColor: "#FF9AC2",
|
||||
fallbackGradient: "linear-gradient(135deg, rgba(255, 154, 194, 0.78), rgba(76, 41, 64, 0.9) 44%, #090B0F 86%)",
|
||||
status: "hidden",
|
||||
order: 60,
|
||||
authentikApplicationSlug: "digital-modules",
|
||||
authentikGroupName: "service-digital-modules",
|
||||
createdAt: "2026-04-10T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "service_internal",
|
||||
slug: "internal-tools",
|
||||
title: "Internal Tools",
|
||||
subtitle: "Внутренний контур",
|
||||
description: "Отключённый сервис для проверки диагностики root-admin.",
|
||||
fullDescription: "Не показывается обычным пользователям, виден root-admin в каталоге.",
|
||||
url: "https://internal.handhdc.ru",
|
||||
launchUrl: null,
|
||||
accentColor: "#F97373",
|
||||
fallbackGradient: "linear-gradient(135deg, rgba(249, 115, 115, 0.78), rgba(73, 32, 32, 0.92) 43%, #090B0F 86%)",
|
||||
status: "disabled",
|
||||
order: 70,
|
||||
authentikApplicationSlug: "internal-tools",
|
||||
authentikGroupName: "service-internal-tools",
|
||||
createdAt: "2026-04-12T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockGrants: ServiceGrant[] = [
|
||||
grant("grant_romashka_task", "service_task_manager", "client", "client_romashka", "member"),
|
||||
grant("grant_romashka_nodedc_leads", "service_nodedc", "group", "group_romashka_leads", "admin"),
|
||||
grant("grant_romashka_1c_accounting", "service_1c", "group", "group_romashka_accounting", "member"),
|
||||
grant("grant_romashka_tender_ops", "service_tender", "group", "group_romashka_ops", "viewer"),
|
||||
grant("grant_romashka_twin_vasya", "service_digital_twin", "user", "user_vasya", "viewer"),
|
||||
grant("grant_roga_task", "service_task_manager", "client", "client_roga_kopyta", "member"),
|
||||
grant("grant_roga_nodedc", "service_nodedc", "client", "client_roga_kopyta", "viewer"),
|
||||
];
|
||||
|
||||
export const mockExceptions: ServiceAccessException[] = [
|
||||
{
|
||||
id: "exception_lena_task_deny",
|
||||
serviceId: "service_task_manager",
|
||||
userId: "user_lena",
|
||||
type: "deny",
|
||||
reason: "Индивидуально отключён Task Manager на период ревизии доступа.",
|
||||
createdAt: "2026-04-28T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockInvites: Invite[] = [
|
||||
{
|
||||
id: "invite_romashka_analyst",
|
||||
clientId: "client_romashka",
|
||||
email: "analyst@romashka.ru",
|
||||
role: "member",
|
||||
invitedByUserId: "user_ivan",
|
||||
token: "romashka-analyst-demo",
|
||||
expiresAt: "2026-05-15T12:00:00Z",
|
||||
status: "sent",
|
||||
createdAt: "2026-04-30T12:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "invite_roga_admin",
|
||||
clientId: "client_roga_kopyta",
|
||||
email: "ops@example.ru",
|
||||
role: "client_admin",
|
||||
invitedByUserId: "user_maria",
|
||||
token: "roga-admin-demo",
|
||||
expiresAt: "2026-05-18T12:00:00Z",
|
||||
status: "created",
|
||||
createdAt: "2026-04-30T14:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSyncStatuses: SyncStatus[] = [
|
||||
sync("sync_romashka_auth", "client_romashka", "ООО Ромашка", "client", "authentik", "synced"),
|
||||
sync("sync_task_auth", "service_task_manager", "Task Manager", "service", "authentik", "synced"),
|
||||
sync(
|
||||
"sync_lena_task",
|
||||
"exception_lena_task_deny",
|
||||
"Deny: Лена / Task Manager",
|
||||
"grant",
|
||||
"task_manager",
|
||||
"pending",
|
||||
null
|
||||
),
|
||||
sync(
|
||||
"sync_roga_nodedc",
|
||||
"client_roga_kopyta",
|
||||
"ООО Рога и Копыта",
|
||||
"client",
|
||||
"nodedc",
|
||||
"error",
|
||||
"OIDC binding ещё не создан для demo-клиента."
|
||||
),
|
||||
];
|
||||
|
||||
export const mockAuditEvents: AuditEvent[] = [
|
||||
audit("audit_1", "2026-05-01T08:40:00Z", "user_root", "Root Admin", "Создан сервис", "service", "Digital Modules", "success", null),
|
||||
audit("audit_2", "2026-05-01T08:20:00Z", "user_ivan", "Иван Петров", "Создан invite", "invite", "analyst@romashka.ru", "success", "Срок действия до 15.05.2026"),
|
||||
audit("audit_3", "2026-04-30T17:10:00Z", "user_root", "Root Admin", "Создано deny-исключение", "access", "Лена / Task Manager", "warning", "Индивидуальное правило перекрыло client grant."),
|
||||
audit("audit_4", "2026-04-30T16:00:00Z", "user_root", "Root Admin", "Ошибка синхронизации", "sync", "ООО Рога и Копыта / NodeDC", "error", "Нет application binding."),
|
||||
];
|
||||
|
||||
function membership(
|
||||
id: string,
|
||||
clientId: string,
|
||||
userId: string,
|
||||
role: ClientMembership["role"],
|
||||
status: ClientMembership["status"] = "active"
|
||||
): ClientMembership {
|
||||
return {
|
||||
id,
|
||||
clientId,
|
||||
userId,
|
||||
role,
|
||||
status,
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function group(id: string, clientId: string, name: string, description: string, memberIds: string[]): ClientGroup {
|
||||
return {
|
||||
id,
|
||||
clientId,
|
||||
name,
|
||||
description,
|
||||
memberIds,
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function grant(
|
||||
id: string,
|
||||
serviceId: string,
|
||||
targetType: ServiceGrant["targetType"],
|
||||
targetId: string,
|
||||
appRole: ServiceGrant["appRole"]
|
||||
): ServiceGrant {
|
||||
return {
|
||||
id,
|
||||
serviceId,
|
||||
targetType,
|
||||
targetId,
|
||||
appRole,
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function sync(
|
||||
id: string,
|
||||
objectId: string,
|
||||
objectName: string,
|
||||
objectType: SyncStatus["objectType"],
|
||||
target: SyncStatus["target"],
|
||||
state: SyncStatus["state"],
|
||||
error: string | null = null
|
||||
): SyncStatus {
|
||||
return {
|
||||
id,
|
||||
objectId,
|
||||
objectName,
|
||||
objectType,
|
||||
target,
|
||||
state,
|
||||
lastSyncAt: state === "pending" ? null : "2026-05-01T08:00:00Z",
|
||||
error,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function audit(
|
||||
id: string,
|
||||
at: string,
|
||||
actorUserId: string,
|
||||
actorName: string,
|
||||
action: string,
|
||||
objectType: string,
|
||||
objectName: string,
|
||||
result: AuditEvent["result"],
|
||||
details: string | null,
|
||||
clientId: string | null = "client_romashka"
|
||||
): AuditEvent {
|
||||
return {
|
||||
id,
|
||||
at,
|
||||
actorUserId,
|
||||
actorName,
|
||||
action,
|
||||
objectType,
|
||||
objectName,
|
||||
clientId,
|
||||
result,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { LauncherData } from "./mockApi";
|
||||
|
||||
export interface StoredFileResponse {
|
||||
ok: true;
|
||||
url: string;
|
||||
fileName: string;
|
||||
originalFileName: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export async function uploadStorageFile(file: File): Promise<StoredFileResponse> {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
const response = await fetch("/api/storage/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
dataUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await readErrorMessage(response);
|
||||
throw new Error(message || "Не удалось сохранить файл в storage");
|
||||
}
|
||||
|
||||
return response.json() as Promise<StoredFileResponse>;
|
||||
}
|
||||
|
||||
export async function loadPersistedLauncherData(): Promise<LauncherData | null> {
|
||||
try {
|
||||
const response = await fetch(`/storage/launcher-data.json?ts=${Date.now()}`, { cache: "no-store" });
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
return (await response.json()) as LauncherData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistLauncherData(data: LauncherData): Promise<void> {
|
||||
const response = await fetch("/api/storage/data", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await readErrorMessage(response);
|
||||
throw new Error(message || "Не удалось сохранить состояние витрины");
|
||||
}
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result)));
|
||||
reader.addEventListener("error", () => reject(reader.error ?? new Error("Не удалось прочитать файл")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
return payload.error;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...values: Array<string | false | null | undefined>): string {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function initials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ClientMembershipRole, ClientMembershipStatus, LauncherGlobalRole } from "../../entities/user/types";
|
||||
|
||||
export interface LauncherPermissions {
|
||||
canOpenAdmin: boolean;
|
||||
canManageClients: boolean;
|
||||
canManageOwnClient: boolean;
|
||||
canManageServiceCatalog: boolean;
|
||||
canInviteUsers: boolean;
|
||||
canManageAccess: boolean;
|
||||
canViewSync: boolean;
|
||||
}
|
||||
|
||||
export function resolveLauncherRole(input: {
|
||||
isRoot?: boolean;
|
||||
membershipRole?: ClientMembershipRole;
|
||||
}): LauncherGlobalRole {
|
||||
if (input.isRoot) return "root_admin";
|
||||
return input.membershipRole ?? "member";
|
||||
}
|
||||
|
||||
export function resolvePermissions(input: {
|
||||
launcherRole: LauncherGlobalRole;
|
||||
membershipStatus?: ClientMembershipStatus;
|
||||
}): LauncherPermissions {
|
||||
const active = input.membershipStatus !== "disabled";
|
||||
const isRoot = input.launcherRole === "root_admin";
|
||||
const isSupport = input.launcherRole === "support_admin";
|
||||
const isClientAdmin = input.launcherRole === "client_owner" || input.launcherRole === "client_admin";
|
||||
|
||||
return {
|
||||
canOpenAdmin: active && (isRoot || isSupport || isClientAdmin),
|
||||
canManageClients: active && isRoot,
|
||||
canManageOwnClient: active && (isRoot || isClientAdmin),
|
||||
canManageServiceCatalog: active && isRoot,
|
||||
canInviteUsers: active && (isRoot || isClientAdmin),
|
||||
canManageAccess: active && (isRoot || isClientAdmin),
|
||||
canViewSync: active && (isRoot || isSupport || isClientAdmin),
|
||||
};
|
||||
}
|
||||
|
||||
export function isClientAdminRole(role: LauncherGlobalRole): boolean {
|
||||
return role === "client_owner" || role === "client_admin";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ variant = "secondary", icon, className, children, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button className={cn("button", `button--${variant}`, className)} {...props}>
|
||||
{icon}
|
||||
{children ? <span>{children}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function IconButton({ label, className, children, ...props }: IconButtonProps) {
|
||||
return (
|
||||
<button className={cn("icon-button", className)} aria-label={label} title={label} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
interface GlassProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
tone?: "default" | "strong" | "soft";
|
||||
}
|
||||
|
||||
export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) {
|
||||
return (
|
||||
<div className={cn("glass-surface", `glass-surface--${tone}`, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlassCard({ children, className, tone = "default", ...props }: GlassProps) {
|
||||
return (
|
||||
<div className={cn("glass-card", `glass-card--${tone}`, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { GlassSurface } from "./Glass";
|
||||
|
||||
export function PortalDropdown({
|
||||
open,
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<GlassSurface className="portal-dropdown" style={style}>
|
||||
{children}
|
||||
</GlassSurface>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ClientStatus } from "../../entities/client/types";
|
||||
import type { ServiceStatus } from "../../entities/service/types";
|
||||
import type { SyncState } from "../../entities/sync/types";
|
||||
import type { LauncherUserStatus } from "../../entities/user/types";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
type BadgeTone = "green" | "yellow" | "red" | "violet" | "muted";
|
||||
|
||||
const serviceLabels: Record<ServiceStatus, string> = {
|
||||
active: "Активен",
|
||||
maintenance: "Техработы",
|
||||
hidden: "Скрыт",
|
||||
disabled: "Отключён",
|
||||
};
|
||||
|
||||
const clientLabels: Record<ClientStatus, string> = {
|
||||
active: "Активен",
|
||||
demo: "Demo",
|
||||
suspended: "Приостановлен",
|
||||
expired: "Истёк",
|
||||
};
|
||||
|
||||
const userLabels: Record<LauncherUserStatus, string> = {
|
||||
active: "Активен",
|
||||
invited: "Приглашён",
|
||||
blocked: "Заблокирован",
|
||||
};
|
||||
|
||||
const syncLabels: Record<SyncState, string> = {
|
||||
synced: "Синхронизировано",
|
||||
pending: "В очереди",
|
||||
error: "Ошибка",
|
||||
disabled: "Отключено",
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
label,
|
||||
tone = "muted",
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
tone?: BadgeTone;
|
||||
className?: string;
|
||||
}) {
|
||||
return <span className={cn("status-badge", `status-badge--${tone}`, className)}>{label}</span>;
|
||||
}
|
||||
|
||||
export function ServiceStatusBadge({ status }: { status: ServiceStatus }) {
|
||||
return <StatusBadge label={serviceLabels[status]} tone={statusTone(status)} />;
|
||||
}
|
||||
|
||||
export function ClientStatusBadge({ status }: { status: ClientStatus }) {
|
||||
return <StatusBadge label={clientLabels[status]} tone={clientTone(status)} />;
|
||||
}
|
||||
|
||||
export function UserStatusBadge({ status }: { status: LauncherUserStatus }) {
|
||||
return <StatusBadge label={userLabels[status]} tone={status === "active" ? "green" : status === "invited" ? "yellow" : "red"} />;
|
||||
}
|
||||
|
||||
export function SyncStatusBadge({ state }: { state: SyncState }) {
|
||||
return <StatusBadge label={syncLabels[state]} tone={syncTone(state)} />;
|
||||
}
|
||||
|
||||
function statusTone(status: ServiceStatus): BadgeTone {
|
||||
if (status === "active") return "green";
|
||||
if (status === "maintenance") return "yellow";
|
||||
if (status === "hidden") return "violet";
|
||||
return "red";
|
||||
}
|
||||
|
||||
function clientTone(status: ClientStatus): BadgeTone {
|
||||
if (status === "active") return "green";
|
||||
if (status === "demo") return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
function syncTone(state: SyncState): BadgeTone {
|
||||
if (state === "synced") return "green";
|
||||
if (state === "pending") return "yellow";
|
||||
if (state === "error") return "red";
|
||||
return "muted";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
import { Activity, Bot, Boxes, ChartNoAxesColumnIncreasing, KeyRound, Network, Sparkles } from "lucide-react";
|
||||
import type { LauncherServiceView } from "../../entities/service/types";
|
||||
import { cn } from "../../shared/lib/cn";
|
||||
import { ServiceStatusBadge } from "../../shared/ui/StatusBadge";
|
||||
|
||||
export function ServiceRail({
|
||||
services,
|
||||
selectedServiceId,
|
||||
onSelect,
|
||||
}: {
|
||||
services: LauncherServiceView[];
|
||||
selectedServiceId?: string;
|
||||
onSelect: (serviceId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="service-rail" aria-label="Доступные сервисы">
|
||||
{services.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
className={cn("service-tile", selectedServiceId === service.id && "service-tile--active")}
|
||||
onClick={() => onSelect(service.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="service-tile__media" style={{ "--tile-accent": service.accentColor ?? "#B5FF5A" } as React.CSSProperties}>
|
||||
<ServiceIcon slug={service.slug} />
|
||||
</span>
|
||||
<span className="service-tile__content">
|
||||
<strong>{service.title}</strong>
|
||||
<small>{service.subtitle}</small>
|
||||
</span>
|
||||
<ServiceStatusBadge status={service.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceIcon({ slug }: { slug: string }) {
|
||||
if (slug.includes("task")) return <ChartNoAxesColumnIncreasing size={18} />;
|
||||
if (slug.includes("1c")) return <Boxes size={18} />;
|
||||
if (slug.includes("tender")) return <KeyRound size={18} />;
|
||||
if (slug.includes("twin")) return <Activity size={18} />;
|
||||
if (slug.includes("digital-modules")) return <Sparkles size={18} />;
|
||||
if (slug.includes("internal")) return <Network size={18} />;
|
||||
return <Bot size={18} />;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
Boxes,
|
||||
ChartNoAxesColumnIncreasing,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
LockKeyhole,
|
||||
Network,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { LauncherServiceView } from "../../entities/service/types";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { ServiceStatusBadge, StatusBadge } from "../../shared/ui/StatusBadge";
|
||||
|
||||
const DEFAULT_STAGE_MEDIA = "/storage/default.gif";
|
||||
|
||||
export function ServiceStage({
|
||||
service,
|
||||
hasServices,
|
||||
onLaunch,
|
||||
}: {
|
||||
service?: LauncherServiceView;
|
||||
hasServices: boolean;
|
||||
onLaunch: (service: LauncherServiceView) => void;
|
||||
}) {
|
||||
if (!hasServices) {
|
||||
return (
|
||||
<section className="service-stage service-stage--empty">
|
||||
<div className="empty-stage-card">
|
||||
<Network size={28} />
|
||||
<h1>Нет доступных сервисов</h1>
|
||||
<p>Для активного клиента не найдено доступных приложений. Проверьте гранты или статус клиента.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const style = {
|
||||
"--service-accent": service?.accentColor ?? "#C3FF66",
|
||||
} as React.CSSProperties;
|
||||
const disabledReason = service
|
||||
? service.status === "maintenance"
|
||||
? "Сервис временно недоступен"
|
||||
: service.userAccess === "denied"
|
||||
? "Доступ не выдан"
|
||||
: service.effectiveAccess.openEnabled
|
||||
? null
|
||||
: "Открытие заблокировано"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="service-stage" style={style}>
|
||||
<div className="stage-video-shell">
|
||||
<div className="stage-video-stream" aria-hidden="true">
|
||||
{service?.media.ambientVideo ? (
|
||||
<StageMedia className="stage-video-gif" src={service.media.ambientVideo} kind={service.media.ambientKind} />
|
||||
) : (
|
||||
<img className="stage-video-gif" src={DEFAULT_STAGE_MEDIA} alt="" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stage-video-topline">
|
||||
<button className="stage-round-button" type="button" aria-label="Назад">
|
||||
<ChevronLeft size={17} />
|
||||
</button>
|
||||
<span>{service?.title ?? "Витрина NODE.DC"}</span>
|
||||
</div>
|
||||
|
||||
<div className="stage-side-controls" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{service ? (
|
||||
<div className="stage-service-overlay">
|
||||
<article className="stage-image-card">
|
||||
<div className="stage-image-card__visual">
|
||||
{service.media.coverImage || service.media.thumbnail ? (
|
||||
<StageMedia
|
||||
className="stage-image-card__image"
|
||||
src={service.media.coverImage ?? service.media.thumbnail ?? ""}
|
||||
kind={service.media.coverKind}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="stage-image-card__figure" />
|
||||
<div className="stage-image-card__glow" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="stage-description-card">
|
||||
<div className="stage-card-label">
|
||||
<ServiceIcon slug={service.slug} />
|
||||
<span>Description</span>
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__copy">
|
||||
<h1>{service.title}</h1>
|
||||
<RichDescription text={service.fullDescription ?? service.description} />
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__chips">
|
||||
<ServiceStatusBadge status={service.status} />
|
||||
<StatusBadge
|
||||
label={service.userAccess === "allowed" ? `Доступ: ${service.appRole ?? "member"}` : "Нет доступа"}
|
||||
tone={service.userAccess === "allowed" ? "green" : "red"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__reason">
|
||||
<span>Почему видно</span>
|
||||
<strong>{service.effectiveAccess.reason}</strong>
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon={
|
||||
service.status === "maintenance" ? (
|
||||
<Wrench size={16} />
|
||||
) : service.userAccess === "denied" ? (
|
||||
<LockKeyhole size={16} />
|
||||
) : (
|
||||
<ExternalLink size={16} />
|
||||
)
|
||||
}
|
||||
onClick={() => onLaunch(service)}
|
||||
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
|
||||
>
|
||||
{disabledReason ?? "Открыть"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon={<ChevronRight size={16} />}
|
||||
onClick={() => onLaunch(service)}
|
||||
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
|
||||
>
|
||||
Перейти
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="stage-video-controls" aria-hidden="true">
|
||||
<button type="button" tabIndex={-1}>
|
||||
<ChevronLeft size={15} />
|
||||
</button>
|
||||
<button type="button" tabIndex={-1}>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceIcon({ slug }: { slug: string }) {
|
||||
if (slug.includes("task")) return <ChartNoAxesColumnIncreasing size={18} />;
|
||||
if (slug.includes("1c")) return <Boxes size={18} />;
|
||||
if (slug.includes("tender")) return <KeyRound size={18} />;
|
||||
if (slug.includes("twin")) return <Activity size={18} />;
|
||||
return <Bot size={18} />;
|
||||
}
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
return <img className={className} src={src} alt="" />;
|
||||
}
|
||||
|
||||
function RichDescription({ text }: { text: string }) {
|
||||
const blocks = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!blocks.length) return null;
|
||||
|
||||
return (
|
||||
<div className="stage-rich-description">
|
||||
{blocks.map((block, index) => {
|
||||
const lines = block.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
const isList = lines.length > 0 && lines.every((line) => /^[-*]\s+/.test(line));
|
||||
|
||||
if (isList) {
|
||||
return (
|
||||
<ul key={`list-${index}`}>
|
||||
{lines.map((line, lineIndex) => (
|
||||
<li key={lineIndex}>{renderInlineRichText(line.replace(/^[-*]\s+/, ""))}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return <p key={`p-${index}`}>{renderInlineRichText(lines.join("\n"))}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInlineRichText(text: string): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_|\n)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(text))) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const token = match[0];
|
||||
const key = `${match.index}-${token}`;
|
||||
|
||||
if (token === "\n") {
|
||||
parts.push(<br key={key} />);
|
||||
} else if (token.startsWith("**") || token.startsWith("__")) {
|
||||
parts.push(<strong key={key}>{token.slice(2, -2)}</strong>);
|
||||
} else if (token.startsWith("`")) {
|
||||
parts.push(<code key={key}>{token.slice(1, -1)}</code>);
|
||||
} else {
|
||||
parts.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isVideoSource(src: string) {
|
||||
return /\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
|
||||
import { initials } from "../../shared/lib/format";
|
||||
|
||||
export function TopBar({
|
||||
me,
|
||||
clients,
|
||||
profileOptions,
|
||||
activeProfileId,
|
||||
activeClientId,
|
||||
adminOpen,
|
||||
onProfileChange,
|
||||
onClientChange,
|
||||
onOpenAdmin,
|
||||
onOpenShowcase,
|
||||
}: {
|
||||
me: MeResponse;
|
||||
clients: Client[];
|
||||
profileOptions: ProfileOption[];
|
||||
activeProfileId: string;
|
||||
activeClientId: string;
|
||||
adminOpen: boolean;
|
||||
onProfileChange: (userId: string) => void;
|
||||
onClientChange: (clientId: string) => void;
|
||||
onOpenAdmin: () => void;
|
||||
onOpenShowcase: () => void;
|
||||
}) {
|
||||
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
||||
const activeClient = availableClients.find((client) => client.id === activeClientId);
|
||||
const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
|
||||
|
||||
return (
|
||||
<header className="nodedc-expanded-toolbar-shell">
|
||||
<div className="nodedc-expanded-toolbar">
|
||||
<div className="nodedc-expanded-toolbar-top">
|
||||
<div className="nodedc-expanded-toolbar-left">
|
||||
<a href="/" aria-label="NODE.DC">
|
||||
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-expanded-toolbar-center">
|
||||
<label className="nodedc-expanded-workspace-button" title={activeClient?.name ?? "Клиент"}>
|
||||
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
|
||||
<select value={activeClientId} onChange={(event) => onClientChange(event.target.value)} aria-label="Выбрать клиента">
|
||||
{availableClients.map((client) => (
|
||||
<option key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
|
||||
<span>Витрина</span>
|
||||
</button>
|
||||
|
||||
<label className="nodedc-expanded-nav-button nodedc-expanded-select-button" data-active="false">
|
||||
<span>{activeProfile?.label ?? me.user.name}</span>
|
||||
<select value={activeProfileId} onChange={(event) => onProfileChange(event.target.value)} aria-label="Выбрать профиль">
|
||||
{profileOptions.map((profile) => (
|
||||
<option key={profile.userId} value={profile.userId}>
|
||||
{profile.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{me.permissions.canOpenAdmin ? (
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onOpenAdmin}>
|
||||
<span>Администрирование</span>
|
||||
</button>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-expanded-toolbar-right">
|
||||
<div className="nodedc-expanded-user-group" title={`${me.user.name} · ${me.user.email}`}>
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active="false">
|
||||
<span>Профиль</span>
|
||||
</button>
|
||||
<button className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" type="button" data-active="false" aria-label="Уведомления">
|
||||
<span className="nodedc-toolbar-icon-active-dot">
|
||||
<Inbox size={20} strokeWidth={1.7} />
|
||||
</span>
|
||||
</button>
|
||||
<button className="nodedc-expanded-user-avatar-button" type="button" aria-label="Профиль пользователя">
|
||||
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user