486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
|
|
import type { AccessRequest } from "../../entities/access-request/types";
|
|
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant, ServiceModuleEntitlement } from "../../entities/access/types";
|
|
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
|
import type { EngineWorkflowAccessRequest } from "../../entities/engine-workflow-access-request/types";
|
|
import type { Invite } from "../../entities/invite/types";
|
|
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
|
import { getServiceLaunchLink } from "../../entities/service/links";
|
|
import type { LauncherServiceView, Service } from "../../entities/service/types";
|
|
import type { SyncStatus } from "../../entities/sync/types";
|
|
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
|
import type {
|
|
ClientGroup,
|
|
ClientMembership,
|
|
ClientMembershipRole,
|
|
LauncherGlobalRole,
|
|
LauncherUser,
|
|
} from "../../entities/user/types";
|
|
import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions";
|
|
import {
|
|
mockAuditEvents,
|
|
mockAccessRequests,
|
|
mockEngineWorkflowAccessRequests,
|
|
mockTaskerInviteRequests,
|
|
mockClients,
|
|
mockExceptions,
|
|
mockGrants,
|
|
mockGroups,
|
|
mockInvites,
|
|
mockMemberships,
|
|
mockServices,
|
|
mockSyncStatuses,
|
|
mockUsers,
|
|
} from "./mockData";
|
|
|
|
export interface AuthentikClaimsMock {
|
|
sub: string;
|
|
email: string;
|
|
name: string;
|
|
avatarUrl?: string | null;
|
|
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"];
|
|
coreAssistantRole: ClientMembership["coreAssistantRole"];
|
|
}>;
|
|
activeClientId: string;
|
|
permissions: LauncherPermissions;
|
|
mockAuthentikClaims: AuthentikClaimsMock;
|
|
}
|
|
|
|
export interface LauncherData {
|
|
clients: Client[];
|
|
users: LauncherUser[];
|
|
memberships: ClientMembership[];
|
|
groups: ClientGroup[];
|
|
services: Service[];
|
|
grants: ServiceGrant[];
|
|
exceptions: ServiceAccessException[];
|
|
serviceModuleEntitlements: ServiceModuleEntitlement[];
|
|
invites: Invite[];
|
|
accessRequests: AccessRequest[];
|
|
revokedAccounts: RevokedAccount[];
|
|
taskerInviteRequests: TaskerInviteRequest[];
|
|
engineWorkflowAccessRequests: EngineWorkflowAccessRequest[];
|
|
syncStatuses: SyncStatus[];
|
|
auditEvents: typeof mockAuditEvents;
|
|
taskManagerMemberships: TaskManagerMembershipAssignment[];
|
|
taskManagerProjectMemberships: TaskManagerProjectMembershipAssignment[];
|
|
settings: LauncherSettings;
|
|
}
|
|
|
|
export interface RevokedAccount {
|
|
id: string;
|
|
email: string;
|
|
name?: string | null;
|
|
sourceUserId?: string | null;
|
|
authentikUserId?: string | null;
|
|
reason: string;
|
|
revokedByUserId?: string | null;
|
|
revokedByUserEmail?: string | null;
|
|
revokedByUserName?: string | null;
|
|
revokedAt: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface TaskManagerMembershipAssignment {
|
|
id: string;
|
|
clientId: string;
|
|
userId: string;
|
|
workspaceSlug: string;
|
|
workspaceName?: string | null;
|
|
role: "guest" | "member" | "admin";
|
|
managedBy?: TaskManagerWorkspaceManagedBy;
|
|
planeUserId?: string | null;
|
|
planeRole?: number | null;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface TaskManagerProjectMembershipAssignment {
|
|
id: string;
|
|
clientId: string;
|
|
userId: string;
|
|
workspaceSlug: string;
|
|
workspaceName?: string | null;
|
|
projectId: string;
|
|
projectIdentifier?: string | null;
|
|
projectName?: string | null;
|
|
role: "guest" | "member" | "admin";
|
|
managedBy?: TaskManagerWorkspaceManagedBy;
|
|
planeUserId?: string | null;
|
|
planeRole?: number | null;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface LauncherSettings {
|
|
brand: {
|
|
logoLinkUrl: string;
|
|
};
|
|
taskManager: {
|
|
workspaceCreationPolicy: TaskManagerWorkspaceCreationPolicy;
|
|
};
|
|
}
|
|
|
|
export type TaskManagerWorkspaceCreationPolicy = "any_authorized_user" | "task_admins_only" | "disabled";
|
|
|
|
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 defaultLauncherSettings: LauncherSettings = {
|
|
brand: {
|
|
logoLinkUrl: "/",
|
|
},
|
|
taskManager: {
|
|
workspaceCreationPolicy: "any_authorized_user",
|
|
},
|
|
};
|
|
|
|
export const initialLauncherData: LauncherData = normalizeLauncherData({
|
|
clients: mockClients,
|
|
users: mockUsers,
|
|
memberships: mockMemberships,
|
|
groups: mockGroups,
|
|
services: mockServices,
|
|
grants: mockGrants,
|
|
exceptions: mockExceptions,
|
|
invites: mockInvites,
|
|
accessRequests: mockAccessRequests,
|
|
revokedAccounts: [],
|
|
taskerInviteRequests: mockTaskerInviteRequests,
|
|
engineWorkflowAccessRequests: mockEngineWorkflowAccessRequests,
|
|
syncStatuses: mockSyncStatuses,
|
|
auditEvents: mockAuditEvents,
|
|
settings: defaultLauncherSettings,
|
|
});
|
|
|
|
export function normalizeLauncherSettings(settings?: Partial<LauncherSettings> | null): LauncherSettings {
|
|
const brand =
|
|
typeof settings?.brand === "object" && settings.brand !== null
|
|
? settings.brand
|
|
: ({} as Partial<LauncherSettings["brand"]>);
|
|
const taskManager =
|
|
typeof settings?.taskManager === "object" && settings.taskManager !== null
|
|
? settings.taskManager
|
|
: ({} as Partial<LauncherSettings["taskManager"]>);
|
|
const logoLinkUrl = typeof brand.logoLinkUrl === "string" && brand.logoLinkUrl.trim() ? brand.logoLinkUrl.trim() : "/";
|
|
const workspaceCreationPolicy = isTaskManagerWorkspaceCreationPolicy(taskManager.workspaceCreationPolicy)
|
|
? taskManager.workspaceCreationPolicy
|
|
: defaultLauncherSettings.taskManager.workspaceCreationPolicy;
|
|
|
|
return {
|
|
brand: {
|
|
logoLinkUrl,
|
|
},
|
|
taskManager: {
|
|
workspaceCreationPolicy,
|
|
},
|
|
};
|
|
}
|
|
|
|
function isTaskManagerWorkspaceCreationPolicy(value: unknown): value is TaskManagerWorkspaceCreationPolicy {
|
|
return value === "any_authorized_user" || value === "task_admins_only" || value === "disabled";
|
|
}
|
|
|
|
export function normalizeLauncherData(data: Partial<LauncherData> | null | undefined): LauncherData {
|
|
const payload = data ?? {};
|
|
|
|
return {
|
|
clients: Array.isArray(payload.clients) ? payload.clients : mockClients,
|
|
users: Array.isArray(payload.users) ? payload.users : mockUsers,
|
|
memberships: Array.isArray(payload.memberships) ? payload.memberships.map(normalizeMembership) : mockMemberships.map(normalizeMembership),
|
|
groups: Array.isArray(payload.groups) ? payload.groups : mockGroups,
|
|
services: Array.isArray(payload.services) ? payload.services : mockServices,
|
|
grants: Array.isArray(payload.grants) ? payload.grants : mockGrants,
|
|
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
|
serviceModuleEntitlements: Array.isArray(payload.serviceModuleEntitlements) ? payload.serviceModuleEntitlements : [],
|
|
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
|
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
|
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],
|
|
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
|
engineWorkflowAccessRequests: Array.isArray(payload.engineWorkflowAccessRequests)
|
|
? payload.engineWorkflowAccessRequests
|
|
: mockEngineWorkflowAccessRequests,
|
|
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
|
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
|
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
|
|
taskManagerProjectMemberships: Array.isArray(payload.taskManagerProjectMemberships) ? payload.taskManagerProjectMemberships : [],
|
|
settings: normalizeLauncherSettings(payload.settings),
|
|
};
|
|
}
|
|
|
|
export const profileOptions: ProfileOption[] = [
|
|
{
|
|
userId: "user_root",
|
|
label: "DC Touch",
|
|
description: "NODE.DC superadmin",
|
|
defaultClientId: "client_romashka",
|
|
},
|
|
{
|
|
userId: "user_silver_psih",
|
|
label: "Silver Psy",
|
|
description: "DCTOUCH manager",
|
|
defaultClientId: "client_romashka",
|
|
},
|
|
];
|
|
|
|
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,
|
|
coreAssistantRole: "admin" 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,
|
|
coreAssistantRole: normalizeCoreAssistantRole(membership.coreAssistantRole),
|
|
}));
|
|
|
|
const fallbackClientId =
|
|
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ??
|
|
availableMemberships[0]?.clientId ??
|
|
PUBLIC_POOL_CLIENT.id;
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeMembership(membership: ClientMembership): ClientMembership {
|
|
return {
|
|
...membership,
|
|
coreAssistantRole: normalizeCoreAssistantRole(membership.coreAssistantRole, membership.userId === "user_root" ? "admin" : "member"),
|
|
};
|
|
}
|
|
|
|
function normalizeCoreAssistantRole(
|
|
value: ClientMembership["coreAssistantRole"] | null | undefined,
|
|
fallback: NonNullable<ClientMembership["coreAssistantRole"]> = "member"
|
|
): NonNullable<ClientMembership["coreAssistantRole"]> {
|
|
if (value === "blocked" || value === "member" || value === "admin") return value;
|
|
return fallback;
|
|
}
|
|
|
|
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 ? getServiceLaunchLink(service) || null : 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.status !== "hidden");
|
|
}
|
|
|
|
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 {
|
|
if (isPublicPoolClientId(clientId)) {
|
|
return PUBLIC_POOL_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];
|
|
}
|