ФУНКЦИИ - NODEDC LAUNCHER: рабочий invite onboarding

This commit is contained in:
DCCONSTRUCTIONS
2026-05-05 15:53:30 +03:00
parent fd921cc400
commit bd1575d18a
4 changed files with 395 additions and 0 deletions
+161
View File
@@ -45,6 +45,7 @@ import {
type LauncherAuthApp,
} from "../shared/api/authApi";
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
import { acceptInvite, fetchPublicInvite, type PublicInviteResponse } from "../shared/api/inviteApi";
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
import { loadPersistedLauncherData } from "../shared/api/storageApi";
import {
@@ -60,7 +61,15 @@ import { TopBar } from "../widgets/top-bar/TopBar";
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
type InviteFlowState =
| { status: "loading" }
| { status: "ready"; payload: PublicInviteResponse }
| { status: "accepting"; payload: PublicInviteResponse }
| { status: "accepted"; payload: PublicInviteResponse }
| { status: "error"; message: string; payload?: PublicInviteResponse };
export function LauncherApp() {
const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []);
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
@@ -70,6 +79,7 @@ export function LauncherApp() {
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
@@ -196,6 +206,27 @@ export function LauncherApp() {
redirectToLogin(authSession.loginUrl);
}, [authSession]);
useEffect(() => {
if (!inviteToken || !authSession?.authenticated) return;
let isMounted = true;
setInviteFlow({ status: "loading" });
fetchPublicInvite(inviteToken)
.then((payload) => {
if (!isMounted) return;
setInviteFlow({ status: "ready", payload });
})
.catch((error: unknown) => {
if (!isMounted) return;
setInviteFlow({ status: "error", message: error instanceof Error ? error.message : "Инвайт не найден" });
});
return () => {
isMounted = false;
};
}, [authSession, inviteToken]);
useEffect(() => {
let isRedirecting = false;
@@ -411,6 +442,24 @@ export function LauncherApp() {
applyControlPlaneMutation(createAdminInvite(invite));
}
async function handleAcceptInvite() {
if (!inviteToken || inviteFlow?.status !== "ready") return;
setInviteFlow({ status: "accepting", payload: inviteFlow.payload });
try {
const result = await acceptInvite(inviteToken);
setData(syncLauncherServiceLinks(result.data));
setInviteFlow({ status: "accepted", payload: inviteFlow.payload });
} catch (error) {
setInviteFlow({
status: "error",
payload: inviteFlow.payload,
message: error instanceof Error ? error.message : "Не удалось принять инвайт",
});
}
}
function handleUpdateInvite(inviteId: string, patch: Partial<Invite>) {
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
}
@@ -532,6 +581,20 @@ export function LauncherApp() {
return null;
}
if (inviteToken) {
return (
<InviteFlowScreen
state={inviteFlow ?? { status: "loading" }}
currentEmail={authSession.user.email}
onAccept={() => void handleAcceptInvite()}
onGoHome={() => {
window.history.replaceState(null, "", "/");
window.location.replace("/");
}}
/>
);
}
const handleLogout = () => {
window.location.replace(authSession.logoutUrl);
};
@@ -673,6 +736,91 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli
return availableClientIds[0] ?? data.clients[0]?.id ?? requestedClientId;
}
function InviteFlowScreen({
state,
currentEmail,
onAccept,
onGoHome,
}: {
state: InviteFlowState;
currentEmail: string;
onAccept: () => void;
onGoHome: () => void;
}) {
const payload = "payload" in state ? state.payload : undefined;
const title =
state.status === "accepted"
? "Доступ подключён"
: state.status === "error"
? "Инвайт недоступен"
: "Приглашение в NODE.DC";
const description = payload
? `Клиент: ${payload.client.name}. Роль: ${membershipRoleLabel(payload.invite.role)}.`
: "Проверяем приглашение и платформенную сессию.";
const emailMismatch = payload && payload.invite.email.toLowerCase() !== currentEmail.toLowerCase();
const inviteStatus = payload?.invite.status;
const isAccepting = state.status === "accepting";
const canAccept = Boolean(
state.status === "ready" &&
!emailMismatch &&
inviteStatus !== "accepted" &&
inviteStatus !== "expired" &&
inviteStatus !== "revoked"
);
return (
<div className="launcher-app">
<main
style={{
display: "grid",
minHeight: "100vh",
placeItems: "center",
padding: "2rem",
}}
>
<section
style={{
display: "grid",
width: "min(34rem, 100%)",
gap: "1rem",
padding: "2rem",
borderRadius: "1.75rem",
background: "linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.035))",
textAlign: "center",
}}
>
<img src="/nodedc-logo.svg" alt="NODE.DC" style={{ justifySelf: "center", width: "11rem" }} />
<p className="eyebrow" style={{ margin: 0 }}>
Invite flow
</p>
<h1 style={{ margin: 0 }}>{title}</h1>
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
{payload ? (
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>
Инвайт: <strong>{payload.invite.email}</strong>. Текущий вход: <strong>{currentEmail}</strong>.
</p>
) : null}
{emailMismatch ? (
<p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>
Нужно войти под почтой, на которую выписан инвайт.
</p>
) : null}
{state.status === "error" ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{state.message}</p> : null}
{state.status === "accepted" ? (
<button className="button button--primary" type="button" onClick={onGoHome}>
Перейти в витрину
</button>
) : (
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
{isAccepting ? "Подключаем доступ" : "Принять приглашение"}
</button>
)}
</section>
</main>
</div>
);
}
function AuthStateScreen({
title,
description,
@@ -720,6 +868,19 @@ function AuthStateScreen({
);
}
function parseInviteToken(pathname: string) {
const match = /^\/invite\/([^/?#]+)\/?$/.exec(pathname);
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
function membershipRoleLabel(role: ClientMembership["role"]) {
return {
client_owner: "Владелец клиента",
client_admin: "Администратор клиента",
member: "Участник",
}[role];
}
function buildLoginRedirectUrl(loginUrl?: string) {
const url = new URL(loginUrl || "/auth/login", window.location.origin);