feat: add launcher frontend MVP

This commit is contained in:
DCCONSTRUCTIONS
2026-05-01 18:39:59 +03:00
parent 63d21a7a57
commit e8c6e76885
46 changed files with 9497 additions and 0 deletions
+272
View File
@@ -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,
};
}
+38
View File
@@ -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;
}
+12
View File
@@ -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;
}
+16
View File
@@ -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;
}
+16
View File
@@ -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;
}
+71
View File
@@ -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;
}
+14
View File
@@ -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;
}
+42
View File
@@ -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;
}
+10
View File
@@ -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>
);
+344
View File
@@ -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];
}
+448
View File
@@ -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,
};
}
+72
View File
@@ -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;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function cn(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(" ");
}
+28
View File
@@ -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("");
}
+43
View File
@@ -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";
}
+30
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+22
View File
@@ -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
);
}
+82
View File
@@ -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
+46
View File
@@ -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} />;
}
+251
View File
@@ -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);
}
+99
View File
@@ -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>
);
}