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

This commit is contained in:
DCCONSTRUCTIONS 2026-05-10 19:57:25 +03:00
parent a579e71b9b
commit fd1cc0b25a
8 changed files with 833 additions and 314 deletions

View File

@ -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;

View File

@ -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);

View File

@ -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,

View File

@ -28,4 +28,5 @@ export interface CreateAccessRequestCommand {
middleName: string; middleName: string;
phone: string; phone: string;
company: string; company: string;
password: string;
} }

View File

@ -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 {

View File

@ -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;

View File

@ -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>
)} )}

View File

@ -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>