1402 lines
45 KiB
JavaScript
1402 lines
45 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
const collectionKeys = [
|
|
"clients",
|
|
"users",
|
|
"memberships",
|
|
"groups",
|
|
"services",
|
|
"grants",
|
|
"exceptions",
|
|
"invites",
|
|
"syncStatuses",
|
|
"auditEvents",
|
|
];
|
|
|
|
const clientTypes = new Set(["company", "person"]);
|
|
const clientStatuses = new Set(["active", "suspended", "demo", "expired"]);
|
|
const userStatuses = new Set(["invited", "active", "blocked"]);
|
|
const membershipRoles = new Set(["client_owner", "client_admin", "member"]);
|
|
const grantTargetTypes = new Set(["client", "group", "user"]);
|
|
const appRoles = new Set(["viewer", "member", "admin", "owner"]);
|
|
const grantStatuses = new Set(["active", "disabled"]);
|
|
const exceptionTypes = new Set(["deny", "allow"]);
|
|
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
|
|
const defaultSettings = {
|
|
brand: {
|
|
logoLinkUrl: "/",
|
|
},
|
|
};
|
|
|
|
export function createControlPlaneStore({ projectRoot }) {
|
|
const publicStorageRoot = join(projectRoot, "public", "storage");
|
|
const distStorageRoot = join(projectRoot, "dist", "storage");
|
|
const dataPath = join(publicStorageRoot, "launcher-data.json");
|
|
|
|
function readData() {
|
|
if (!existsSync(dataPath)) {
|
|
return normalizeData({});
|
|
}
|
|
|
|
return normalizeData(JSON.parse(readFileSync(dataPath, "utf8")));
|
|
}
|
|
|
|
async function writeData(data) {
|
|
const normalizedData = normalizeData(data);
|
|
const payload = `${JSON.stringify(normalizedData, null, 2)}\n`;
|
|
|
|
await Promise.all(
|
|
getWritableStorageRoots().map(async (storageRoot) => {
|
|
await mkdir(storageRoot, { recursive: true });
|
|
await writeJsonAtomically(join(storageRoot, "launcher-data.json"), payload);
|
|
})
|
|
);
|
|
|
|
return normalizedData;
|
|
}
|
|
|
|
function getSnapshot(identity) {
|
|
const data = readData();
|
|
|
|
return {
|
|
actor: resolveActor(data, identity),
|
|
counts: Object.fromEntries(collectionKeys.map((key) => [key, data[key].length])),
|
|
data,
|
|
};
|
|
}
|
|
|
|
async function replaceData(payload, identity) {
|
|
const data = normalizeData(payload);
|
|
const actor = resolveActor(data, identity);
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлено control-plane состояние",
|
|
objectType: "control_plane",
|
|
objectName: "Launcher data",
|
|
result: "success",
|
|
details: "Полная запись launcher-data.json через backend store.",
|
|
});
|
|
|
|
return writeData(data);
|
|
}
|
|
|
|
async function createClient(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const name = requireString(payload?.name, "name");
|
|
const client = {
|
|
id: uniqueId(data.clients, "client", name),
|
|
type: pickEnum(payload?.type, clientTypes, "company"),
|
|
name,
|
|
legalName: nullableString(payload?.legalName),
|
|
inn: nullableString(payload?.inn),
|
|
status: pickEnum(payload?.status, clientStatuses, "active"),
|
|
contractStartsAt: nullableString(payload?.contractStartsAt),
|
|
contractEndsAt: nullableString(payload?.contractEndsAt),
|
|
paidUntil: nullableString(payload?.paidUntil),
|
|
demoEndsAt: nullableString(payload?.demoEndsAt),
|
|
contactName: nullableString(payload?.contactName),
|
|
contactEmail: nullableString(payload?.contactEmail),
|
|
notes: nullableString(payload?.notes),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
data.clients.push(client);
|
|
addAuditEvent(data, actor, {
|
|
action: "Создан клиент",
|
|
objectType: "client",
|
|
objectName: client.name,
|
|
clientId: client.id,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, client, "client");
|
|
|
|
await writeData(data);
|
|
return { client, data };
|
|
}
|
|
|
|
async function updateClient(clientId, payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const client = findById(data.clients, clientId, "client");
|
|
|
|
client.type = pickEnum(payload?.type, clientTypes, client.type);
|
|
client.name = optionalString(payload?.name, client.name);
|
|
client.legalName = nullableStringWithFallback(payload?.legalName, client.legalName ?? null);
|
|
client.inn = nullableStringWithFallback(payload?.inn, client.inn ?? null);
|
|
client.status = pickEnum(payload?.status, clientStatuses, client.status);
|
|
client.contractStartsAt = nullableStringWithFallback(payload?.contractStartsAt, client.contractStartsAt ?? null);
|
|
client.contractEndsAt = nullableStringWithFallback(payload?.contractEndsAt, client.contractEndsAt ?? null);
|
|
client.paidUntil = nullableStringWithFallback(payload?.paidUntil, client.paidUntil ?? null);
|
|
client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null);
|
|
client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null);
|
|
client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null);
|
|
client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null);
|
|
client.updatedAt = isoNow();
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлён клиент",
|
|
objectType: "client",
|
|
objectName: client.name,
|
|
clientId: client.id,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, client, "client");
|
|
|
|
await writeData(data);
|
|
return { client, data };
|
|
}
|
|
|
|
async function deleteClient(clientId, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const client = findById(data.clients, clientId, "client");
|
|
|
|
if (data.clients.length <= 1) {
|
|
throw new Error("Cannot delete the last client");
|
|
}
|
|
|
|
const deletedGroupIds = new Set(data.groups.filter((group) => group.clientId === clientId).map((group) => group.id));
|
|
data.clients = data.clients.filter((item) => item.id !== clientId);
|
|
data.memberships = data.memberships.filter((membership) => membership.clientId !== clientId);
|
|
data.groups = data.groups.filter((group) => group.clientId !== clientId);
|
|
data.grants = data.grants.filter(
|
|
(grant) =>
|
|
!(grant.targetType === "client" && grant.targetId === clientId) &&
|
|
!(grant.targetType === "group" && deletedGroupIds.has(grant.targetId))
|
|
);
|
|
data.invites = data.invites.filter((invite) => invite.clientId !== clientId);
|
|
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== clientId);
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Удалён клиент",
|
|
objectType: "client",
|
|
objectName: client.name,
|
|
clientId,
|
|
result: "warning",
|
|
});
|
|
|
|
await writeData(data);
|
|
return { client, data };
|
|
}
|
|
|
|
async function updateSettings(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const patch = typeof payload === "object" && payload !== null ? payload : {};
|
|
const settings = normalizeSettings({
|
|
...data.settings,
|
|
...patch,
|
|
brand: {
|
|
...(data.settings?.brand ?? {}),
|
|
...(patch.brand ?? {}),
|
|
},
|
|
});
|
|
|
|
data.settings = settings;
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлены системные настройки",
|
|
objectType: "settings",
|
|
objectName: "Brand settings",
|
|
result: "success",
|
|
details: `Logo link: ${settings.brand.logoLinkUrl}`,
|
|
});
|
|
|
|
await writeData(data);
|
|
return { settings, data };
|
|
}
|
|
|
|
async function updateUserProfile(userId, payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const user = findById(data.users, userId, "user");
|
|
const now = isoNow();
|
|
|
|
user.name = optionalString(payload?.name, user.name);
|
|
user.email = optionalString(payload?.email, user.email);
|
|
user.phone = nullableStringWithFallback(payload?.phone, user.phone);
|
|
user.position = nullableStringWithFallback(payload?.position, user.position);
|
|
user.notes = nullableStringWithFallback(payload?.notes, user.notes);
|
|
user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null);
|
|
user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus);
|
|
user.updatedAt = now;
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлён профиль пользователя",
|
|
objectType: "user",
|
|
objectName: user.email,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, user, "user");
|
|
|
|
await writeData(data);
|
|
return { user, data };
|
|
}
|
|
|
|
async function createUser(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const clientId = requireString(payload?.clientId, "clientId");
|
|
const client = findById(data.clients, clientId, "client");
|
|
const email = requireString(payload?.email, "email").toLowerCase();
|
|
const existingUser = data.users.find((item) => item.email.toLowerCase() === email);
|
|
const user =
|
|
existingUser ??
|
|
{
|
|
id: uniqueId(data.users, "user", email),
|
|
authentikUserId: nullableString(payload?.authentikUserId),
|
|
name: optionalString(payload?.name, email.split("@")[0]),
|
|
email,
|
|
phone: nullableString(payload?.phone),
|
|
position: nullableString(payload?.position),
|
|
notes: nullableString(payload?.notes),
|
|
avatarUrl: nullableString(payload?.avatarUrl),
|
|
globalStatus: pickEnum(payload?.globalStatus, userStatuses, "active"),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
if (existingUser) {
|
|
user.name = optionalString(payload?.name, user.name);
|
|
user.phone = nullableStringWithFallback(payload?.phone, user.phone ?? null);
|
|
user.position = nullableStringWithFallback(payload?.position, user.position ?? null);
|
|
user.notes = nullableStringWithFallback(payload?.notes, user.notes ?? null);
|
|
user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null);
|
|
user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus);
|
|
user.updatedAt = now;
|
|
} else {
|
|
data.users.push(user);
|
|
}
|
|
|
|
const existingMembership = data.memberships.find((membership) => membership.clientId === clientId && membership.userId === user.id);
|
|
|
|
if (existingMembership) {
|
|
throw new Error(`User ${email} already belongs to client ${client.name}`);
|
|
}
|
|
|
|
const membership = {
|
|
id: uniqueId(data.memberships, "mem", `${clientId}-${email}`),
|
|
clientId,
|
|
userId: user.id,
|
|
role: pickEnum(payload?.role, membershipRoles, "member"),
|
|
status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
data.memberships.push(membership);
|
|
|
|
if (Array.isArray(payload?.groupIds)) {
|
|
for (const group of data.groups) {
|
|
if (group.clientId !== clientId || !payload.groupIds.includes(group.id) || group.memberIds.includes(user.id)) {
|
|
continue;
|
|
}
|
|
|
|
group.memberIds.push(user.id);
|
|
group.updatedAt = now;
|
|
}
|
|
}
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь",
|
|
objectType: "user",
|
|
objectName: email,
|
|
clientId: client.id,
|
|
result: "success",
|
|
details: `Role: ${membership.role}; status: ${membership.status}`,
|
|
});
|
|
markPendingSync(data, user, "user", email);
|
|
|
|
await writeData(data);
|
|
return { user, membership, data };
|
|
}
|
|
|
|
async function updateMembership(membershipId, payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const membership = findById(data.memberships, membershipId, "membership");
|
|
const user = findById(data.users, membership.userId, "user");
|
|
|
|
membership.role = pickEnum(payload?.role, membershipRoles, membership.role);
|
|
membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status);
|
|
membership.updatedAt = isoNow();
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлено членство",
|
|
objectType: "user",
|
|
objectName: user.email,
|
|
clientId: membership.clientId,
|
|
result: "success",
|
|
details: `Role: ${membership.role}; status: ${membership.status}`,
|
|
});
|
|
markPendingSync(data, user, "user");
|
|
|
|
await writeData(data);
|
|
return { membership, data };
|
|
}
|
|
|
|
async function deleteMembership(membershipId, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const membership = findById(data.memberships, membershipId, "membership");
|
|
const user = findById(data.users, membership.userId, "user");
|
|
|
|
data.memberships = data.memberships.filter((item) => item.id !== membershipId);
|
|
data.groups = data.groups.map((group) =>
|
|
group.clientId === membership.clientId
|
|
? {
|
|
...group,
|
|
memberIds: group.memberIds.filter((userId) => userId !== membership.userId),
|
|
updatedAt: isoNow(),
|
|
}
|
|
: group
|
|
);
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Удалено членство",
|
|
objectType: "user",
|
|
objectName: user.email,
|
|
clientId: membership.clientId,
|
|
result: "warning",
|
|
});
|
|
markPendingSync(data, user, "user");
|
|
|
|
await writeData(data);
|
|
return { membership, data };
|
|
}
|
|
|
|
async function createInvite(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const clientId = requireString(payload?.clientId, "clientId");
|
|
const client = findById(data.clients, clientId, "client");
|
|
const email = requireString(payload?.email, "email").toLowerCase();
|
|
const role = pickEnum(payload?.role, membershipRoles, "member");
|
|
const expiresAt = optionalString(payload?.expiresAt, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString());
|
|
const invite = {
|
|
id: uniqueId(data.invites, "invite", email),
|
|
clientId,
|
|
email,
|
|
role,
|
|
invitedByUserId: actor.id,
|
|
token: randomUUID(),
|
|
expiresAt,
|
|
status: "created",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
data.invites.push(invite);
|
|
addAuditEvent(data, actor, {
|
|
action: "Создан инвайт",
|
|
objectType: "invite",
|
|
objectName: email,
|
|
clientId: client.id,
|
|
result: "success",
|
|
details: `Роль: ${role}`,
|
|
});
|
|
markPendingSync(data, invite, "invite", email);
|
|
|
|
await writeData(data);
|
|
return { invite, data };
|
|
}
|
|
|
|
async function updateInvite(inviteId, payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const invite = findById(data.invites, inviteId, "invite");
|
|
|
|
invite.email = optionalString(payload?.email, invite.email).toLowerCase();
|
|
invite.role = pickEnum(payload?.role, membershipRoles, invite.role);
|
|
invite.expiresAt = optionalString(payload?.expiresAt, invite.expiresAt);
|
|
invite.status = pickEnum(payload?.status, new Set(["created", "sent", "accepted", "expired", "revoked"]), invite.status);
|
|
invite.updatedAt = isoNow();
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлён инвайт",
|
|
objectType: "invite",
|
|
objectName: invite.email,
|
|
clientId: invite.clientId,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, invite, "invite", invite.email);
|
|
|
|
await writeData(data);
|
|
return { invite, data };
|
|
}
|
|
|
|
async function deleteInvite(inviteId, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const invite = findById(data.invites, inviteId, "invite");
|
|
|
|
data.invites = data.invites.filter((item) => item.id !== inviteId);
|
|
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== inviteId);
|
|
addAuditEvent(data, actor, {
|
|
action: "Удалён инвайт",
|
|
objectType: "invite",
|
|
objectName: invite.email,
|
|
clientId: invite.clientId,
|
|
result: "warning",
|
|
});
|
|
|
|
await writeData(data);
|
|
return { invite, data };
|
|
}
|
|
|
|
function getInviteByToken(token) {
|
|
const data = readData();
|
|
const invite = findInviteByToken(data, token);
|
|
const client = findById(data.clients, invite.clientId, "client");
|
|
|
|
return {
|
|
invite: toPublicInvite(invite),
|
|
client: toPublicClient(client),
|
|
};
|
|
}
|
|
|
|
function prepareInviteRegistration(token, payload = {}) {
|
|
const data = readData();
|
|
const result = applyInviteRegistration(data, token, payload, { commit: false });
|
|
|
|
return result;
|
|
}
|
|
|
|
async function commitInviteRegistration(token, payload = {}, provisioning) {
|
|
const data = readData();
|
|
const result = applyInviteRegistration(data, token, payload, { commit: true, provisioning });
|
|
|
|
await writeData(data);
|
|
return result;
|
|
}
|
|
|
|
async function acceptInvite(token, identity) {
|
|
const data = readData();
|
|
const invite = findInviteByToken(data, token);
|
|
const client = findById(data.clients, invite.clientId, "client");
|
|
const email = requireInviteIdentityEmail(identity);
|
|
const now = isoNow();
|
|
|
|
if (invite.email.toLowerCase() !== email) {
|
|
throw new Error("Этот инвайт выписан на другую почту");
|
|
}
|
|
|
|
if (invite.status === "revoked") {
|
|
throw new Error("Инвайт отозван");
|
|
}
|
|
|
|
if (invite.status === "expired" || isInviteExpired(invite)) {
|
|
invite.status = "expired";
|
|
invite.updatedAt = now;
|
|
await writeData(data);
|
|
throw new Error("Срок действия инвайта истёк");
|
|
}
|
|
|
|
const actor = resolveActor(data, identity);
|
|
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
|
|
|
if (user) {
|
|
user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null);
|
|
user.name = optionalString(identity?.name, user.name);
|
|
user.avatarUrl = nullableStringWithFallback(identity?.avatarUrl, user.avatarUrl ?? null);
|
|
user.globalStatus = "active";
|
|
user.updatedAt = now;
|
|
} else {
|
|
user = {
|
|
id: uniqueId(data.users, "user", email),
|
|
authentikUserId: nullableString(identity?.sub),
|
|
name: optionalString(identity?.name, email.split("@")[0]),
|
|
email,
|
|
phone: null,
|
|
position: null,
|
|
notes: `Создан через инвайт клиента ${client.name}.`,
|
|
avatarUrl: nullableString(identity?.avatarUrl),
|
|
globalStatus: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
data.users.push(user);
|
|
}
|
|
|
|
let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id);
|
|
|
|
if (membership) {
|
|
membership.role = invite.role;
|
|
membership.status = "active";
|
|
membership.updatedAt = now;
|
|
} else {
|
|
membership = {
|
|
id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`),
|
|
clientId: invite.clientId,
|
|
userId: user.id,
|
|
role: invite.role,
|
|
status: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
data.memberships.push(membership);
|
|
}
|
|
|
|
invite.status = "accepted";
|
|
invite.updatedAt = now;
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Инвайт принят",
|
|
objectType: "invite",
|
|
objectName: invite.email,
|
|
clientId: client.id,
|
|
result: "success",
|
|
details: `Role: ${invite.role}`,
|
|
});
|
|
markPendingSync(data, user, "user", email);
|
|
|
|
await writeData(data);
|
|
return { invite, client, user, membership, data };
|
|
}
|
|
|
|
async function createGroup(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const clientId = requireString(payload?.clientId, "clientId");
|
|
const client = findById(data.clients, clientId, "client");
|
|
const groupName = optionalString(payload?.name, "Новая группа");
|
|
const group = {
|
|
id: uniqueId(data.groups, "group", `${clientId}-${groupName}`),
|
|
clientId,
|
|
name: groupName,
|
|
description: nullableString(payload?.description),
|
|
memberIds: Array.isArray(payload?.memberIds) ? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId)) : [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
data.groups.push(group);
|
|
addAuditEvent(data, actor, {
|
|
action: "Создана группа",
|
|
objectType: "group",
|
|
objectName: group.name,
|
|
clientId: client.id,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, group, "group");
|
|
|
|
await writeData(data);
|
|
return { group, data };
|
|
}
|
|
|
|
async function updateGroup(groupId, payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const group = findById(data.groups, groupId, "group");
|
|
|
|
group.name = optionalString(payload?.name, group.name);
|
|
group.description = nullableStringWithFallback(payload?.description, group.description ?? null);
|
|
group.memberIds = Array.isArray(payload?.memberIds)
|
|
? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId))
|
|
: group.memberIds;
|
|
group.updatedAt = isoNow();
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлена группа",
|
|
objectType: "group",
|
|
objectName: group.name,
|
|
clientId: group.clientId,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, group, "group");
|
|
|
|
await writeData(data);
|
|
return { group, data };
|
|
}
|
|
|
|
async function deleteGroup(groupId, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const group = findById(data.groups, groupId, "group");
|
|
|
|
data.groups = data.groups.filter((item) => item.id !== groupId);
|
|
data.grants = data.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId));
|
|
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== groupId);
|
|
addAuditEvent(data, actor, {
|
|
action: "Удалена группа",
|
|
objectType: "group",
|
|
objectName: group.name,
|
|
clientId: group.clientId,
|
|
result: "warning",
|
|
});
|
|
|
|
await writeData(data);
|
|
return { group, data };
|
|
}
|
|
|
|
async function upsertGrant(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const serviceId = requireString(payload?.serviceId, "serviceId");
|
|
const targetType = pickEnum(payload?.targetType, grantTargetTypes, "client");
|
|
const targetId = requireString(payload?.targetId, "targetId");
|
|
const service = findById(data.services, serviceId, "service");
|
|
|
|
assertGrantTargetExists(data, targetType, targetId);
|
|
|
|
const existingGrant = data.grants.find(
|
|
(grant) => grant.serviceId === serviceId && grant.targetType === targetType && grant.targetId === targetId
|
|
);
|
|
const grant =
|
|
existingGrant ??
|
|
{
|
|
id: uniqueId(data.grants, "grant", `${service.slug}-${targetType}-${targetId}`),
|
|
serviceId,
|
|
targetType,
|
|
targetId,
|
|
createdAt: now,
|
|
};
|
|
|
|
grant.appRole = pickEnum(payload?.appRole, appRoles, existingGrant?.appRole ?? "member");
|
|
grant.status = pickEnum(payload?.status, grantStatuses, existingGrant?.status ?? "active");
|
|
grant.updatedAt = now;
|
|
|
|
if (!existingGrant) {
|
|
data.grants.push(grant);
|
|
}
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: existingGrant ? "Обновлён доступ" : "Создан доступ",
|
|
objectType: "grant",
|
|
objectName: `${service.slug}:${targetType}:${targetId}`,
|
|
result: "success",
|
|
details: `App role: ${grant.appRole}; status: ${grant.status}`,
|
|
});
|
|
markPendingSync(data, grant, "grant", `${service.slug}:${targetType}:${targetId}`);
|
|
|
|
await writeData(data);
|
|
return { grant, data };
|
|
}
|
|
|
|
async function upsertException(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const serviceId = requireString(payload?.serviceId, "serviceId");
|
|
const userId = requireString(payload?.userId, "userId");
|
|
const type = pickEnum(payload?.type, exceptionTypes, "deny");
|
|
const service = findById(data.services, serviceId, "service");
|
|
const user = findById(data.users, userId, "user");
|
|
|
|
const existingException = data.exceptions.find(
|
|
(exception) => exception.serviceId === serviceId && exception.userId === userId
|
|
);
|
|
const exception =
|
|
existingException ??
|
|
{
|
|
id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`),
|
|
serviceId,
|
|
userId,
|
|
createdAt: now,
|
|
};
|
|
|
|
exception.type = type;
|
|
exception.reason = nullableString(payload?.reason);
|
|
exception.updatedAt = now;
|
|
|
|
if (!existingException) {
|
|
data.exceptions.push(exception);
|
|
}
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: existingException ? "Обновлено исключение доступа" : "Создано исключение доступа",
|
|
objectType: "exception",
|
|
objectName: `${service.slug}:${user.email}`,
|
|
result: "success",
|
|
details: `Type: ${type}`,
|
|
});
|
|
markPendingSync(data, exception, "grant", `${service.slug}:${user.email}`);
|
|
|
|
await writeData(data);
|
|
return { exception, data };
|
|
}
|
|
|
|
async function setUserServiceAccess(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const userId = requireString(payload?.userId, "userId");
|
|
const serviceId = requireString(payload?.serviceId, "serviceId");
|
|
const value = requireString(payload?.value, "value");
|
|
const user = findById(data.users, userId, "user");
|
|
const service = findById(data.services, serviceId, "service");
|
|
|
|
const directGrant = data.grants.find(
|
|
(grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId
|
|
);
|
|
data.grants = data.grants.filter(
|
|
(grant) => !(grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId)
|
|
);
|
|
data.exceptions = data.exceptions.filter((exception) => !(exception.serviceId === serviceId && exception.userId === userId));
|
|
|
|
if (value === "deny") {
|
|
data.exceptions.push({
|
|
id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`),
|
|
serviceId,
|
|
userId,
|
|
type: "deny",
|
|
reason: "Создано из матрицы доступа.",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
} else if (appRoles.has(value) && value !== "owner") {
|
|
data.grants.push({
|
|
id: directGrant?.id ?? uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`),
|
|
serviceId,
|
|
targetType: "user",
|
|
targetId: userId,
|
|
appRole: value,
|
|
status: "active",
|
|
createdAt: directGrant?.createdAt ?? now,
|
|
updatedAt: now,
|
|
});
|
|
} else if (value !== "unset") {
|
|
throw new Error(`Unsupported access value: ${value}`);
|
|
}
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлён доступ пользователя к сервису",
|
|
objectType: "grant",
|
|
objectName: `${user.email} / ${service.slug}`,
|
|
result: "success",
|
|
details: `Value: ${value}`,
|
|
});
|
|
markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`);
|
|
|
|
await writeData(data);
|
|
return { data };
|
|
}
|
|
|
|
async function updateService(serviceId, payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const service = findById(data.services, serviceId, "service");
|
|
|
|
Object.assign(service, sanitizeServicePatch(payload, service));
|
|
Object.assign(service, syncServiceLaunchLink(service));
|
|
service.updatedAt = isoNow();
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Обновлён сервис",
|
|
objectType: "service",
|
|
objectName: service.title,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, service, "service");
|
|
|
|
await writeData(data);
|
|
return { service, data };
|
|
}
|
|
|
|
async function reorderServices(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const orderedServiceIds = Array.isArray(payload?.orderedServiceIds) ? payload.orderedServiceIds : [];
|
|
const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10]));
|
|
const now = isoNow();
|
|
|
|
data.services = data.services.map((service) => ({
|
|
...service,
|
|
order: orderById.get(service.id) ?? service.order,
|
|
updatedAt: orderById.has(service.id) ? now : service.updatedAt,
|
|
}));
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Изменён порядок сервисов",
|
|
objectType: "service",
|
|
objectName: "Каталог сервисов",
|
|
result: "success",
|
|
});
|
|
|
|
await writeData(data);
|
|
return { data };
|
|
}
|
|
|
|
async function createService(payload, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const now = isoNow();
|
|
const nextIndex = data.services.length + 1;
|
|
const nextOrder = Math.max(0, ...data.services.map((service) => Number(service.order) || 0)) + 10;
|
|
const title = optionalString(payload?.title, "New Service");
|
|
const service = syncServiceLaunchLink({
|
|
id: uniqueId(data.services, "service", title),
|
|
slug: optionalString(payload?.slug, `new-service-${nextIndex}`),
|
|
title,
|
|
subtitle: nullableString(payload?.subtitle) ?? "Новый сервис",
|
|
description: optionalString(payload?.description, "Описание сервиса для витрины."),
|
|
fullDescription: nullableString(payload?.fullDescription) ?? "Заполните описание, медиа и ссылку запуска в редакторе контента.",
|
|
url: optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"),
|
|
launchUrl: nullableString(payload?.launchUrl) ?? optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"),
|
|
accentColor: nullableString(payload?.accentColor) ?? "#F7F8F4",
|
|
fallbackGradient:
|
|
nullableString(payload?.fallbackGradient) ??
|
|
"linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)",
|
|
coverMediaSource: nullableString(payload?.coverMediaSource) ?? "url",
|
|
coverMediaKind: nullableString(payload?.coverMediaKind) ?? "image",
|
|
ambientMediaSource: nullableString(payload?.ambientMediaSource) ?? "url",
|
|
ambientMediaKind: nullableString(payload?.ambientMediaKind) ?? "gif",
|
|
status: pickEnum(payload?.status, serviceStatuses, "hidden"),
|
|
order: nextOrder,
|
|
authentikApplicationSlug: nullableString(payload?.authentikApplicationSlug) ?? `new-service-${nextIndex}`,
|
|
authentikGroupName: nullableString(payload?.authentikGroupName) ?? `service-new-${nextIndex}`,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
data.services.push(service);
|
|
addAuditEvent(data, actor, {
|
|
action: "Создан сервис",
|
|
objectType: "service",
|
|
objectName: service.title,
|
|
result: "success",
|
|
});
|
|
markPendingSync(data, service, "service");
|
|
|
|
await writeData(data);
|
|
return { service, data };
|
|
}
|
|
|
|
async function deleteService(serviceId, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const service = findById(data.services, serviceId, "service");
|
|
|
|
data.services = data.services.filter((item) => item.id !== serviceId);
|
|
data.grants = data.grants.filter((grant) => grant.serviceId !== serviceId);
|
|
data.exceptions = data.exceptions.filter((exception) => exception.serviceId !== serviceId);
|
|
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== serviceId);
|
|
addAuditEvent(data, actor, {
|
|
action: "Удалён сервис",
|
|
objectType: "service",
|
|
objectName: service.title,
|
|
result: "warning",
|
|
});
|
|
|
|
await writeData(data);
|
|
return { service, data };
|
|
}
|
|
|
|
async function retrySync(syncId, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const syncStatus = findById(data.syncStatuses, syncId, "sync status");
|
|
|
|
syncStatus.state = "pending";
|
|
syncStatus.error = null;
|
|
syncStatus.updatedAt = isoNow();
|
|
addAuditEvent(data, actor, {
|
|
action: "Повтор sync",
|
|
objectType: syncStatus.objectType,
|
|
objectName: syncStatus.objectName,
|
|
result: "success",
|
|
details: `Target: ${syncStatus.target}`,
|
|
});
|
|
|
|
await writeData(data);
|
|
return { syncStatus, data };
|
|
}
|
|
|
|
async function markUserAuthentikProvisioned(userId, provisioning, identity) {
|
|
const data = readData();
|
|
const actor = resolveActor(data, identity);
|
|
const user = findById(data.users, userId, "user");
|
|
const now = isoNow();
|
|
|
|
user.authentikUserId = provisioning.authentikUserId ?? user.authentikUserId ?? null;
|
|
user.email = optionalString(provisioning.email, user.email).toLowerCase();
|
|
user.name = optionalString(provisioning.name, user.name);
|
|
user.updatedAt = now;
|
|
|
|
const syncStatus = data.syncStatuses.find(
|
|
(status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id
|
|
);
|
|
const objectName = user.email;
|
|
|
|
if (syncStatus) {
|
|
syncStatus.objectName = objectName;
|
|
syncStatus.state = "synced";
|
|
syncStatus.lastSyncAt = now;
|
|
syncStatus.error = null;
|
|
syncStatus.updatedAt = now;
|
|
} else {
|
|
data.syncStatuses.push({
|
|
id: uniqueId(data.syncStatuses, "sync", `user-${user.id}`),
|
|
objectId: user.id,
|
|
objectName,
|
|
objectType: "user",
|
|
target: "authentik",
|
|
state: "synced",
|
|
lastSyncAt: now,
|
|
error: null,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
addAuditEvent(data, actor, {
|
|
action: "Пользователь синхронизирован в Authentik",
|
|
objectType: "user",
|
|
objectName,
|
|
result: "success",
|
|
details: `Groups: ${(provisioning.groups ?? []).join(", ") || "none"}`,
|
|
});
|
|
|
|
await writeData(data);
|
|
return { user, data };
|
|
}
|
|
|
|
function buildAuthentikSyncPlan() {
|
|
const data = readData();
|
|
|
|
return {
|
|
mode: "dry-run",
|
|
source: "launcher-control-plane",
|
|
target: "authentik",
|
|
users: data.users.map((user) => ({
|
|
id: user.id,
|
|
authentikUserId: user.authentikUserId ?? null,
|
|
email: user.email,
|
|
name: user.name,
|
|
avatarUrl: user.avatarUrl ?? null,
|
|
active: user.globalStatus === "active",
|
|
})),
|
|
groups: [
|
|
"nodedc:superadmin",
|
|
"nodedc:launcher:admin",
|
|
"nodedc:launcher:user",
|
|
...data.services.flatMap((service) => (service.authentikGroupName ? [service.authentikGroupName] : [])),
|
|
...data.groups.map((group) => `client:${group.clientId}:group:${slugify(group.name)}`),
|
|
],
|
|
accessProjection: {
|
|
services: data.services.length,
|
|
grants: data.grants.filter((grant) => grant.status === "active").length,
|
|
exceptions: data.exceptions.length,
|
|
pendingSyncObjects: data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik" && syncStatus.state === "pending").length,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getWritableStorageRoots() {
|
|
const roots = [publicStorageRoot];
|
|
|
|
if (existsSync(join(projectRoot, "dist"))) {
|
|
roots.push(distStorageRoot);
|
|
}
|
|
|
|
return roots;
|
|
}
|
|
|
|
return {
|
|
buildAuthentikSyncPlan,
|
|
createClient,
|
|
createGroup,
|
|
createInvite,
|
|
createService,
|
|
createUser,
|
|
deleteClient,
|
|
deleteGroup,
|
|
deleteInvite,
|
|
deleteMembership,
|
|
deleteService,
|
|
acceptInvite,
|
|
commitInviteRegistration,
|
|
getInviteByToken,
|
|
getSnapshot,
|
|
prepareInviteRegistration,
|
|
readData,
|
|
replaceData,
|
|
reorderServices,
|
|
retrySync,
|
|
markUserAuthentikProvisioned,
|
|
setUserServiceAccess,
|
|
updateClient,
|
|
updateGroup,
|
|
updateInvite,
|
|
updateMembership,
|
|
updateService,
|
|
updateSettings,
|
|
updateUserProfile,
|
|
upsertException,
|
|
upsertGrant,
|
|
writeData,
|
|
};
|
|
}
|
|
|
|
function normalizeData(payload) {
|
|
const data = typeof payload === "object" && payload !== null ? { ...payload } : {};
|
|
|
|
for (const key of collectionKeys) {
|
|
if (!Array.isArray(data[key])) {
|
|
data[key] = [];
|
|
}
|
|
}
|
|
|
|
data.settings = normalizeSettings(data.settings);
|
|
return data;
|
|
}
|
|
|
|
function normalizeSettings(payload) {
|
|
const settings = typeof payload === "object" && payload !== null ? payload : {};
|
|
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
|
|
|
|
return {
|
|
brand: {
|
|
logoLinkUrl: optionalString(brand.logoLinkUrl, defaultSettings.brand.logoLinkUrl),
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveActor(data, identity) {
|
|
const user = data.users.find(
|
|
(item) =>
|
|
(identity?.sub && item.authentikUserId === identity.sub) ||
|
|
(identity?.email && item.email.toLowerCase() === identity.email.toLowerCase())
|
|
);
|
|
|
|
if (user) {
|
|
return {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
source: "launcher",
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: identity?.sub ? `oidc:${identity.sub}` : "system",
|
|
name: identity?.name || identity?.email || "System",
|
|
email: identity?.email || null,
|
|
source: "oidc",
|
|
};
|
|
}
|
|
|
|
function addAuditEvent(data, actor, event) {
|
|
data.auditEvents.push({
|
|
id: uniqueId(data.auditEvents, "audit", event.objectName ?? event.objectType ?? "event"),
|
|
at: isoNow(),
|
|
actorUserId: actor.id,
|
|
actorName: actor.name,
|
|
action: event.action,
|
|
objectType: event.objectType,
|
|
objectName: event.objectName,
|
|
clientId: event.clientId ?? null,
|
|
result: event.result ?? "success",
|
|
details: event.details ?? null,
|
|
});
|
|
}
|
|
|
|
function markPendingSync(data, object, objectType, objectName = object.name ?? object.email ?? object.id) {
|
|
const now = isoNow();
|
|
const existingStatus = data.syncStatuses.find(
|
|
(status) => status.target === "authentik" && status.objectType === objectType && status.objectId === object.id
|
|
);
|
|
|
|
if (existingStatus) {
|
|
existingStatus.objectName = objectName;
|
|
existingStatus.state = "pending";
|
|
existingStatus.error = null;
|
|
existingStatus.updatedAt = now;
|
|
return;
|
|
}
|
|
|
|
data.syncStatuses.push({
|
|
id: uniqueId(data.syncStatuses, "sync", `${objectType}-${object.id}`),
|
|
objectId: object.id,
|
|
objectName,
|
|
objectType,
|
|
target: "authentik",
|
|
state: "pending",
|
|
lastSyncAt: null,
|
|
error: null,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
async function writeJsonAtomically(filePath, payload) {
|
|
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
|
|
await writeFile(tempPath, payload, "utf8");
|
|
await rename(tempPath, filePath);
|
|
}
|
|
|
|
function assertGrantTargetExists(data, targetType, targetId) {
|
|
if (targetType === "client") {
|
|
findById(data.clients, targetId, "client");
|
|
} else if (targetType === "group") {
|
|
findById(data.groups, targetId, "group");
|
|
} else {
|
|
findById(data.users, targetId, "user");
|
|
}
|
|
}
|
|
|
|
function findInviteByToken(data, token) {
|
|
const normalizedToken = requireString(token, "token");
|
|
const invite = data.invites.find((candidate) => candidate.token === normalizedToken);
|
|
|
|
if (!invite) {
|
|
throw new Error("Инвайт не найден");
|
|
}
|
|
|
|
return invite;
|
|
}
|
|
|
|
function requireInviteIdentityEmail(identity) {
|
|
const email = typeof identity?.email === "string" ? identity.email.trim().toLowerCase() : "";
|
|
|
|
if (!email) {
|
|
throw new Error("Для принятия инвайта нужна подтверждённая почта");
|
|
}
|
|
|
|
return email;
|
|
}
|
|
|
|
function isInviteExpired(invite) {
|
|
if (!invite.expiresAt) return false;
|
|
return Number.isFinite(Date.parse(invite.expiresAt)) && Date.parse(invite.expiresAt) <= Date.now();
|
|
}
|
|
|
|
function toPublicInvite(invite) {
|
|
return {
|
|
id: invite.id,
|
|
role: invite.role,
|
|
expiresAt: invite.expiresAt,
|
|
status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status,
|
|
};
|
|
}
|
|
|
|
function toPublicClient(client) {
|
|
return {
|
|
id: client.id,
|
|
name: client.name,
|
|
status: client.status,
|
|
};
|
|
}
|
|
|
|
function applyInviteRegistration(data, token, payload, { commit, provisioning = null } = {}) {
|
|
const invite = findInviteByToken(data, token);
|
|
const client = findById(data.clients, invite.clientId, "client");
|
|
const now = isoNow();
|
|
const requestedEmail = normalizeInviteRegistrationEmail(payload?.email);
|
|
const email = invite.email.toLowerCase();
|
|
const name = optionalString(payload?.name, requestedEmail.split("@")[0]);
|
|
|
|
validateInviteCanBeRegistered(invite);
|
|
|
|
if (!requestedEmail || requestedEmail !== email) {
|
|
throw new Error("Для этой почты нет активного инвайта");
|
|
}
|
|
|
|
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
|
|
|
if (user?.authentikUserId && !provisioning) {
|
|
throw new Error("Аккаунт уже существует. Войдите под почтой инвайта.");
|
|
}
|
|
|
|
if (user) {
|
|
user.name = name;
|
|
user.globalStatus = "active";
|
|
user.authentikUserId = provisioning?.authentikUserId ?? user.authentikUserId ?? null;
|
|
user.updatedAt = now;
|
|
} else {
|
|
user = {
|
|
id: uniqueId(data.users, "user", email),
|
|
authentikUserId: provisioning?.authentikUserId ?? null,
|
|
name,
|
|
email,
|
|
phone: null,
|
|
position: null,
|
|
notes: `Создан через публичную регистрацию по инвайту клиента ${client.name}.`,
|
|
avatarUrl: null,
|
|
globalStatus: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
data.users.push(user);
|
|
}
|
|
|
|
let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id);
|
|
|
|
if (membership) {
|
|
membership.role = invite.role;
|
|
membership.status = "active";
|
|
membership.updatedAt = now;
|
|
} else {
|
|
membership = {
|
|
id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`),
|
|
clientId: invite.clientId,
|
|
userId: user.id,
|
|
role: invite.role,
|
|
status: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
data.memberships.push(membership);
|
|
}
|
|
|
|
invite.status = "accepted";
|
|
invite.updatedAt = now;
|
|
markPendingSync(data, user, "user", email);
|
|
|
|
if (commit) {
|
|
addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "invite" }, {
|
|
action: "Регистрация по инвайту",
|
|
objectType: "invite",
|
|
objectName: invite.email,
|
|
clientId: client.id,
|
|
result: "success",
|
|
details: `Role: ${invite.role}`,
|
|
});
|
|
}
|
|
|
|
return { invite, client, user, membership, data };
|
|
}
|
|
|
|
function normalizeInviteRegistrationEmail(value) {
|
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
}
|
|
|
|
function validateInviteCanBeRegistered(invite) {
|
|
if (invite.status === "accepted") {
|
|
throw new Error("Инвайт уже принят");
|
|
}
|
|
|
|
if (invite.status === "revoked") {
|
|
throw new Error("Инвайт отозван");
|
|
}
|
|
|
|
if (invite.status === "expired" || isInviteExpired(invite)) {
|
|
throw new Error("Срок действия инвайта истёк");
|
|
}
|
|
}
|
|
|
|
function findById(items, id, label) {
|
|
const item = items.find((candidate) => candidate.id === id);
|
|
|
|
if (!item) {
|
|
throw new Error(`Unknown ${label}: ${id}`);
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
function requireString(value, fieldName) {
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
throw new Error(`Field ${fieldName} is required`);
|
|
}
|
|
|
|
return value.trim();
|
|
}
|
|
|
|
function optionalString(value, fallback) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
}
|
|
|
|
function nullableString(value) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function nullableStringWithFallback(value, fallback) {
|
|
return value === undefined ? fallback : nullableString(value);
|
|
}
|
|
|
|
function pickEnum(value, allowedValues, fallback) {
|
|
return typeof value === "string" && allowedValues.has(value) ? value : fallback;
|
|
}
|
|
|
|
function uniqueId(items, prefix, seed) {
|
|
const base = `${prefix}_${slugify(seed)}`;
|
|
let candidate = base;
|
|
let counter = 1;
|
|
const ids = new Set(items.map((item) => item.id));
|
|
|
|
while (ids.has(candidate)) {
|
|
counter += 1;
|
|
candidate = `${base}_${counter}`;
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
function sanitizeServicePatch(payload, service) {
|
|
const patch = {};
|
|
const stringFields = [
|
|
"slug",
|
|
"title",
|
|
"subtitle",
|
|
"description",
|
|
"fullDescription",
|
|
"url",
|
|
"launchUrl",
|
|
"iconUrl",
|
|
"coverImageUrl",
|
|
"coverMediaKind",
|
|
"coverMediaSource",
|
|
"coverMediaFileName",
|
|
"previewVideoUrl",
|
|
"ambientVideoUrl",
|
|
"ambientMediaKind",
|
|
"ambientMediaSource",
|
|
"ambientMediaFileName",
|
|
"accentColor",
|
|
"fallbackGradient",
|
|
"authentikApplicationSlug",
|
|
"authentikGroupName",
|
|
];
|
|
|
|
for (const field of stringFields) {
|
|
if (field in (payload ?? {})) {
|
|
patch[field] = nullableStringWithFallback(payload[field], service[field] ?? null);
|
|
}
|
|
}
|
|
|
|
if (typeof payload?.order === "number") {
|
|
patch.order = payload.order;
|
|
}
|
|
|
|
if ("isAvailableForAllNewClients" in (payload ?? {})) {
|
|
patch.isAvailableForAllNewClients = Boolean(payload.isAvailableForAllNewClients);
|
|
}
|
|
|
|
patch.status = pickEnum(payload?.status, serviceStatuses, service.status);
|
|
return patch;
|
|
}
|
|
|
|
function syncServiceLaunchLink(service) {
|
|
const launchLink = String(service.launchUrl || service.url || "").trim();
|
|
|
|
return {
|
|
...service,
|
|
url: launchLink,
|
|
launchUrl: launchLink || null,
|
|
};
|
|
}
|
|
|
|
function slugify(value) {
|
|
const slug =
|
|
String(value)
|
|
.normalize("NFKD")
|
|
.replace(/[^\w]+/g, "_")
|
|
.replace(/^_+|_+$/g, "")
|
|
.toLowerCase()
|
|
.slice(0, 80) || randomUUID().slice(0, 8);
|
|
|
|
return slug;
|
|
}
|
|
|
|
function isoNow() {
|
|
return new Date().toISOString();
|
|
}
|