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 requestPayload = sanitizeAccessRequestPayload(payload);
const email = requestPayload.email.toLowerCase();
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
if (existingUser?.globalStatus === "active") {
const hasOnlyPendingAccessRequest =
data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") &&
data.memberships.some(
(membership) =>
membership.userId === existingUser.id &&
membership.clientId === publicPoolClientId &&
membership.source === "access_request" &&
membership.status === "disabled"
);
if (!hasOnlyPendingAccessRequest) {
throw new Error("Аккаунт с этой почтой уже существует. Войдите в NODE.DC или обратитесь к администратору.");
}
}
const user = upsertAccessRequestUser(data, requestPayload, now);
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
markPendingSync(data, user, "user", user.email);
const existingRequest = data.accessRequests.find(
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
);
@ -608,7 +629,7 @@ export function createControlPlaneStore({ projectRoot }) {
});
await writeData(data);
return { accessRequest: existingRequest, data };
return { accessRequest: existingRequest, user, data };
}
const accessRequest = {
@ -637,7 +658,7 @@ export function createControlPlaneStore({ projectRoot }) {
});
await writeData(data);
return { accessRequest, data };
return { accessRequest, user, data };
}
async function updateAccessRequest(accessRequestId, payload, identity) {
@ -685,33 +706,35 @@ export function createControlPlaneStore({ projectRoot }) {
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
if (accessRequest.status === "approved" && accessRequest.approvedInviteId) {
const existingInvite = findById(data.invites, accessRequest.approvedInviteId, "invite");
return { accessRequest, invite: existingInvite, data };
const user = upsertAccessRequestUser(data, accessRequest, now);
const client = findClientById(data, accessRequest.targetClientId);
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
status: "active",
clientId: client.id,
invitedByUserId: actor.id,
now,
});
if (client.id !== publicPoolClientId) {
data.memberships = data.memberships.filter(
(candidate) =>
!(
candidate.userId === user.id &&
candidate.clientId === publicPoolClientId &&
candidate.source === "access_request" &&
candidate.status === "disabled"
)
);
}
const client = findClientById(data, accessRequest.targetClientId);
const invite = {
id: uniqueId(data.invites, "invite", accessRequest.email),
clientId: client.id,
email: accessRequest.email,
role: accessRequest.role,
invitedByUserId: actor.id,
source: "access_request",
sourceTaskerInviteRequestId: null,
sourceTaskerInviteId: null,
sourceWorkspaceSlug: null,
sourceWorkspaceName: null,
token: randomUUID(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
status: "created",
createdAt: now,
updatedAt: now,
};
if (accessRequest.status === "approved") {
markPendingSync(data, user, "user", user.email);
await writeData(data);
return { accessRequest, user, membership, invite: null, data };
}
data.invites.push(invite);
accessRequest.status = "approved";
accessRequest.approvedInviteId = invite.id;
accessRequest.approvedInviteId = null;
accessRequest.reviewedByUserId = actor.id;
accessRequest.reviewedAt = now;
accessRequest.updatedAt = now;
@ -722,12 +745,12 @@ export function createControlPlaneStore({ projectRoot }) {
objectName: accessRequest.email,
clientId: client.id === publicPoolClientId ? null : client.id,
result: "success",
details: `Invite: ${invite.id}; target: ${client.name}; role: ${invite.role}`,
details: `Account activated; target: ${client.name}; role: ${membership.role}`,
});
markPendingSync(data, invite, "invite", invite.email);
markPendingSync(data, user, "user", user.email);
await writeData(data);
return { accessRequest, invite, data };
return { accessRequest, user, membership, invite: null, data };
}
async function rejectAccessRequest(accessRequestId, payload, identity) {
@ -2312,6 +2335,77 @@ function sanitizeAccessRequestPayload(payload) {
};
}
function upsertAccessRequestUser(data, requestPayload, now) {
const email = requestPayload.email.toLowerCase();
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
const userName = buildAccessRequestUserName(requestPayload);
if (existingUser) {
existingUser.name = userName;
existingUser.phone = requestPayload.phone;
existingUser.notes = `Public access request: ${requestPayload.company}`;
existingUser.globalStatus = existingUser.globalStatus === "blocked" ? "blocked" : "active";
existingUser.updatedAt = now;
return existingUser;
}
const user = {
id: uniqueId(data.users, "user", email),
authentikUserId: null,
email,
name: userName,
phone: requestPayload.phone,
position: null,
notes: `Public access request: ${requestPayload.company}`,
avatarUrl: null,
globalStatus: "active",
createdAt: now,
updatedAt: now,
};
data.users.push(user);
return user;
}
function upsertAccessRequestMembership(data, user, requestPayload, options = {}) {
const now = options.now ?? isoNow();
const clientId = options.clientId ?? publicPoolClientId;
const role = pickEnum(requestPayload.role, membershipRoles, "member");
const existingMembership = data.memberships.find(
(membership) => membership.userId === user.id && membership.clientId === clientId
);
if (existingMembership) {
existingMembership.role = role;
existingMembership.status = options.status ?? existingMembership.status;
existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null;
existingMembership.source = existingMembership.source ?? "access_request";
existingMembership.updatedAt = now;
return existingMembership;
}
const membership = {
id: uniqueId(data.memberships, "mem", `${clientId}-${user.id}`),
clientId,
userId: user.id,
role,
status: options.status ?? "disabled",
invitedByUserId: options.invitedByUserId ?? null,
inviteId: null,
source: "access_request",
sourceTaskerInviteRequestId: null,
createdAt: now,
updatedAt: now,
};
data.memberships.push(membership);
return membership;
}
function buildAccessRequestUserName(requestPayload) {
return [requestPayload.lastName, requestPayload.firstName, requestPayload.middleName].filter(Boolean).join(" ");
}
function normalizeTaskManagerInviteRole(value) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : value;

