feat(launcher): add platform admin and public access flows
This commit is contained in:
+122
-28
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user