Compare commits

..

No commits in common. "2b34cf9f1bbd927c69b41e0f22674e45af189922" and "0a3243c9e8a8bff716f1c0465b484b2673b0add7" have entirely different histories.

21 changed files with 450 additions and 5883 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

View File

@ -7,11 +7,6 @@ const platformGroups = {
taskManagerAdmin: "nodedc:taskmanager:admin", taskManagerAdmin: "nodedc:taskmanager:admin",
taskManagerUser: "nodedc:taskmanager:user", taskManagerUser: "nodedc:taskmanager:user",
}; };
const publicPoolClientId = "client_public_pool";
const publicPoolClient = {
id: publicPoolClientId,
status: "active",
};
export function createAuthentikSyncClient({ baseUrl, token }) { export function createAuthentikSyncClient({ baseUrl, token }) {
const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, ""); const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, "");
@ -67,31 +62,6 @@ 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) { async function findUserByIdOrEmail(authentikUserId, email) {
if (authentikUserId) { if (authentikUserId) {
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`); const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`);
@ -180,7 +150,6 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
} }
return { return {
deleteUser,
isConfigured, isConfigured,
provisionUser, provisionUser,
}; };
@ -203,7 +172,7 @@ export function resolveRequiredGroups(data, user) {
return [...groupNames]; return [...groupNames];
} }
for (const client of getUserRuntimeClients(data, user.id)) { for (const client of data.clients) {
const membership = getRuntimeMembership(data, user.id, client.id); const membership = getRuntimeMembership(data, user.id, client.id);
if (membership.status !== "active") { if (membership.status !== "active") {
@ -234,19 +203,6 @@ export function resolveRequiredGroups(data, user) {
return [...groupNames]; return [...groupNames];
} }
function getUserRuntimeClients(data, userId) {
const clients = [...data.clients];
const hasPublicPoolMembership = data.memberships.some(
(membership) => membership.userId === userId && membership.clientId === publicPoolClientId
);
if (hasPublicPoolMembership) {
clients.push(publicPoolClient);
}
return clients;
}
function generatePasswordValue() { function generatePasswordValue() {
return `NDC-${randomBytes(15).toString("base64url")}`; return `NDC-${randomBytes(15).toString("base64url")}`;
} }

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,6 @@ const oidcStateCookieName = "nodedc_oidc_state";
const maxOidcStateCookieEntries = 8; const maxOidcStateCookieEntries = 8;
const sessionCookieName = "nodedc_session"; const sessionCookieName = "nodedc_session";
const noStoreCacheControl = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; const noStoreCacheControl = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
const publicPoolClientId = "client_public_pool";
loadEnvFiles([ loadEnvFiles([
process.env.NODEDC_PLATFORM_ENV, process.env.NODEDC_PLATFORM_ENV,
@ -68,42 +67,6 @@ app.get("/api/public/brand", (_req, res) => {
res.json(buildPublicBrandResponse(snapshot.data.settings)); 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);
if (!authentikSyncClient.isConfigured()) {
res.status(503).json({ error: "Authentik API не настроен. Заявку с паролем сейчас создать нельзя." });
return;
}
const result = await controlPlaneStore.createAccessRequest(req.body);
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);
}
}));
app.get("/auth/login", asyncRoute(async (req, res) => { app.get("/auth/login", asyncRoute(async (req, res) => {
ensureOidcConfigured(); ensureOidcConfigured();
@ -411,7 +374,7 @@ app.post("/api/internal/access/check", (req, res) => {
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug); const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
const allowed = Boolean(app?.hasAccess); const allowed = Boolean(app?.hasAccess);
const workspacePolicy = const workspacePolicy =
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user) : null; serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed) : null;
res.json({ res.json({
ok: true, ok: true,
@ -432,84 +395,6 @@ app.post("/api/internal/access/check", (req, res) => {
}); });
}); });
app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
});
return;
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC tasker invite request" });
const inviterPayload = typeof req.body?.inviter === "object" && req.body.inviter !== null ? req.body.inviter : req.body;
const inviter = findInternalAccessUser(snapshot.data, {
subject: inviterPayload.subject,
email: inviterPayload.email,
userId: inviterPayload.userId,
});
if (!inviter) {
res.status(404).json({ ok: false, error: "inviter_not_found" });
return;
}
const groups = resolveRequiredGroups(snapshot.data, inviter);
const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager");
const workspacePolicy = resolveTaskManagerWorkspacePolicy(snapshot.data, groups, Boolean(app?.hasAccess), inviter);
if (!app?.hasAccess || workspacePolicy.inviteApproval !== "nodedc") {
res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy });
return;
}
const result = await controlPlaneStore.createTaskerInviteRequest({
taskerInviteId: req.body?.taskerInviteId,
workspaceId: req.body?.workspace?.id ?? req.body?.workspaceId,
workspaceSlug: req.body?.workspace?.slug ?? req.body?.workspaceSlug,
workspaceName: req.body?.workspace?.name ?? req.body?.workspaceName,
inviteeEmail: req.body?.invitee?.email ?? req.body?.inviteeEmail,
role: req.body?.invitee?.role ?? req.body?.role,
inviterUserId: inviter.id,
inviterPlaneUserId: inviterPayload.planeUserId,
inviterEmail: inviter.email,
inviterName: inviter.name,
}, inviter);
publishControlPlaneEvent("tasker.invite-request.created", [inviter.id]);
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
}));
app.post("/api/internal/tasker/invite-requests/cancel", asyncRoute(async (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
});
return;
}
const result = await controlPlaneStore.cancelTaskerInviteRequest(req.body, {
name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core",
email: req.body?.cancelledBy?.email,
source: "tasker",
});
const syncResult = await syncUsersToAuthentik(result.data, result.affectedUserIds ?? [], {
name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core",
email: req.body?.cancelledBy?.email,
source: "tasker",
});
if (result.taskerInviteRequest) {
publishControlPlaneEvent("tasker.invite-request.cancelled", [
result.taskerInviteRequest.inviterUserId,
...syncResult.userIds,
]);
}
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
}));
app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => { app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
const { actor } = getLauncherProfileContext(req.nodedcSession); const { actor } = getLauncherProfileContext(req.nodedcSession);
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user); const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
@ -593,14 +478,13 @@ app.post("/api/invites/:token/register", asyncRoute(async (req, res) => {
); );
publishControlPlaneEvent("invite.registered", [result.user.id]); publishControlPlaneEvent("invite.registered", [result.user.id]);
const redirectUrl = resolveInviteRedirectUrl(result.invite);
res.json({ res.json({
...result, ...result,
user: storeResult.user, user: storeResult.user,
data: storeResult.data, data: storeResult.data,
provisioning: toProvisioningResponse(provisionedUser), provisioning: toProvisioningResponse(provisionedUser),
loginUrl: buildLoginRedirectUrl(redirectUrl, { forceLogin: true, includeReturnTo: true }), loginUrl: buildLoginRedirectUrl("/", { forceLogin: true, includeReturnTo: true }),
redirectUrl, redirectUrl: "/",
authenticated: true, authenticated: true,
}); });
})); }));
@ -618,44 +502,9 @@ app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, re
const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user);
publishControlPlaneEvent("invite.accepted", syncResult.userIds); publishControlPlaneEvent("invite.accepted", syncResult.userIds);
res.json({ ...result, data: syncResult.data, redirectUrl: resolveInviteRedirectUrl(result.invite) }); res.json({ ...result, data: syncResult.data });
})); }));
app.get("/tasker-workspace-invite/:taskerInviteRequestId", (req, res) => {
const session = getCurrentSession(req);
if (!session) {
res.redirect(buildLoginRedirectUrl(req.originalUrl, { forceLogin: true }));
return;
}
const runtimeContext = getRuntimeSessionContext(session);
const request = controlPlaneStore
.getSnapshot({ name: "NODE.DC tasker invite redirect" })
.data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId);
if (!request || request.status !== "approved") {
res.status(404).send("Workspace-инвайт не найден или ещё не подтверждён NODE.DC.");
return;
}
if (session.user.email?.toLowerCase() !== request.inviteeEmail.toLowerCase()) {
res.status(403).send("Этот workspace-инвайт выписан на другую почту.");
return;
}
const handoffToken = createServiceHandoff("task-manager", runtimeContext.user);
const taskBaseUrl = getTaskBaseUrl();
const targetUrl = new URL("/auth/nodedc/handoff/", taskBaseUrl);
targetUrl.searchParams.set("token", handoffToken);
targetUrl.searchParams.set(
"next_path",
`/auth/nodedc/workspace-invite/accept/${encodeURIComponent(request.id)}/`
);
res.redirect(targetUrl.toString());
});
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => { app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(scopeAdminSnapshot(req)); res.json(scopeAdminSnapshot(req));
}); });
@ -720,7 +569,6 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id); const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null; const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null;
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
if (!workspaceSlug) { if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
@ -737,7 +585,6 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
avatarUrl: resolveUserAvatarPublicUrl(user), avatarUrl: resolveUserAvatarPublicUrl(user),
role, role,
companyRole: membership?.role ?? null, companyRole: membership?.role ?? null,
managedBy: workspaceManagedBy,
setLastWorkspace: req.body?.setLastWorkspace !== false, setLastWorkspace: req.body?.setLastWorkspace !== false,
}, },
}); });
@ -748,7 +595,6 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
userId: user.id, userId: user.id,
workspaceSlug, workspaceSlug,
role, role,
managedBy: workspaceManagedBy,
taskManager, taskManager,
}, },
req.nodedcSession.user req.nodedcSession.user
@ -795,19 +641,17 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
email: user.email, email: user.email,
subject: user.authentikUserId ?? undefined, subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user), avatarUrl: resolveUserAvatarPublicUrl(user),
role: "admin", role: "admin",
companyRole: membership.role, companyRole: membership.role,
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug), setLastWorkspace: false,
setLastWorkspace: false, },
}, });
});
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership( const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
{ {
clientId: client.id, clientId: client.id,
userId: user.id, userId: user.id,
workspaceSlug, workspaceSlug,
role: "admin", role: "admin",
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
taskManager, taskManager,
}, },
req.nodedcSession.user req.nodedcSession.user
@ -863,7 +707,6 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug); const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug);
const projectId = normalizeOptionalText(req.body?.projectId); const projectId = normalizeOptionalText(req.body?.projectId);
const role = normalizeTaskManagerRole(req.body?.role); const role = normalizeTaskManagerRole(req.body?.role);
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
if (!workspaceSlug) { if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
@ -889,7 +732,6 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
subject: user.authentikUserId ?? undefined, subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user), avatarUrl: resolveUserAvatarPublicUrl(user),
role, role,
managedBy: workspaceManagedBy,
setLastWorkspace: false, setLastWorkspace: false,
}, },
}); });
@ -901,7 +743,6 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
workspaceSlug, workspaceSlug,
projectId, projectId,
role, role,
managedBy: workspaceManagedBy,
taskManager, taskManager,
}, },
req.nodedcSession.user req.nodedcSession.user
@ -1023,26 +864,6 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); 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) => { app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.params.userId)) { if (!assertAdminCanManageUser(req, res, req.params.userId)) {
return; return;
@ -1145,105 +966,6 @@ app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(asyn
res.json(scopeAdminMutationResult(req, result)); res.json(scopeAdminMutationResult(req, result));
})); }));
app.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
try {
const result = await controlPlaneStore.updateAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.access-request.updated");
res.json(scopeAdminMutationResult(req, result));
} catch (error) {
sendAccessRequestApiError(res, error);
}
}));
app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
try {
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);
}
}));
app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
try {
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.access-request.rejected");
res.json(scopeAdminMutationResult(req, result));
} catch (error) {
sendAccessRequestApiError(res, error);
}
}));
app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId);
if (!taskerInviteRequest) {
res.status(404).json({ error: "tasker_invite_request_not_found" });
return;
}
const platformInviteResult = await controlPlaneStore.ensureTaskerInvitePlatformInvite(
req.params.taskerInviteRequestId,
req.nodedcSession.user
);
const platformInviteLink = buildPlatformInviteUrl(platformInviteResult.invite);
const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/approve/", {
body: {
taskerInviteId: taskerInviteRequest.taskerInviteId,
requestId: taskerInviteRequest.id,
platformInviteLink,
},
});
const result = await controlPlaneStore.approveTaskerInviteRequest(
req.params.taskerInviteRequestId,
{
taskerInviteLink: taskerResult.invite?.taskerInviteLink ?? taskerResult.invite?.tasker_invite_link ?? taskerResult.invite?.inviteLink ?? null,
platformInviteId: platformInviteResult.invite.id,
platformInviteToken: platformInviteResult.invite.token,
comment: req.body?.comment,
},
req.nodedcSession.user
);
publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]);
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
}));
app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId);
if (!taskerInviteRequest) {
res.status(404).json({ error: "tasker_invite_request_not_found" });
return;
}
const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/reject/", {
body: {
taskerInviteId: taskerInviteRequest.taskerInviteId,
requestId: taskerInviteRequest.id,
comment: req.body?.comment,
},
});
const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]);
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
}));
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => { app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) { if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
return; return;
@ -1651,20 +1373,6 @@ function sendInviteApiError(res, error) {
res.status(status).json({ error: message }); res.status(status).json({ error: message });
} }
function sendAccessRequestApiError(res, error) {
const message = error instanceof Error ? error.message : "Заявка недоступна";
const status =
message.includes("Unknown access_request") || message.includes("не найден")
? 404
: message.includes("нельзя")
? 409
: message.includes("required") || message.includes("Введите")
? 400
: 400;
res.status(status).json({ error: message });
}
function sanitizeSelfProfilePatch(payload) { function sanitizeSelfProfilePatch(payload) {
return { return {
name: payload?.name, name: payload?.name,
@ -1924,72 +1632,6 @@ function resolveTaskManagerRoleForMembership(role) {
return role === "client_owner" || role === "client_admin" ? "admin" : "member"; return role === "client_owner" || role === "client_admin" ? "admin" : "member";
} }
function normalizeTaskManagerWorkspaceManagedBy(value) {
return value === "tasker" ? "tasker" : "launcher";
}
function getClientTaskManagerWorkspaces(client) {
const taskManager = client?.integrations?.taskManager;
const workspaces = Array.isArray(taskManager?.workspaces) ? taskManager.workspaces : [];
const legacySlug = normalizeOptionalText(taskManager?.workspaceSlug);
if (!legacySlug || workspaces.some((workspace) => normalizeOptionalText(workspace?.slug) === legacySlug)) {
return workspaces;
}
return [
...workspaces,
{
slug: legacySlug,
name: normalizeOptionalText(taskManager?.workspaceName),
isPrimary: true,
managedBy: "launcher",
},
];
}
function resolveTaskManagerWorkspaceBinding(client, workspaceSlug) {
const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug);
if (!normalizedWorkspaceSlug) return null;
return (
getClientTaskManagerWorkspaces(client).find((workspace) => normalizeOptionalText(workspace?.slug) === normalizedWorkspaceSlug) ?? null
);
}
function resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug) {
return normalizeTaskManagerWorkspaceManagedBy(resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy);
}
function resolveTaskManagerWorkspaceAssignments(data, user) {
if (!user?.id) return [];
const bySlug = new Map();
for (const membership of data.taskManagerMemberships ?? []) {
if (membership.userId !== user.id) continue;
const workspaceSlug = normalizeOptionalText(membership.workspaceSlug);
if (!workspaceSlug) continue;
const client = data.clients.find((candidate) => candidate.id === membership.clientId);
const managedBy = normalizeTaskManagerWorkspaceManagedBy(
membership.managedBy ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy
);
const current = bySlug.get(workspaceSlug);
if (current && current.managedBy === "launcher") continue;
bySlug.set(workspaceSlug, {
slug: workspaceSlug,
name: normalizeOptionalText(membership.workspaceName ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.name),
managedBy,
clientId: client?.id ?? membership.clientId ?? null,
clientName: client?.name ?? null,
role: normalizeTaskManagerRole(membership.role) ?? "member",
});
}
return [...bySlug.values()];
}
function createServiceHandoff(serviceSlug, user) { function createServiceHandoff(serviceSlug, user) {
pruneExpiredServiceHandoffs(); pruneExpiredServiceHandoffs();
@ -2037,27 +1679,15 @@ function pruneExpiredServiceHandoffs() {
} }
} }
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user) { function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user"; const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
const groupSet = new Set(groups); const groupSet = new Set(groups);
const isSuperAdmin = groupSet.has("nodedc:superadmin"); const isSuperAdmin = groupSet.has("nodedc:superadmin");
const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin"); const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin");
const workspaces = resolveTaskManagerWorkspaceAssignments(data, user);
const hasLauncherManagedWorkspace = workspaces.some((workspace) => workspace.managedBy === "launcher");
const isPublicPoolUser = data.memberships.some(
(membership) => membership.userId === user?.id && membership.clientId === publicPoolClientId && membership.status === "active"
);
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "tasker";
if (!hasTaskManagerAccess) { if (!hasTaskManagerAccess) {
return { return {
mode, mode,
managedBy: defaultManagedBy,
defaultManagedBy,
inviteApproval: "disabled",
defaultInviteApproval,
workspaces,
canCreateWorkspace: false, canCreateWorkspace: false,
reason: "Нет доступа к Operational Core.", reason: "Нет доступа к Operational Core.",
}; };
@ -2066,37 +1696,14 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
if (mode === "disabled") { if (mode === "disabled") {
return { return {
mode, mode,
managedBy: defaultManagedBy,
defaultManagedBy,
inviteApproval: "disabled",
defaultInviteApproval,
workspaces,
canCreateWorkspace: false, canCreateWorkspace: false,
reason: "Создание рабочих пространств отключено на уровне платформы.", reason: "Создание рабочих пространств отключено на уровне платформы.",
}; };
} }
if (hasLauncherManagedWorkspace && !isSuperAdmin) {
return {
mode,
managedBy: "launcher",
defaultManagedBy: "launcher",
inviteApproval: "launcher",
defaultInviteApproval: "launcher",
workspaces,
canCreateWorkspace: false,
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
};
}
if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) { if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) {
return { return {
mode, mode,
managedBy: defaultManagedBy,
defaultManagedBy,
inviteApproval: defaultInviteApproval,
defaultInviteApproval,
workspaces,
canCreateWorkspace: false, canCreateWorkspace: false,
reason: "Создание рабочих пространств доступно только администраторам Operational Core.", reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
}; };
@ -2104,11 +1711,6 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
return { return {
mode, mode,
managedBy: "tasker",
defaultManagedBy: "tasker",
inviteApproval: defaultInviteApproval,
defaultInviteApproval,
workspaces,
canCreateWorkspace: true, canCreateWorkspace: true,
reason: "Создание рабочих пространств разрешено платформенной policy.", reason: "Создание рабочих пространств разрешено платформенной policy.",
}; };
@ -2739,8 +2341,6 @@ function scopeControlPlaneData(data, scope) {
memberships, memberships,
groups: data.groups.filter((group) => clientIds.has(group.clientId)), groups: data.groups.filter((group) => clientIds.has(group.clientId)),
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)), invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
accessRequests: [],
taskerInviteRequests: [],
grants: data.grants.filter((grant) => { grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.has(grant.targetId); if (grant.targetType === "group") return groupIds.has(grant.targetId);
@ -2850,18 +2450,6 @@ function buildLoginRedirectUrl(returnTo, { forceLogin = false, includeReturnTo =
return loginUrl.toString(); return loginUrl.toString();
} }
function buildPlatformInviteUrl(invite) {
return new URL(`/invite/${encodeURIComponent(invite.token)}`, config.appBaseUrl).toString();
}
function resolveInviteRedirectUrl(invite) {
if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) {
return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`;
}
return "/";
}
function buildOidcLogoutUrl(discovery, returnTo = "/", idToken = null) { function buildOidcLogoutUrl(discovery, returnTo = "/", idToken = null) {
const issuerUrl = new URL(discovery.issuer || config.issuer); const issuerUrl = new URL(discovery.issuer || config.issuer);
const logoutUrl = new URL("/if/flow/default-invalidation-flow/", issuerUrl.origin); const logoutUrl = new URL("/if/flow/default-invalidation-flow/", issuerUrl.origin);

View File

@ -1,12 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Client } from "../entities/client/types"; import type { Client } from "../entities/client/types";
import type { Invite } from "../entities/invite/types"; import type { Invite } from "../entities/invite/types";
import { syncServiceLaunchLink } from "../entities/service/links"; import { syncServiceLaunchLink } from "../entities/service/links";
import type { LauncherServiceView, Service } from "../entities/service/types"; import type { LauncherServiceView, Service } from "../entities/service/types";
import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types"; import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types";
import { import {
approveAdminAccessRequest,
approveAdminTaskerInviteRequest,
createAdminClient, createAdminClient,
createAdminGroup, createAdminGroup,
createAdminInvite, createAdminInvite,
@ -17,20 +15,16 @@ import {
deleteAdminInvite, deleteAdminInvite,
deleteAdminMembership, deleteAdminMembership,
deleteAdminService, deleteAdminService,
deleteAdminUser,
ensureAdminTaskManagerProjectMembership, ensureAdminTaskManagerProjectMembership,
ensureAdminTaskManagerWorkspaceMembership, ensureAdminTaskManagerWorkspaceMembership,
fetchAdminTaskManagerWorkspaces, fetchAdminTaskManagerWorkspaces,
fetchControlPlaneSnapshot, fetchControlPlaneSnapshot,
reorderAdminServices, reorderAdminServices,
retryAdminSync, retryAdminSync,
rejectAdminAccessRequest,
rejectAdminTaskerInviteRequest,
removeAdminTaskManagerProjectMembership, removeAdminTaskManagerProjectMembership,
removeAdminTaskManagerWorkspaceMembership, removeAdminTaskManagerWorkspaceMembership,
setAdminUserServiceAccess, setAdminUserServiceAccess,
updateAdminClient, updateAdminClient,
updateAdminAccessRequest,
updateAdminGroup, updateAdminGroup,
updateAdminInvite, updateAdminInvite,
updateAdminMembership, updateAdminMembership,
@ -41,7 +35,6 @@ import {
type TaskManagerWorkspaceMemberRole, type TaskManagerWorkspaceMemberRole,
type TaskManagerWorkspaceSummary, type TaskManagerWorkspaceSummary,
} from "../shared/api/adminApi"; } from "../shared/api/adminApi";
import { createAccessRequest, type CreateAccessRequestResponse } from "../shared/api/accessRequestApi";
import { import {
buildLauncherServices, buildLauncherServices,
buildMe, buildMe,
@ -60,7 +53,6 @@ import {
} from "../shared/api/authApi"; } from "../shared/api/authApi";
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi"; import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi";
import type { AccessRequest, CreateAccessRequestCommand } from "../entities/access-request/types";
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { loadPersistedLauncherData } from "../shared/api/storageApi";
import { import {
@ -73,7 +65,7 @@ import {
import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel"; import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel";
import { ServiceRail } from "../widgets/service-rail/ServiceRail"; import { ServiceRail } from "../widgets/service-rail/ServiceRail";
import { ServiceStage } from "../widgets/service-stage/ServiceStage"; import { ServiceStage } from "../widgets/service-stage/ServiceStage";
import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar"; import { TopBar } from "../widgets/top-bar/TopBar";
let lastAuthRedirect: { url: string; startedAt: number } | null = null; let lastAuthRedirect: { url: string; startedAt: number } | null = null;
@ -88,13 +80,11 @@ type InviteFlowState =
export function LauncherApp() { export function LauncherApp() {
const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []); const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []);
const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []);
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData)); const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId); const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId); const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>(); const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
const [adminOpen, setAdminOpen] = useState(false); const [adminOpen, setAdminOpen] = useState(false);
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
const [authSession, setAuthSession] = useState<AuthSession | null>(null); const [authSession, setAuthSession] = useState<AuthSession | null>(null);
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null); const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
@ -105,24 +95,9 @@ export function LauncherApp() {
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false); const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null); const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null)); const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
const runtimeDataRef = useRef(data);
const runtimeProfileIdRef = useRef(activeProfileId);
const runtimeClientIdRef = useRef(activeClientId);
useEffect(() => {
runtimeDataRef.current = data;
runtimeProfileIdRef.current = activeProfileId;
runtimeClientIdRef.current = activeClientId;
}, [activeClientId, activeProfileId, data]);
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0]; const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
const currentAccessRequest = useMemo(() => {
if (!authSession?.authenticated || !authSession.user.email) return null;
const sessionEmail = authSession.user.email.toLowerCase();
return data.accessRequests.find((request) => request.email.toLowerCase() === sessionEmail && request.status !== "approved") ?? null;
}, [authSession, data.accessRequests]);
const runtimeMe = useMemo(() => { const runtimeMe = useMemo(() => {
if (!authSession?.authenticated) return me; if (!authSession?.authenticated) return me;
@ -243,10 +218,10 @@ export function LauncherApp() {
useEffect(() => { useEffect(() => {
if (!authSession || authSession.authenticated) return; if (!authSession || authSession.authenticated) return;
if (inviteToken || isAccessRequestRoute) return; if (inviteToken) return;
redirectToLogin(authSession.loginUrl); redirectToLogin(authSession.loginUrl);
}, [authSession, inviteToken, isAccessRequestRoute]); }, [authSession, inviteToken]);
useEffect(() => { useEffect(() => {
if (!inviteToken) return; if (!inviteToken) return;
@ -291,7 +266,6 @@ export function LauncherApp() {
if (!isMounted) return; if (!isMounted) return;
if (!session.authenticated) { if (!session.authenticated) {
if (inviteToken || isAccessRequestRoute) return;
redirectToLogin(session.loginUrl); redirectToLogin(session.loginUrl);
return; return;
} }
@ -299,7 +273,7 @@ export function LauncherApp() {
setAuthSession(session); setAuthSession(session);
}) })
.catch(() => { .catch(() => {
if (isMounted && !inviteToken && !isAccessRequestRoute) { if (isMounted) {
redirectToLogin("/auth/login"); redirectToLogin("/auth/login");
} }
}); });
@ -311,7 +285,7 @@ export function LauncherApp() {
isMounted = false; isMounted = false;
window.removeEventListener("pageshow", validateRestoredSession); window.removeEventListener("pageshow", validateRestoredSession);
}; };
}, [inviteToken, isAccessRequestRoute]); }, []);
useEffect(() => { useEffect(() => {
if (!authSession?.authenticated) return; if (!authSession?.authenticated) return;
@ -367,60 +341,49 @@ export function LauncherApp() {
void refreshTaskManagerWorkspaces(); void refreshTaskManagerWorkspaces();
}, [adminOpen, canOpenAdminApi]); }, [adminOpen, canOpenAdminApi]);
const refreshRuntimeState = useCallback(async () => {
try {
const nextSession = await fetchAuthSession();
setAuthSession(nextSession);
if (!nextSession.authenticated) {
setAuthApps([]);
return;
}
const currentData = runtimeDataRef.current;
const nextContext = resolveAuthenticatedContext(
currentData,
nextSession,
runtimeProfileIdRef.current,
runtimeClientIdRef.current
);
const nextMe = buildMe(currentData, nextContext.profileId, nextContext.clientId);
const [persistedData, apps] = await Promise.all([
nextSession.isSuperAdmin || nextMe.permissions.canOpenAdmin
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
: loadPersistedLauncherData(),
fetchAvailableApps(),
]);
if (persistedData) {
setData(syncLauncherServiceLinks(persistedData));
}
setAuthApps(apps);
} catch (error: unknown) {
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
}
}, []);
useEffect(() => { useEffect(() => {
if (!authSession?.authenticated) return; if (!authSession?.authenticated) return;
let isMounted = true; let isMounted = true;
const refreshMountedRuntimeState = async () => { const refreshRuntimeState = async () => {
await refreshRuntimeState(); try {
if (!isMounted) return; const nextSession = await fetchAuthSession();
if (!isMounted) return;
setAuthSession(nextSession);
if (!nextSession.authenticated) {
setAuthApps([]);
return;
}
const nextContext = resolveAuthenticatedContext(data, nextSession, activeProfileId, activeClientId);
const nextMe = buildMe(data, nextContext.profileId, nextContext.clientId);
const [persistedData, apps] = await Promise.all([
nextMe.permissions.canOpenAdmin
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
: loadPersistedLauncherData(),
fetchAvailableApps(),
]);
if (!isMounted) return;
if (persistedData) {
setData(syncLauncherServiceLinks(persistedData));
}
setAuthApps(apps);
} catch (error: unknown) {
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
}
}; };
const eventSource = new EventSource("/api/events"); const eventSource = new EventSource("/api/events");
eventSource.addEventListener("nodedc-ready", () => {
void refreshMountedRuntimeState();
});
eventSource.addEventListener("nodedc-runtime", () => { eventSource.addEventListener("nodedc-runtime", () => {
void refreshMountedRuntimeState(); void refreshRuntimeState();
}); });
eventSource.onerror = () => { eventSource.onerror = () => {
@ -431,25 +394,7 @@ export function LauncherApp() {
isMounted = false; isMounted = false;
eventSource.close(); eventSource.close();
}; };
}, [authSession?.authenticated, refreshRuntimeState]); }, [authSession?.authenticated]);
useEffect(() => {
if (!authSession?.authenticated) return;
const refreshVisibleRuntimeState = () => {
if (document.visibilityState === "visible") {
void refreshRuntimeState();
}
};
window.addEventListener("focus", refreshVisibleRuntimeState);
document.addEventListener("visibilitychange", refreshVisibleRuntimeState);
return () => {
window.removeEventListener("focus", refreshVisibleRuntimeState);
document.removeEventListener("visibilitychange", refreshVisibleRuntimeState);
};
}, [authSession?.authenticated, refreshRuntimeState]);
function handleProfileChange(userId: string) { function handleProfileChange(userId: string) {
const profile = profileOptions.find((option) => option.userId === userId); const profile = profileOptions.find((option) => option.userId === userId);
@ -616,10 +561,6 @@ export function LauncherApp() {
try { try {
const result = await acceptInvite(inviteToken); const result = await acceptInvite(inviteToken);
setData(syncLauncherServiceLinks(result.data)); setData(syncLauncherServiceLinks(result.data));
if (result.redirectUrl && result.redirectUrl !== "/") {
window.location.assign(result.redirectUrl);
return;
}
setInviteFlow({ status: "accepted", payload: inviteFlow.payload }); setInviteFlow({ status: "accepted", payload: inviteFlow.payload });
} catch (error) { } catch (error) {
setInviteFlow({ setInviteFlow({
@ -660,32 +601,6 @@ export function LauncherApp() {
applyControlPlaneMutation(deleteAdminInvite(inviteId)); applyControlPlaneMutation(deleteAdminInvite(inviteId));
} }
function handleUpdateAccessRequest(accessRequestId: string, patch: Parameters<typeof updateAdminAccessRequest>[1]) {
applyControlPlaneMutation(updateAdminAccessRequest(accessRequestId, patch));
}
function handleApproveAccessRequest(accessRequestId: string, patch: Parameters<typeof approveAdminAccessRequest>[1]) {
applyControlPlaneMutation(approveAdminAccessRequest(accessRequestId, patch));
}
function handleRejectAccessRequest(accessRequestId: string, patch: Parameters<typeof rejectAdminAccessRequest>[1]) {
applyControlPlaneMutation(rejectAdminAccessRequest(accessRequestId, patch));
}
function handleApproveTaskerInviteRequest(
taskerInviteRequestId: string,
patch: Parameters<typeof approveAdminTaskerInviteRequest>[1]
) {
applyControlPlaneMutation(approveAdminTaskerInviteRequest(taskerInviteRequestId, patch));
}
function handleRejectTaskerInviteRequest(
taskerInviteRequestId: string,
patch: Parameters<typeof rejectAdminTaskerInviteRequest>[1]
) {
applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch));
}
function handleRetrySync(syncId: string) { function handleRetrySync(syncId: string) {
applyControlPlaneMutation(retryAdminSync(syncId)); applyControlPlaneMutation(retryAdminSync(syncId));
} }
@ -733,10 +648,6 @@ export function LauncherApp() {
applyControlPlaneMutation(updateAdminUserProfile(userId, patch)); applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
} }
function handleDeleteUser(userId: string) {
applyControlPlaneMutation(deleteAdminUser(userId));
}
async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) { async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) {
const result = await updateOwnProfile(patch); const result = await updateOwnProfile(patch);
setData(syncLauncherServiceLinks(result.data)); setData(syncLauncherServiceLinks(result.data));
@ -795,20 +706,11 @@ export function LauncherApp() {
setSelectedServiceId((current) => (current === serviceId ? undefined : current)); setSelectedServiceId((current) => (current === serviceId ? undefined : current));
} }
if (isAccessRequestRoute) {
return (
<AccessRequestScreen
onSubmit={createAccessRequest}
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)}
/>
);
}
if (inviteToken) { if (inviteToken) {
return ( return (
<InviteFlowScreen <InviteFlowScreen
state={inviteFlow ?? { status: "loading" }} state={inviteFlow ?? { status: "loading" }}
authenticatedEmail={authSession?.authenticated ? authSession.user.email : null} isAuthenticated={Boolean(authSession?.authenticated)}
onAccept={() => void handleAcceptInvite()} onAccept={() => void handleAcceptInvite()}
onRegister={(command) => void handleRegisterInvite(command)} onRegister={(command) => void handleRegisterInvite(command)}
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)} onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)}
@ -836,10 +738,6 @@ export function LauncherApp() {
window.location.replace(authSession.logoutUrl); window.location.replace(authSession.logoutUrl);
}; };
if (currentAccessRequest) {
return <AccessRequestPendingScreen accessRequest={currentAccessRequest} onLogout={handleLogout} />;
}
return ( return (
<div className="launcher-app"> <div className="launcher-app">
<TopBar <TopBar
@ -849,18 +747,9 @@ export function LauncherApp() {
activeProfileId={activeProfileId} activeProfileId={activeProfileId}
activeClientId={resolvedClientId} activeClientId={resolvedClientId}
adminOpen={adminOpen} adminOpen={adminOpen}
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
onProfileChange={handleProfileChange} onProfileChange={handleProfileChange}
onClientChange={setActiveClientId} onClientChange={setActiveClientId}
onOpenAdmin={() => { onToggleAdmin={() => setAdminOpen((current) => !current)}
setAdminMode("admin");
setAdminOpen((current) => !(current && adminMode === "admin"));
}}
onOpenPlatform={() => {
if (runtimeMe.launcherRole !== "root_admin") return;
setAdminMode("platform");
setAdminOpen((current) => !(current && adminMode === "platform"));
}}
onOpenShowcase={() => setAdminOpen(false)} onOpenShowcase={() => setAdminOpen(false)}
onOpenProfileSettings={() => setProfileSettingsOpen(true)} onOpenProfileSettings={() => setProfileSettingsOpen(true)}
onLogout={handleLogout} onLogout={handleLogout}
@ -879,25 +768,18 @@ export function LauncherApp() {
<AdminOverlay <AdminOverlay
data={data} data={data}
me={runtimeMe} me={runtimeMe}
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
activeClientId={resolvedClientId} activeClientId={resolvedClientId}
onClose={() => setAdminOpen(false)} onClose={() => setAdminOpen(false)}
onSetUserServiceAccess={handleSetUserServiceAccess} onSetUserServiceAccess={handleSetUserServiceAccess}
onCreateInvite={handleCreateInvite} onCreateInvite={handleCreateInvite}
onUpdateInvite={handleUpdateInvite} onUpdateInvite={handleUpdateInvite}
onDeleteInvite={handleDeleteInvite} onDeleteInvite={handleDeleteInvite}
onUpdateAccessRequest={handleUpdateAccessRequest}
onApproveAccessRequest={handleApproveAccessRequest}
onRejectAccessRequest={handleRejectAccessRequest}
onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest}
onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest}
onRetrySync={handleRetrySync} onRetrySync={handleRetrySync}
onCreateClient={handleCreateClient} onCreateClient={handleCreateClient}
onUpdateClient={handleUpdateClient} onUpdateClient={handleUpdateClient}
onDeleteClient={handleDeleteClient} onDeleteClient={handleDeleteClient}
onCreateUser={handleCreateUser} onCreateUser={handleCreateUser}
onUpdateUser={handleUpdateUser} onUpdateUser={handleUpdateUser}
onDeleteUser={handleDeleteUser}
onUpdateMembership={handleUpdateMembership} onUpdateMembership={handleUpdateMembership}
onDeleteMembership={handleDeleteMembership} onDeleteMembership={handleDeleteMembership}
pendingAccessAssignments={pendingAccessAssignments} pendingAccessAssignments={pendingAccessAssignments}
@ -946,223 +828,6 @@ function accessAssignmentKey(userId: string, serviceId: string) {
return `${userId}:${serviceId}`; return `${userId}:${serviceId}`;
} }
function AccessRequestScreen({
onSubmit,
onLogin,
}: {
onSubmit: (command: CreateAccessRequestCommand) => Promise<CreateAccessRequestResponse>;
onLogin: () => void;
}) {
const [values, setValues] = useState<CreateAccessRequestCommand & { passwordConfirm: string }>({
email: "",
firstName: "",
lastName: "",
middleName: "",
phone: "",
company: "",
password: "",
passwordConfirm: "",
});
const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
const isSubmitted = status === "submitted";
const normalizedEmail = values.email.trim().toLowerCase();
const passwordMismatch = Boolean(values.passwordConfirm && values.password !== values.passwordConfirm);
const canSubmit = Boolean(
normalizedEmail.includes("@") &&
values.firstName.trim() &&
values.lastName.trim() &&
values.middleName.trim() &&
values.phone.trim() &&
values.company.trim() &&
values.password.length >= 8 &&
values.password === values.passwordConfirm &&
status !== "submitting"
);
const updateField = (field: keyof typeof values, value: string) => {
setValues((current) => ({ ...current, [field]: value }));
};
return (
<div className="launcher-app nodedc-auth-page">
<NodeDcAuthBrandHeader />
<main className="nodedc-auth-page__main">
<section className="nodedc-auth-card nodedc-access-request-card" aria-live="polite">
<div className="nodedc-auth-card__copy">
<h1>NODE.DC.</h1>
<p>{isSubmitted ? "Вы запросили доступ." : "Работайте во всех измерениях."}</p>
</div>
{!isSubmitted ? (
<p className="nodedc-auth-card__status">
Заполните обязательные поля и задайте пароль. После подтверждения вы войдёте в NODE.DC по этой почте и паролю.
</p>
) : null}
{message ? <p className="nodedc-auth-card__status">{message}</p> : null}
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
{isSubmitted ? (
<div className="nodedc-auth-card__form">
<button className="button button--primary" type="button" onClick={onLogin}>
Войти в NODE.DC
</button>
</div>
) : (
<form
className="nodedc-auth-card__form"
onSubmit={(event) => {
event.preventDefault();
if (!canSubmit) return;
setStatus("submitting");
setMessage(null);
onSubmit({
email: normalizedEmail,
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
middleName: values.middleName.trim(),
phone: values.phone.trim(),
company: values.company.trim(),
password: values.password,
})
.then(() => {
setStatus("submitted");
setMessage("Заявка отправлена администратору. После подтверждения войдите в NODE.DC по указанному паролю.");
})
.catch((error) => {
setStatus("error");
setMessage(error instanceof Error ? error.message : "Не удалось отправить заявку.");
});
}}
>
<label className="nodedc-auth-card__field">
<span>Эл. почта</span>
<input
value={values.email}
type="email"
placeholder="email@company.ru"
autoComplete="email"
onChange={(event) => updateField("email", event.target.value)}
/>
</label>
<div className="nodedc-auth-card__field-grid">
<label className="nodedc-auth-card__field">
<span>Фамилия</span>
<input
value={values.lastName}
placeholder="Иванов"
autoComplete="family-name"
onChange={(event) => updateField("lastName", event.target.value)}
/>
</label>
<label className="nodedc-auth-card__field">
<span>Имя</span>
<input
value={values.firstName}
placeholder="Иван"
autoComplete="given-name"
onChange={(event) => updateField("firstName", event.target.value)}
/>
</label>
</div>
<label className="nodedc-auth-card__field">
<span>Отчество</span>
<input value={values.middleName} placeholder="Иванович" onChange={(event) => updateField("middleName", event.target.value)} />
</label>
<label className="nodedc-auth-card__field">
<span>Телефон</span>
<input
value={values.phone}
type="tel"
placeholder="+7 999 000-00-00"
autoComplete="tel"
onChange={(event) => updateField("phone", event.target.value)}
/>
</label>
<label className="nodedc-auth-card__field">
<span>Компания</span>
<input
value={values.company}
placeholder="Название компании"
autoComplete="organization"
onChange={(event) => updateField("company", event.target.value)}
/>
</label>
<div className="nodedc-auth-card__field-grid">
<label className="nodedc-auth-card__field">
<span>Пароль</span>
<input
value={values.password}
type="password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
onChange={(event) => updateField("password", event.target.value)}
/>
</label>
<label className="nodedc-auth-card__field">
<span>Повторите пароль</span>
<input
value={values.passwordConfirm}
type="password"
placeholder="Ещё раз"
autoComplete="new-password"
onChange={(event) => updateField("passwordConfirm", event.target.value)}
/>
</label>
</div>
<button className="button button--primary" type="submit" disabled={!canSubmit}>
{status === "submitting" ? "Отправляем заявку" : "Запросить доступ"}
</button>
<button className="button button--secondary" type="button" onClick={onLogin}>
Уже есть аккаунт
</button>
</form>
)}
</section>
</main>
</div>
);
}
function AccessRequestPendingScreen({
accessRequest,
onLogout,
}: {
accessRequest: AccessRequest;
onLogout: () => void;
}) {
const isRejected = accessRequest.status === "rejected";
return (
<div className="launcher-app nodedc-auth-page">
<NodeDcAuthBrandHeader />
<main className="nodedc-auth-page__main">
<section className="nodedc-auth-card nodedc-access-request-card" aria-live="polite">
<div className="nodedc-auth-card__copy">
<h1>NODE.DC.</h1>
<p>{isRejected ? "Заявка отклонена." : "Заявка ожидает подтверждения."}</p>
</div>
<div className="nodedc-invite-card__details">
<span>Почта: {accessRequest.email}</span>
<span>Компания: {accessRequest.company}</span>
</div>
<p className="nodedc-auth-card__status">
{isRejected
? "Администратор отклонил заявку. Если это ошибка, отправьте новый запрос или свяжитесь с NODE.DC."
: "Администратор проверит данные. После подтверждения вы попадёте в Launcher без отдельной регистрации по инвайту."}
</p>
<div className="nodedc-auth-card__form">
<button className="button button--primary" type="button" onClick={onLogout}>
Вернуться ко входу
</button>
</div>
</section>
</main>
</div>
);
}
function resolveAuthenticatedContext( function resolveAuthenticatedContext(
data: LauncherData, data: LauncherData,
session: AuthenticatedSession, session: AuthenticatedSession,
@ -1213,7 +878,7 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli
function InviteFlowScreen({ function InviteFlowScreen({
state, state,
authenticatedEmail, isAuthenticated,
onAccept, onAccept,
onRegister, onRegister,
onLogin, onLogin,
@ -1221,7 +886,7 @@ function InviteFlowScreen({
onGoHome, onGoHome,
}: { }: {
state: InviteFlowState; state: InviteFlowState;
authenticatedEmail: string | null; isAuthenticated: boolean;
onAccept: () => void; onAccept: () => void;
onRegister: (command: RegisterInviteCommand) => void; onRegister: (command: RegisterInviteCommand) => void;
onLogin: () => void; onLogin: () => void;
@ -1234,33 +899,12 @@ function InviteFlowScreen({
const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState("");
const payload = "payload" in state ? state.payload : undefined; const payload = "payload" in state ? state.payload : undefined;
const inviteStatus = payload?.invite.status; const inviteStatus = payload?.invite.status;
const inviteEmail = payload?.account.email ?? payload?.invite.email ?? "";
const normalizedInviteEmail = inviteEmail.toLowerCase();
const existingAccount = Boolean(payload?.account.exists);
const isAuthenticated = Boolean(authenticatedEmail);
const isAuthenticatedAsInvitee = Boolean(
authenticatedEmail &&
normalizedInviteEmail &&
authenticatedEmail.toLowerCase() === normalizedInviteEmail
);
const isAuthenticatedAsDifferentUser = Boolean(
authenticatedEmail &&
normalizedInviteEmail &&
authenticatedEmail.toLowerCase() !== normalizedInviteEmail
);
const isAccepting = state.status === "accepting"; const isAccepting = state.status === "accepting";
const isRegistering = state.status === "registering"; const isRegistering = state.status === "registering";
const inviteTargetUrl = payload?.redirectUrl;
const canOpenInviteTarget = Boolean(
payload?.invite.source === "tasker_workspace_invite" &&
inviteTargetUrl &&
inviteTargetUrl !== "/" &&
(state.status === "accepted" || inviteStatus === "accepted")
);
const requiresAccountSwitch = state.status === "error" && state.message.includes("другую почту"); const requiresAccountSwitch = state.status === "error" && state.message.includes("другую почту");
const canAccept = Boolean( const canAccept = Boolean(
state.status === "ready" && state.status === "ready" &&
isAuthenticatedAsInvitee && isAuthenticated &&
inviteStatus !== "accepted" && inviteStatus !== "accepted" &&
inviteStatus !== "expired" && inviteStatus !== "expired" &&
inviteStatus !== "revoked" inviteStatus !== "revoked"
@ -1269,7 +913,6 @@ function InviteFlowScreen({
const canShowRegistrationForm = Boolean( const canShowRegistrationForm = Boolean(
payload && payload &&
!isAuthenticated && !isAuthenticated &&
!existingAccount &&
!isTerminalInvite && !isTerminalInvite &&
(state.status === "ready" || state.status === "registering" || state.status === "error") (state.status === "ready" || state.status === "registering" || state.status === "error")
); );
@ -1284,25 +927,12 @@ function InviteFlowScreen({
password === passwordConfirm password === passwordConfirm
); );
const details = payload const details = payload
? payload.invite.source === "tasker_workspace_invite" ? [
? [ `Рабочая область: ${payload.client.name}`,
`Контур: ${payload.client.name}`, `Роль: ${membershipRoleLabel(payload.invite.role)}`,
`Workspace: ${payload.invite.sourceWorkspaceName ?? payload.invite.sourceWorkspaceSlug ?? "Operational Core"}`, ]
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
]
: [
`Рабочая область: ${payload.client.name}`,
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
]
: ["Проверяем приглашение и платформенную сессию"]; : ["Проверяем приглашение и платформенную сессию"];
const statusMessage = resolveInviteStatusMessage(state, { const statusMessage = resolveInviteStatusMessage(state, isAuthenticated, inviteStatus);
existingAccount,
inviteEmail,
inviteStatus,
isAuthenticated,
isAuthenticatedAsInvitee,
isAuthenticatedAsDifferentUser,
});
return ( return (
<div className="launcher-app nodedc-auth-page"> <div className="launcher-app nodedc-auth-page">
@ -1374,11 +1004,7 @@ function InviteFlowScreen({
Уже есть аккаунт Уже есть аккаунт
</button> </button>
</form> </form>
) : existingAccount && !isAuthenticated && !isTerminalInvite ? ( ) : requiresAccountSwitch ? (
<button className="button button--primary" type="button" onClick={onLogin}>
Войти и принять приглашение
</button>
) : (existingAccount && isAuthenticatedAsDifferentUser && !isTerminalInvite) || requiresAccountSwitch ? (
<button className="button button--primary" type="button" onClick={onSwitchAccount}> <button className="button button--primary" type="button" onClick={onSwitchAccount}>
Сменить аккаунт Сменить аккаунт
</button> </button>
@ -1387,18 +1013,8 @@ function InviteFlowScreen({
Войти в NODE.DC Войти в NODE.DC
</button> </button>
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? ( ) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
<button <button className="button button--primary" type="button" onClick={onGoHome}>
className="button button--primary" Перейти в витрину
type="button"
onClick={() => {
if (canOpenInviteTarget && inviteTargetUrl) {
window.location.assign(inviteTargetUrl);
return;
}
onGoHome();
}}
>
{canOpenInviteTarget ? "Перейти в workspace" : "Перейти в витрину"}
</button> </button>
) : ( ) : (
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}> <button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
@ -1421,26 +1037,7 @@ function NodeDcAuthBrandHeader() {
); );
} }
function resolveInviteStatusMessage( function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boolean, inviteStatus?: Invite["status"]) {
state: InviteFlowState,
context: {
existingAccount: boolean;
inviteEmail: string;
inviteStatus?: Invite["status"];
isAuthenticated: boolean;
isAuthenticatedAsInvitee: boolean;
isAuthenticatedAsDifferentUser: boolean;
}
) {
const {
existingAccount,
inviteEmail,
inviteStatus,
isAuthenticated,
isAuthenticatedAsInvitee,
isAuthenticatedAsDifferentUser,
} = context;
if (state.status === "loading") return "Проверяем приглашение."; if (state.status === "loading") return "Проверяем приглашение.";
if (state.status === "accepting") return "Подключаем доступ к рабочей области."; if (state.status === "accepting") return "Подключаем доступ к рабочей области.";
if (state.status === "registering") return "Создаём аккаунт и подключаем доступ."; if (state.status === "registering") return "Создаём аккаунт и подключаем доступ.";
@ -1448,9 +1045,6 @@ function resolveInviteStatusMessage(
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён."; if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
if (inviteStatus === "expired") return "Срок действия приглашения истёк."; if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
if (inviteStatus === "revoked") return "Приглашение отозвано."; if (inviteStatus === "revoked") return "Приглашение отозвано.";
if (existingAccount && !isAuthenticated) return `Аккаунт ${inviteEmail} уже есть в NODE.DC. Войдите под этой почтой, чтобы принять приглашение.`;
if (existingAccount && isAuthenticatedAsDifferentUser) return `Сейчас открыт другой аккаунт. Смените пользователя и войдите под ${inviteEmail}.`;
if (existingAccount && isAuthenticatedAsInvitee) return "Аккаунт найден. Подтвердите подключение к workspace.";
if (!isAuthenticated) return "Введите почту, имя и пароль для регистрации по приглашению."; if (!isAuthenticated) return "Введите почту, имя и пароль для регистрации по приглашению.";
return null; return null;
} }
@ -1507,10 +1101,6 @@ function parseInviteToken(pathname: string) {
return match?.[1] ? decodeURIComponent(match[1]) : null; return match?.[1] ? decodeURIComponent(match[1]) : null;
} }
function isAccessRequestPath(pathname: string) {
return /^\/(?:request-access|access-request)\/?$/.test(pathname);
}
function membershipRoleLabel(role: ClientMembership["role"]) { function membershipRoleLabel(role: ClientMembership["role"]) {
return { return {
client_owner: "Владелец клиента", client_owner: "Владелец клиента",

View File

@ -1,32 +0,0 @@
import type { ClientMembershipRole } from "../user/types";
export type AccessRequestStatus = "new" | "approved" | "rejected";
export interface AccessRequest {
id: string;
email: string;
firstName: string;
lastName: string;
middleName: string;
phone: string;
company: string;
status: AccessRequestStatus;
targetClientId: string;
role: ClientMembershipRole;
approvedInviteId?: string | null;
reviewedByUserId?: string | null;
reviewedAt?: string | null;
comment?: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateAccessRequestCommand {
email: string;
firstName: string;
lastName: string;
middleName: string;
phone: string;
company: string;
password: string;
}

View File

@ -1,12 +1,10 @@
export type ClientType = "company" | "person"; export type ClientType = "company" | "person";
export type ClientStatus = "active" | "suspended" | "demo" | "expired"; export type ClientStatus = "active" | "suspended" | "demo" | "expired";
export type TaskManagerWorkspaceManagedBy = "launcher" | "tasker";
export interface ClientTaskManagerWorkspaceBinding { export interface ClientTaskManagerWorkspaceBinding {
slug: string; slug: string;
name?: string | null; name?: string | null;
isPrimary?: boolean; isPrimary?: boolean;
managedBy?: TaskManagerWorkspaceManagedBy;
} }
export interface Client { export interface Client {

View File

@ -8,11 +8,6 @@ export interface Invite {
email: string; email: string;
role: ClientMembershipRole; role: ClientMembershipRole;
invitedByUserId: string; invitedByUserId: string;
source?: "launcher" | "access_request" | "tasker_workspace_invite";
sourceTaskerInviteRequestId?: string | null;
sourceTaskerInviteId?: string | null;
sourceWorkspaceSlug?: string | null;
sourceWorkspaceName?: string | null;
token: string; token: string;
expiresAt: string; expiresAt: string;
status: InviteStatus; status: InviteStatus;

View File

@ -1,27 +0,0 @@
import type { Client } from "../client/types";
export const PUBLIC_POOL_CLIENT_ID = "client_public_pool";
export const PUBLIC_POOL_CONTEXT_LABEL = "Открытый контур";
export const PUBLIC_POOL_CONTEXT_DESCRIPTION = "Public access pool";
export const PUBLIC_POOL_CLIENT: Client = {
id: PUBLIC_POOL_CLIENT_ID,
type: "person",
name: PUBLIC_POOL_CONTEXT_LABEL,
legalName: PUBLIC_POOL_CONTEXT_DESCRIPTION,
status: "active",
contractStartsAt: null,
contractEndsAt: null,
paidUntil: null,
demoEndsAt: null,
contactName: "NODE.DC",
contactEmail: null,
avatarUrl: null,
notes: "Системный контур для публичных заявок, публичных инвайтов и self-service пользователей.",
createdAt: "2026-05-09T00:00:00.000Z",
updatedAt: "2026-05-09T00:00:00.000Z",
};
export function isPublicPoolClientId(clientId: string | null | undefined): boolean {
return clientId === PUBLIC_POOL_CLIENT_ID;
}

View File

@ -1,25 +0,0 @@
export type TaskerInviteRequestStatus = "new" | "approved" | "rejected" | "cancelled";
export type TaskerInviteRequestRole = "guest" | "member" | "admin";
export interface TaskerInviteRequest {
id: string;
taskerInviteId: string;
workspaceId?: string | null;
workspaceSlug: string;
workspaceName: string;
inviteeEmail: string;
role: TaskerInviteRequestRole;
inviterUserId?: string | null;
inviterPlaneUserId?: string | null;
inviterEmail: string;
inviterName: string;
status: TaskerInviteRequestStatus;
taskerInviteLink?: string | null;
platformInviteId?: string | null;
platformInviteToken?: string | null;
reviewedByUserId?: string | null;
reviewedAt?: string | null;
comment?: string | null;
createdAt: string;
updatedAt: string;
}

View File

@ -30,10 +30,6 @@ export interface ClientMembership {
userId: string; userId: string;
role: ClientMembershipRole; role: ClientMembershipRole;
status: ClientMembershipStatus; status: ClientMembershipStatus;
invitedByUserId?: string | null;
inviteId?: string | null;
source?: "launcher" | "access_request" | "tasker_workspace_invite" | null;
sourceTaskerInviteRequestId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@ -1,40 +0,0 @@
import type { AccessRequest, CreateAccessRequestCommand } from "../../entities/access-request/types";
export interface CreateAccessRequestResponse {
accessRequest: AccessRequest;
}
export async function createAccessRequest(command: CreateAccessRequestCommand): Promise<CreateAccessRequestResponse> {
return requestJson<CreateAccessRequestResponse>("/api/access-requests", {
method: "POST",
body: JSON.stringify(command),
});
}
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(url, {
...init,
headers,
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return (await response.json()) as T;
}
async function readErrorMessage(response: Response) {
try {
const payload = (await response.json()) as { error?: string };
return payload.error ?? response.statusText;
} catch {
return response.statusText;
}
}

View File

@ -1,10 +1,8 @@
import type { AccessRequest } from "../../entities/access-request/types";
import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types"; import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types";
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types"; import type { Client } from "../../entities/client/types";
import type { Invite } from "../../entities/invite/types"; import type { Invite } from "../../entities/invite/types";
import type { Service } from "../../entities/service/types"; import type { Service } from "../../entities/service/types";
import type { SyncStatus } from "../../entities/sync/types"; import type { SyncStatus } from "../../entities/sync/types";
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types"; import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
import type { LauncherData, LauncherSettings } from "./mockApi"; import type { LauncherData, LauncherSettings } from "./mockApi";
@ -33,38 +31,10 @@ export interface ControlPlaneMutationResult {
} | null; } | null;
} }
export interface AccessRequestMutationResult extends ControlPlaneMutationResult {
accessRequest: AccessRequest;
}
export interface AccessRequestApproveResult extends AccessRequestMutationResult {
invite?: Invite | null;
membership?: ClientMembership | null;
user?: LauncherUser | null;
}
export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationResult {
taskerInviteRequest: TaskerInviteRequest;
tasker?: {
ok: boolean;
invite?: {
id: string;
email: string;
status: string;
inviteLink?: string | null;
invite_link?: string | null;
taskerInviteLink?: string | null;
tasker_invite_link?: string | null;
platformInviteLink?: string | null;
};
};
}
export interface TaskManagerWorkspaceSummary { export interface TaskManagerWorkspaceSummary {
id: string; id: string;
slug: string; slug: string;
name: string; name: string;
managedBy?: TaskManagerWorkspaceManagedBy;
ownerEmail: string | null; ownerEmail: string | null;
memberCount: number; memberCount: number;
projects?: TaskManagerProjectSummary[]; projects?: TaskManagerProjectSummary[];
@ -181,10 +151,6 @@ export async function updateAdminUserProfile(userId: string, patch: Partial<Laun
}); });
} }
export async function deleteAdminUser(userId: string): Promise<ControlPlaneMutationResult> {
return requestJson<ControlPlaneMutationResult>(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" });
}
export async function createAdminUser(payload: { export async function createAdminUser(payload: {
clientId: string; clientId: string;
email: string; email: string;
@ -337,62 +303,6 @@ export async function deleteAdminInvite(inviteId: string): Promise<ControlPlaneM
return requestJson<ControlPlaneMutationResult>(`/api/admin/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE" }); return requestJson<ControlPlaneMutationResult>(`/api/admin/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE" });
} }
export async function updateAdminAccessRequest(
accessRequestId: string,
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
): Promise<AccessRequestMutationResult> {
return requestJson<AccessRequestMutationResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
export async function approveAdminAccessRequest(
accessRequestId: string,
payload: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">> = {}
): Promise<AccessRequestApproveResult> {
return requestJson<AccessRequestApproveResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}/approve`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function rejectAdminAccessRequest(
accessRequestId: string,
payload: Partial<Pick<AccessRequest, "comment">> = {}
): Promise<AccessRequestMutationResult> {
return requestJson<AccessRequestMutationResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}/reject`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function approveAdminTaskerInviteRequest(
taskerInviteRequestId: string,
payload: Partial<Pick<TaskerInviteRequest, "comment">> = {}
): Promise<TaskerInviteRequestMutationResult> {
return requestJson<TaskerInviteRequestMutationResult>(
`/api/admin/tasker-invite-requests/${encodeURIComponent(taskerInviteRequestId)}/approve`,
{
method: "POST",
body: JSON.stringify(payload),
}
);
}
export async function rejectAdminTaskerInviteRequest(
taskerInviteRequestId: string,
payload: Partial<Pick<TaskerInviteRequest, "comment">> = {}
): Promise<TaskerInviteRequestMutationResult> {
return requestJson<TaskerInviteRequestMutationResult>(
`/api/admin/tasker-invite-requests/${encodeURIComponent(taskerInviteRequestId)}/reject`,
{
method: "POST",
body: JSON.stringify(payload),
}
);
}
export async function setAdminUserServiceAccess(payload: { export async function setAdminUserServiceAccess(payload: {
userId: string; userId: string;
serviceId: string; serviceId: string;

View File

@ -4,13 +4,8 @@ import type { Invite } from "../../entities/invite/types";
import type { LauncherData } from "./mockApi"; import type { LauncherData } from "./mockApi";
export interface PublicInviteResponse { export interface PublicInviteResponse {
invite: Pick<Invite, "id" | "email" | "role" | "expiresAt" | "status" | "source" | "sourceWorkspaceName" | "sourceWorkspaceSlug">; invite: Pick<Invite, "id" | "role" | "expiresAt" | "status">;
client: Pick<Client, "id" | "name" | "status">; client: Pick<Client, "id" | "name" | "status">;
redirectUrl?: string;
account: {
exists: boolean;
email: string;
};
} }
export interface AcceptInviteResponse { export interface AcceptInviteResponse {
@ -19,7 +14,6 @@ export interface AcceptInviteResponse {
user: LauncherUser; user: LauncherUser;
membership: ClientMembership; membership: ClientMembership;
data: LauncherData; data: LauncherData;
redirectUrl?: string;
} }
export interface RegisterInviteCommand { export interface RegisterInviteCommand {

View File

@ -1,13 +1,10 @@
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess"; import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
import type { AccessRequest } from "../../entities/access-request/types";
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types"; import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types"; import type { Client } from "../../entities/client/types";
import type { Invite } from "../../entities/invite/types"; import type { Invite } from "../../entities/invite/types";
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
import { getServiceLaunchLink } from "../../entities/service/links"; import { getServiceLaunchLink } from "../../entities/service/links";
import type { LauncherServiceView, Service } from "../../entities/service/types"; import type { LauncherServiceView, Service } from "../../entities/service/types";
import type { SyncStatus } from "../../entities/sync/types"; import type { SyncStatus } from "../../entities/sync/types";
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
import type { import type {
ClientGroup, ClientGroup,
ClientMembership, ClientMembership,
@ -18,8 +15,6 @@ import type {
import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions"; import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions";
import { import {
mockAuditEvents, mockAuditEvents,
mockAccessRequests,
mockTaskerInviteRequests,
mockClients, mockClients,
mockExceptions, mockExceptions,
mockGrants, mockGrants,
@ -63,9 +58,6 @@ export interface LauncherData {
grants: ServiceGrant[]; grants: ServiceGrant[];
exceptions: ServiceAccessException[]; exceptions: ServiceAccessException[];
invites: Invite[]; invites: Invite[];
accessRequests: AccessRequest[];
revokedAccounts: RevokedAccount[];
taskerInviteRequests: TaskerInviteRequest[];
syncStatuses: SyncStatus[]; syncStatuses: SyncStatus[];
auditEvents: typeof mockAuditEvents; auditEvents: typeof mockAuditEvents;
taskManagerMemberships: TaskManagerMembershipAssignment[]; taskManagerMemberships: TaskManagerMembershipAssignment[];
@ -73,21 +65,6 @@ export interface LauncherData {
settings: LauncherSettings; settings: LauncherSettings;
} }
export interface RevokedAccount {
id: string;
email: string;
name?: string | null;
sourceUserId?: string | null;
authentikUserId?: string | null;
reason: string;
revokedByUserId?: string | null;
revokedByUserEmail?: string | null;
revokedByUserName?: string | null;
revokedAt: string;
createdAt: string;
updatedAt: string;
}
export interface TaskManagerMembershipAssignment { export interface TaskManagerMembershipAssignment {
id: string; id: string;
clientId: string; clientId: string;
@ -95,7 +72,6 @@ export interface TaskManagerMembershipAssignment {
workspaceSlug: string; workspaceSlug: string;
workspaceName?: string | null; workspaceName?: string | null;
role: "guest" | "member" | "admin"; role: "guest" | "member" | "admin";
managedBy?: TaskManagerWorkspaceManagedBy;
planeUserId?: string | null; planeUserId?: string | null;
planeRole?: number | null; planeRole?: number | null;
updatedAt: string; updatedAt: string;
@ -111,7 +87,6 @@ export interface TaskManagerProjectMembershipAssignment {
projectIdentifier?: string | null; projectIdentifier?: string | null;
projectName?: string | null; projectName?: string | null;
role: "guest" | "member" | "admin"; role: "guest" | "member" | "admin";
managedBy?: TaskManagerWorkspaceManagedBy;
planeUserId?: string | null; planeUserId?: string | null;
planeRole?: number | null; planeRole?: number | null;
updatedAt: string; updatedAt: string;
@ -167,9 +142,6 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({
grants: mockGrants, grants: mockGrants,
exceptions: mockExceptions, exceptions: mockExceptions,
invites: mockInvites, invites: mockInvites,
accessRequests: mockAccessRequests,
revokedAccounts: [],
taskerInviteRequests: mockTaskerInviteRequests,
syncStatuses: mockSyncStatuses, syncStatuses: mockSyncStatuses,
auditEvents: mockAuditEvents, auditEvents: mockAuditEvents,
settings: defaultLauncherSettings, settings: defaultLauncherSettings,
@ -215,9 +187,6 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
grants: Array.isArray(payload.grants) ? payload.grants : mockGrants, grants: Array.isArray(payload.grants) ? payload.grants : mockGrants,
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions, exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites, invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses, syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents, auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [], taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
@ -383,10 +352,6 @@ export function buildAccessMatrix(data: LauncherData, clientId: string, includeA
} }
export function getClient(data: LauncherData, clientId: string): Client { export function getClient(data: LauncherData, clientId: string): Client {
if (isPublicPoolClientId(clientId)) {
return PUBLIC_POOL_CLIENT;
}
const client = data.clients.find((item) => item.id === clientId); const client = data.clients.find((item) => item.id === clientId);
if (!client) throw new Error(`Unknown client: ${clientId}`); if (!client) throw new Error(`Unknown client: ${clientId}`);
return client; return client;

View File

@ -1,6 +1,4 @@
import type { AuditEvent } from "../../entities/audit/types"; import type { AuditEvent } from "../../entities/audit/types";
import type { AccessRequest } from "../../entities/access-request/types";
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
import type { Client } from "../../entities/client/types"; import type { Client } from "../../entities/client/types";
import type { Invite } from "../../entities/invite/types"; import type { Invite } from "../../entities/invite/types";
import type { Service } from "../../entities/service/types"; import type { Service } from "../../entities/service/types";
@ -213,9 +211,6 @@ export const mockExceptions: ServiceAccessException[] = [];
export const mockInvites: Invite[] = []; export const mockInvites: Invite[] = [];
export const mockAccessRequests: AccessRequest[] = [];
export const mockTaskerInviteRequests: TaskerInviteRequest[] = [];
export const mockSyncStatuses: SyncStatus[] = [ export const mockSyncStatuses: SyncStatus[] = [
sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"), sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"),
sync("sync_dc_touch_authentik", "user_root", "dcctouch@gmail.com", "user", "authentik", "synced"), sync("sync_dc_touch_authentik", "user_root", "dcctouch@gmail.com", "user", "authentik", "synced"),

View File

@ -174,10 +174,6 @@ code {
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
} }
.nodedc-access-request-card {
width: min(100%, 36rem);
}
.nodedc-auth-card__copy { .nodedc-auth-card__copy {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@ -228,12 +224,6 @@ code {
gap: 1.05rem; gap: 1.05rem;
} }
.nodedc-auth-card__field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.05rem;
}
.nodedc-auth-card__field { .nodedc-auth-card__field {
display: grid; display: grid;
gap: 0.42rem; gap: 0.42rem;
@ -1690,92 +1680,24 @@ code {
border: 0; border: 0;
border-radius: var(--launcher-radius-circle); border-radius: var(--launcher-radius-circle);
outline: none; outline: none;
background: rgba(255, 255, 255, 0.04); background: rgba(64, 64, 64, 0.48);
padding: var(--admin-control-inset) calc(var(--admin-control-inset) + 1.9rem) var(--admin-control-inset) padding: var(--admin-control-inset) calc(var(--admin-control-inset) + 1.9rem) var(--admin-control-inset)
var(--admin-control-inset); var(--admin-control-inset);
color: rgba(255, 255, 255, 0.66); color: var(--text-primary);
font: inherit; font: inherit;
text-align: left; text-align: left;
opacity: 0.66;
box-shadow: none; box-shadow: none;
cursor: pointer; cursor: pointer;
transition:
background 160ms ease,
color 160ms ease,
opacity 160ms ease;
}
.admin-panel-context-switcher {
display: grid;
gap: 0.48rem;
}
.admin-panel-context-group {
display: grid;
gap: 0.28rem;
}
.admin-panel-context-group__label {
padding-inline: 0.35rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 850;
letter-spacing: 0.12em;
text-transform: uppercase;
} }
.admin-panel-client-select:hover, .admin-panel-client-select:hover,
.admin-panel-client-select:focus, .admin-panel-client-select:focus,
.admin-panel-client-select:focus-visible, .admin-panel-client-select:focus-visible,
.admin-panel-client-select[aria-expanded="true"], .admin-panel-client-select[aria-expanded="true"] {
.admin-panel-client-select--active {
border: 0; border: 0;
outline: none; outline: none;
box-shadow: none; box-shadow: none;
background: rgba(74, 74, 74, 0.5); background: rgba(74, 74, 74, 0.5);
color: var(--text-primary);
opacity: 1;
}
.admin-panel-client-select--company {
display: grid;
grid-template-columns: minmax(0, 1fr) calc(var(--admin-control-ring) + 0.22rem);
gap: 0;
padding: 0;
}
.admin-panel-client-select__main {
display: flex;
min-width: 0;
min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2));
align-items: center;
gap: 0.65rem;
border: 0;
background: transparent;
color: inherit;
font: inherit;
padding: var(--admin-control-inset) 0 var(--admin-control-inset) var(--admin-control-inset);
text-align: left;
cursor: pointer;
}
.admin-panel-client-select__toggle {
display: grid;
width: calc(var(--admin-control-ring) + 0.22rem);
min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2));
place-items: center;
border: 0;
border-radius: var(--launcher-radius-circle);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.admin-panel-client-select__toggle:hover,
.admin-panel-client-select__toggle:focus-visible {
background: rgba(255, 255, 255, 0.07);
color: var(--text-primary);
outline: none;
} }
.admin-panel-client-select__icon, .admin-panel-client-select__icon,
@ -1803,33 +1725,17 @@ code {
white-space: nowrap; white-space: nowrap;
} }
.admin-panel-client-select__body {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.admin-panel-client-select__description {
min-width: 0;
overflow: hidden;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-client-select__chevron { .admin-panel-client-select__chevron {
position: relative; position: absolute;
top: auto; top: 50%;
right: auto; right: var(--admin-control-inset);
display: block; display: grid;
width: 0.44rem; width: 1.85rem;
height: 0.44rem; height: 1.85rem;
border-right: 1.6px solid currentColor; place-items: center;
border-bottom: 1.6px solid currentColor; color: var(--text-muted);
transform: translateY(-0.12rem) rotate(45deg); transform: translateY(-50%);
pointer-events: auto; pointer-events: none;
} }
.admin-panel-client-select select { .admin-panel-client-select select {
@ -2205,24 +2111,6 @@ code {
padding: 1rem; padding: 1rem;
} }
.client-profile-card {
display: grid;
gap: 1rem;
}
.client-profile-card__head {
align-items: flex-start;
}
.client-profile-card .service-content-modal__grid {
overflow: visible;
padding-right: 0;
}
.client-profile-card .service-content-modal__foot {
margin-top: 0.15rem;
}
.activity-list { .activity-list {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
@ -2283,7 +2171,7 @@ code {
} }
.admin-data-table--users { .admin-data-table--users {
min-width: 78rem; min-width: 66rem;
table-layout: fixed; table-layout: fixed;
} }
@ -2313,7 +2201,7 @@ code {
.admin-data-table--users th:nth-child(3), .admin-data-table--users th:nth-child(3),
.admin-data-table--users td:nth-child(3) { .admin-data-table--users td:nth-child(3) {
width: 13.5rem; width: 12rem;
} }
.admin-data-table--users th:nth-child(4), .admin-data-table--users th:nth-child(4),
@ -2323,146 +2211,14 @@ code {
.admin-data-table--users th:nth-child(5), .admin-data-table--users th:nth-child(5),
.admin-data-table--users td:nth-child(5) { .admin-data-table--users td:nth-child(5) {
width: 15rem; width: 18rem;
} }
.admin-data-table--users th:nth-child(6), .admin-data-table--users th:nth-child(6),
.admin-data-table--users td:nth-child(6) { .admin-data-table--users td:nth-child(6) {
width: 14rem;
}
.admin-data-table--users th:nth-child(7),
.admin-data-table--users td:nth-child(7) {
width: 10.2rem; width: 10.2rem;
} }
.table-shell--platform-users {
margin-top: 0;
}
.admin-data-table--platform-users {
min-width: 82rem;
table-layout: fixed;
}
.admin-data-table--platform-users th,
.admin-data-table--platform-users td {
white-space: nowrap;
}
.admin-data-table--platform-users th:nth-child(1),
.admin-data-table--platform-users td:nth-child(1) {
width: 17rem;
}
.admin-data-table--platform-users th:nth-child(2),
.admin-data-table--platform-users td:nth-child(2) {
width: 18rem;
}
.admin-data-table--platform-users th:nth-child(3),
.admin-data-table--platform-users td:nth-child(3) {
width: 16rem;
}
.admin-data-table--platform-users th:nth-child(4),
.admin-data-table--platform-users td:nth-child(4) {
width: 10rem;
}
.admin-data-table--platform-users th:nth-child(5),
.admin-data-table--platform-users td:nth-child(5) {
width: 9rem;
}
.admin-data-table--platform-users th:nth-child(6),
.admin-data-table--platform-users td:nth-child(6) {
width: 3rem;
}
.platform-user-origin {
display: grid;
gap: 0.18rem;
min-width: 0;
}
.platform-user-origin strong,
.platform-user-origin small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-user-origin strong {
color: var(--text-primary);
font-size: 0.82rem;
font-weight: 820;
}
.platform-user-origin small {
color: var(--text-muted);
font-size: 0.72rem;
}
.admin-data-table--public-access-users {
min-width: 86rem;
}
.admin-data-table--public-access-users th:nth-child(1),
.admin-data-table--public-access-users td:nth-child(1) {
width: 17rem;
min-width: 17rem;
}
.admin-data-table--public-access-users th:nth-child(2),
.admin-data-table--public-access-users td:nth-child(2) {
width: 14rem;
}
.admin-data-table--public-access-users th:nth-child(3),
.admin-data-table--public-access-users td:nth-child(3),
.admin-data-table--public-access-users th:nth-child(4),
.admin-data-table--public-access-users td:nth-child(4),
.admin-data-table--public-access-users th:nth-child(5),
.admin-data-table--public-access-users td:nth-child(5) {
width: 12rem;
}
.admin-data-table--public-access-users th:nth-child(6),
.admin-data-table--public-access-users td:nth-child(6) {
width: 14rem;
}
.admin-data-table--public-access-users .access-cell {
max-width: 12.5rem;
min-height: 2.75rem;
}
.membership-inviter-cell {
display: grid;
gap: 0.18rem;
min-width: 0;
max-width: 13.5rem;
}
.membership-inviter-cell span,
.membership-inviter-cell small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.membership-inviter-cell span {
color: var(--text-primary);
font-weight: 760;
}
.membership-inviter-cell small {
color: var(--text-muted);
font-size: 0.71rem;
}
.admin-static-pill { .admin-static-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2612,20 +2368,6 @@ code {
font-size: 0.72rem; font-size: 0.72rem;
} }
.admin-table-text {
display: block;
min-width: 0;
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-table-text--strong {
font-size: 0.86rem;
font-weight: 780;
}
.admin-table-input--select { .admin-table-input--select {
appearance: none; appearance: none;
background: rgba(255, 255, 255, 0.045); background: rgba(255, 255, 255, 0.045);
@ -3531,14 +3273,6 @@ code {
overflow: hidden; overflow: hidden;
} }
.access-layout--single {
grid-template-columns: minmax(0, 1fr);
}
.access-tabs-card {
grid-column: 1 / -1;
}
.matrix-scroll { .matrix-scroll {
overflow: auto; overflow: auto;
border-radius: calc(var(--launcher-radius-card) - 0.85rem); border-radius: calc(var(--launcher-radius-card) - 0.85rem);
@ -3604,12 +3338,6 @@ code {
font-size: 0.72rem; font-size: 0.72rem;
} }
.access-user-cell__inviter {
color: var(--accent-lime);
font-size: 0.68rem;
letter-spacing: 0.01em;
}
.access-main-stack { .access-main-stack {
display: grid; display: grid;
width: 10.8rem; width: 10.8rem;
@ -3888,41 +3616,6 @@ code {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.admin-tabs-card {
display: grid;
gap: 0.65rem;
padding: 0.75rem;
}
.admin-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.admin-tab-button {
min-height: 2.4rem;
border: 0;
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.055);
color: var(--text-secondary);
padding: 0 0.95rem;
font-size: 0.78rem;
font-weight: 820;
cursor: pointer;
}
.admin-tab-button:hover,
.admin-tab-button:focus-visible {
background: rgba(255, 255, 255, 0.09);
color: var(--text-primary);
}
.admin-tab-button--active {
background: rgba(247, 248, 244, 0.96);
color: rgb(var(--nodedc-on-accent-rgb));
}
.invite-form { .invite-form {
display: grid; display: grid;
align-content: start; align-content: start;
@ -4054,160 +3747,6 @@ code {
white-space: nowrap; white-space: nowrap;
} }
.admin-data-table--access-requests {
width: max-content;
table-layout: auto;
}
.access-request-table-scroll {
max-width: 100%;
overflow-x: auto;
overflow-y: visible;
margin: 0 -0.25rem;
padding: 0 0.25rem 0.35rem;
}
.admin-data-table--access-requests th,
.admin-data-table--access-requests td {
width: 1%;
padding-inline: 0.78rem;
vertical-align: middle;
white-space: nowrap;
}
.admin-data-table--access-requests th:nth-child(7),
.admin-data-table--access-requests td:nth-child(7) {
min-width: 4.5rem;
}
.admin-data-table--access-requests th:nth-child(8),
.admin-data-table--access-requests td:nth-child(8) {
min-width: 4.75rem;
padding-right: 0.35rem;
}
.admin-data-table--access-requests .admin-table-select-wrap {
width: max-content;
}
.admin-data-table--access-requests .admin-table-select-trigger {
width: auto;
min-width: 0;
max-width: 13rem;
padding-inline: 0.82rem 0.68rem;
}
.admin-data-table--access-requests td:nth-child(4) .admin-table-select-trigger {
min-width: 11rem;
}
.admin-data-table--access-requests td:nth-child(5) .admin-table-select-trigger {
min-width: 8.4rem;
max-width: 9.5rem;
}
.access-request-applicant {
display: grid;
gap: 0.18rem;
width: max-content;
min-width: 8.5rem;
max-width: 16rem;
}
.access-request-applicant strong,
.access-request-applicant small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.access-request-applicant small {
color: var(--text-muted);
}
.access-request-contact {
display: grid;
gap: 0.18rem;
width: max-content;
min-width: 10.5rem;
max-width: 18rem;
}
.access-request-contact span,
.access-request-contact small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.access-request-contact small {
color: var(--text-muted);
}
.admin-data-table--access-requests .invite-link-cell {
width: min(24rem, 42vw);
}
.access-request-decision-cluster {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.45rem;
padding: 0.24rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.055);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 10px 24px rgba(0, 0, 0, 0.12);
}
.access-request-decision-button {
display: grid;
width: 1.95rem;
min-width: 1.95rem;
height: 1.95rem;
place-items: center;
border: 0;
border-radius: 999px;
padding: 0;
transition:
transform 160ms ease,
background 160ms ease,
color 160ms ease,
opacity 160ms ease;
}
.access-request-decision-button:hover:not(:disabled) {
transform: translateY(-1px);
}
.access-request-decision-button:disabled {
cursor: progress;
opacity: 0.55;
}
.access-request-decision-button--accept {
background: rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.94);
}
.access-request-decision-button--accept:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.access-request-decision-button--decline {
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.58);
}
.access-request-decision-button--decline:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.13);
color: rgba(255, 255, 255, 0.82);
}
.admin-helper-note { .admin-helper-note {
max-width: 38rem; max-width: 38rem;
margin: 0.22rem 0 0; margin: 0.22rem 0 0;
@ -4629,10 +4168,6 @@ code {
padding: 0; padding: 0;
} }
.nodedc-auth-card__field-grid {
grid-template-columns: 1fr;
}
.nodedc-expanded-toolbar-shell { .nodedc-expanded-toolbar-shell {
padding: 1rem 1rem 0.75rem; padding: 1rem 1rem 0.75rem;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,9 @@
import { Inbox } from "lucide-react"; import { Inbox } from "lucide-react";
import type { Client } from "../../entities/client/types"; import type { Client } from "../../entities/client/types";
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi"; import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
import { initials } from "../../shared/lib/format"; import { initials } from "../../shared/lib/format";
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui"; import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
export type LauncherAdminMode = "admin" | "platform";
export function TopBar({ export function TopBar({
me, me,
clients, clients,
@ -14,11 +11,9 @@ export function TopBar({
activeProfileId, activeProfileId,
activeClientId, activeClientId,
adminOpen, adminOpen,
adminMode,
onProfileChange, onProfileChange,
onClientChange, onClientChange,
onOpenAdmin, onToggleAdmin,
onOpenPlatform,
onOpenShowcase, onOpenShowcase,
onOpenProfileSettings, onOpenProfileSettings,
onLogout, onLogout,
@ -30,29 +25,22 @@ export function TopBar({
activeProfileId: string; activeProfileId: string;
activeClientId: string; activeClientId: string;
adminOpen: boolean; adminOpen: boolean;
adminMode: LauncherAdminMode;
onProfileChange: (userId: string) => void; onProfileChange: (userId: string) => void;
onClientChange: (clientId: string) => void; onClientChange: (clientId: string) => void;
onOpenAdmin: () => void; onToggleAdmin: () => void;
onOpenPlatform: () => void;
onOpenShowcase: () => void; onOpenShowcase: () => void;
onOpenProfileSettings: () => void; onOpenProfileSettings: () => void;
onLogout?: () => void; onLogout?: () => void;
brandLinkUrl?: string; brandLinkUrl?: string;
}) { }) {
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId)); const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
const clientsWithPublicPool = [ const availableClients = clients.filter((client) => availableClientIds.has(client.id));
...clients,
availableClientIds.has(PUBLIC_POOL_CLIENT.id) && !clients.some((client) => isPublicPoolClientId(client.id)) ? PUBLIC_POOL_CLIENT : null,
].filter((client): client is Client => Boolean(client));
const availableClients = clientsWithPublicPool.filter((client) => availableClientIds.has(client.id));
const activeClient = availableClients.find((client) => client.id === activeClientId); const activeClient = availableClients.find((client) => client.id === activeClientId);
const clientOptions = availableClients.map((client) => ({ const clientOptions = availableClients.map((client) => ({
value: client.id, value: client.id,
label: client.name, label: client.name,
description: client.legalName ?? undefined, description: client.legalName ?? undefined,
})); }));
const canOpenPlatform = me.launcherRole === "root_admin";
return ( return (
<header className="nodedc-expanded-toolbar-shell"> <header className="nodedc-expanded-toolbar-shell">
@ -93,26 +81,10 @@ export function TopBar({
</button> </button>
{me.permissions.canOpenAdmin ? ( {me.permissions.canOpenAdmin ? (
<button <button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onToggleAdmin}>
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "admin"}
onClick={onOpenAdmin}
>
<span>Администрирование</span> <span>Администрирование</span>
</button> </button>
) : null} ) : null}
{canOpenPlatform ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "platform"}
onClick={onOpenPlatform}
>
<span>Платформа</span>
</button>
) : null}
</nav> </nav>
</div> </div>