View File

@ -70,8 +70,26 @@ app.get("/api/public/brand", (_req, res) => {
app.post("/api/access-requests", asyncRoute(async (req, res) => {
try {
const password = sanitizeNewPassword(req.body?.password);
if (!authentikSyncClient.isConfigured()) {
res.status(503).json({ error: "Authentik API не настроен. Заявку с паролем сейчас создать нельзя." });
return;
}
const result = await controlPlaneStore.createAccessRequest(req.body);
publishControlPlaneEvent("access-request.created");
const provisioning = await authentikSyncClient.provisionUser({
data: result.data,
userId: result.user.id,
password,
});
await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, {
sub: "public-access-request",
name: "NODE.DC public request",
email: result.user.email,
});
publishControlPlaneEvent("access-request.created", [result.user.id]);
res.status(201).json({ accessRequest: result.accessRequest });
} catch (error) {
sendAccessRequestApiError(res, error);
@ -1111,8 +1129,19 @@ app.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, r
app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
try {
const result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.access-request.approved");
let result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
let provisioning = null;
if (result.user && authentikSyncClient.isConfigured()) {
provisioning = await authentikSyncClient.provisionUser({
data: result.data,
userId: result.user.id,
});
const syncResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, req.nodedcSession.user);
result = { ...result, data: syncResult.data, user: syncResult.user, provisioning };
}
publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []);
res.json(scopeAdminMutationResult(req, result));
} catch (error) {
sendAccessRequestApiError(res, error);

View File

@ -59,7 +59,7 @@ import {
} from "../shared/api/authApi";
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
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 { loadPersistedLauncherData } from "../shared/api/storageApi";
import {
@ -72,7 +72,7 @@ import {
import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel";
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
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;
@ -93,6 +93,7 @@ export function LauncherApp() {
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
const [adminOpen, setAdminOpen] = useState(false);
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
@ -115,6 +116,12 @@ export function LauncherApp() {
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
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(() => {
if (!authSession?.authenticated) return me;
@ -824,6 +831,10 @@ export function LauncherApp() {
window.location.replace(authSession.logoutUrl);
};
if (currentAccessRequest) {
return <AccessRequestPendingScreen accessRequest={currentAccessRequest} onLogout={handleLogout} />;
}
return (
<div className="launcher-app">
<TopBar
@ -833,9 +844,18 @@ export function LauncherApp() {
activeProfileId={activeProfileId}
activeClientId={resolvedClientId}
adminOpen={adminOpen}
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
onProfileChange={handleProfileChange}
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)}
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
onLogout={handleLogout}
@ -854,6 +874,7 @@ export function LauncherApp() {
<AdminOverlay
data={data}
me={runtimeMe}
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
activeClientId={resolvedClientId}
onClose={() => setAdminOpen(false)}
onSetUserServiceAccess={handleSetUserServiceAccess}
@ -926,18 +947,21 @@ function AccessRequestScreen({
onSubmit: (command: CreateAccessRequestCommand) => Promise<CreateAccessRequestResponse>;
onLogin: () => void;
}) {
const [values, setValues] = useState<CreateAccessRequestCommand>({
const [values, setValues] = useState<CreateAccessRequestCommand & { passwordConfirm: string }>({
email: "",
firstName: "",
lastName: "",
middleName: "",
phone: "",
company: "",
password: "",
passwordConfirm: "",
});
const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
const isSubmitted = status === "submitted";
const normalizedEmail = values.email.trim().toLowerCase();
const passwordMismatch = Boolean(values.passwordConfirm && values.password !== values.passwordConfirm);
const canSubmit = Boolean(
normalizedEmail.includes("@") &&
values.firstName.trim() &&
@ -945,10 +969,12 @@ function AccessRequestScreen({
values.middleName.trim() &&
values.phone.trim() &&
values.company.trim() &&
values.password.length >= 8 &&
values.password === values.passwordConfirm &&
status !== "submitting"
);
const updateField = (field: keyof CreateAccessRequestCommand, value: string) => {
const updateField = (field: keyof typeof values, value: string) => {
setValues((current) => ({ ...current, [field]: value }));
};
@ -964,10 +990,11 @@ function AccessRequestScreen({
{!isSubmitted ? (
<p className="nodedc-auth-card__status">
Заполните обязательные поля. Заявка попадёт в очередь NODE.DC, после approve администратор передаст ссылку инвайта.
Заполните обязательные поля и задайте пароль. После подтверждения вы войдёте в NODE.DC по этой почте и паролю.
</p>
) : null}
{message ? <p className="nodedc-auth-card__status">{message}</p> : null}
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
{isSubmitted ? (
<div className="nodedc-auth-card__form">
@ -991,10 +1018,11 @@ function AccessRequestScreen({
middleName: values.middleName.trim(),
phone: values.phone.trim(),
company: values.company.trim(),
password: values.password,
})
.then(() => {
setStatus("submitted");
setMessage("Заявка отправлена администратору. Администратор проверит данные. Дождитесь результатов.");
setMessage("Заявка отправлена администратору. После подтверждения войдите в NODE.DC по указанному паролю.");
})
.catch((error) => {
setStatus("error");
@ -1055,6 +1083,28 @@ function AccessRequestScreen({
onChange={(event) => updateField("company", event.target.value)}
/>
</label>
<div className="nodedc-auth-card__field-grid">
<label className="nodedc-auth-card__field">
<span>Пароль</span>
<input
value={values.password}
type="password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
onChange={(event) => updateField("password", event.target.value)}
/>
</label>
<label className="nodedc-auth-card__field">
<span>Повторите пароль</span>
<input
value={values.passwordConfirm}
type="password"
placeholder="Ещё раз"
autoComplete="new-password"
onChange={(event) => updateField("passwordConfirm", event.target.value)}
/>
</label>
</div>
<button className="button button--primary" type="submit" disabled={!canSubmit}>
{status === "submitting" ? "Отправляем заявку" : "Запросить доступ"}
</button>
@ -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(
data: LauncherData,
session: AuthenticatedSession,

View File

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

View File

@ -38,7 +38,9 @@ export interface AccessRequestMutationResult extends ControlPlaneMutationResult
}
export interface AccessRequestApproveResult extends AccessRequestMutationResult {
invite: Invite;
invite?: Invite | null;
membership?: ClientMembership | null;
user?: LauncherUser | null;
}
export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationResult {

View File

@ -1690,24 +1690,92 @@ code {
border: 0;
border-radius: var(--launcher-radius-circle);
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)
var(--admin-control-inset);
color: var(--text-primary);
color: rgba(255, 255, 255, 0.66);
font: inherit;
text-align: left;
opacity: 0.66;
box-shadow: none;
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:focus,
.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;
outline: none;
box-shadow: none;
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,
@ -1735,17 +1803,33 @@ code {
white-space: nowrap;
}
.admin-panel-client-select__chevron {
position: absolute;
top: 50%;
right: var(--admin-control-inset);
.admin-panel-client-select__body {
display: grid;
width: 1.85rem;
height: 1.85rem;
place-items: center;
min-width: 0;
gap: 0.12rem;
}
.admin-panel-client-select__description {
min-width: 0;
overflow: hidden;
color: var(--text-muted);
transform: translateY(-50%);
pointer-events: none;
font-size: 0.72rem;
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 {
@ -2121,6 +2205,24 @@ code {
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 {
display: grid;
gap: 0.5rem;

View File

@ -89,6 +89,7 @@ type AdminSection =
| "audit"
| "misc"
| "company";
type AdminOverlayMode = "admin" | "platform";
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
@ -125,14 +126,10 @@ export interface EnsureTaskManagerProjectMemberCommand {
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: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
{ id: "users", label: "Участники", icon: <UsersRound size={16} /> },
{ id: "groups", label: "Группы", icon: <ListChecks size={16} /> },
{ id: "clients", label: "Компании", icon: <Building2 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: "audit", label: "Аудит", icon: <ClipboardList 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 }> = [
{ 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: "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 }> = [
@ -160,6 +156,7 @@ const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.R
export function AdminOverlay({
data,
me,
mode,
activeClientId,
onClose,
onSetUserServiceAccess,
@ -199,6 +196,7 @@ export function AdminOverlay({
}: {
data: LauncherData;
me: MeResponse;
mode: AdminOverlayMode;
activeClientId: string;
onClose: () => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
@ -243,18 +241,25 @@ export function AdminOverlay({
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
}) {
const isRoot = me.launcherRole === "root_admin";
const isPlatformMode = isRoot && mode === "platform";
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
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 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 selectedClientExists = selectedContextIsPublicPool || data.clients.some((client) => client.id === selectedClientId);
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
const isPublicPoolContext = isRoot && isPublicPoolClientId(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 selectedAccessCell =
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
@ -268,6 +273,12 @@ export function AdminOverlay({
}
}, [data.clients, isRoot, selectedClientExists]);
useEffect(() => {
if (isRoot && !selectedCompanyClientExists && data.clients.length) {
setSelectedCompanyClientId(data.clients[0].id);
}
}, [data.clients, isRoot, selectedCompanyClientExists]);
useEffect(() => {
if (activeSection && !sections.some((section) => section.id === activeSection)) {
setActiveSection(sections[0]?.id ?? null);
@ -299,44 +310,99 @@ export function AdminOverlay({
<div className="admin-panel-nav__head">
<div>
<p className="eyebrow">NODE.DC</p>
<h2>Администрирование</h2>
<h2>{isPlatformMode ? "Платформа" : "Администрирование"}</h2>
</div>
<IconButton label="Закрыть администрирование" className="admin-panel-close" onClick={onClose}>
<IconButton label={isPlatformMode ? "Закрыть платформу" : "Закрыть администрирование"} className="admin-panel-close" onClick={onClose}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</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
value={selectedClientId}
options={[
{ value: PUBLIC_POOL_CLIENT_ID, label: PUBLIC_POOL_CONTEXT_LABEL, description: PUBLIC_POOL_CONTEXT_DESCRIPTION },
...data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined })),
]}
label="Выбрать контур администрирования"
value={scopedCompanyClientId}
options={data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))}
label="Выбрать компанию"
searchable
minMenuWidth={292}
onChange={(clientId) => {
setSelectedCompanyClientId(clientId);
setSelectedClientId(clientId);
setSelectedCell(null);
}}
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
<button
<div
ref={setTriggerRef}
className="admin-panel-client-select"
type="button"
aria-label="Выбрать клиента"
className={cn(
"admin-panel-client-select",
"admin-panel-client-select--company",
!selectedContextIsPublicPool && "admin-panel-client-select--active"
)}
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">
{selectedContextIsPublicPool ? <Globe2 size={16} /> : <Building2 size={16} />}
<Building2 size={16} />
</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" />
</button>
</div>
)}
/>
</div>
</div>
) : (
<div className="admin-panel-client-select">
<span className="admin-panel-client-select__icon">
@ -377,9 +443,19 @@ export function AdminOverlay({
/>
<div className="admin-panel-content__body">
{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}
{activeSection === "clients" && isRoot ? (
{activeSection === "clients" && isPlatformMode ? (
<ClientsSection
data={data}
taskManagerWorkspaces={taskManagerWorkspaces}
@ -454,7 +530,6 @@ export function AdminOverlay({
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
{activeSection === "company" ? <CompanySection data={data} clientId={scopedClientId} /> : null}
</div>
</section>
) : null}
@ -494,17 +569,28 @@ function AdminHeader({
function OverviewSection({
data,
clientId,
isRoot,
isPlatformMode,
isPublicPoolContext,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
onRefreshTaskManagerWorkspaces,
onUpdateClient,
}: {
data: LauncherData;
clientId: string;
isRoot: boolean;
isPlatformMode: 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 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 approvedAccessRequests = data.accessRequests.filter((request) => request.status === "approved").length;
const publicInvites = data.invites.filter((invite) => invite.clientId === PUBLIC_POOL_CLIENT_ID).length;
@ -537,13 +623,61 @@ function OverviewSection({
);
}
if (isPlatformMode) {
return (
<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={clientServiceCount} hint="Гранты клиента" />
<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">
<h3>Последние действия</h3>
<div className="activity-list">
@ -586,8 +720,8 @@ function ClientsSection({
<>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<h3>Клиенты</h3>
<IconButton label="Создать клиента" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateClient}>
<h3>Компании</h3>
<IconButton label="Добавить компанию" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateClient}>
<Plus size={17} />
</IconButton>
</div>
@ -1133,6 +1267,15 @@ function getPrimaryTaskManagerWorkspace(client: Client): ClientTaskManagerWorksp
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 {
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 modalActionAccentRgb = [247, 248, 244] as const;
type EntityModalDeleteConfig = {
label: string;
title: string;
description: ReactNode;
onConfirm: () => void;
};
function ServicesSection({
data,
isPublicPoolContext,
@ -1849,6 +1999,66 @@ function ClientEditorModal({
onSave: (patch: Partial<Client>) => void;
onDelete: () => void;
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 [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(client.avatarUrl ?? null);
@ -1877,6 +2087,14 @@ function ClientEditorModal({
setDraft((current) => ({ ...current, [key]: value }));
}
function resetDraft() {
setDraft(client);
setAvatarPreviewUrl(client.avatarUrl ?? null);
setUploadingAvatar(false);
setStorageError(null);
onCancel?.();
}
function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) {
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
setDraft((current) => ({
@ -1939,25 +2157,8 @@ function ClientEditorModal({
}
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>
}
/>
<div className="service-content-modal__grid">
<>
<div className="service-content-modal__grid client-profile-editor-grid">
<label className="service-content-field">
<span>Название</span>
<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} />
</label>
</div>
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={
canDelete
? {
label: "Удалить",
title: "Удалить компанию",
description: (
<>
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
<EntityModalFoot onClose={resetDraft} onSave={() => onSave(draft)} deleteConfig={deleteConfig} />
</>
),
onConfirm: onDelete,
}
: undefined
}
/>
</article>
</div>
);
}
@ -2355,12 +2538,7 @@ function EntityModalFoot({
}: {
onClose: () => void;
onSave: () => void;
deleteConfig?: {
label: string;
title: string;
description: ReactNode;
onConfirm: () => void;
};
deleteConfig?: EntityModalDeleteConfig;
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
@ -3322,7 +3500,7 @@ function AccessRequestsPanel({
<div>
<h3>Входящие запросы доступа</h3>
<p className="admin-helper-note">
Перед approve выберите целевой контур: оставить пользователя в открытом пуле или направить его в enterprise-клиента.
Перед approve выберите целевой контур. Аккаунт уже создан в Authentik и активируется после подтверждения.
</p>
</div>
</div>
@ -3343,7 +3521,7 @@ function AccessRequestsPanel({
<th>Назначение</th>
<th>Роль</th>
<th>Статус</th>
<th>Инвайт</th>
<th>Аккаунт</th>
<th aria-label="Действия" />
</tr>
</thead>
@ -3411,6 +3589,8 @@ function AccessRequestsPanel({
<Copy size={11} />
</IconButton>
</div>
) : accessRequest.status === "approved" ? (
<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 { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
export type LauncherAdminMode = "admin" | "platform";
export function TopBar({
me,
clients,
@ -12,9 +14,11 @@ export function TopBar({
activeProfileId,
activeClientId,
adminOpen,
adminMode,
onProfileChange,
onClientChange,
onToggleAdmin,
onOpenAdmin,
onOpenPlatform,
onOpenShowcase,
onOpenProfileSettings,
onLogout,
@ -26,9 +30,11 @@ export function TopBar({
activeProfileId: string;
activeClientId: string;
adminOpen: boolean;
adminMode: LauncherAdminMode;
onProfileChange: (userId: string) => void;
onClientChange: (clientId: string) => void;
onToggleAdmin: () => void;
onOpenAdmin: () => void;
onOpenPlatform: () => void;
onOpenShowcase: () => void;
onOpenProfileSettings: () => void;
onLogout?: () => void;
@ -46,6 +52,7 @@ export function TopBar({
label: client.name,
description: client.legalName ?? undefined,
}));
const canOpenPlatform = me.launcherRole === "root_admin";
return (
<header className="nodedc-expanded-toolbar-shell">
@ -86,10 +93,26 @@ export function TopBar({
</button>
{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>
</button>
) : null}
{canOpenPlatform ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "platform"}
onClick={onOpenPlatform}
>
<span>Платформа</span>
</button>
) : null}
</nav>
</div>