ФУНКЦИИ - NODEDC LAUNCHER: рабочий invite onboarding
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user