feat: add launcher frontend MVP
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user