ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher control plane и доступы
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user