Compare commits
6 Commits
0a3243c9e8
...
2b34cf9f1b
| Author | SHA1 | Date |
|---|---|---|
|
|
2b34cf9f1b | |
|
|
8be33c53da | |
|
|
fd1cc0b25a | |
|
|
a579e71b9b | |
|
|
01e0988031 | |
|
|
fd1d5baef3 |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
|
|
@ -7,6 +7,11 @@ 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(/\/$/, "");
|
||||||
|
|
@ -62,6 +67,31 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteUser({ data, userId }) {
|
||||||
|
ensureConfigured();
|
||||||
|
|
||||||
|
const user = findById(data.users, userId, "user");
|
||||||
|
const existingUser = await findUserByIdOrEmail(user.authentikUserId, user.email);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
return {
|
||||||
|
deleted: false,
|
||||||
|
email: user.email,
|
||||||
|
authentikUserId: user.authentikUserId ?? null,
|
||||||
|
authentikPk: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestJson(`/api/v3/core/users/${encodeURIComponent(existingUser.pk)}/`, { method: "DELETE" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: true,
|
||||||
|
email: user.email,
|
||||||
|
authentikUserId: user.authentikUserId ?? null,
|
||||||
|
authentikPk: existingUser.pk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function findUserByIdOrEmail(authentikUserId, email) {
|
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)}`);
|
||||||
|
|
@ -150,6 +180,7 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
deleteUser,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
provisionUser,
|
provisionUser,
|
||||||
};
|
};
|
||||||
|
|
@ -172,7 +203,7 @@ export function resolveRequiredGroups(data, user) {
|
||||||
return [...groupNames];
|
return [...groupNames];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const client of data.clients) {
|
for (const client of getUserRuntimeClients(data, user.id)) {
|
||||||
const membership = getRuntimeMembership(data, user.id, client.id);
|
const membership = getRuntimeMembership(data, user.id, client.id);
|
||||||
|
|
||||||
if (membership.status !== "active") {
|
if (membership.status !== "active") {
|
||||||
|
|
@ -203,6 +234,19 @@ 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
|
|
@ -20,6 +20,7 @@ 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,
|
||||||
|
|
@ -67,6 +68,42 @@ 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();
|
||||||
|
|
||||||
|
|
@ -374,7 +411,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) : null;
|
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user) : null;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -395,6 +432,84 @@ 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);
|
||||||
|
|
@ -478,13 +593,14 @@ 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("/", { forceLogin: true, includeReturnTo: true }),
|
loginUrl: buildLoginRedirectUrl(redirectUrl, { forceLogin: true, includeReturnTo: true }),
|
||||||
redirectUrl: "/",
|
redirectUrl,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
@ -502,9 +618,44 @@ 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 });
|
res.json({ ...result, data: syncResult.data, redirectUrl: resolveInviteRedirectUrl(result.invite) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
|
|
@ -569,6 +720,7 @@ 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" });
|
||||||
|
|
@ -585,6 +737,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -595,6 +748,7 @@ 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
|
||||||
|
|
@ -643,6 +797,7 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
|
||||||
avatarUrl: resolveUserAvatarPublicUrl(user),
|
avatarUrl: resolveUserAvatarPublicUrl(user),
|
||||||
role: "admin",
|
role: "admin",
|
||||||
companyRole: membership.role,
|
companyRole: membership.role,
|
||||||
|
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
|
||||||
setLastWorkspace: false,
|
setLastWorkspace: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -652,6 +807,7 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
|
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
|
||||||
taskManager,
|
taskManager,
|
||||||
},
|
},
|
||||||
req.nodedcSession.user
|
req.nodedcSession.user
|
||||||
|
|
@ -707,6 +863,7 @@ 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" });
|
||||||
|
|
@ -732,6 +889,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -743,6 +901,7 @@ 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
|
||||||
|
|
@ -864,6 +1023,26 @@ 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;
|
||||||
|
|
@ -966,6 +1145,105 @@ 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;
|
||||||
|
|
@ -1373,6 +1651,20 @@ 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,
|
||||||
|
|
@ -1632,6 +1924,72 @@ 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();
|
||||||
|
|
||||||
|
|
@ -1679,15 +2037,27 @@ function pruneExpiredServiceHandoffs() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
|
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user) {
|
||||||
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.",
|
||||||
};
|
};
|
||||||
|
|
@ -1696,14 +2066,37 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
|
||||||
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.",
|
||||||
};
|
};
|
||||||
|
|
@ -1711,6 +2104,11 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
|
managedBy: "tasker",
|
||||||
|
defaultManagedBy: "tasker",
|
||||||
|
inviteApproval: defaultInviteApproval,
|
||||||
|
defaultInviteApproval,
|
||||||
|
workspaces,
|
||||||
canCreateWorkspace: true,
|
canCreateWorkspace: true,
|
||||||
reason: "Создание рабочих пространств разрешено платформенной policy.",
|
reason: "Создание рабочих пространств разрешено платформенной policy.",
|
||||||
};
|
};
|
||||||
|
|
@ -2341,6 +2739,8 @@ 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);
|
||||||
|
|
@ -2450,6 +2850,18 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, 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,
|
||||||
|
|
@ -15,16 +17,20 @@ 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,
|
||||||
|
|
@ -35,6 +41,7 @@ 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,
|
||||||
|
|
@ -53,6 +60,7 @@ 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 {
|
||||||
|
|
@ -65,7 +73,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 } from "../widgets/top-bar/TopBar";
|
import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar";
|
||||||
|
|
||||||
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
||||||
|
|
||||||
|
|
@ -80,11 +88,13 @@ 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);
|
||||||
|
|
@ -95,9 +105,24 @@ 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;
|
||||||
|
|
||||||
|
|
@ -218,10 +243,10 @@ export function LauncherApp() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession || authSession.authenticated) return;
|
if (!authSession || authSession.authenticated) return;
|
||||||
if (inviteToken) return;
|
if (inviteToken || isAccessRequestRoute) return;
|
||||||
|
|
||||||
redirectToLogin(authSession.loginUrl);
|
redirectToLogin(authSession.loginUrl);
|
||||||
}, [authSession, inviteToken]);
|
}, [authSession, inviteToken, isAccessRequestRoute]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inviteToken) return;
|
if (!inviteToken) return;
|
||||||
|
|
@ -266,6 +291,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +299,7 @@ export function LauncherApp() {
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (isMounted) {
|
if (isMounted && !inviteToken && !isAccessRequestRoute) {
|
||||||
redirectToLogin("/auth/login");
|
redirectToLogin("/auth/login");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -285,7 +311,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;
|
||||||
|
|
@ -341,17 +367,10 @@ export function LauncherApp() {
|
||||||
void refreshTaskManagerWorkspaces();
|
void refreshTaskManagerWorkspaces();
|
||||||
}, [adminOpen, canOpenAdminApi]);
|
}, [adminOpen, canOpenAdminApi]);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshRuntimeState = useCallback(async () => {
|
||||||
if (!authSession?.authenticated) return;
|
|
||||||
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const refreshRuntimeState = async () => {
|
|
||||||
try {
|
try {
|
||||||
const nextSession = await fetchAuthSession();
|
const nextSession = await fetchAuthSession();
|
||||||
|
|
||||||
if (!isMounted) return;
|
|
||||||
|
|
||||||
setAuthSession(nextSession);
|
setAuthSession(nextSession);
|
||||||
|
|
||||||
if (!nextSession.authenticated) {
|
if (!nextSession.authenticated) {
|
||||||
|
|
@ -359,17 +378,21 @@ export function LauncherApp() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextContext = resolveAuthenticatedContext(data, nextSession, activeProfileId, activeClientId);
|
const currentData = runtimeDataRef.current;
|
||||||
const nextMe = buildMe(data, nextContext.profileId, nextContext.clientId);
|
const nextContext = resolveAuthenticatedContext(
|
||||||
|
currentData,
|
||||||
|
nextSession,
|
||||||
|
runtimeProfileIdRef.current,
|
||||||
|
runtimeClientIdRef.current
|
||||||
|
);
|
||||||
|
const nextMe = buildMe(currentData, nextContext.profileId, nextContext.clientId);
|
||||||
const [persistedData, apps] = await Promise.all([
|
const [persistedData, apps] = await Promise.all([
|
||||||
nextMe.permissions.canOpenAdmin
|
nextSession.isSuperAdmin || nextMe.permissions.canOpenAdmin
|
||||||
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
||||||
: loadPersistedLauncherData(),
|
: loadPersistedLauncherData(),
|
||||||
fetchAvailableApps(),
|
fetchAvailableApps(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!isMounted) return;
|
|
||||||
|
|
||||||
if (persistedData) {
|
if (persistedData) {
|
||||||
setData(syncLauncherServiceLinks(persistedData));
|
setData(syncLauncherServiceLinks(persistedData));
|
||||||
}
|
}
|
||||||
|
|
@ -378,12 +401,26 @@ export function LauncherApp() {
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authSession?.authenticated) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const refreshMountedRuntimeState = async () => {
|
||||||
|
await refreshRuntimeState();
|
||||||
|
if (!isMounted) return;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 refreshRuntimeState();
|
void refreshMountedRuntimeState();
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
|
|
@ -394,7 +431,25 @@ export function LauncherApp() {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [authSession?.authenticated]);
|
}, [authSession?.authenticated, refreshRuntimeState]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
@ -561,6 +616,10 @@ 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({
|
||||||
|
|
@ -601,6 +660,32 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
@ -648,6 +733,10 @@ 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));
|
||||||
|
|
@ -706,11 +795,20 @@ 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" }}
|
||||||
isAuthenticated={Boolean(authSession?.authenticated)}
|
authenticatedEmail={authSession?.authenticated ? authSession.user.email : null}
|
||||||
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)}
|
||||||
|
|
@ -738,6 +836,10 @@ 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
|
||||||
|
|
@ -747,9 +849,18 @@ 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}
|
||||||
onToggleAdmin={() => setAdminOpen((current) => !current)}
|
onOpenAdmin={() => {
|
||||||
|
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}
|
||||||
|
|
@ -768,18 +879,25 @@ 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}
|
||||||
|
|
@ -828,6 +946,223 @@ 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,
|
||||||
|
|
@ -878,7 +1213,7 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli
|
||||||
|
|
||||||
function InviteFlowScreen({
|
function InviteFlowScreen({
|
||||||
state,
|
state,
|
||||||
isAuthenticated,
|
authenticatedEmail,
|
||||||
onAccept,
|
onAccept,
|
||||||
onRegister,
|
onRegister,
|
||||||
onLogin,
|
onLogin,
|
||||||
|
|
@ -886,7 +1221,7 @@ function InviteFlowScreen({
|
||||||
onGoHome,
|
onGoHome,
|
||||||
}: {
|
}: {
|
||||||
state: InviteFlowState;
|
state: InviteFlowState;
|
||||||
isAuthenticated: boolean;
|
authenticatedEmail: string | null;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
onRegister: (command: RegisterInviteCommand) => void;
|
onRegister: (command: RegisterInviteCommand) => void;
|
||||||
onLogin: () => void;
|
onLogin: () => void;
|
||||||
|
|
@ -899,12 +1234,33 @@ 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" &&
|
||||||
isAuthenticated &&
|
isAuthenticatedAsInvitee &&
|
||||||
inviteStatus !== "accepted" &&
|
inviteStatus !== "accepted" &&
|
||||||
inviteStatus !== "expired" &&
|
inviteStatus !== "expired" &&
|
||||||
inviteStatus !== "revoked"
|
inviteStatus !== "revoked"
|
||||||
|
|
@ -913,6 +1269,7 @@ 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")
|
||||||
);
|
);
|
||||||
|
|
@ -927,12 +1284,25 @@ function InviteFlowScreen({
|
||||||
password === passwordConfirm
|
password === passwordConfirm
|
||||||
);
|
);
|
||||||
const details = payload
|
const details = payload
|
||||||
|
? payload.invite.source === "tasker_workspace_invite"
|
||||||
? [
|
? [
|
||||||
|
`Контур: ${payload.client.name}`,
|
||||||
|
`Workspace: ${payload.invite.sourceWorkspaceName ?? payload.invite.sourceWorkspaceSlug ?? "Operational Core"}`,
|
||||||
|
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
||||||
|
]
|
||||||
|
: [
|
||||||
`Рабочая область: ${payload.client.name}`,
|
`Рабочая область: ${payload.client.name}`,
|
||||||
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
||||||
]
|
]
|
||||||
: ["Проверяем приглашение и платформенную сессию"];
|
: ["Проверяем приглашение и платформенную сессию"];
|
||||||
const statusMessage = resolveInviteStatusMessage(state, isAuthenticated, inviteStatus);
|
const statusMessage = resolveInviteStatusMessage(state, {
|
||||||
|
existingAccount,
|
||||||
|
inviteEmail,
|
||||||
|
inviteStatus,
|
||||||
|
isAuthenticated,
|
||||||
|
isAuthenticatedAsInvitee,
|
||||||
|
isAuthenticatedAsDifferentUser,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="launcher-app nodedc-auth-page">
|
<div className="launcher-app nodedc-auth-page">
|
||||||
|
|
@ -1004,7 +1374,11 @@ function InviteFlowScreen({
|
||||||
Уже есть аккаунт
|
Уже есть аккаунт
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : requiresAccountSwitch ? (
|
) : existingAccount && !isAuthenticated && !isTerminalInvite ? (
|
||||||
|
<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>
|
||||||
|
|
@ -1013,8 +1387,18 @@ function InviteFlowScreen({
|
||||||
Войти в NODE.DC
|
Войти в NODE.DC
|
||||||
</button>
|
</button>
|
||||||
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
|
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
|
||||||
<button className="button button--primary" type="button" onClick={onGoHome}>
|
<button
|
||||||
Перейти в витрину
|
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}>
|
||||||
|
|
@ -1037,7 +1421,26 @@ function NodeDcAuthBrandHeader() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boolean, inviteStatus?: Invite["status"]) {
|
function resolveInviteStatusMessage(
|
||||||
|
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 "Создаём аккаунт и подключаем доступ.";
|
||||||
|
|
@ -1045,6 +1448,9 @@ function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boo
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -1101,6 +1507,10 @@ 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: "Владелец клиента",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
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 } from "../../entities/client/types";
|
import type { Client, TaskManagerWorkspaceManagedBy } 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";
|
||||||
|
|
||||||
|
|
@ -31,10 +33,38 @@ 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[];
|
||||||
|
|
@ -151,6 +181,10 @@ 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;
|
||||||
|
|
@ -303,6 +337,62 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,13 @@ 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" | "role" | "expiresAt" | "status">;
|
invite: Pick<Invite, "id" | "email" | "role" | "expiresAt" | "status" | "source" | "sourceWorkspaceName" | "sourceWorkspaceSlug">;
|
||||||
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 {
|
||||||
|
|
@ -14,6 +19,7 @@ export interface AcceptInviteResponse {
|
||||||
user: LauncherUser;
|
user: LauncherUser;
|
||||||
membership: ClientMembership;
|
membership: ClientMembership;
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
|
redirectUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterInviteCommand {
|
export interface RegisterInviteCommand {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
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 } from "../../entities/client/types";
|
import type { Client, TaskManagerWorkspaceManagedBy } 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,
|
||||||
|
|
@ -15,6 +18,8 @@ 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,
|
||||||
|
|
@ -58,6 +63,9 @@ 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[];
|
||||||
|
|
@ -65,6 +73,21 @@ 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;
|
||||||
|
|
@ -72,6 +95,7 @@ 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;
|
||||||
|
|
@ -87,6 +111,7 @@ 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;
|
||||||
|
|
@ -142,6 +167,9 @@ 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,
|
||||||
|
|
@ -187,6 +215,9 @@ 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 : [],
|
||||||
|
|
@ -352,6 +383,10 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
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";
|
||||||
|
|
@ -211,6 +213,9 @@ 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"),
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,10 @@ 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;
|
||||||
|
|
@ -224,6 +228,12 @@ 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;
|
||||||
|
|
@ -1680,24 +1690,92 @@ code {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--launcher-radius-circle);
|
border-radius: var(--launcher-radius-circle);
|
||||||
outline: none;
|
outline: none;
|
||||||
background: rgba(64, 64, 64, 0.48);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
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: var(--text-primary);
|
color: rgba(255, 255, 255, 0.66);
|
||||||
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,
|
||||||
|
|
@ -1725,17 +1803,33 @@ code {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-panel-client-select__chevron {
|
.admin-panel-client-select__body {
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: var(--admin-control-inset);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 1.85rem;
|
min-width: 0;
|
||||||
height: 1.85rem;
|
gap: 0.12rem;
|
||||||
place-items: center;
|
}
|
||||||
|
|
||||||
|
.admin-panel-client-select__description {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
transform: translateY(-50%);
|
font-size: 0.72rem;
|
||||||
pointer-events: none;
|
font-weight: 750;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-client-select__chevron {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
display: block;
|
||||||
|
width: 0.44rem;
|
||||||
|
height: 0.44rem;
|
||||||
|
border-right: 1.6px solid currentColor;
|
||||||
|
border-bottom: 1.6px solid currentColor;
|
||||||
|
transform: translateY(-0.12rem) rotate(45deg);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-panel-client-select select {
|
.admin-panel-client-select select {
|
||||||
|
|
@ -2111,6 +2205,24 @@ 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;
|
||||||
|
|
@ -2171,7 +2283,7 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-data-table--users {
|
.admin-data-table--users {
|
||||||
min-width: 66rem;
|
min-width: 78rem;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2201,7 +2313,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: 12rem;
|
width: 13.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-data-table--users th:nth-child(4),
|
.admin-data-table--users th:nth-child(4),
|
||||||
|
|
@ -2211,14 +2323,146 @@ 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: 18rem;
|
width: 15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
|
@ -2368,6 +2612,20 @@ 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);
|
||||||
|
|
@ -3273,6 +3531,14 @@ 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);
|
||||||
|
|
@ -3338,6 +3604,12 @@ 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;
|
||||||
|
|
@ -3616,6 +3888,41 @@ 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;
|
||||||
|
|
@ -3747,6 +4054,160 @@ 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;
|
||||||
|
|
@ -4168,6 +4629,10 @@ 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
|
|
@ -1,9 +1,12 @@
|
||||||
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,
|
||||||
|
|
@ -11,9 +14,11 @@ export function TopBar({
|
||||||
activeProfileId,
|
activeProfileId,
|
||||||
activeClientId,
|
activeClientId,
|
||||||
adminOpen,
|
adminOpen,
|
||||||
|
adminMode,
|
||||||
onProfileChange,
|
onProfileChange,
|
||||||
onClientChange,
|
onClientChange,
|
||||||
onToggleAdmin,
|
onOpenAdmin,
|
||||||
|
onOpenPlatform,
|
||||||
onOpenShowcase,
|
onOpenShowcase,
|
||||||
onOpenProfileSettings,
|
onOpenProfileSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
|
@ -25,22 +30,29 @@ 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;
|
||||||
onToggleAdmin: () => void;
|
onOpenAdmin: () => 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 availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
const clientsWithPublicPool = [
|
||||||
|
...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">
|
||||||
|
|
@ -81,10 +93,26 @@ export function TopBar({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{me.permissions.canOpenAdmin ? (
|
{me.permissions.canOpenAdmin ? (
|
||||||
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onToggleAdmin}>
|
<button
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue