ФУНКЦИИ - NODEDC LAUNCHER: рабочий invite onboarding
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user