ФУНКЦИИ - NODEDC LAUNCHER: управление пользователями платформы
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user