ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher control plane и доступы

This commit is contained in:
DCCONSTRUCTIONS
2026-05-04 17:16:47 +03:00
parent de0a0d2948
commit b221ccb83e
19 changed files with 4137 additions and 999 deletions
+112 -8
View File
@@ -89,6 +89,16 @@ export interface SetUserServiceAccessCommand {
value: AccessAssignmentValue;
}
export interface CreateUserCommand {
clientId: string;
email: string;
name?: string;
role: ClientMembershipRole;
groupIds: string[];
provisionAuth: boolean;
generatePassword: boolean;
}
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
@@ -124,9 +134,11 @@ export function AdminOverlay({
onCreateClient,
onUpdateClient,
onDeleteClient,
onCreateUser,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
pendingAccessAssignments,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
@@ -147,9 +159,11 @@ export function AdminOverlay({
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
onCreateUser: (command: CreateUserCommand) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
@@ -264,7 +278,7 @@ export function AdminOverlay({
{activeSection ? (
<section className="admin-panel-content">
<AdminHeader />
<AdminHeader onCloseContent={() => setActiveSection(null)} />
<div className="admin-panel-content__body">
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
{activeSection === "clients" && isRoot ? (
@@ -275,6 +289,7 @@ export function AdminOverlay({
data={data}
clientId={scopedClientId}
isRoot={isRoot}
onCreateUser={onCreateUser}
onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
@@ -305,6 +320,7 @@ export function AdminOverlay({
selectedCell={selectedAccessCell}
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
onSetUserServiceAccess={onSetUserServiceAccess}
pendingAccessAssignments={pendingAccessAssignments}
/>
) : null}
{activeSection === "invites" ? (
@@ -327,7 +343,7 @@ export function AdminOverlay({
);
}
function AdminHeader() {
function AdminHeader({ onCloseContent }: { onCloseContent: () => void }) {
return (
<div className="admin-header">
<div className="admin-header__actions">
@@ -337,6 +353,9 @@ function AdminHeader() {
<IconButton label="Синхронизация" className="admin-circle-action admin-circle-action--solid" type="button">
<RefreshCw size={16} />
</IconButton>
<IconButton label="Закрыть панель раздела" className="admin-circle-action admin-content-close" type="button" onClick={onCloseContent}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
</div>
);
@@ -496,6 +515,7 @@ function UsersSection({
data,
clientId,
isRoot,
onCreateUser,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
@@ -503,26 +523,93 @@ function UsersSection({
data: LauncherData;
clientId: string;
isRoot: boolean;
onCreateUser: (command: CreateUserCommand) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
}) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const [newUserEmail, setNewUserEmail] = useState("");
const [newUserName, setNewUserName] = useState("");
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
const [newUserGroupId, setNewUserGroupId] = useState<string>("none");
const rows = isRoot
? data.memberships.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }))
: data.memberships
.filter((membership) => membership.clientId === clientId)
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
const editingRow = rows.find((row) => row.membership.id === editingMembershipId) ?? null;
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
const groupOptions: Array<NodeDcSelectOption<string>> = [
{ value: "none", label: "Без группы" },
...clientGroups.map((group) => ({ value: group.id, label: group.name })),
];
function handleCreateUser() {
const email = newUserEmail.trim();
if (!email) {
return;
}
onCreateUser({
clientId,
email,
name: newUserName.trim() || undefined,
role: newUserRole,
groupIds: newUserGroupId === "none" ? [] : [newUserGroupId],
provisionAuth: true,
generatePassword: true,
});
setNewUserEmail("");
setNewUserName("");
setNewUserRole("member");
setNewUserGroupId("none");
}
return (
<>
<GlassSurface className="invite-form invite-form--compact">
<div className="table-toolbar">
<div>
<p className="eyebrow">Launcher Authentik</p>
<h3>Создать участника</h3>
</div>
<IconButton
label="Создать участника"
className="admin-circle-action admin-circle-action--solid"
type="button"
disabled={!newUserEmail.trim()}
onClick={handleCreateUser}
>
<Plus size={17} />
</IconButton>
</div>
<div className="invite-form__fields">
<input value={newUserEmail} onChange={(event) => setNewUserEmail(event.target.value)} placeholder="email@company.ru" />
<input value={newUserName} onChange={(event) => setNewUserName(event.target.value)} placeholder="Имя пользователя" />
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={newUserRole}
options={membershipRoleOptions}
label="Роль"
onChange={(role) => setNewUserRole(role)}
/>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={newUserGroupId}
options={groupOptions}
label="Группа"
onChange={(groupId) => setNewUserGroupId(groupId)}
/>
</div>
</GlassSurface>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<h3>Участники</h3>
<IconButton label="Создать инвайт" className="admin-circle-action admin-circle-action--solid" type="button">
<MailPlus size={16} />
</IconButton>
</div>
<table className="admin-data-table">
<thead>
@@ -1823,12 +1910,14 @@ function AccessSection({
selectedCell,
onSelectCell,
onSetUserServiceAccess,
pendingAccessAssignments,
}: {
data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>;
selectedCell: AccessMatrixCell | null;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
}) {
const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null;
@@ -1893,6 +1982,7 @@ function AccessSection({
<AccessCellControl
cell={cell}
active={active}
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
onSelectCell={onSelectCell}
onSetAccess={(value) => onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value })}
/>
@@ -1926,15 +2016,18 @@ function AccessSection({
function AccessCellControl({
cell,
active,
pendingValue,
onSelectCell,
onSetAccess,
}: {
cell: AccessMatrixCell;
active: boolean;
pendingValue?: AccessAssignmentValue;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetAccess: (value: AccessAssignmentValue) => void;
}) {
const assignmentValue = accessAssignmentValue(cell);
const isPending = pendingValue !== undefined;
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
return (
<NodeDcSelect
@@ -1943,6 +2036,7 @@ function AccessCellControl({
label={`Назначить доступ ${cell.userId} / ${cell.serviceId}`}
minMenuWidth={172}
menuClassName="access-cell-menu"
disabled={isPending}
onChange={(value) => onSetAccess(value)}
trigger={({ open, toggle, setTriggerRef }) => (
<button
@@ -1952,17 +2046,19 @@ function AccessCellControl({
cell.effectiveAccess.allowed && "access-cell--allowed",
!cell.effectiveAccess.allowed && "access-cell--denied",
cell.effectiveAccess.source === "exception" && "access-cell--exception",
isPending && "access-cell--pending",
active && "access-cell--active"
)}
type="button"
aria-expanded={open}
aria-busy={isPending}
onClick={() => {
onSelectCell(cell);
toggle();
}}
>
<strong>{accessCellTitle(cell)}</strong>
<span>{sourceLabel(cell.effectiveAccess.source)}</span>
<strong>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
</button>
)}
/>
@@ -2308,6 +2404,14 @@ function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue {
return "unset";
}
function accessAssignmentLabel(value: AccessAssignmentValue): string {
return accessAssignmentOptions.find((option) => option.value === value)?.label ?? value;
}
function accessCellKey(userId: string, serviceId: string): string {
return `${userId}:${serviceId}`;
}
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
if (!source) return "—";
const labels = {
@@ -0,0 +1,186 @@
import { useEffect, useState } from "react";
import { KeyRound, Save, Upload, X } from "lucide-react";
import type { LauncherUser } from "../../entities/user/types";
import { uploadStorageFile } from "../../shared/api/storageApi";
import { initials } from "../../shared/lib/format";
import { Button, IconButton } from "../../shared/ui/Button";
export function ProfileSettingsPanel({
user,
onClose,
onSaveProfile,
onChangePassword,
}: {
user: LauncherUser;
onClose: () => void;
onSaveProfile: (patch: Partial<LauncherUser>) => Promise<void>;
onChangePassword: (newPassword: string) => Promise<void>;
}) {
const [draft, setDraft] = useState<LauncherUser>(user);
const [newPassword, setNewPassword] = useState("");
const [uploading, setUploading] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => setDraft(user), [user]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
setDraft((current) => ({ ...current, [key]: value }));
}
async function handleAvatarUpload(file: File | undefined) {
if (!file) return;
setUploading(true);
setMessage(null);
try {
const result = await uploadStorageFile(file);
update("avatarUrl", result.url);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
} finally {
setUploading(false);
}
}
async function handleSaveProfile() {
setSavingProfile(true);
setMessage(null);
try {
await onSaveProfile({
name: draft.name,
email: draft.email,
phone: draft.phone ?? null,
position: draft.position ?? null,
avatarUrl: draft.avatarUrl ?? null,
});
setMessage("Профиль сохранён");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
} finally {
setSavingProfile(false);
}
}
async function handleSavePassword() {
if (newPassword.length < 8) {
setMessage("Пароль должен быть не короче 8 символов");
return;
}
setSavingPassword(true);
setMessage(null);
try {
await onChangePassword(newPassword);
setNewPassword("");
setMessage("Пароль обновлён");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось обновить пароль");
} finally {
setSavingPassword(false);
}
}
return (
<aside className="profile-settings-layer" aria-label="Настройки профиля">
<section className="profile-settings-panel">
<div className="profile-settings-panel__head">
<div>
<p className="eyebrow">Профиль</p>
<h2>Настройки</h2>
</div>
<IconButton label="Закрыть настройки" className="admin-panel-close" type="button" onClick={onClose}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
<div className="profile-settings-panel__body">
<div className="profile-settings-avatar-card">
{draft.avatarUrl ? (
<img className="profile-settings-avatar-card__image" src={draft.avatarUrl} alt="" />
) : (
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
)}
<label className="profile-settings-upload">
<Upload size={15} />
<span>{uploading ? "Загружаем" : "Загрузить аватар"}</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={uploading}
onChange={(event) => void handleAvatarUpload(event.target.files?.[0])}
/>
</label>
</div>
<label className="profile-settings-field">
<span>Имя</span>
<input value={draft.name} onChange={(event) => update("name", event.target.value)} />
</label>
<label className="profile-settings-field">
<span>Email</span>
<input value={draft.email} onChange={(event) => update("email", event.target.value)} />
</label>
<label className="profile-settings-field">
<span>Телефон</span>
<input value={draft.phone ?? ""} onChange={(event) => update("phone", event.target.value || null)} />
</label>
<label className="profile-settings-field">
<span>Должность</span>
<input value={draft.position ?? ""} onChange={(event) => update("position", event.target.value || null)} />
</label>
<div className="profile-settings-divider" />
<label className="profile-settings-field">
<span>Новый пароль</span>
<input
value={newPassword}
type="password"
placeholder="Минимум 8 символов"
onChange={(event) => setNewPassword(event.target.value)}
/>
</label>
</div>
{message ? <p className="profile-settings-message">{message}</p> : null}
<div className="profile-settings-panel__foot">
<Button
variant="secondary"
surface="modal"
type="button"
icon={<KeyRound size={16} />}
disabled={savingPassword || !newPassword}
onClick={() => void handleSavePassword()}
>
{savingPassword ? "Обновляем" : "Сменить пароль"}
</Button>
<Button
variant="accent"
surface="modal"
type="button"
icon={<Save size={16} />}
disabled={savingProfile || uploading}
onClick={() => void handleSaveProfile()}
>
{savingProfile ? "Сохраняем" : "Сохранить"}
</Button>
</div>
</section>
</aside>
);
}
+22 -40
View File
@@ -17,6 +17,8 @@ import type { LauncherServiceView } from "../../entities/service/types";
import { Button } from "../../shared/ui/Button";
import { ServiceStatusBadge, StatusBadge } from "../../shared/ui/StatusBadge";
const stageActionAccentRgb = [247, 248, 244] as const;
export function ServiceStage({
service,
hasServices,
@@ -50,7 +52,7 @@ export function ServiceStage({
? service.status === "maintenance"
? "Сервис временно недоступен"
: service.userAccess === "denied"
? "Доступ не выдан"
? "Нет доступа"
: service.effectiveAccess.openEnabled
? null
: "Открытие заблокировано"
@@ -63,20 +65,6 @@ export function ServiceStage({
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} />
</div>
<div className="stage-video-topline">
<button className="stage-round-button" type="button" aria-label="Назад">
<ChevronLeft size={17} />
</button>
<span>{service?.title ?? "Витрина NODE.DC"}</span>
</div>
<div className="stage-side-controls" aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
{service ? (
<div className="stage-service-overlay">
<article className="stage-image-card">
@@ -104,26 +92,24 @@ export function ServiceStage({
<div className="stage-description-card__copy">
<h1>{service.title}</h1>
</div>
<div className="stage-description-card__description">
<RichDescription text={service.fullDescription ?? service.description} />
</div>
<div className="stage-description-card__chips">
<ServiceStatusBadge status={service.status} />
<StatusBadge
label={service.userAccess === "allowed" ? `Доступ: ${service.appRole ?? "member"}` : "Нет доступа"}
tone={service.userAccess === "allowed" ? "green" : "red"}
/>
</div>
<div className="stage-description-card__reason">
<span>Почему видно</span>
<strong>{service.effectiveAccess.reason}</strong>
</div>
<div className="stage-description-card__actions">
<div className="stage-description-card__footer">
<div className="stage-description-card__chips">
<ServiceStatusBadge status={service.status} />
<StatusBadge
label={service.userAccess === "allowed" ? `Доступ: ${service.appRole ?? "member"}` : "Нет доступа"}
tone={service.userAccess === "allowed" ? "green" : "muted"}
/>
</div>
<Button
type="button"
variant="primary"
variant="accent"
accentRgb={stageActionAccentRgb}
icon={
service.status === "maintenance" ? (
<Wrench size={16} />
@@ -138,19 +124,15 @@ export function ServiceStage({
>
{disabledReason ?? "Открыть"}
</Button>
<Button
type="button"
variant="secondary"
icon={<ChevronRight size={16} />}
onClick={() => onLaunch(service)}
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
>
Перейти
</Button>
</div>
</aside>
</div>
) : null}
) : (
<div className="stage-empty-title" aria-label="NodeDC витрина модулей">
<span>NODE.DC</span>
<strong>Витрина модулей</strong>
</div>
)}
<div className="stage-video-controls">
<button type="button" aria-label="Предыдущий сервис" onClick={onSelectPrevious}>
+8 -1
View File
@@ -15,6 +15,7 @@ export function TopBar({
onClientChange,
onToggleAdmin,
onOpenShowcase,
onOpenProfileSettings,
onLogout,
}: {
me: MeResponse;
@@ -27,6 +28,7 @@ export function TopBar({
onClientChange: (clientId: string) => void;
onToggleAdmin: () => void;
onOpenShowcase: () => void;
onOpenProfileSettings: () => void;
onLogout?: () => void;
}) {
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
@@ -114,6 +116,7 @@ export function TopBar({
<div className="nodedc-expanded-toolbar-right">
<NodeDcProfileMenu
user={me.user}
onSettings={onOpenProfileSettings}
onLogout={onLogout}
trigger={({ open, toggle, setTriggerRef }) => (
<div
@@ -141,7 +144,11 @@ export function TopBar({
</span>
</span>
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
{me.user.avatarUrl ? (
<img className="nodedc-expanded-user-avatar" src={me.user.avatarUrl} alt="" style={{ objectFit: "cover" }} />
) : (
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
)}
</span>
</div>
)}