ФУНКЦИИ - NODEDC LAUNCHER: рабочий invite onboarding

This commit is contained in:
DCCONSTRUCTIONS
2026-05-05 15:53:30 +03:00
parent fd921cc400
commit bd1575d18a
4 changed files with 395 additions and 0 deletions
+141
View File
@@ -450,6 +450,101 @@ export function createControlPlaneStore({ projectRoot }) {
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),
};
}
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);
@@ -901,6 +996,8 @@ export function createControlPlaneStore({ projectRoot }) {
deleteInvite,
deleteMembership,
deleteService,
acceptInvite,
getInviteByToken,
getSnapshot,
readData,
replaceData,
@@ -1028,6 +1125,50 @@ function assertGrantTargetExists(data, targetType, targetId) {
}
}
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,
email: invite.email,
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 findById(items, id, label) {
const item = items.find((candidate) => candidate.id === id);
+38
View File
@@ -353,6 +353,30 @@ app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) =>
res.json({ data: result.data, ok: true });
}));
app.get("/api/invites/:token", (req, res) => {
try {
res.json(controlPlaneStore.getInviteByToken(req.params.token));
} catch (error) {
sendInviteApiError(res, error);
}
});
app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, res) => {
let result;
try {
result = await controlPlaneStore.acceptInvite(req.params.token, req.nodedcSession.user);
} catch (error) {
sendInviteApiError(res, error);
return;
}
const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user);
publishControlPlaneEvent("invite.accepted", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
});
@@ -759,6 +783,20 @@ function sanitizeNewPassword(value) {
return value;
}
function sendInviteApiError(res, error) {
const message = error instanceof Error ? error.message : "Инвайт недоступен";
const status =
message.includes("не найден")
? 404
: message.includes("другую почту")
? 403
: message.includes("истёк") || message.includes("отозван")
? 410
: 400;
res.status(status).json({ error: message });
}
function sanitizeSelfProfilePatch(payload) {
return {
name: payload?.name,