feat(launcher): add platform admin and public access flows

This commit is contained in:
DCCONSTRUCTIONS
2026-05-10 19:57:25 +03:00
parent a579e71b9b
commit fd1cc0b25a
8 changed files with 833 additions and 314 deletions
+122 -28
View File
@@ -588,6 +588,27 @@ export function createControlPlaneStore({ projectRoot }) {
const now = isoNow();
const requestPayload = sanitizeAccessRequestPayload(payload);
const email = requestPayload.email.toLowerCase();
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
if (existingUser?.globalStatus === "active") {
const hasOnlyPendingAccessRequest =
data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") &&
data.memberships.some(
(membership) =>
membership.userId === existingUser.id &&
membership.clientId === publicPoolClientId &&
membership.source === "access_request" &&
membership.status === "disabled"
);
if (!hasOnlyPendingAccessRequest) {
throw new Error("Аккаунт с этой почтой уже существует. Войдите в NODE.DC или обратитесь к администратору.");
}
}
const user = upsertAccessRequestUser(data, requestPayload, now);
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
markPendingSync(data, user, "user", user.email);
const existingRequest = data.accessRequests.find(
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
);
@@ -608,7 +629,7 @@ export function createControlPlaneStore({ projectRoot }) {
});
await writeData(data);
return { accessRequest: existingRequest, data };
return { accessRequest: existingRequest, user, data };
}
const accessRequest = {
@@ -637,7 +658,7 @@ export function createControlPlaneStore({ projectRoot }) {
});
await writeData(data);
return { accessRequest, data };
return { accessRequest, user, data };
}
async function updateAccessRequest(accessRequestId, payload, identity) {
@@ -685,33 +706,35 @@ export function createControlPlaneStore({ projectRoot }) {
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
if (accessRequest.status === "approved" && accessRequest.approvedInviteId) {
const existingInvite = findById(data.invites, accessRequest.approvedInviteId, "invite");
return { accessRequest, invite: existingInvite, data };
const user = upsertAccessRequestUser(data, accessRequest, now);
const client = findClientById(data, accessRequest.targetClientId);
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
status: "active",
clientId: client.id,
invitedByUserId: actor.id,
now,
});
if (client.id !== publicPoolClientId) {
data.memberships = data.memberships.filter(
(candidate) =>
!(
candidate.userId === user.id &&
candidate.clientId === publicPoolClientId &&
candidate.source === "access_request" &&
candidate.status === "disabled"
)
);
}
const client = findClientById(data, accessRequest.targetClientId);
const invite = {
id: uniqueId(data.invites, "invite", accessRequest.email),
clientId: client.id,
email: accessRequest.email,
role: accessRequest.role,
invitedByUserId: actor.id,
source: "access_request",
sourceTaskerInviteRequestId: null,
sourceTaskerInviteId: null,
sourceWorkspaceSlug: null,
sourceWorkspaceName: null,
token: randomUUID(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
status: "created",
createdAt: now,
updatedAt: now,
};
if (accessRequest.status === "approved") {
markPendingSync(data, user, "user", user.email);
await writeData(data);
return { accessRequest, user, membership, invite: null, data };
}
data.invites.push(invite);
accessRequest.status = "approved";
accessRequest.approvedInviteId = invite.id;
accessRequest.approvedInviteId = null;
accessRequest.reviewedByUserId = actor.id;
accessRequest.reviewedAt = now;
accessRequest.updatedAt = now;
@@ -722,12 +745,12 @@ export function createControlPlaneStore({ projectRoot }) {
objectName: accessRequest.email,
clientId: client.id === publicPoolClientId ? null : client.id,
result: "success",
details: `Invite: ${invite.id}; target: ${client.name}; role: ${invite.role}`,
details: `Account activated; target: ${client.name}; role: ${membership.role}`,
});
markPendingSync(data, invite, "invite", invite.email);
markPendingSync(data, user, "user", user.email);
await writeData(data);
return { accessRequest, invite, data };
return { accessRequest, user, membership, invite: null, data };
}
async function rejectAccessRequest(accessRequestId, payload, identity) {
@@ -2312,6 +2335,77 @@ function sanitizeAccessRequestPayload(payload) {
};
}
function upsertAccessRequestUser(data, requestPayload, now) {
const email = requestPayload.email.toLowerCase();
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
const userName = buildAccessRequestUserName(requestPayload);
if (existingUser) {
existingUser.name = userName;
existingUser.phone = requestPayload.phone;
existingUser.notes = `Public access request: ${requestPayload.company}`;
existingUser.globalStatus = existingUser.globalStatus === "blocked" ? "blocked" : "active";
existingUser.updatedAt = now;
return existingUser;
}
const user = {
id: uniqueId(data.users, "user", email),
authentikUserId: null,
email,
name: userName,
phone: requestPayload.phone,
position: null,
notes: `Public access request: ${requestPayload.company}`,
avatarUrl: null,
globalStatus: "active",
createdAt: now,
updatedAt: now,
};
data.users.push(user);
return user;
}
function upsertAccessRequestMembership(data, user, requestPayload, options = {}) {
const now = options.now ?? isoNow();
const clientId = options.clientId ?? publicPoolClientId;
const role = pickEnum(requestPayload.role, membershipRoles, "member");
const existingMembership = data.memberships.find(
(membership) => membership.userId === user.id && membership.clientId === clientId
);
if (existingMembership) {
existingMembership.role = role;
existingMembership.status = options.status ?? existingMembership.status;
existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null;
existingMembership.source = existingMembership.source ?? "access_request";
existingMembership.updatedAt = now;
return existingMembership;
}
const membership = {
id: uniqueId(data.memberships, "mem", `${clientId}-${user.id}`),
clientId,
userId: user.id,
role,
status: options.status ?? "disabled",
invitedByUserId: options.invitedByUserId ?? null,
inviteId: null,
source: "access_request",
sourceTaskerInviteRequestId: null,
createdAt: now,
updatedAt: now,
};
data.memberships.push(membership);
return membership;
}
function buildAccessRequestUserName(requestPayload) {
return [requestPayload.lastName, requestPayload.firstName, requestPayload.middleName].filter(Boolean).join(" ");
}
function normalizeTaskManagerInviteRole(value) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : value;
+32 -3
View File
@@ -70,8 +70,26 @@ app.get("/api/public/brand", (_req, res) => {
app.post("/api/access-requests", asyncRoute(async (req, res) => {
try {
const password = sanitizeNewPassword(req.body?.password);
if (!authentikSyncClient.isConfigured()) {
res.status(503).json({ error: "Authentik API не настроен. Заявку с паролем сейчас создать нельзя." });
return;
}
const result = await controlPlaneStore.createAccessRequest(req.body);
publishControlPlaneEvent("access-request.created");
const provisioning = await authentikSyncClient.provisionUser({
data: result.data,
userId: result.user.id,
password,
});
await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, {
sub: "public-access-request",
name: "NODE.DC public request",
email: result.user.email,
});
publishControlPlaneEvent("access-request.created", [result.user.id]);
res.status(201).json({ accessRequest: result.accessRequest });
} catch (error) {
sendAccessRequestApiError(res, error);
@@ -1111,8 +1129,19 @@ app.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, r
app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
try {
const result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.access-request.approved");
let result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
let provisioning = null;
if (result.user && authentikSyncClient.isConfigured()) {
provisioning = await authentikSyncClient.provisionUser({
data: result.data,
userId: result.user.id,
});
const syncResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, req.nodedcSession.user);
result = { ...result, data: syncResult.data, user: syncResult.user, provisioning };
}
publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []);
res.json(scopeAdminMutationResult(req, result));
} catch (error) {
sendAccessRequestApiError(res, error);