feat(launcher): add platform admin and public access flows
This commit is contained in:
parent
a579e71b9b
commit
fd1cc0b25a
|
|
@ -588,6 +588,27 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
const now = isoNow();
|
const now = isoNow();
|
||||||
const requestPayload = sanitizeAccessRequestPayload(payload);
|
const requestPayload = sanitizeAccessRequestPayload(payload);
|
||||||
const email = requestPayload.email.toLowerCase();
|
const email = requestPayload.email.toLowerCase();
|
||||||
|
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
|
||||||
|
|
||||||
|
if (existingUser?.globalStatus === "active") {
|
||||||
|
const hasOnlyPendingAccessRequest =
|
||||||
|
data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") &&
|
||||||
|
data.memberships.some(
|
||||||
|
(membership) =>
|
||||||
|
membership.userId === existingUser.id &&
|
||||||
|
membership.clientId === publicPoolClientId &&
|
||||||
|
membership.source === "access_request" &&
|
||||||
|
membership.status === "disabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasOnlyPendingAccessRequest) {
|
||||||
|
throw new Error("Аккаунт с этой почтой уже существует. Войдите в NODE.DC или обратитесь к администратору.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = upsertAccessRequestUser(data, requestPayload, now);
|
||||||
|
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
|
||||||
|
markPendingSync(data, user, "user", user.email);
|
||||||
const existingRequest = data.accessRequests.find(
|
const existingRequest = data.accessRequests.find(
|
||||||
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
|
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
|
||||||
);
|
);
|
||||||
|
|
@ -608,7 +629,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeData(data);
|
await writeData(data);
|
||||||
return { accessRequest: existingRequest, data };
|
return { accessRequest: existingRequest, user, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessRequest = {
|
const accessRequest = {
|
||||||
|
|
@ -637,7 +658,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeData(data);
|
await writeData(data);
|
||||||
return { accessRequest, data };
|
return { accessRequest, user, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateAccessRequest(accessRequestId, payload, identity) {
|
async function updateAccessRequest(accessRequestId, payload, identity) {
|
||||||
|
|
@ -685,33 +706,35 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
||||||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||||||
|
|
||||||
if (accessRequest.status === "approved" && accessRequest.approvedInviteId) {
|
const user = upsertAccessRequestUser(data, accessRequest, now);
|
||||||
const existingInvite = findById(data.invites, accessRequest.approvedInviteId, "invite");
|
const client = findClientById(data, accessRequest.targetClientId);
|
||||||
return { accessRequest, invite: existingInvite, data };
|
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
|
||||||
|
status: "active",
|
||||||
|
clientId: client.id,
|
||||||
|
invitedByUserId: actor.id,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (client.id !== publicPoolClientId) {
|
||||||
|
data.memberships = data.memberships.filter(
|
||||||
|
(candidate) =>
|
||||||
|
!(
|
||||||
|
candidate.userId === user.id &&
|
||||||
|
candidate.clientId === publicPoolClientId &&
|
||||||
|
candidate.source === "access_request" &&
|
||||||
|
candidate.status === "disabled"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = findClientById(data, accessRequest.targetClientId);
|
if (accessRequest.status === "approved") {
|
||||||
const invite = {
|
markPendingSync(data, user, "user", user.email);
|
||||||
id: uniqueId(data.invites, "invite", accessRequest.email),
|
await writeData(data);
|
||||||
clientId: client.id,
|
return { accessRequest, user, membership, invite: null, data };
|
||||||
email: accessRequest.email,
|
}
|
||||||
role: accessRequest.role,
|
|
||||||
invitedByUserId: actor.id,
|
|
||||||
source: "access_request",
|
|
||||||
sourceTaskerInviteRequestId: null,
|
|
||||||
sourceTaskerInviteId: null,
|
|
||||||
sourceWorkspaceSlug: null,
|
|
||||||
sourceWorkspaceName: null,
|
|
||||||
token: randomUUID(),
|
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
status: "created",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
data.invites.push(invite);
|
|
||||||
accessRequest.status = "approved";
|
accessRequest.status = "approved";
|
||||||
accessRequest.approvedInviteId = invite.id;
|
accessRequest.approvedInviteId = null;
|
||||||
accessRequest.reviewedByUserId = actor.id;
|
accessRequest.reviewedByUserId = actor.id;
|
||||||
accessRequest.reviewedAt = now;
|
accessRequest.reviewedAt = now;
|
||||||
accessRequest.updatedAt = now;
|
accessRequest.updatedAt = now;
|
||||||
|
|
@ -722,12 +745,12 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
objectName: accessRequest.email,
|
objectName: accessRequest.email,
|
||||||
clientId: client.id === publicPoolClientId ? null : client.id,
|
clientId: client.id === publicPoolClientId ? null : client.id,
|
||||||
result: "success",
|
result: "success",
|
||||||
details: `Invite: ${invite.id}; target: ${client.name}; role: ${invite.role}`,
|
details: `Account activated; target: ${client.name}; role: ${membership.role}`,
|
||||||
});
|
});
|
||||||
markPendingSync(data, invite, "invite", invite.email);
|
markPendingSync(data, user, "user", user.email);
|
||||||
|
|
||||||
await writeData(data);
|
await writeData(data);
|
||||||
return { accessRequest, invite, data };
|
return { accessRequest, user, membership, invite: null, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rejectAccessRequest(accessRequestId, payload, identity) {
|
async function rejectAccessRequest(accessRequestId, payload, identity) {
|
||||||
|
|
@ -2312,6 +2335,77 @@ function sanitizeAccessRequestPayload(payload) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertAccessRequestUser(data, requestPayload, now) {
|
||||||
|
const email = requestPayload.email.toLowerCase();
|
||||||
|
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
|
||||||
|
const userName = buildAccessRequestUserName(requestPayload);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
existingUser.name = userName;
|
||||||
|
existingUser.phone = requestPayload.phone;
|
||||||
|
existingUser.notes = `Public access request: ${requestPayload.company}`;
|
||||||
|
existingUser.globalStatus = existingUser.globalStatus === "blocked" ? "blocked" : "active";
|
||||||
|
existingUser.updatedAt = now;
|
||||||
|
return existingUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: uniqueId(data.users, "user", email),
|
||||||
|
authentikUserId: null,
|
||||||
|
email,
|
||||||
|
name: userName,
|
||||||
|
phone: requestPayload.phone,
|
||||||
|
position: null,
|
||||||
|
notes: `Public access request: ${requestPayload.company}`,
|
||||||
|
avatarUrl: null,
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
data.users.push(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertAccessRequestMembership(data, user, requestPayload, options = {}) {
|
||||||
|
const now = options.now ?? isoNow();
|
||||||
|
const clientId = options.clientId ?? publicPoolClientId;
|
||||||
|
const role = pickEnum(requestPayload.role, membershipRoles, "member");
|
||||||
|
const existingMembership = data.memberships.find(
|
||||||
|
(membership) => membership.userId === user.id && membership.clientId === clientId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingMembership) {
|
||||||
|
existingMembership.role = role;
|
||||||
|
existingMembership.status = options.status ?? existingMembership.status;
|
||||||
|
existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null;
|
||||||
|
existingMembership.source = existingMembership.source ?? "access_request";
|
||||||
|
existingMembership.updatedAt = now;
|
||||||
|
return existingMembership;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = {
|
||||||
|
id: uniqueId(data.memberships, "mem", `${clientId}-${user.id}`),
|
||||||
|
clientId,
|
||||||
|
userId: user.id,
|
||||||
|
role,
|
||||||
|
status: options.status ?? "disabled",
|
||||||
|
invitedByUserId: options.invitedByUserId ?? null,
|
||||||
|
inviteId: null,
|
||||||
|
source: "access_request",
|
||||||
|
sourceTaskerInviteRequestId: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
data.memberships.push(membership);
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccessRequestUserName(requestPayload) {
|
||||||
|
return [requestPayload.lastName, requestPayload.firstName, requestPayload.middleName].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTaskManagerInviteRole(value) {
|
function normalizeTaskManagerInviteRole(value) {
|
||||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : value;
|
const normalized = typeof value === "string" ? value.trim().toLowerCase() : value;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,26 @@ app.get("/api/public/brand", (_req, res) => {
|
||||||
|
|
||||||
app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
||||||
try {
|
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 result = await controlPlaneStore.createAccessRequest(req.body);
|
||||||
publishControlPlaneEvent("access-request.created");
|
const provisioning = await authentikSyncClient.provisionUser({
|
||||||
|
data: result.data,
|
||||||
|
userId: result.user.id,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, {
|
||||||
|
sub: "public-access-request",
|
||||||
|
name: "NODE.DC public request",
|
||||||
|
email: result.user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
publishControlPlaneEvent("access-request.created", [result.user.id]);
|
||||||
res.status(201).json({ accessRequest: result.accessRequest });
|
res.status(201).json({ accessRequest: result.accessRequest });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendAccessRequestApiError(res, error);
|
sendAccessRequestApiError(res, error);
|
||||||
|
|
@ -1111,8 +1129,19 @@ app.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, r
|
||||||
|
|
||||||
app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
let result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.access-request.approved");
|
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));
|
res.json(scopeAdminMutationResult(req, result));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendAccessRequestApiError(res, error);
|
sendAccessRequestApiError(res, error);
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,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 { CreateAccessRequestCommand } from "../entities/access-request/types";
|
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 {
|
||||||
|
|
@ -72,7 +72,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;
|
||||||
|
|
||||||
|
|
@ -93,6 +93,7 @@ export function LauncherApp() {
|
||||||
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);
|
||||||
|
|
@ -115,6 +116,12 @@ export function LauncherApp() {
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -824,6 +831,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
|
||||||
|
|
@ -833,9 +844,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}
|
||||||
|
|
@ -854,6 +874,7 @@ 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}
|
||||||
|
|
@ -926,18 +947,21 @@ function AccessRequestScreen({
|
||||||
onSubmit: (command: CreateAccessRequestCommand) => Promise<CreateAccessRequestResponse>;
|
onSubmit: (command: CreateAccessRequestCommand) => Promise<CreateAccessRequestResponse>;
|
||||||
onLogin: () => void;
|
onLogin: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [values, setValues] = useState<CreateAccessRequestCommand>({
|
const [values, setValues] = useState<CreateAccessRequestCommand & { passwordConfirm: string }>({
|
||||||
email: "",
|
email: "",
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
middleName: "",
|
middleName: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
company: "",
|
company: "",
|
||||||
|
password: "",
|
||||||
|
passwordConfirm: "",
|
||||||
});
|
});
|
||||||
const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle");
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const isSubmitted = status === "submitted";
|
const isSubmitted = status === "submitted";
|
||||||
const normalizedEmail = values.email.trim().toLowerCase();
|
const normalizedEmail = values.email.trim().toLowerCase();
|
||||||
|
const passwordMismatch = Boolean(values.passwordConfirm && values.password !== values.passwordConfirm);
|
||||||
const canSubmit = Boolean(
|
const canSubmit = Boolean(
|
||||||
normalizedEmail.includes("@") &&
|
normalizedEmail.includes("@") &&
|
||||||
values.firstName.trim() &&
|
values.firstName.trim() &&
|
||||||
|
|
@ -945,10 +969,12 @@ function AccessRequestScreen({
|
||||||
values.middleName.trim() &&
|
values.middleName.trim() &&
|
||||||
values.phone.trim() &&
|
values.phone.trim() &&
|
||||||
values.company.trim() &&
|
values.company.trim() &&
|
||||||
|
values.password.length >= 8 &&
|
||||||
|
values.password === values.passwordConfirm &&
|
||||||
status !== "submitting"
|
status !== "submitting"
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateField = (field: keyof CreateAccessRequestCommand, value: string) => {
|
const updateField = (field: keyof typeof values, value: string) => {
|
||||||
setValues((current) => ({ ...current, [field]: value }));
|
setValues((current) => ({ ...current, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -964,10 +990,11 @@ function AccessRequestScreen({
|
||||||
|
|
||||||
{!isSubmitted ? (
|
{!isSubmitted ? (
|
||||||
<p className="nodedc-auth-card__status">
|
<p className="nodedc-auth-card__status">
|
||||||
Заполните обязательные поля. Заявка попадёт в очередь NODE.DC, после approve администратор передаст ссылку инвайта.
|
Заполните обязательные поля и задайте пароль. После подтверждения вы войдёте в NODE.DC по этой почте и паролю.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{message ? <p className="nodedc-auth-card__status">{message}</p> : null}
|
{message ? <p className="nodedc-auth-card__status">{message}</p> : null}
|
||||||
|
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
|
||||||
|
|
||||||
{isSubmitted ? (
|
{isSubmitted ? (
|
||||||
<div className="nodedc-auth-card__form">
|
<div className="nodedc-auth-card__form">
|
||||||
|
|
@ -991,10 +1018,11 @@ function AccessRequestScreen({
|
||||||
middleName: values.middleName.trim(),
|
middleName: values.middleName.trim(),
|
||||||
phone: values.phone.trim(),
|
phone: values.phone.trim(),
|
||||||
company: values.company.trim(),
|
company: values.company.trim(),
|
||||||
|
password: values.password,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setStatus("submitted");
|
setStatus("submitted");
|
||||||
setMessage("Заявка отправлена администратору. Администратор проверит данные. Дождитесь результатов.");
|
setMessage("Заявка отправлена администратору. После подтверждения войдите в NODE.DC по указанному паролю.");
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|
@ -1055,6 +1083,28 @@ function AccessRequestScreen({
|
||||||
onChange={(event) => updateField("company", event.target.value)}
|
onChange={(event) => updateField("company", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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}>
|
<button className="button button--primary" type="submit" disabled={!canSubmit}>
|
||||||
{status === "submitting" ? "Отправляем заявку" : "Запросить доступ"}
|
{status === "submitting" ? "Отправляем заявку" : "Запросить доступ"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1069,6 +1119,44 @@ function AccessRequestScreen({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,5 @@ export interface CreateAccessRequestCommand {
|
||||||
middleName: string;
|
middleName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
company: string;
|
company: string;
|
||||||
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ export interface AccessRequestMutationResult extends ControlPlaneMutationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessRequestApproveResult extends AccessRequestMutationResult {
|
export interface AccessRequestApproveResult extends AccessRequestMutationResult {
|
||||||
invite: Invite;
|
invite?: Invite | null;
|
||||||
|
membership?: ClientMembership | null;
|
||||||
|
user?: LauncherUser | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationResult {
|
export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationResult {
|
||||||
|
|
|
||||||
|
|
@ -1690,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,
|
||||||
|
|
@ -1735,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 {
|
||||||
|
|
@ -2121,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;
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ type AdminSection =
|
||||||
| "audit"
|
| "audit"
|
||||||
| "misc"
|
| "misc"
|
||||||
| "company";
|
| "company";
|
||||||
|
type AdminOverlayMode = "admin" | "platform";
|
||||||
|
|
||||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||||||
|
|
@ -125,14 +126,10 @@ export interface EnsureTaskManagerProjectMemberCommand {
|
||||||
role: TaskManagerWorkspaceMemberRole;
|
role: TaskManagerWorkspaceMemberRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
const platformSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||||
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
|
{ id: "clients", label: "Компании", icon: <Building2 size={16} /> },
|
||||||
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
|
|
||||||
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
|
|
||||||
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
||||||
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
|
||||||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
|
||||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||||||
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
||||||
{ id: "misc", label: "Разное", icon: <SlidersHorizontal size={16} /> },
|
{ id: "misc", label: "Разное", icon: <SlidersHorizontal size={16} /> },
|
||||||
|
|
@ -140,11 +137,10 @@ const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNo
|
||||||
|
|
||||||
const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||||
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
|
|
||||||
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
|
|
||||||
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
|
||||||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
||||||
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
|
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
|
||||||
|
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
||||||
|
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||||
|
|
@ -160,6 +156,7 @@ const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.R
|
||||||
export function AdminOverlay({
|
export function AdminOverlay({
|
||||||
data,
|
data,
|
||||||
me,
|
me,
|
||||||
|
mode,
|
||||||
activeClientId,
|
activeClientId,
|
||||||
onClose,
|
onClose,
|
||||||
onSetUserServiceAccess,
|
onSetUserServiceAccess,
|
||||||
|
|
@ -199,6 +196,7 @@ export function AdminOverlay({
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
me: MeResponse;
|
me: MeResponse;
|
||||||
|
mode: AdminOverlayMode;
|
||||||
activeClientId: string;
|
activeClientId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||||
|
|
@ -243,18 +241,25 @@ export function AdminOverlay({
|
||||||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||||||
}) {
|
}) {
|
||||||
const isRoot = me.launcherRole === "root_admin";
|
const isRoot = me.launcherRole === "root_admin";
|
||||||
|
const isPlatformMode = isRoot && mode === "platform";
|
||||||
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
|
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
|
||||||
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
|
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
|
||||||
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
|
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
|
||||||
|
const [selectedCompanyClientId, setSelectedCompanyClientId] = useState(
|
||||||
|
data.clients.some((client) => client.id === activeClientId) ? activeClientId : data.clients[0]?.id ?? activeClientId
|
||||||
|
);
|
||||||
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
||||||
|
|
||||||
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
|
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
|
||||||
|
const selectedCompanyClientExists = data.clients.some((client) => client.id === selectedCompanyClientId);
|
||||||
|
const scopedCompanyClientId = selectedCompanyClientExists ? selectedCompanyClientId : fallbackClientId;
|
||||||
const selectedContextIsPublicPool = isRoot && isPublicPoolClientId(selectedClientId);
|
const selectedContextIsPublicPool = isRoot && isPublicPoolClientId(selectedClientId);
|
||||||
const selectedClientExists = selectedContextIsPublicPool || data.clients.some((client) => client.id === selectedClientId);
|
const selectedClientExists = selectedContextIsPublicPool || data.clients.some((client) => client.id === selectedClientId);
|
||||||
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
|
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
|
||||||
const isPublicPoolContext = isRoot && isPublicPoolClientId(scopedClientId);
|
const isPublicPoolContext = isRoot && isPublicPoolClientId(scopedClientId);
|
||||||
const currentClient = getClient(data, scopedClientId);
|
const currentClient = getClient(data, scopedClientId);
|
||||||
const sections = isPublicPoolContext ? publicPoolSections : isRoot ? rootSections : clientSections;
|
const selectedCompanyClient = getClient(data, scopedCompanyClientId);
|
||||||
|
const sections = isPlatformMode ? platformSections : isPublicPoolContext ? publicPoolSections : clientSections;
|
||||||
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
|
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
|
||||||
const selectedAccessCell =
|
const selectedAccessCell =
|
||||||
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
|
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
|
||||||
|
|
@ -268,6 +273,12 @@ export function AdminOverlay({
|
||||||
}
|
}
|
||||||
}, [data.clients, isRoot, selectedClientExists]);
|
}, [data.clients, isRoot, selectedClientExists]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRoot && !selectedCompanyClientExists && data.clients.length) {
|
||||||
|
setSelectedCompanyClientId(data.clients[0].id);
|
||||||
|
}
|
||||||
|
}, [data.clients, isRoot, selectedCompanyClientExists]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection && !sections.some((section) => section.id === activeSection)) {
|
if (activeSection && !sections.some((section) => section.id === activeSection)) {
|
||||||
setActiveSection(sections[0]?.id ?? null);
|
setActiveSection(sections[0]?.id ?? null);
|
||||||
|
|
@ -299,44 +310,99 @@ export function AdminOverlay({
|
||||||
<div className="admin-panel-nav__head">
|
<div className="admin-panel-nav__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">NODE.DC</p>
|
<p className="eyebrow">NODE.DC</p>
|
||||||
<h2>Администрирование</h2>
|
<h2>{isPlatformMode ? "Платформа" : "Администрирование"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<IconButton label="Закрыть администрирование" className="admin-panel-close" onClick={onClose}>
|
<IconButton label={isPlatformMode ? "Закрыть платформу" : "Закрыть администрирование"} className="admin-panel-close" onClick={onClose}>
|
||||||
<X size={15} strokeWidth={1.45} />
|
<X size={15} strokeWidth={1.45} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isRoot ? (
|
{isPlatformMode ? (
|
||||||
|
<div className="admin-panel-client-select admin-panel-client-select--static">
|
||||||
|
<span className="admin-panel-client-select__icon">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="admin-panel-client-select__name">Root Admin</span>
|
||||||
|
</div>
|
||||||
|
) : isRoot ? (
|
||||||
|
<div className="admin-panel-context-switcher">
|
||||||
|
<button
|
||||||
|
className={cn("admin-panel-client-select", selectedContextIsPublicPool && "admin-panel-client-select--active")}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selectedContextIsPublicPool}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedClientId(PUBLIC_POOL_CLIENT_ID);
|
||||||
|
setSelectedCell(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="admin-panel-client-select__icon">
|
||||||
|
<Globe2 size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="admin-panel-client-select__body">
|
||||||
|
<span className="admin-panel-client-select__name">{PUBLIC_POOL_CONTEXT_LABEL}</span>
|
||||||
|
<span className="admin-panel-client-select__description">{PUBLIC_POOL_CONTEXT_DESCRIPTION}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="admin-panel-context-group">
|
||||||
|
<span className="admin-panel-context-group__label">Компании</span>
|
||||||
<NodeDcSelect
|
<NodeDcSelect
|
||||||
value={selectedClientId}
|
value={scopedCompanyClientId}
|
||||||
options={[
|
options={data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))}
|
||||||
{ value: PUBLIC_POOL_CLIENT_ID, label: PUBLIC_POOL_CONTEXT_LABEL, description: PUBLIC_POOL_CONTEXT_DESCRIPTION },
|
label="Выбрать компанию"
|
||||||
...data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined })),
|
|
||||||
]}
|
|
||||||
label="Выбрать контур администрирования"
|
|
||||||
searchable
|
searchable
|
||||||
minMenuWidth={292}
|
minMenuWidth={292}
|
||||||
onChange={(clientId) => {
|
onChange={(clientId) => {
|
||||||
|
setSelectedCompanyClientId(clientId);
|
||||||
setSelectedClientId(clientId);
|
setSelectedClientId(clientId);
|
||||||
setSelectedCell(null);
|
setSelectedCell(null);
|
||||||
}}
|
}}
|
||||||
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
|
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
|
||||||
<button
|
<div
|
||||||
ref={setTriggerRef}
|
ref={setTriggerRef}
|
||||||
className="admin-panel-client-select"
|
className={cn(
|
||||||
type="button"
|
"admin-panel-client-select",
|
||||||
aria-label="Выбрать клиента"
|
"admin-panel-client-select--company",
|
||||||
|
!selectedContextIsPublicPool && "admin-panel-client-select--active"
|
||||||
|
)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={toggle}
|
>
|
||||||
|
<button
|
||||||
|
className="admin-panel-client-select__main"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={!selectedContextIsPublicPool}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedClientId(scopedCompanyClientId);
|
||||||
|
setSelectedCell(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="admin-panel-client-select__icon">
|
<span className="admin-panel-client-select__icon">
|
||||||
{selectedContextIsPublicPool ? <Globe2 size={16} /> : <Building2 size={16} />}
|
<Building2 size={16} />
|
||||||
</span>
|
</span>
|
||||||
<span className="admin-panel-client-select__name">{selectedOption?.label ?? currentClient.name}</span>
|
<span className="admin-panel-client-select__body">
|
||||||
|
<span className="admin-panel-client-select__name">{selectedOption?.label ?? selectedCompanyClient.name}</span>
|
||||||
|
{selectedOption?.description ? (
|
||||||
|
<span className="admin-panel-client-select__description">{selectedOption.description}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="admin-panel-client-select__toggle"
|
||||||
|
type="button"
|
||||||
|
aria-label="Выбрать другую компанию"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="admin-panel-client-select__chevron" aria-hidden="true" />
|
<span className="admin-panel-client-select__chevron" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-panel-client-select">
|
<div className="admin-panel-client-select">
|
||||||
<span className="admin-panel-client-select__icon">
|
<span className="admin-panel-client-select__icon">
|
||||||
|
|
@ -377,9 +443,19 @@ export function AdminOverlay({
|
||||||
/>
|
/>
|
||||||
<div className="admin-panel-content__body">
|
<div className="admin-panel-content__body">
|
||||||
{activeSection === "overview" ? (
|
{activeSection === "overview" ? (
|
||||||
<OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} isPublicPoolContext={isPublicPoolContext} />
|
<OverviewSection
|
||||||
|
data={data}
|
||||||
|
clientId={scopedClientId}
|
||||||
|
isPlatformMode={isPlatformMode}
|
||||||
|
isPublicPoolContext={isPublicPoolContext}
|
||||||
|
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||||
|
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||||||
|
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||||
|
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
|
||||||
|
onUpdateClient={onUpdateClient}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "clients" && isRoot ? (
|
{activeSection === "clients" && isPlatformMode ? (
|
||||||
<ClientsSection
|
<ClientsSection
|
||||||
data={data}
|
data={data}
|
||||||
taskManagerWorkspaces={taskManagerWorkspaces}
|
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||||
|
|
@ -454,7 +530,6 @@ export function AdminOverlay({
|
||||||
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
||||||
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
|
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
|
||||||
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
|
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
|
||||||
{activeSection === "company" ? <CompanySection data={data} clientId={scopedClientId} /> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -494,17 +569,28 @@ function AdminHeader({
|
||||||
function OverviewSection({
|
function OverviewSection({
|
||||||
data,
|
data,
|
||||||
clientId,
|
clientId,
|
||||||
isRoot,
|
isPlatformMode,
|
||||||
isPublicPoolContext,
|
isPublicPoolContext,
|
||||||
|
taskManagerWorkspaces,
|
||||||
|
taskManagerWorkspacesLoading,
|
||||||
|
taskManagerWorkspacesError,
|
||||||
|
onRefreshTaskManagerWorkspaces,
|
||||||
|
onUpdateClient,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
isRoot: boolean;
|
isPlatformMode: boolean;
|
||||||
isPublicPoolContext: boolean;
|
isPublicPoolContext: boolean;
|
||||||
|
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||||||
|
taskManagerWorkspacesLoading: boolean;
|
||||||
|
taskManagerWorkspacesError: string | null;
|
||||||
|
onRefreshTaskManagerWorkspaces: () => void;
|
||||||
|
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const client = getClient(data, clientId);
|
||||||
const clientUsers = getClientUsers(data, clientId);
|
const clientUsers = getClientUsers(data, clientId);
|
||||||
const clientServiceCount = data.grants.filter((grant) => grant.targetType === "client" && grant.targetId === clientId).length;
|
const clientServiceCount = data.grants.filter((grant) => grant.targetType === "client" && grant.targetId === clientId).length;
|
||||||
const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isRoot || sync.objectId === clientId)).length;
|
const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isPlatformMode || sync.objectId === clientId)).length;
|
||||||
const newAccessRequests = data.accessRequests.filter((request) => request.status === "new").length;
|
const newAccessRequests = data.accessRequests.filter((request) => request.status === "new").length;
|
||||||
const approvedAccessRequests = data.accessRequests.filter((request) => request.status === "approved").length;
|
const approvedAccessRequests = data.accessRequests.filter((request) => request.status === "approved").length;
|
||||||
const publicInvites = data.invites.filter((invite) => invite.clientId === PUBLIC_POOL_CLIENT_ID).length;
|
const publicInvites = data.invites.filter((invite) => invite.clientId === PUBLIC_POOL_CLIENT_ID).length;
|
||||||
|
|
@ -537,13 +623,61 @@ function OverviewSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPlatformMode) {
|
||||||
return (
|
return (
|
||||||
<section className="admin-section-grid">
|
<section className="admin-section-grid">
|
||||||
<MetricCard label={isRoot ? "Клиентов" : "Участников"} value={isRoot ? data.clients.length : clientUsers.length} hint="Текущая область" />
|
<MetricCard label="Компаний" value={data.clients.length} hint="Контуры платформы" />
|
||||||
|
<MetricCard label="Активных сервисов" value={data.services.filter((service) => service.status === "active").length} hint="В каталоге" />
|
||||||
|
<MetricCard label="Клиентских грантов" value={data.grants.filter((grant) => grant.targetType === "client").length} hint="Назначения контуров" />
|
||||||
|
<MetricCard label="Ошибок sync" value={syncErrors} hint="Требуют внимания" danger={syncErrors > 0} />
|
||||||
|
|
||||||
|
<GlassSurface className="admin-wide-card">
|
||||||
|
<h3>Последние действия платформы</h3>
|
||||||
|
<div className="activity-list">
|
||||||
|
{data.auditEvents.slice(0, 5).map((event) => (
|
||||||
|
<div key={event.id} className="activity-row">
|
||||||
|
<span>{formatDateTime(event.at)}</span>
|
||||||
|
<strong>{event.action}</strong>
|
||||||
|
<em>{event.objectName}</em>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-section-grid">
|
||||||
|
<MetricCard label="Участников" value={clientUsers.length} hint="Текущий контур" />
|
||||||
<MetricCard label="Активных сервисов" value={data.services.filter((service) => service.status === "active").length} hint="В каталоге" />
|
<MetricCard label="Активных сервисов" value={data.services.filter((service) => service.status === "active").length} hint="В каталоге" />
|
||||||
<MetricCard label="Подключений клиента" value={clientServiceCount} hint="Гранты клиента" />
|
<MetricCard label="Подключений клиента" value={clientServiceCount} hint="Гранты клиента" />
|
||||||
<MetricCard label="Ошибок sync" value={syncErrors} hint="Требуют внимания" danger={syncErrors > 0} />
|
<MetricCard label="Ошибок sync" value={syncErrors} hint="Требуют внимания" danger={syncErrors > 0} />
|
||||||
|
|
||||||
|
<GlassSurface className="admin-wide-card client-profile-card">
|
||||||
|
<div className="table-toolbar client-profile-card__head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Клиент</p>
|
||||||
|
<h3>Профиль контура</h3>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
|
||||||
|
className="admin-circle-action admin-circle-action--solid"
|
||||||
|
type="button"
|
||||||
|
disabled={taskManagerWorkspacesLoading}
|
||||||
|
onClick={onRefreshTaskManagerWorkspaces}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<ClientProfileEditorForm
|
||||||
|
client={client}
|
||||||
|
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||||
|
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||||
|
onSave={(patch) => onUpdateClient(client.id, patch)}
|
||||||
|
/>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
<GlassSurface className="admin-wide-card">
|
<GlassSurface className="admin-wide-card">
|
||||||
<h3>Последние действия</h3>
|
<h3>Последние действия</h3>
|
||||||
<div className="activity-list">
|
<div className="activity-list">
|
||||||
|
|
@ -586,8 +720,8 @@ function ClientsSection({
|
||||||
<>
|
<>
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<h3>Клиенты</h3>
|
<h3>Компании</h3>
|
||||||
<IconButton label="Создать клиента" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateClient}>
|
<IconButton label="Добавить компанию" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateClient}>
|
||||||
<Plus size={17} />
|
<Plus size={17} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1133,6 +1267,15 @@ function getPrimaryTaskManagerWorkspace(client: Client): ClientTaskManagerWorksp
|
||||||
return workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
return workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatClientTaskManagerWorkspaces(client: Client): string {
|
||||||
|
const workspaces = getClientTaskManagerWorkspaces(client);
|
||||||
|
if (!workspaces.length) return "—";
|
||||||
|
|
||||||
|
return workspaces
|
||||||
|
.map((workspace) => `${workspace.name ?? workspace.slug}${workspace.isPrimary ? " · основной" : ""}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
function getTaskManagerMembershipRole(data: LauncherData, clientId: string, userId: string, workspaceSlug: string): TaskManagerWorkspaceMemberRole {
|
function getTaskManagerMembershipRole(data: LauncherData, clientId: string, userId: string, workspaceSlug: string): TaskManagerWorkspaceMemberRole {
|
||||||
return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset";
|
return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset";
|
||||||
}
|
}
|
||||||
|
|
@ -1215,6 +1358,13 @@ const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWor
|
||||||
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
|
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
|
||||||
const modalActionAccentRgb = [247, 248, 244] as const;
|
const modalActionAccentRgb = [247, 248, 244] as const;
|
||||||
|
|
||||||
|
type EntityModalDeleteConfig = {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
description: ReactNode;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
function ServicesSection({
|
function ServicesSection({
|
||||||
data,
|
data,
|
||||||
isPublicPoolContext,
|
isPublicPoolContext,
|
||||||
|
|
@ -1849,6 +1999,66 @@ function ClientEditorModal({
|
||||||
onSave: (patch: Partial<Client>) => void;
|
onSave: (patch: Partial<Client>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
|
||||||
|
<article className="service-content-modal admin-entity-modal client-editor-modal">
|
||||||
|
<EntityModalHead
|
||||||
|
eyebrow="Клиент"
|
||||||
|
title={client.name}
|
||||||
|
onClose={onClose}
|
||||||
|
actions={
|
||||||
|
<IconButton
|
||||||
|
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
|
||||||
|
className="admin-circle-action admin-circle-action--solid"
|
||||||
|
type="button"
|
||||||
|
disabled={taskManagerWorkspacesLoading}
|
||||||
|
onClick={onRefreshTaskManagerWorkspaces}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ClientProfileEditorForm
|
||||||
|
client={client}
|
||||||
|
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||||
|
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||||
|
onCancel={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
deleteConfig={
|
||||||
|
canDelete
|
||||||
|
? {
|
||||||
|
label: "Удалить",
|
||||||
|
title: "Удалить компанию",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
onConfirm: onDelete,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientProfileEditorForm({
|
||||||
|
client,
|
||||||
|
taskManagerWorkspaces,
|
||||||
|
taskManagerWorkspacesError,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
deleteConfig,
|
||||||
|
}: {
|
||||||
|
client: Client;
|
||||||
|
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||||||
|
taskManagerWorkspacesError: string | null;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onSave: (patch: Partial<Client>) => void;
|
||||||
|
deleteConfig?: EntityModalDeleteConfig;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<Client>(client);
|
const [draft, setDraft] = useState<Client>(client);
|
||||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(client.avatarUrl ?? null);
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(client.avatarUrl ?? null);
|
||||||
|
|
@ -1877,6 +2087,14 @@ function ClientEditorModal({
|
||||||
setDraft((current) => ({ ...current, [key]: value }));
|
setDraft((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetDraft() {
|
||||||
|
setDraft(client);
|
||||||
|
setAvatarPreviewUrl(client.avatarUrl ?? null);
|
||||||
|
setUploadingAvatar(false);
|
||||||
|
setStorageError(null);
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
|
||||||
function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) {
|
function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) {
|
||||||
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
|
|
@ -1939,25 +2157,8 @@ function ClientEditorModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
|
<>
|
||||||
<article className="service-content-modal admin-entity-modal client-editor-modal">
|
<div className="service-content-modal__grid client-profile-editor-grid">
|
||||||
<EntityModalHead
|
|
||||||
eyebrow="Клиент"
|
|
||||||
title={client.name}
|
|
||||||
onClose={onClose}
|
|
||||||
actions={
|
|
||||||
<IconButton
|
|
||||||
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
|
|
||||||
className="admin-circle-action admin-circle-action--solid"
|
|
||||||
type="button"
|
|
||||||
disabled={taskManagerWorkspacesLoading}
|
|
||||||
onClick={onRefreshTaskManagerWorkspaces}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="service-content-modal__grid">
|
|
||||||
<label className="service-content-field">
|
<label className="service-content-field">
|
||||||
<span>Название</span>
|
<span>Название</span>
|
||||||
<input value={draft.name} onChange={(event) => update("name", event.target.value)} />
|
<input value={draft.name} onChange={(event) => update("name", event.target.value)} />
|
||||||
|
|
@ -2119,26 +2320,8 @@ function ClientEditorModal({
|
||||||
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
|
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<EntityModalFoot
|
<EntityModalFoot onClose={resetDraft} onSave={() => onSave(draft)} deleteConfig={deleteConfig} />
|
||||||
onClose={onClose}
|
|
||||||
onSave={() => onSave(draft)}
|
|
||||||
deleteConfig={
|
|
||||||
canDelete
|
|
||||||
? {
|
|
||||||
label: "Удалить",
|
|
||||||
title: "Удалить компанию",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
|
|
||||||
</>
|
</>
|
||||||
),
|
|
||||||
onConfirm: onDelete,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2355,12 +2538,7 @@ function EntityModalFoot({
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
deleteConfig?: {
|
deleteConfig?: EntityModalDeleteConfig;
|
||||||
label: string;
|
|
||||||
title: string;
|
|
||||||
description: ReactNode;
|
|
||||||
onConfirm: () => void;
|
|
||||||
};
|
|
||||||
}) {
|
}) {
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -3322,7 +3500,7 @@ function AccessRequestsPanel({
|
||||||
<div>
|
<div>
|
||||||
<h3>Входящие запросы доступа</h3>
|
<h3>Входящие запросы доступа</h3>
|
||||||
<p className="admin-helper-note">
|
<p className="admin-helper-note">
|
||||||
Перед approve выберите целевой контур: оставить пользователя в открытом пуле или направить его в enterprise-клиента.
|
Перед approve выберите целевой контур. Аккаунт уже создан в Authentik и активируется после подтверждения.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3343,7 +3521,7 @@ function AccessRequestsPanel({
|
||||||
<th>Назначение</th>
|
<th>Назначение</th>
|
||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Инвайт</th>
|
<th>Аккаунт</th>
|
||||||
<th aria-label="Действия" />
|
<th aria-label="Действия" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -3411,6 +3589,8 @@ function AccessRequestsPanel({
|
||||||
<Copy size={11} />
|
<Copy size={11} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
) : accessRequest.status === "approved" ? (
|
||||||
|
<span className="muted-text">Активен</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted-text">—</span>
|
<span className="muted-text">—</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ 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,
|
||||||
|
|
@ -12,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,
|
||||||
|
|
@ -26,9 +30,11 @@ 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;
|
||||||
|
|
@ -46,6 +52,7 @@ export function TopBar({
|
||||||
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">
|
||||||
|
|
@ -86,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