ФУНКЦИИ - NODEDC LAUNCHER: управление пользователями платформы

This commit is contained in:
DCCONSTRUCTIONS
2026-05-11 12:20:46 +03:00
parent 8be33c53da
commit 2b34cf9f1b
9 changed files with 1130 additions and 245 deletions
+26
View File
@@ -67,6 +67,31 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
};
}
async function deleteUser({ data, userId }) {
ensureConfigured();
const user = findById(data.users, userId, "user");
const existingUser = await findUserByIdOrEmail(user.authentikUserId, user.email);
if (!existingUser) {
return {
deleted: false,
email: user.email,
authentikUserId: user.authentikUserId ?? null,
authentikPk: null,
};
}
await requestJson(`/api/v3/core/users/${encodeURIComponent(existingUser.pk)}/`, { method: "DELETE" });
return {
deleted: true,
email: user.email,
authentikUserId: user.authentikUserId ?? null,
authentikPk: existingUser.pk,
};
}
async function findUserByIdOrEmail(authentikUserId, email) {
if (authentikUserId) {
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`);
@@ -155,6 +180,7 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
}
return {
deleteUser,
isConfigured,
provisionUser,
};
+205
View File
@@ -13,6 +13,7 @@ const collectionKeys = [
"exceptions",
"invites",
"accessRequests",
"revokedAccounts",
"taskerInviteRequests",
"syncStatuses",
"auditEvents",
@@ -473,6 +474,7 @@ export function createControlPlaneStore({ projectRoot }) {
}
}
clearRevokedAccount(data, email);
addAuditEvent(data, actor, {
action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь",
objectType: "user",
@@ -541,6 +543,93 @@ export function createControlPlaneStore({ projectRoot }) {
return { membership, data };
}
async function deleteUser(userId, identity) {
const data = readData();
const actor = resolveActor(data, identity);
const user = findById(data.users, userId, "user");
if (user.id === "user_root") {
throw new Error("Системного root-пользователя нельзя удалить");
}
const email = user.email.toLowerCase();
const removedMembershipIds = new Set(data.memberships.filter((membership) => membership.userId === user.id).map((membership) => membership.id));
const removedInviteIds = new Set(
data.invites
.filter((invite) => invite.email.toLowerCase() === email)
.map((invite) => invite.id)
);
const removedAccessRequestIds = new Set(
data.accessRequests
.filter((request) => request.email.toLowerCase() === email)
.map((request) => request.id)
);
const removedTaskerInviteRequestIds = new Set(
data.taskerInviteRequests
.filter(
(request) =>
request.inviteeEmail.toLowerCase() === email ||
request.inviterEmail.toLowerCase() === email ||
request.inviterUserId === user.id
)
.map((request) => request.id)
);
upsertRevokedAccount(data, user, actor);
data.users = data.users.filter((candidate) => candidate.id !== user.id);
data.memberships = data.memberships.filter((membership) => membership.userId !== user.id);
data.groups = data.groups.map((group) => ({
...group,
memberIds: group.memberIds.filter((memberId) => memberId !== user.id),
updatedAt: group.memberIds.includes(user.id) ? isoNow() : group.updatedAt,
}));
data.grants = data.grants.filter((grant) => !(grant.targetType === "user" && grant.targetId === user.id));
data.exceptions = data.exceptions.filter((exception) => exception.userId !== user.id);
data.invites = data.invites.filter(
(invite) => invite.email.toLowerCase() !== email && invite.invitedByUserId !== user.id
);
data.accessRequests = data.accessRequests.filter((request) => request.email.toLowerCase() !== email);
data.taskerInviteRequests = data.taskerInviteRequests.filter(
(request) =>
request.inviteeEmail.toLowerCase() !== email &&
request.inviterEmail.toLowerCase() !== email &&
request.inviterUserId !== user.id
);
data.taskManagerMemberships = data.taskManagerMemberships.filter((membership) => membership.userId !== user.id);
data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter((membership) => membership.userId !== user.id);
data.syncStatuses = data.syncStatuses.filter((syncStatus) => {
if (syncStatus.objectId === user.id || syncStatus.objectName?.toLowerCase?.() === email) return false;
if (removedInviteIds.has(syncStatus.objectId)) return false;
if (removedAccessRequestIds.has(syncStatus.objectId)) return false;
if (removedTaskerInviteRequestIds.has(syncStatus.objectId)) return false;
if (removedMembershipIds.has(syncStatus.objectId)) return false;
return true;
});
addAuditEvent(data, actor, {
action: "Пользователь удалён полностью",
objectType: "user",
objectName: email,
clientId: null,
result: "warning",
details: `Удалены профиль, членства: ${removedMembershipIds.size}, инвайты: ${removedInviteIds.size}, заявки: ${
removedAccessRequestIds.size + removedTaskerInviteRequestIds.size
}`,
});
await writeData(data);
return {
user,
affected: {
memberships: removedMembershipIds.size,
invites: removedInviteIds.size,
accessRequests: removedAccessRequestIds.size,
taskerInviteRequests: removedTaskerInviteRequestIds.size,
},
data,
};
}
async function createInvite(payload, identity) {
const data = readData();
const actor = resolveActor(data, identity);
@@ -590,6 +679,10 @@ export function createControlPlaneStore({ projectRoot }) {
const email = requestPayload.email.toLowerCase();
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
if (existingUser?.globalStatus === "blocked") {
throw new Error("Аккаунт с этой почтой заблокирован. Обратитесь к администратору NODE.DC.");
}
if (existingUser?.globalStatus === "active") {
const hasOnlyPendingAccessRequest =
data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") &&
@@ -608,6 +701,7 @@ export function createControlPlaneStore({ projectRoot }) {
const user = upsertAccessRequestUser(data, requestPayload, now);
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
clearRevokedAccount(data, email);
markPendingSync(data, user, "user", user.email);
const existingRequest = data.accessRequests.find(
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
@@ -706,6 +800,11 @@ export function createControlPlaneStore({ projectRoot }) {
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === accessRequest.email.toLowerCase());
if (existingUser?.globalStatus === "blocked") {
throw new Error("Заблокированный аккаунт нельзя подтвердить без ручного разблокирования.");
}
const user = upsertAccessRequestUser(data, accessRequest, now);
const client = findClientById(data, accessRequest.targetClientId);
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
@@ -1108,6 +1207,10 @@ export function createControlPlaneStore({ projectRoot }) {
const actor = resolveActor(data, identity);
let user = data.users.find((item) => item.email.toLowerCase() === email);
if (user?.globalStatus === "blocked") {
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
}
if (user) {
user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null);
user.name = optionalString(identity?.name, user.name);
@@ -1159,6 +1262,7 @@ export function createControlPlaneStore({ projectRoot }) {
}
ensureTaskerInviteServiceAccess(data, invite, user, now);
clearRevokedAccount(data, email);
invite.status = "accepted";
invite.updatedAt = now;
@@ -1536,6 +1640,7 @@ export function createControlPlaneStore({ projectRoot }) {
user.email = optionalString(provisioning.email, user.email).toLowerCase();
user.name = optionalString(provisioning.name, user.name);
user.updatedAt = now;
clearRevokedAccount(data, user.email);
const syncStatus = data.syncStatuses.find(
(status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id
@@ -1615,6 +1720,34 @@ export function createControlPlaneStore({ projectRoot }) {
return roots;
}
function getLoginAccountStatus(email) {
const data = readData();
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) {
return { status: "unknown" };
}
const existingUser = data.users.find((user) => normalizeEmail(user.email) === normalizedEmail);
if (existingUser) {
return { status: "unknown" };
}
const revokedAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === normalizedEmail);
if (revokedAccount) {
return { status: "revoked", revokedAt: revokedAccount.revokedAt ?? null };
}
const hardDeleteAuditEvent = data.auditEvents.find(
(event) =>
normalizeEmail(event.objectName) === normalizedEmail &&
event.objectType === "user" &&
event.action === "Пользователь удалён полностью"
);
return hardDeleteAuditEvent ? { status: "revoked", revokedAt: hardDeleteAuditEvent.at ?? null } : { status: "unknown" };
}
return {
approveAccessRequest,
approveTaskerInviteRequest,
@@ -1632,11 +1765,13 @@ export function createControlPlaneStore({ projectRoot }) {
deleteInvite,
deleteMembership,
deleteService,
deleteUser,
rejectAccessRequest,
rejectTaskerInviteRequest,
acceptInvite,
commitInviteRegistration,
getInviteByToken,
getLoginAccountStatus,
getSnapshot,
ensureTaskerInvitePlatformInvite,
prepareInviteRegistration,
@@ -1679,10 +1814,33 @@ function normalizeData(payload) {
integrations: normalizeClientIntegrations(client.integrations),
}));
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean);
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
return data;
}
function normalizeRevokedAccount(payload) {
if (typeof payload !== "object" || payload === null) return null;
const email = normalizeEmail(payload.email);
if (!email) return null;
const now = isoNow();
return {
id: optionalString(payload.id, `revoked_account_${slugify(email)}`),
email,
name: nullableStringWithFallback(payload.name, null),
sourceUserId: nullableStringWithFallback(payload.sourceUserId, null),
authentikUserId: nullableStringWithFallback(payload.authentikUserId, null),
reason: optionalString(payload.reason, "hard_deleted"),
revokedByUserId: nullableStringWithFallback(payload.revokedByUserId, null),
revokedByUserEmail: nullableStringWithFallback(payload.revokedByUserEmail, null),
revokedByUserName: nullableStringWithFallback(payload.revokedByUserName, null),
revokedAt: optionalString(payload.revokedAt, now),
createdAt: optionalString(payload.createdAt, now),
updatedAt: optionalString(payload.updatedAt, now),
};
}
function normalizeAccessRequest(payload) {
if (typeof payload !== "object" || payload === null) return null;
const now = isoNow();
@@ -1943,6 +2101,44 @@ function addAuditEvent(data, actor, event) {
});
}
function upsertRevokedAccount(data, user, actor) {
const email = normalizeEmail(user.email);
if (!email) return null;
const now = isoNow();
const existingAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === email);
const patch = {
email,
name: user.name ?? null,
sourceUserId: user.id,
authentikUserId: user.authentikUserId ?? null,
reason: "hard_deleted",
revokedByUserId: actor.id ?? null,
revokedByUserEmail: actor.email ?? null,
revokedByUserName: actor.name ?? null,
revokedAt: now,
updatedAt: now,
};
if (existingAccount) {
Object.assign(existingAccount, patch);
return existingAccount;
}
const revokedAccount = {
id: uniqueId(data.revokedAccounts, "revoked_account", email),
...patch,
createdAt: now,
};
data.revokedAccounts.push(revokedAccount);
return revokedAccount;
}
function clearRevokedAccount(data, email) {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) return;
data.revokedAccounts = data.revokedAccounts.filter((account) => normalizeEmail(account.email) !== normalizedEmail);
}
function markPendingSync(data, object, objectType, objectName = object.name ?? object.email ?? object.id) {
const now = isoNow();
const existingStatus = data.syncStatuses.find(
@@ -2182,6 +2378,10 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
let user = data.users.find((item) => item.email.toLowerCase() === email);
if (user?.globalStatus === "blocked") {
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
}
if (user?.authentikUserId && !provisioning) {
throw new Error("Аккаунт уже существует. Войдите под почтой инвайта.");
}
@@ -2236,6 +2436,7 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
}
ensureTaskerInviteServiceAccess(data, invite, user, now);
clearRevokedAccount(data, email);
invite.status = "accepted";
invite.updatedAt = now;
markPendingSync(data, user, "user", email);
@@ -2441,6 +2642,10 @@ function nullableStringWithFallback(value, fallback) {
return value === undefined ? fallback : nullableString(value);
}
function normalizeEmail(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function pickEnum(value, allowedValues, fallback) {
return typeof value === "string" && allowedValues.has(value) ? value : fallback;
}
+28
View File
@@ -68,6 +68,14 @@ app.get("/api/public/brand", (_req, res) => {
res.json(buildPublicBrandResponse(snapshot.data.settings));
});
app.get("/api/public/login-account-status", (req, res) => {
const email = typeof req.query.email === "string" ? req.query.email : "";
res.setHeader("Access-Control-Allow-Origin", "*");
setNoStore(res);
res.json(controlPlaneStore.getLoginAccountStatus(email));
});
app.post("/api/access-requests", asyncRoute(async (req, res) => {
try {
const password = sanitizeNewPassword(req.body?.password);
@@ -1015,6 +1023,26 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const user = snapshot.data.users.find((candidate) => candidate.id === req.params.userId);
if (!user) {
res.status(404).json({ error: "user_not_found" });
return;
}
let authentik = null;
if (authentikSyncClient.isConfigured()) {
authentik = await authentikSyncClient.deleteUser({ data: snapshot.data, userId: req.params.userId });
}
const result = await controlPlaneStore.deleteUser(req.params.userId, req.nodedcSession.user);
publishControlPlaneEvent("admin.user.deleted", [req.params.userId]);
res.json({ ...scopeAdminMutationResult(req, result), authentik });
}));
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
return;