ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: открытый контур и self-service инвайты
This commit is contained in:
+368
-52
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Client } from "../entities/client/types";
|
||||
import type { Invite } from "../entities/invite/types";
|
||||
import { syncServiceLaunchLink } from "../entities/service/links";
|
||||
import type { LauncherServiceView, Service } from "../entities/service/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types";
|
||||
import {
|
||||
approveAdminAccessRequest,
|
||||
approveAdminTaskerInviteRequest,
|
||||
createAdminClient,
|
||||
createAdminGroup,
|
||||
createAdminInvite,
|
||||
@@ -21,10 +23,13 @@ import {
|
||||
fetchControlPlaneSnapshot,
|
||||
reorderAdminServices,
|
||||
retryAdminSync,
|
||||
rejectAdminAccessRequest,
|
||||
rejectAdminTaskerInviteRequest,
|
||||
removeAdminTaskManagerProjectMembership,
|
||||
removeAdminTaskManagerWorkspaceMembership,
|
||||
setAdminUserServiceAccess,
|
||||
updateAdminClient,
|
||||
updateAdminAccessRequest,
|
||||
updateAdminGroup,
|
||||
updateAdminInvite,
|
||||
updateAdminMembership,
|
||||
@@ -35,6 +40,7 @@ import {
|
||||
type TaskManagerWorkspaceMemberRole,
|
||||
type TaskManagerWorkspaceSummary,
|
||||
} from "../shared/api/adminApi";
|
||||
import { createAccessRequest, type CreateAccessRequestResponse } from "../shared/api/accessRequestApi";
|
||||
import {
|
||||
buildLauncherServices,
|
||||
buildMe,
|
||||
@@ -53,6 +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 { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||
import {
|
||||
@@ -80,6 +87,7 @@ type InviteFlowState =
|
||||
|
||||
export function LauncherApp() {
|
||||
const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []);
|
||||
const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []);
|
||||
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
|
||||
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
||||
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
|
||||
@@ -95,6 +103,15 @@ export function LauncherApp() {
|
||||
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
|
||||
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
|
||||
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
|
||||
const runtimeDataRef = useRef(data);
|
||||
const runtimeProfileIdRef = useRef(activeProfileId);
|
||||
const runtimeClientIdRef = useRef(activeClientId);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeDataRef.current = data;
|
||||
runtimeProfileIdRef.current = activeProfileId;
|
||||
runtimeClientIdRef.current = activeClientId;
|
||||
}, [activeClientId, activeProfileId, data]);
|
||||
|
||||
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
||||
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
|
||||
@@ -218,10 +235,10 @@ export function LauncherApp() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession || authSession.authenticated) return;
|
||||
if (inviteToken) return;
|
||||
if (inviteToken || isAccessRequestRoute) return;
|
||||
|
||||
redirectToLogin(authSession.loginUrl);
|
||||
}, [authSession, inviteToken]);
|
||||
}, [authSession, inviteToken, isAccessRequestRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inviteToken) return;
|
||||
@@ -266,6 +283,7 @@ export function LauncherApp() {
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!session.authenticated) {
|
||||
if (inviteToken || isAccessRequestRoute) return;
|
||||
redirectToLogin(session.loginUrl);
|
||||
return;
|
||||
}
|
||||
@@ -273,7 +291,7 @@ export function LauncherApp() {
|
||||
setAuthSession(session);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) {
|
||||
if (isMounted && !inviteToken && !isAccessRequestRoute) {
|
||||
redirectToLogin("/auth/login");
|
||||
}
|
||||
});
|
||||
@@ -285,7 +303,7 @@ export function LauncherApp() {
|
||||
isMounted = false;
|
||||
window.removeEventListener("pageshow", validateRestoredSession);
|
||||
};
|
||||
}, []);
|
||||
}, [inviteToken, isAccessRequestRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
@@ -341,49 +359,60 @@ export function LauncherApp() {
|
||||
void refreshTaskManagerWorkspaces();
|
||||
}, [adminOpen, canOpenAdminApi]);
|
||||
|
||||
const refreshRuntimeState = useCallback(async () => {
|
||||
try {
|
||||
const nextSession = await fetchAuthSession();
|
||||
|
||||
setAuthSession(nextSession);
|
||||
|
||||
if (!nextSession.authenticated) {
|
||||
setAuthApps([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentData = runtimeDataRef.current;
|
||||
const nextContext = resolveAuthenticatedContext(
|
||||
currentData,
|
||||
nextSession,
|
||||
runtimeProfileIdRef.current,
|
||||
runtimeClientIdRef.current
|
||||
);
|
||||
const nextMe = buildMe(currentData, nextContext.profileId, nextContext.clientId);
|
||||
const [persistedData, apps] = await Promise.all([
|
||||
nextSession.isSuperAdmin || nextMe.permissions.canOpenAdmin
|
||||
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
||||
: loadPersistedLauncherData(),
|
||||
fetchAvailableApps(),
|
||||
]);
|
||||
|
||||
if (persistedData) {
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
|
||||
setAuthApps(apps);
|
||||
} catch (error: unknown) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const refreshRuntimeState = async () => {
|
||||
try {
|
||||
const nextSession = await fetchAuthSession();
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setAuthSession(nextSession);
|
||||
|
||||
if (!nextSession.authenticated) {
|
||||
setAuthApps([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextContext = resolveAuthenticatedContext(data, nextSession, activeProfileId, activeClientId);
|
||||
const nextMe = buildMe(data, nextContext.profileId, nextContext.clientId);
|
||||
const [persistedData, apps] = await Promise.all([
|
||||
nextMe.permissions.canOpenAdmin
|
||||
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
||||
: loadPersistedLauncherData(),
|
||||
fetchAvailableApps(),
|
||||
]);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (persistedData) {
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
|
||||
setAuthApps(apps);
|
||||
} catch (error: unknown) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||
}
|
||||
const refreshMountedRuntimeState = async () => {
|
||||
await refreshRuntimeState();
|
||||
if (!isMounted) return;
|
||||
};
|
||||
|
||||
const eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("nodedc-ready", () => {
|
||||
void refreshMountedRuntimeState();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("nodedc-runtime", () => {
|
||||
void refreshRuntimeState();
|
||||
void refreshMountedRuntimeState();
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
@@ -394,7 +423,25 @@ export function LauncherApp() {
|
||||
isMounted = false;
|
||||
eventSource.close();
|
||||
};
|
||||
}, [authSession?.authenticated]);
|
||||
}, [authSession?.authenticated, refreshRuntimeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
const refreshVisibleRuntimeState = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void refreshRuntimeState();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("focus", refreshVisibleRuntimeState);
|
||||
document.addEventListener("visibilitychange", refreshVisibleRuntimeState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", refreshVisibleRuntimeState);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleRuntimeState);
|
||||
};
|
||||
}, [authSession?.authenticated, refreshRuntimeState]);
|
||||
|
||||
function handleProfileChange(userId: string) {
|
||||
const profile = profileOptions.find((option) => option.userId === userId);
|
||||
@@ -561,6 +608,10 @@ export function LauncherApp() {
|
||||
try {
|
||||
const result = await acceptInvite(inviteToken);
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
if (result.redirectUrl && result.redirectUrl !== "/") {
|
||||
window.location.assign(result.redirectUrl);
|
||||
return;
|
||||
}
|
||||
setInviteFlow({ status: "accepted", payload: inviteFlow.payload });
|
||||
} catch (error) {
|
||||
setInviteFlow({
|
||||
@@ -601,6 +652,32 @@ export function LauncherApp() {
|
||||
applyControlPlaneMutation(deleteAdminInvite(inviteId));
|
||||
}
|
||||
|
||||
function handleUpdateAccessRequest(accessRequestId: string, patch: Parameters<typeof updateAdminAccessRequest>[1]) {
|
||||
applyControlPlaneMutation(updateAdminAccessRequest(accessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleApproveAccessRequest(accessRequestId: string, patch: Parameters<typeof approveAdminAccessRequest>[1]) {
|
||||
applyControlPlaneMutation(approveAdminAccessRequest(accessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleRejectAccessRequest(accessRequestId: string, patch: Parameters<typeof rejectAdminAccessRequest>[1]) {
|
||||
applyControlPlaneMutation(rejectAdminAccessRequest(accessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleApproveTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
patch: Parameters<typeof approveAdminTaskerInviteRequest>[1]
|
||||
) {
|
||||
applyControlPlaneMutation(approveAdminTaskerInviteRequest(taskerInviteRequestId, patch));
|
||||
}
|
||||
|
||||
function handleRejectTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
patch: Parameters<typeof rejectAdminTaskerInviteRequest>[1]
|
||||
) {
|
||||
applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch));
|
||||
}
|
||||
|
||||
function handleRetrySync(syncId: string) {
|
||||
applyControlPlaneMutation(retryAdminSync(syncId));
|
||||
}
|
||||
@@ -706,11 +783,20 @@ export function LauncherApp() {
|
||||
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
|
||||
}
|
||||
|
||||
if (isAccessRequestRoute) {
|
||||
return (
|
||||
<AccessRequestScreen
|
||||
onSubmit={createAccessRequest}
|
||||
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (inviteToken) {
|
||||
return (
|
||||
<InviteFlowScreen
|
||||
state={inviteFlow ?? { status: "loading" }}
|
||||
isAuthenticated={Boolean(authSession?.authenticated)}
|
||||
authenticatedEmail={authSession?.authenticated ? authSession.user.email : null}
|
||||
onAccept={() => void handleAcceptInvite()}
|
||||
onRegister={(command) => void handleRegisterInvite(command)}
|
||||
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)}
|
||||
@@ -774,6 +860,11 @@ export function LauncherApp() {
|
||||
onCreateInvite={handleCreateInvite}
|
||||
onUpdateInvite={handleUpdateInvite}
|
||||
onDeleteInvite={handleDeleteInvite}
|
||||
onUpdateAccessRequest={handleUpdateAccessRequest}
|
||||
onApproveAccessRequest={handleApproveAccessRequest}
|
||||
onRejectAccessRequest={handleRejectAccessRequest}
|
||||
onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest}
|
||||
onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest}
|
||||
onRetrySync={handleRetrySync}
|
||||
onCreateClient={handleCreateClient}
|
||||
onUpdateClient={handleUpdateClient}
|
||||
@@ -828,6 +919,156 @@ function accessAssignmentKey(userId: string, serviceId: string) {
|
||||
return `${userId}:${serviceId}`;
|
||||
}
|
||||
|
||||
function AccessRequestScreen({
|
||||
onSubmit,
|
||||
onLogin,
|
||||
}: {
|
||||
onSubmit: (command: CreateAccessRequestCommand) => Promise<CreateAccessRequestResponse>;
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<CreateAccessRequestCommand>({
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
middleName: "",
|
||||
phone: "",
|
||||
company: "",
|
||||
});
|
||||
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 canSubmit = Boolean(
|
||||
normalizedEmail.includes("@") &&
|
||||
values.firstName.trim() &&
|
||||
values.lastName.trim() &&
|
||||
values.middleName.trim() &&
|
||||
values.phone.trim() &&
|
||||
values.company.trim() &&
|
||||
status !== "submitting"
|
||||
);
|
||||
|
||||
const updateField = (field: keyof CreateAccessRequestCommand, value: string) => {
|
||||
setValues((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
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>{isSubmitted ? "Вы запросили доступ." : "Работайте во всех измерениях."}</p>
|
||||
</div>
|
||||
|
||||
{!isSubmitted ? (
|
||||
<p className="nodedc-auth-card__status">
|
||||
Заполните обязательные поля. Заявка попадёт в очередь NODE.DC, после approve администратор передаст ссылку инвайта.
|
||||
</p>
|
||||
) : null}
|
||||
{message ? <p className="nodedc-auth-card__status">{message}</p> : null}
|
||||
|
||||
{isSubmitted ? (
|
||||
<div className="nodedc-auth-card__form">
|
||||
<button className="button button--primary" type="button" onClick={onLogin}>
|
||||
Войти в NODE.DC
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="nodedc-auth-card__form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
setStatus("submitting");
|
||||
setMessage(null);
|
||||
onSubmit({
|
||||
email: normalizedEmail,
|
||||
firstName: values.firstName.trim(),
|
||||
lastName: values.lastName.trim(),
|
||||
middleName: values.middleName.trim(),
|
||||
phone: values.phone.trim(),
|
||||
company: values.company.trim(),
|
||||
})
|
||||
.then(() => {
|
||||
setStatus("submitted");
|
||||
setMessage("Заявка отправлена администратору. Администратор проверит данные. Дождитесь результатов.");
|
||||
})
|
||||
.catch((error) => {
|
||||
setStatus("error");
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось отправить заявку.");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Эл. почта</span>
|
||||
<input
|
||||
value={values.email}
|
||||
type="email"
|
||||
placeholder="email@company.ru"
|
||||
autoComplete="email"
|
||||
onChange={(event) => updateField("email", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="nodedc-auth-card__field-grid">
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Фамилия</span>
|
||||
<input
|
||||
value={values.lastName}
|
||||
placeholder="Иванов"
|
||||
autoComplete="family-name"
|
||||
onChange={(event) => updateField("lastName", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Имя</span>
|
||||
<input
|
||||
value={values.firstName}
|
||||
placeholder="Иван"
|
||||
autoComplete="given-name"
|
||||
onChange={(event) => updateField("firstName", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Отчество</span>
|
||||
<input value={values.middleName} placeholder="Иванович" onChange={(event) => updateField("middleName", event.target.value)} />
|
||||
</label>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Телефон</span>
|
||||
<input
|
||||
value={values.phone}
|
||||
type="tel"
|
||||
placeholder="+7 999 000-00-00"
|
||||
autoComplete="tel"
|
||||
onChange={(event) => updateField("phone", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Компания</span>
|
||||
<input
|
||||
value={values.company}
|
||||
placeholder="Название компании"
|
||||
autoComplete="organization"
|
||||
onChange={(event) => updateField("company", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="button button--primary" type="submit" disabled={!canSubmit}>
|
||||
{status === "submitting" ? "Отправляем заявку" : "Запросить доступ"}
|
||||
</button>
|
||||
<button className="button button--secondary" type="button" onClick={onLogin}>
|
||||
Уже есть аккаунт
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAuthenticatedContext(
|
||||
data: LauncherData,
|
||||
session: AuthenticatedSession,
|
||||
@@ -878,7 +1119,7 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli
|
||||
|
||||
function InviteFlowScreen({
|
||||
state,
|
||||
isAuthenticated,
|
||||
authenticatedEmail,
|
||||
onAccept,
|
||||
onRegister,
|
||||
onLogin,
|
||||
@@ -886,7 +1127,7 @@ function InviteFlowScreen({
|
||||
onGoHome,
|
||||
}: {
|
||||
state: InviteFlowState;
|
||||
isAuthenticated: boolean;
|
||||
authenticatedEmail: string | null;
|
||||
onAccept: () => void;
|
||||
onRegister: (command: RegisterInviteCommand) => void;
|
||||
onLogin: () => void;
|
||||
@@ -899,12 +1140,33 @@ function InviteFlowScreen({
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const payload = "payload" in state ? state.payload : undefined;
|
||||
const inviteStatus = payload?.invite.status;
|
||||
const inviteEmail = payload?.account.email ?? payload?.invite.email ?? "";
|
||||
const normalizedInviteEmail = inviteEmail.toLowerCase();
|
||||
const existingAccount = Boolean(payload?.account.exists);
|
||||
const isAuthenticated = Boolean(authenticatedEmail);
|
||||
const isAuthenticatedAsInvitee = Boolean(
|
||||
authenticatedEmail &&
|
||||
normalizedInviteEmail &&
|
||||
authenticatedEmail.toLowerCase() === normalizedInviteEmail
|
||||
);
|
||||
const isAuthenticatedAsDifferentUser = Boolean(
|
||||
authenticatedEmail &&
|
||||
normalizedInviteEmail &&
|
||||
authenticatedEmail.toLowerCase() !== normalizedInviteEmail
|
||||
);
|
||||
const isAccepting = state.status === "accepting";
|
||||
const isRegistering = state.status === "registering";
|
||||
const inviteTargetUrl = payload?.redirectUrl;
|
||||
const canOpenInviteTarget = Boolean(
|
||||
payload?.invite.source === "tasker_workspace_invite" &&
|
||||
inviteTargetUrl &&
|
||||
inviteTargetUrl !== "/" &&
|
||||
(state.status === "accepted" || inviteStatus === "accepted")
|
||||
);
|
||||
const requiresAccountSwitch = state.status === "error" && state.message.includes("другую почту");
|
||||
const canAccept = Boolean(
|
||||
state.status === "ready" &&
|
||||
isAuthenticated &&
|
||||
isAuthenticatedAsInvitee &&
|
||||
inviteStatus !== "accepted" &&
|
||||
inviteStatus !== "expired" &&
|
||||
inviteStatus !== "revoked"
|
||||
@@ -913,6 +1175,7 @@ function InviteFlowScreen({
|
||||
const canShowRegistrationForm = Boolean(
|
||||
payload &&
|
||||
!isAuthenticated &&
|
||||
!existingAccount &&
|
||||
!isTerminalInvite &&
|
||||
(state.status === "ready" || state.status === "registering" || state.status === "error")
|
||||
);
|
||||
@@ -927,12 +1190,25 @@ function InviteFlowScreen({
|
||||
password === passwordConfirm
|
||||
);
|
||||
const details = payload
|
||||
? [
|
||||
`Рабочая область: ${payload.client.name}`,
|
||||
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
||||
]
|
||||
? payload.invite.source === "tasker_workspace_invite"
|
||||
? [
|
||||
`Контур: ${payload.client.name}`,
|
||||
`Workspace: ${payload.invite.sourceWorkspaceName ?? payload.invite.sourceWorkspaceSlug ?? "Operational Core"}`,
|
||||
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
||||
]
|
||||
: [
|
||||
`Рабочая область: ${payload.client.name}`,
|
||||
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
||||
]
|
||||
: ["Проверяем приглашение и платформенную сессию"];
|
||||
const statusMessage = resolveInviteStatusMessage(state, isAuthenticated, inviteStatus);
|
||||
const statusMessage = resolveInviteStatusMessage(state, {
|
||||
existingAccount,
|
||||
inviteEmail,
|
||||
inviteStatus,
|
||||
isAuthenticated,
|
||||
isAuthenticatedAsInvitee,
|
||||
isAuthenticatedAsDifferentUser,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="launcher-app nodedc-auth-page">
|
||||
@@ -1004,7 +1280,11 @@ function InviteFlowScreen({
|
||||
Уже есть аккаунт
|
||||
</button>
|
||||
</form>
|
||||
) : requiresAccountSwitch ? (
|
||||
) : existingAccount && !isAuthenticated && !isTerminalInvite ? (
|
||||
<button className="button button--primary" type="button" onClick={onLogin}>
|
||||
Войти и принять приглашение
|
||||
</button>
|
||||
) : (existingAccount && isAuthenticatedAsDifferentUser && !isTerminalInvite) || requiresAccountSwitch ? (
|
||||
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
|
||||
Сменить аккаунт
|
||||
</button>
|
||||
@@ -1013,8 +1293,18 @@ function InviteFlowScreen({
|
||||
Войти в NODE.DC
|
||||
</button>
|
||||
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
|
||||
<button className="button button--primary" type="button" onClick={onGoHome}>
|
||||
Перейти в витрину
|
||||
<button
|
||||
className="button button--primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (canOpenInviteTarget && inviteTargetUrl) {
|
||||
window.location.assign(inviteTargetUrl);
|
||||
return;
|
||||
}
|
||||
onGoHome();
|
||||
}}
|
||||
>
|
||||
{canOpenInviteTarget ? "Перейти в workspace" : "Перейти в витрину"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
|
||||
@@ -1037,7 +1327,26 @@ function NodeDcAuthBrandHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boolean, inviteStatus?: Invite["status"]) {
|
||||
function resolveInviteStatusMessage(
|
||||
state: InviteFlowState,
|
||||
context: {
|
||||
existingAccount: boolean;
|
||||
inviteEmail: string;
|
||||
inviteStatus?: Invite["status"];
|
||||
isAuthenticated: boolean;
|
||||
isAuthenticatedAsInvitee: boolean;
|
||||
isAuthenticatedAsDifferentUser: boolean;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
existingAccount,
|
||||
inviteEmail,
|
||||
inviteStatus,
|
||||
isAuthenticated,
|
||||
isAuthenticatedAsInvitee,
|
||||
isAuthenticatedAsDifferentUser,
|
||||
} = context;
|
||||
|
||||
if (state.status === "loading") return "Проверяем приглашение.";
|
||||
if (state.status === "accepting") return "Подключаем доступ к рабочей области.";
|
||||
if (state.status === "registering") return "Создаём аккаунт и подключаем доступ.";
|
||||
@@ -1045,6 +1354,9 @@ function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boo
|
||||
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
|
||||
if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
|
||||
if (inviteStatus === "revoked") return "Приглашение отозвано.";
|
||||
if (existingAccount && !isAuthenticated) return `Аккаунт ${inviteEmail} уже есть в NODE.DC. Войдите под этой почтой, чтобы принять приглашение.`;
|
||||
if (existingAccount && isAuthenticatedAsDifferentUser) return `Сейчас открыт другой аккаунт. Смените пользователя и войдите под ${inviteEmail}.`;
|
||||
if (existingAccount && isAuthenticatedAsInvitee) return "Аккаунт найден. Подтвердите подключение к workspace.";
|
||||
if (!isAuthenticated) return "Введите почту, имя и пароль для регистрации по приглашению.";
|
||||
return null;
|
||||
}
|
||||
@@ -1101,6 +1413,10 @@ function parseInviteToken(pathname: string) {
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function isAccessRequestPath(pathname: string) {
|
||||
return /^\/(?:request-access|access-request)\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
function membershipRoleLabel(role: ClientMembership["role"]) {
|
||||
return {
|
||||
client_owner: "Владелец клиента",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ClientMembershipRole } from "../user/types";
|
||||
|
||||
export type AccessRequestStatus = "new" | "approved" | "rejected";
|
||||
|
||||
export interface AccessRequest {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
status: AccessRequestStatus;
|
||||
targetClientId: string;
|
||||
role: ClientMembershipRole;
|
||||
approvedInviteId?: string | null;
|
||||
reviewedByUserId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
comment?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateAccessRequestCommand {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export interface Invite {
|
||||
email: string;
|
||||
role: ClientMembershipRole;
|
||||
invitedByUserId: string;
|
||||
source?: "launcher" | "access_request" | "tasker_workspace_invite";
|
||||
sourceTaskerInviteRequestId?: string | null;
|
||||
sourceTaskerInviteId?: string | null;
|
||||
sourceWorkspaceSlug?: string | null;
|
||||
sourceWorkspaceName?: string | null;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
status: InviteStatus;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Client } from "../client/types";
|
||||
|
||||
export const PUBLIC_POOL_CLIENT_ID = "client_public_pool";
|
||||
export const PUBLIC_POOL_CONTEXT_LABEL = "Открытый контур";
|
||||
export const PUBLIC_POOL_CONTEXT_DESCRIPTION = "Public access pool";
|
||||
|
||||
export const PUBLIC_POOL_CLIENT: Client = {
|
||||
id: PUBLIC_POOL_CLIENT_ID,
|
||||
type: "person",
|
||||
name: PUBLIC_POOL_CONTEXT_LABEL,
|
||||
legalName: PUBLIC_POOL_CONTEXT_DESCRIPTION,
|
||||
status: "active",
|
||||
contractStartsAt: null,
|
||||
contractEndsAt: null,
|
||||
paidUntil: null,
|
||||
demoEndsAt: null,
|
||||
contactName: "NODE.DC",
|
||||
contactEmail: null,
|
||||
avatarUrl: null,
|
||||
notes: "Системный контур для публичных заявок, публичных инвайтов и self-service пользователей.",
|
||||
createdAt: "2026-05-09T00:00:00.000Z",
|
||||
updatedAt: "2026-05-09T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export function isPublicPoolClientId(clientId: string | null | undefined): boolean {
|
||||
return clientId === PUBLIC_POOL_CLIENT_ID;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type TaskerInviteRequestStatus = "new" | "approved" | "rejected" | "cancelled";
|
||||
export type TaskerInviteRequestRole = "guest" | "member" | "admin";
|
||||
|
||||
export interface TaskerInviteRequest {
|
||||
id: string;
|
||||
taskerInviteId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceSlug: string;
|
||||
workspaceName: string;
|
||||
inviteeEmail: string;
|
||||
role: TaskerInviteRequestRole;
|
||||
inviterUserId?: string | null;
|
||||
inviterPlaneUserId?: string | null;
|
||||
inviterEmail: string;
|
||||
inviterName: string;
|
||||
status: TaskerInviteRequestStatus;
|
||||
taskerInviteLink?: string | null;
|
||||
platformInviteId?: string | null;
|
||||
platformInviteToken?: string | null;
|
||||
reviewedByUserId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
comment?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -30,6 +30,10 @@ export interface ClientMembership {
|
||||
userId: string;
|
||||
role: ClientMembershipRole;
|
||||
status: ClientMembershipStatus;
|
||||
invitedByUserId?: string | null;
|
||||
inviteId?: string | null;
|
||||
source?: "launcher" | "access_request" | "tasker_workspace_invite" | null;
|
||||
sourceTaskerInviteRequestId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AccessRequest, CreateAccessRequestCommand } from "../../entities/access-request/types";
|
||||
|
||||
export interface CreateAccessRequestResponse {
|
||||
accessRequest: AccessRequest;
|
||||
}
|
||||
|
||||
export async function createAccessRequest(command: CreateAccessRequestCommand): Promise<CreateAccessRequestResponse> {
|
||||
return requestJson<CreateAccessRequestResponse>("/api/access-requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
return payload.error ?? response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AccessRequest } from "../../entities/access-request/types";
|
||||
import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types";
|
||||
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||
import type { LauncherData, LauncherSettings } from "./mockApi";
|
||||
|
||||
@@ -31,6 +33,31 @@ export interface ControlPlaneMutationResult {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AccessRequestMutationResult extends ControlPlaneMutationResult {
|
||||
accessRequest: AccessRequest;
|
||||
}
|
||||
|
||||
export interface AccessRequestApproveResult extends AccessRequestMutationResult {
|
||||
invite: Invite;
|
||||
}
|
||||
|
||||
export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationResult {
|
||||
taskerInviteRequest: TaskerInviteRequest;
|
||||
tasker?: {
|
||||
ok: boolean;
|
||||
invite?: {
|
||||
id: string;
|
||||
email: string;
|
||||
status: string;
|
||||
inviteLink?: string | null;
|
||||
invite_link?: string | null;
|
||||
taskerInviteLink?: string | null;
|
||||
tasker_invite_link?: string | null;
|
||||
platformInviteLink?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskManagerWorkspaceSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -304,6 +331,62 @@ export async function deleteAdminInvite(inviteId: string): Promise<ControlPlaneM
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateAdminAccessRequest(
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
): Promise<AccessRequestMutationResult> {
|
||||
return requestJson<AccessRequestMutationResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveAdminAccessRequest(
|
||||
accessRequestId: string,
|
||||
payload: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">> = {}
|
||||
): Promise<AccessRequestApproveResult> {
|
||||
return requestJson<AccessRequestApproveResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectAdminAccessRequest(
|
||||
accessRequestId: string,
|
||||
payload: Partial<Pick<AccessRequest, "comment">> = {}
|
||||
): Promise<AccessRequestMutationResult> {
|
||||
return requestJson<AccessRequestMutationResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveAdminTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
payload: Partial<Pick<TaskerInviteRequest, "comment">> = {}
|
||||
): Promise<TaskerInviteRequestMutationResult> {
|
||||
return requestJson<TaskerInviteRequestMutationResult>(
|
||||
`/api/admin/tasker-invite-requests/${encodeURIComponent(taskerInviteRequestId)}/approve`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function rejectAdminTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
payload: Partial<Pick<TaskerInviteRequest, "comment">> = {}
|
||||
): Promise<TaskerInviteRequestMutationResult> {
|
||||
return requestJson<TaskerInviteRequestMutationResult>(
|
||||
`/api/admin/tasker-invite-requests/${encodeURIComponent(taskerInviteRequestId)}/reject`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function setAdminUserServiceAccess(payload: {
|
||||
userId: string;
|
||||
serviceId: string;
|
||||
|
||||
@@ -4,8 +4,13 @@ import type { Invite } from "../../entities/invite/types";
|
||||
import type { LauncherData } from "./mockApi";
|
||||
|
||||
export interface PublicInviteResponse {
|
||||
invite: Pick<Invite, "id" | "role" | "expiresAt" | "status">;
|
||||
invite: Pick<Invite, "id" | "email" | "role" | "expiresAt" | "status" | "source" | "sourceWorkspaceName" | "sourceWorkspaceSlug">;
|
||||
client: Pick<Client, "id" | "name" | "status">;
|
||||
redirectUrl?: string;
|
||||
account: {
|
||||
exists: boolean;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AcceptInviteResponse {
|
||||
@@ -14,6 +19,7 @@ export interface AcceptInviteResponse {
|
||||
user: LauncherUser;
|
||||
membership: ClientMembership;
|
||||
data: LauncherData;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
export interface RegisterInviteCommand {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
|
||||
import type { AccessRequest } from "../../entities/access-request/types";
|
||||
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
||||
import { getServiceLaunchLink } from "../../entities/service/links";
|
||||
import type { LauncherServiceView, Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||
import type {
|
||||
ClientGroup,
|
||||
ClientMembership,
|
||||
@@ -15,6 +18,8 @@ import type {
|
||||
import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions";
|
||||
import {
|
||||
mockAuditEvents,
|
||||
mockAccessRequests,
|
||||
mockTaskerInviteRequests,
|
||||
mockClients,
|
||||
mockExceptions,
|
||||
mockGrants,
|
||||
@@ -58,6 +63,8 @@ export interface LauncherData {
|
||||
grants: ServiceGrant[];
|
||||
exceptions: ServiceAccessException[];
|
||||
invites: Invite[];
|
||||
accessRequests: AccessRequest[];
|
||||
taskerInviteRequests: TaskerInviteRequest[];
|
||||
syncStatuses: SyncStatus[];
|
||||
auditEvents: typeof mockAuditEvents;
|
||||
taskManagerMemberships: TaskManagerMembershipAssignment[];
|
||||
@@ -144,6 +151,8 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({
|
||||
grants: mockGrants,
|
||||
exceptions: mockExceptions,
|
||||
invites: mockInvites,
|
||||
accessRequests: mockAccessRequests,
|
||||
taskerInviteRequests: mockTaskerInviteRequests,
|
||||
syncStatuses: mockSyncStatuses,
|
||||
auditEvents: mockAuditEvents,
|
||||
settings: defaultLauncherSettings,
|
||||
@@ -189,6 +198,8 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
||||
grants: Array.isArray(payload.grants) ? payload.grants : mockGrants,
|
||||
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
||||
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
||||
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
||||
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
||||
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
|
||||
@@ -354,6 +365,10 @@ export function buildAccessMatrix(data: LauncherData, clientId: string, includeA
|
||||
}
|
||||
|
||||
export function getClient(data: LauncherData, clientId: string): Client {
|
||||
if (isPublicPoolClientId(clientId)) {
|
||||
return PUBLIC_POOL_CLIENT;
|
||||
}
|
||||
|
||||
const client = data.clients.find((item) => item.id === clientId);
|
||||
if (!client) throw new Error(`Unknown client: ${clientId}`);
|
||||
return client;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { AuditEvent } from "../../entities/audit/types";
|
||||
import type { AccessRequest } from "../../entities/access-request/types";
|
||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { Service } from "../../entities/service/types";
|
||||
@@ -211,6 +213,9 @@ export const mockExceptions: ServiceAccessException[] = [];
|
||||
|
||||
export const mockInvites: Invite[] = [];
|
||||
|
||||
export const mockAccessRequests: AccessRequest[] = [];
|
||||
export const mockTaskerInviteRequests: TaskerInviteRequest[] = [];
|
||||
|
||||
export const mockSyncStatuses: SyncStatus[] = [
|
||||
sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"),
|
||||
sync("sync_dc_touch_authentik", "user_root", "dcctouch@gmail.com", "user", "authentik", "synced"),
|
||||
|
||||
+242
-3
@@ -174,6 +174,10 @@ code {
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
}
|
||||
|
||||
.nodedc-access-request-card {
|
||||
width: min(100%, 36rem);
|
||||
}
|
||||
|
||||
.nodedc-auth-card__copy {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -224,6 +228,12 @@ code {
|
||||
gap: 1.05rem;
|
||||
}
|
||||
|
||||
.nodedc-auth-card__field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.05rem;
|
||||
}
|
||||
|
||||
.nodedc-auth-card__field {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
@@ -2171,7 +2181,7 @@ code {
|
||||
}
|
||||
|
||||
.admin-data-table--users {
|
||||
min-width: 66rem;
|
||||
min-width: 78rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
@@ -2201,7 +2211,7 @@ code {
|
||||
|
||||
.admin-data-table--users th:nth-child(3),
|
||||
.admin-data-table--users td:nth-child(3) {
|
||||
width: 12rem;
|
||||
width: 13.5rem;
|
||||
}
|
||||
|
||||
.admin-data-table--users th:nth-child(4),
|
||||
@@ -2211,14 +2221,44 @@ code {
|
||||
|
||||
.admin-data-table--users th:nth-child(5),
|
||||
.admin-data-table--users td:nth-child(5) {
|
||||
width: 18rem;
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.admin-data-table--users th:nth-child(6),
|
||||
.admin-data-table--users td:nth-child(6) {
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
.admin-data-table--users th:nth-child(7),
|
||||
.admin-data-table--users td:nth-child(7) {
|
||||
width: 10.2rem;
|
||||
}
|
||||
|
||||
.membership-inviter-cell {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
max-width: 13.5rem;
|
||||
}
|
||||
|
||||
.membership-inviter-cell span,
|
||||
.membership-inviter-cell small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.membership-inviter-cell span {
|
||||
color: var(--text-primary);
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.membership-inviter-cell small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.71rem;
|
||||
}
|
||||
|
||||
.admin-static-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -3338,6 +3378,12 @@ code {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.access-user-cell__inviter {
|
||||
color: var(--accent-lime);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.access-main-stack {
|
||||
display: grid;
|
||||
width: 10.8rem;
|
||||
@@ -3616,6 +3662,41 @@ code {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-tabs-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-tab-button {
|
||||
min-height: 2.4rem;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.95rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 820;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-tab-button:hover,
|
||||
.admin-tab-button:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-tab-button--active {
|
||||
background: rgba(247, 248, 244, 0.96);
|
||||
color: rgb(var(--nodedc-on-accent-rgb));
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -3747,6 +3828,160 @@ code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests {
|
||||
width: max-content;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.access-request-table-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
margin: 0 -0.25rem;
|
||||
padding: 0 0.25rem 0.35rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests th,
|
||||
.admin-data-table--access-requests td {
|
||||
width: 1%;
|
||||
padding-inline: 0.78rem;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests th:nth-child(7),
|
||||
.admin-data-table--access-requests td:nth-child(7) {
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests th:nth-child(8),
|
||||
.admin-data-table--access-requests td:nth-child(8) {
|
||||
min-width: 4.75rem;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests .admin-table-select-wrap {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests .admin-table-select-trigger {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: 13rem;
|
||||
padding-inline: 0.82rem 0.68rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests td:nth-child(4) .admin-table-select-trigger {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests td:nth-child(5) .admin-table-select-trigger {
|
||||
min-width: 8.4rem;
|
||||
max-width: 9.5rem;
|
||||
}
|
||||
|
||||
.access-request-applicant {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
width: max-content;
|
||||
min-width: 8.5rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.access-request-applicant strong,
|
||||
.access-request-applicant small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.access-request-applicant small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.access-request-contact {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
width: max-content;
|
||||
min-width: 10.5rem;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.access-request-contact span,
|
||||
.access-request-contact small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.access-request-contact small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests .invite-link-cell {
|
||||
width: min(24rem, 42vw);
|
||||
}
|
||||
|
||||
.access-request-decision-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.45rem;
|
||||
padding: 0.24rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.access-request-decision-button {
|
||||
display: grid;
|
||||
width: 1.95rem;
|
||||
min-width: 1.95rem;
|
||||
height: 1.95rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background 160ms ease,
|
||||
color 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.access-request-decision-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.access-request-decision-button:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.access-request-decision-button--accept {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.access-request-decision-button--accept:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.access-request-decision-button--decline {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.access-request-decision-button--decline:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.admin-helper-note {
|
||||
max-width: 38rem;
|
||||
margin: 0.22rem 0 0;
|
||||
@@ -4168,6 +4403,10 @@ code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-auth-card__field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-expanded-toolbar-shell {
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
||||
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
|
||||
import { initials } from "../../shared/lib/format";
|
||||
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
|
||||
@@ -34,7 +35,11 @@ export function TopBar({
|
||||
brandLinkUrl?: string;
|
||||
}) {
|
||||
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
||||
const clientsWithPublicPool = [
|
||||
...clients,
|
||||
availableClientIds.has(PUBLIC_POOL_CLIENT.id) && !clients.some((client) => isPublicPoolClientId(client.id)) ? PUBLIC_POOL_CLIENT : null,
|
||||
].filter((client): client is Client => Boolean(client));
|
||||
const availableClients = clientsWithPublicPool.filter((client) => availableClientIds.has(client.id));
|
||||
const activeClient = availableClients.find((client) => client.id === activeClientId);
|
||||
const clientOptions = availableClients.map((client) => ({
|
||||
value: client.id,
|
||||
|
||||
Reference in New Issue
Block a user