feat(launcher): add platform admin and public access flows
This commit is contained in:
parent
a579e71b9b
commit
fd1cc0b25a
|
|
@ -588,6 +588,27 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
const now = isoNow();
|
||||
const 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ export interface CreateAccessRequestCommand {
|
|||
middleName: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
password: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue