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
@@ -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;
}